API Layer¶
Litestar REST + WebSocket API: controllers, authentication, guards, and channels.
App¶
app
¶
Litestar application factory.
Creates and configures the Litestar application with all controllers, middleware, exception handlers, plugins, and lifecycle hooks (startup/shutdown).
create_app
¶
Create and configure the Litestar application.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
RootConfig | None
|
Root company configuration. |
None
|
clock
|
Clock | None
|
Optional clock seam threaded into the construction phase so a
test can drive a deterministic boot ( |
None
|
overrides
|
AppOverrides | None
|
Optional dependency injections (chiefly tests / bespoke wiring); any field left unset is auto-wired from config and the environment. An injected double always wins over the auto-wired one. |
None
|
_skip_lifecycle_shutdown
|
bool
|
Test-only flag. When |
False
|
Returns:
| Type | Description |
|---|---|
Litestar
|
Configured Litestar application. |
Source code in src/synthorg/api/app.py
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | |
Config¶
config
¶
API configuration models.
Frozen Pydantic models for CORS, rate limiting, server,
authentication, and the top-level ApiConfig that aggregates
them all.
CorsConfig
pydantic-model
¶
Bases: BaseModel
CORS configuration for the API.
Attributes:
| Name | Type | Description |
|---|---|---|
allowed_origins |
tuple[str, ...]
|
Origins permitted to make cross-origin requests. |
allow_methods |
tuple[str, ...]
|
HTTP methods permitted in cross-origin requests. |
allow_headers |
tuple[str, ...]
|
Headers permitted in cross-origin requests. |
allow_credentials |
bool
|
Whether credentials (cookies, auth) are allowed in cross-origin requests. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
allowed_origins(tuple[str, ...]) -
allow_methods(tuple[str, ...]) -
allow_headers(tuple[str, ...]) -
allow_credentials(bool)
Validators:
-
_validate_wildcard_credentials
allowed_origins
pydantic-field
¶
Origins permitted to make cross-origin requests
allow_methods
pydantic-field
¶
HTTP methods permitted in cross-origin requests
allow_headers
pydantic-field
¶
Headers permitted in cross-origin requests
allow_credentials
pydantic-field
¶
Whether credentials (cookies) are allowed
RateLimitTimeUnit
¶
Bases: StrEnum
Valid time windows for rate limiting.
RateLimitConfig
pydantic-model
¶
Bases: BaseModel
API rate limiting configuration.
Three tiers stacked around the auth middleware:
- IP floor (outermost, un-gated): keyed by client IP, applies to every request -- including ones the auth middleware rejects with 401. Guards against flood attacks that burn auth-validation cycles on protected endpoints with forged tokens.
- Unauthenticated (middle, only when
scope["user"]isNone): keyed by client IP, aggressive cap on brute-force against login/setup/logout. - Authenticated (innermost, only when
scope["user"]is set): keyed by user ID, generous cap for normal dashboard use.
Keying authenticated limits by user ID instead of IP prevents multi-user deployments behind a shared gateway or NAT from collectively exhausting a single per-IP budget.
Attributes:
| Name | Type | Description |
|---|---|---|
floor_max_requests |
int
|
Maximum total requests per time window (by IP) across the whole API. Catches traffic that auth_middleware rejects before the unauth tier sees it. |
unauth_max_requests |
int
|
Maximum unauthenticated requests per time window (by IP). |
auth_max_requests |
int
|
Maximum authenticated requests per time window (by user ID). |
time_unit |
RateLimitTimeUnit
|
Time window ( |
exclude_paths |
tuple[str, ...]
|
Paths excluded from rate limiting. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
floor_max_requests(int) -
unauth_max_requests(int) -
auth_max_requests(int) -
time_unit(RateLimitTimeUnit) -
exclude_paths(tuple[str, ...]) -
max_rpm_default(int)
Validators:
-
_validate_floor_above_user_tiers -
_apply_mirrors -
_reject_legacy_max_requests
floor_max_requests
pydantic-field
¶
Maximum total requests per time window (by IP) across the whole API, including requests rejected by the auth middleware. Defense-in-depth against floods of invalid auth attempts on protected endpoints. The floor wraps both user-gated tiers in the middleware stack, so it must be >= auth_max_requests AND >= unauth_max_requests -- a lower floor would silently cap either the authenticated per-user budget or the unauthenticated per-IP budget below its documented value (especially behind a shared NAT where many users share one IP). Enforced by :meth:_validate_floor_above_user_tiers.
unauth_max_requests
pydantic-field
¶
Maximum unauthenticated requests per time window (by IP)
auth_max_requests
pydantic-field
¶
Maximum authenticated requests per time window (by user ID)
exclude_paths
pydantic-field
¶
Paths excluded from rate limiting
max_rpm_default
pydantic-field
¶
Fallback requests-per-minute applied to per-connection coordinators when the catalog does not provide a limiter (mirrors the api.max_rpm_default setting; restart required)
ServerConfig
pydantic-model
¶
Bases: BaseModel
Uvicorn server configuration.
Host, port, TLS paths, trusted-proxy list, and the compression /
request-size limits are resolved at boot via
:func:synthorg.settings.bootstrap_resolver.resolve_init_value
against the api.* registry entries rather than carried on this
model. Only the worker-process / auto-reload / WebSocket-ping knobs
that uvicorn needs at construction time live here.
Attributes:
| Name | Type | Description |
|---|---|---|
reload |
bool
|
Enable auto-reload for development. |
workers |
int
|
Number of worker processes. |
ws_ping_interval |
float
|
WebSocket ping interval in seconds (0 to disable). |
ws_ping_timeout |
float
|
WebSocket pong timeout in seconds. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
reload(bool) -
workers(int) -
ws_ping_interval(float) -
ws_ping_timeout(float)
ws_ping_interval
pydantic-field
¶
WebSocket ping interval in seconds (0 to disable)
ApiConfig
pydantic-model
¶
Bases: BaseModel
Top-level API configuration aggregating all sub-configs.
Attributes:
| Name | Type | Description |
|---|---|---|
cors |
CorsConfig
|
CORS configuration. |
rate_limit |
RateLimitConfig
|
Global three-tier rate limiting configuration (IP floor un-gated, unauthenticated by IP, authenticated by user ID). |
rate_limiter_enabled |
bool
|
Master kill switch for the three-tier
global rate limiter. Mirrors the
|
per_op_rate_limit |
PerOpRateLimitConfig
|
Per-operation throttling configuration (layered on top of the global three-tier limiter). |
per_op_concurrency |
PerOpConcurrencyConfig
|
Per-operation inflight concurrency capping (layered on top of the sliding-window per-op limiter; caps simultaneous long-running requests per operation per subject). |
server |
ServerConfig
|
Uvicorn server configuration. |
auth |
AuthConfig
|
Authentication configuration. |
api_prefix |
NotBlankStr
|
URL prefix for all API routes. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
cors(CorsConfig) -
rate_limit(RateLimitConfig) -
rate_limiter_enabled(bool) -
per_op_rate_limit(PerOpRateLimitConfig) -
per_op_concurrency(PerOpConcurrencyConfig) -
server(ServerConfig) -
auth(AuthConfig) -
api_prefix(NotBlankStr)
Validators:
-
_apply_mirrors
rate_limit
pydantic-field
¶
Global three-tier rate limiting configuration: un-gated IP floor, unauthenticated by IP, authenticated by user ID
rate_limiter_enabled
pydantic-field
¶
Master kill switch for the three-tier global rate limiter. Mirrors the api.rate_limiter_enabled registry entry (read_only_post_init=True): the boot-time resolver in api/app.py reads SYNTHORG_API_RATE_LIMITER_ENABLED and falls through to the registered default (env > code default per the Cat-2 precedence model).
per_op_rate_limit
pydantic-field
¶
Per-operation throttling (layered on the global limiter)
per_op_concurrency
pydantic-field
¶
Per-operation inflight concurrency capping (layered on the sliding-window per-op limiter; caps simultaneous long-running requests per (operation, subject))
DTOs¶
dto
¶
Request/response DTOs and envelope models.
Response envelopes wrap all API responses in a consistent structure. Request DTOs define write-operation payloads (separate from domain models because they omit server-generated fields).
ErrorDetail
pydantic-model
¶
Bases: BaseModel
Structured error metadata (RFC 9457).
Self-contained so agents can parse it without referencing the parent envelope.
Attributes:
| Name | Type | Description |
|---|---|---|
detail |
NotBlankStr
|
Human-readable occurrence-specific explanation. |
error_code |
ErrorCode
|
Machine-readable error code (by convention, 4-digit
category-grouped; see |
error_category |
ErrorCategory
|
High-level error category. |
retryable |
bool
|
Whether the client should retry the request. |
retry_after |
int | None
|
Seconds to wait before retrying ( |
instance |
NotBlankStr
|
Request correlation ID for log tracing. |
title |
NotBlankStr
|
Static per-category title (e.g. "Authentication Error"). |
type |
NotBlankStr
|
Documentation URI for the error category. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
detail(NotBlankStr) -
error_code(ErrorCode) -
error_category(ErrorCategory) -
retryable(bool) -
retry_after(int | None) -
instance(NotBlankStr) -
title(NotBlankStr) -
type(NotBlankStr)
Validators:
-
_validate_retry_after_consistency
retry_after
pydantic-field
¶
Seconds to wait before retrying (null when not applicable).
ProblemDetail
pydantic-model
¶
Bases: BaseModel
Bare RFC 9457 application/problem+json response body.
Returned when the client sends Accept: application/problem+json.
Attributes:
| Name | Type | Description |
|---|---|---|
type |
NotBlankStr
|
Documentation URI for the error category. |
title |
NotBlankStr
|
Static per-category title. |
status |
int
|
HTTP status code. |
detail |
NotBlankStr
|
Human-readable occurrence-specific explanation. |
instance |
NotBlankStr
|
Request correlation ID for log tracing. |
error_code |
ErrorCode
|
Machine-readable 4-digit error code. |
error_category |
ErrorCategory
|
High-level error category. |
retryable |
bool
|
Whether the client should retry the request. |
retry_after |
int | None
|
Seconds to wait before retrying ( |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
type(NotBlankStr) -
title(NotBlankStr) -
status(int) -
detail(NotBlankStr) -
instance(NotBlankStr) -
error_code(ErrorCode) -
error_category(ErrorCategory) -
retryable(bool) -
retry_after(int | None)
Validators:
-
_validate_retry_after_consistency
retry_after
pydantic-field
¶
Seconds to wait before retrying (null when not applicable).
ApiResponse
pydantic-model
¶
Bases: BaseModel
Standard API response envelope.
Attributes:
| Name | Type | Description |
|---|---|---|
data |
T | None
|
Response payload ( |
error |
str | None
|
Operator-facing error message ( |
error_detail |
ErrorDetail | None
|
Structured error metadata ( |
success |
bool
|
Whether the request succeeded (computed from |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
data(T | None) -
error(str | None) -
error_detail(ErrorDetail | None)
Validators:
-
_validate_error_detail_consistency
success
property
¶
Whether the request succeeded (derived from error).
Returns:
| Type | Description |
|---|---|
bool
|
|
PaginationMeta
pydantic-model
¶
Bases: BaseModel
Pagination metadata for list responses.
Cursor-based: clients receive an opaque next_cursor and walk
forward until has_more is False.
Attributes:
| Name | Type | Description |
|---|---|---|
limit |
int
|
Maximum items per page. |
next_cursor |
str | None
|
Opaque cursor for the next page ( |
has_more |
bool
|
Whether more items follow the current page. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
limit(int) -
next_cursor(str | None) -
has_more(bool)
Validators:
-
_validate_cursor_consistency
PaginatedResponse
pydantic-model
¶
Bases: BaseModel
Paginated API response envelope.
Attributes:
| Name | Type | Description |
|---|---|---|
data |
tuple[T, ...]
|
Page of items. |
error |
str | None
|
Error message ( |
error_detail |
ErrorDetail | None
|
Structured error metadata ( |
pagination |
PaginationMeta
|
Pagination metadata. |
degraded_sources |
tuple[NotBlankStr, ...]
|
Data sources that failed gracefully, resulting in partial data. Empty when all sources responded normally. |
success |
bool
|
Whether the request succeeded (computed from |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
data(tuple[T, ...]) -
error(str | None) -
error_detail(ErrorDetail | None) -
pagination(PaginationMeta) -
degraded_sources(tuple[NotBlankStr, ...])
Validators:
-
_validate_error_detail_consistency
CreateArtifactRequest
pydantic-model
¶
Bases: BaseModel
Payload for creating a new artifact.
Attributes:
| Name | Type | Description |
|---|---|---|
type |
ArtifactType
|
Artifact type (code, tests, documentation). |
path |
NotBlankStr
|
Logical file/directory path of the artifact. |
task_id |
NotBlankStr
|
ID of the originating task. |
created_by |
NotBlankStr
|
Agent ID of the creator. |
description |
str
|
Human-readable description. |
content_type |
str
|
MIME content type (empty if no content stored). |
project_id |
NotBlankStr | None
|
Optional project ID to link the artifact to. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
type(ArtifactType) -
path(NotBlankStr) -
task_id(NotBlankStr) -
created_by(NotBlankStr) -
description(str) -
content_type(str) -
project_id(NotBlankStr | None)
content_type
pydantic-field
¶
MIME type of the artifact content (empty when no content is stored).
CreateProjectRequest
pydantic-model
¶
Bases: BaseModel
Payload for creating a new project.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
NotBlankStr
|
Project display name. |
description |
str
|
Detailed project description. |
team |
tuple[NotBlankStr, ...]
|
Agent IDs assigned to the project. |
lead |
NotBlankStr | None
|
Agent ID of the project lead. |
deadline |
str | None
|
Optional deadline (ISO 8601 string). |
budget |
float
|
Total budget in base currency. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
name(NotBlankStr) -
description(str) -
team(tuple[NotBlankStr, ...]) -
lead(NotBlankStr | None) -
deadline(str | None) -
budget(float)
Validators:
-
_validate_request
CreateTaskRequest
pydantic-model
¶
Bases: BaseModel
Payload for creating a new task.
Attributes:
| Name | Type | Description |
|---|---|---|
title |
NotBlankStr
|
Short task title. |
description |
NotBlankStr
|
Detailed task description. |
type |
TaskType
|
Task work type. |
priority |
Priority
|
Task priority level. |
project |
NotBlankStr
|
Project ID. |
created_by |
NotBlankStr
|
Agent name of the creator. |
assigned_to |
NotBlankStr | None
|
Optional assignee agent ID. |
estimated_complexity |
Complexity
|
Complexity estimate. |
budget_limit |
float
|
Maximum spend in base currency. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
title(NotBlankStr) -
description(NotBlankStr) -
type(TaskType) -
priority(Priority) -
project(NotBlankStr) -
created_by(NotBlankStr) -
assigned_to(NotBlankStr | None) -
estimated_complexity(Complexity) -
budget_limit(float)
UpdateTaskRequest
pydantic-model
¶
Bases: BaseModel
Payload for updating task fields.
All fields are optional -- only provided fields are updated.
Attributes:
| Name | Type | Description |
|---|---|---|
title |
NotBlankStr | None
|
New title. |
description |
NotBlankStr | None
|
New description. |
priority |
Priority | None
|
New priority. |
assigned_to |
NotBlankStr | None
|
New assignee. |
budget_limit |
float | None
|
New budget limit. |
expected_version |
int | None
|
Optimistic concurrency guard. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
title(NotBlankStr | None) -
description(NotBlankStr | None) -
priority(Priority | None) -
assigned_to(NotBlankStr | None) -
budget_limit(float | None) -
expected_version(int | None)
TransitionTaskRequest
pydantic-model
¶
Bases: BaseModel
Payload for a task status transition.
Attributes:
| Name | Type | Description |
|---|---|---|
target_status |
TaskStatus
|
The desired target status. |
assigned_to |
NotBlankStr | None
|
Optional assignee override for the transition. |
expected_version |
int | None
|
Optimistic concurrency guard. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
target_status(TaskStatus) -
assigned_to(NotBlankStr | None) -
expected_version(int | None)
RegisterExperimentVariantRequest
pydantic-model
¶
Bases: BaseModel
Payload for registering an A/B experiment variant.
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
variant(NotBlankStr) -
weight(int) -
description(str)
AssignExperimentRequest
pydantic-model
¶
Bases: BaseModel
Payload for requesting a deterministic variant assignment.
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
ExecuteTaskRequest
pydantic-model
¶
Bases: BaseModel
Payload for the worker-callable POST /tasks/{id}/execute endpoint.
Mirrors the TaskClaim envelope fields the worker carries so the
backend's WorkerExecutionService has the same provenance the
dispatcher captured when it built the claim. The endpoint only
needs the status pair and the dedup key; the task body is read
server-side via the task repository.
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
TaskBoardSubmissionResponse
pydantic-model
¶
Bases: BaseModel
Acknowledgement envelope for POST /tasks (HTTP 202 Accepted).
The board hands the filing to the work-entry adapter; the adapter
drives the pipeline spine in a detached background coroutine. The
spine creates the task during its intake phase, so this response
carries the correlation id rather than a task id: the board UI
correlates the eventual task.created WS event by this id.
Attributes:
| Name | Type | Description |
|---|---|---|
correlation_id |
NotBlankStr
|
End-to-end trace id stamped onto the work item. |
title |
NotBlankStr
|
Title submitted by the user (echoed for UX confirmation). |
project |
NotBlankStr
|
Project the task was filed into. |
status |
Literal['submitted']
|
Always |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
correlation_id(NotBlankStr) -
title(NotBlankStr) -
project(NotBlankStr) -
status(Literal['submitted'])
CancelTaskRequest
pydantic-model
¶
Bases: BaseModel
Payload for cancelling a task.
Attributes:
| Name | Type | Description |
|---|---|---|
reason |
NotBlankStr
|
Reason for cancellation. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
CreateApprovalRequest
pydantic-model
¶
Bases: BaseModel
Payload for creating a new approval item.
Attributes:
| Name | Type | Description |
|---|---|---|
action_type |
NotBlankStr
|
Kind of action requiring approval
( |
title |
NotBlankStr
|
Short summary. |
description |
NotBlankStr
|
Detailed explanation. |
risk_level |
ApprovalRiskLevel
|
Assessed risk level. |
ttl_seconds |
int | None
|
Optional time-to-live in seconds (min 60, max 604 800 = 7 days). |
task_id |
NotBlankStr | None
|
Optional associated task. |
metadata |
dict[str, str]
|
Additional key-value pairs. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
action_type(NotBlankStr) -
title(NotBlankStr) -
description(NotBlankStr) -
risk_level(ApprovalRiskLevel) -
ttl_seconds(int | None) -
task_id(NotBlankStr | None) -
metadata(dict[str, str])
Validators:
-
_validate_action_type_format→action_type -
_validate_metadata_bounds
action_type
pydantic-field
¶
Kind of action requiring approval in category:action format.
description
pydantic-field
¶
Detailed explanation of the action and why it requires approval.
ttl_seconds
pydantic-field
¶
Optional time-to-live in seconds before the approval auto-expires (minimum 60, maximum 604800 = 7 days).
ApproveRequest
pydantic-model
¶
Bases: BaseModel
Payload for approving an approval item.
Attributes:
| Name | Type | Description |
|---|---|---|
comment |
NotBlankStr | None
|
Optional comment explaining the approval. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
comment(NotBlankStr | None)
RejectRequest
pydantic-model
¶
Bases: BaseModel
Payload for rejecting an approval item.
Attributes:
| Name | Type | Description |
|---|---|---|
reason |
NotBlankStr
|
Mandatory reason for rejection. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
reason(NotBlankStr)
CoordinateTaskRequest
pydantic-model
¶
Bases: BaseModel
Payload for triggering multi-agent coordination on a task.
Attributes:
| Name | Type | Description |
|---|---|---|
agent_names |
tuple[NotBlankStr, ...] | None
|
Agent names to coordinate with ( |
max_subtasks |
int
|
Maximum subtasks for decomposition. |
max_concurrency_per_wave |
int | None
|
Override for max concurrency per wave. |
fail_fast |
bool | None
|
Override for fail-fast behaviour ( |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
agent_names(tuple[NotBlankStr, ...] | None) -
max_subtasks(int) -
max_concurrency_per_wave(int | None) -
fail_fast(bool | None)
Validators:
-
_validate_unique_agent_names
CoordinationPhaseResponse
pydantic-model
¶
Bases: BaseModel
Response model for a single coordination phase.
Attributes:
| Name | Type | Description |
|---|---|---|
phase |
NotBlankStr
|
Phase name. |
success |
bool
|
Whether the phase completed successfully. |
duration_seconds |
float
|
Wall-clock duration of the phase. |
error |
NotBlankStr | None
|
Error description if the phase failed. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
phase(NotBlankStr) -
success(bool) -
duration_seconds(float) -
error(NotBlankStr | None)
Validators:
-
_validate_success_error_consistency
CoordinationResultResponse
pydantic-model
¶
Bases: BaseModel
Response model for a complete coordination run.
Attributes:
| Name | Type | Description |
|---|---|---|
parent_task_id |
NotBlankStr
|
ID of the parent task. |
topology |
NotBlankStr
|
Resolved coordination topology. |
total_duration_seconds |
float
|
Total wall-clock duration. |
total_cost |
float
|
Total cost across all waves. |
phases |
tuple[CoordinationPhaseResponse, ...]
|
Phase results in execution order. |
wave_count |
int
|
Number of execution waves. |
is_success |
bool
|
Whether all phases succeeded (computed). |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
parent_task_id(NotBlankStr) -
topology(NotBlankStr) -
total_duration_seconds(float) -
total_cost(float) -
currency(str) -
phases(tuple[CoordinationPhaseResponse, ...]) -
wave_count(int)
RollbackAgentIdentityRequest
pydantic-model
¶
Bases: BaseModel
Request body for rolling back an agent identity to a previous version.
Attributes:
| Name | Type | Description |
|---|---|---|
target_version |
int
|
Snapshot version number to restore content from (monotonic counter in the agent_identity_versions table). |
reason |
NotBlankStr | None
|
Optional human-readable justification recorded alongside the evolution event for audit purposes. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
target_version(int) -
reason(NotBlankStr | None)
Errors¶
The error taxonomy and exception classes live in
synthorg.core:
synthorg.core.error_taxonomy--ErrorCategory,ErrorCode, RFC 9457 helperssynthorg.core.domain_errors--DomainErrorbase + concrete subclasses (NotFoundError,ConflictError,ValidationError, ...)synthorg.core.persistence_errors--PersistenceErrorhierarchy
Guards¶
guards
¶
Route guards for access control.
Guards read the authenticated user identity from connection.user
(populated by the auth middleware) and check role-based permissions.
The require_roles factory creates guards for arbitrary role sets.
Pre-built constants cover common patterns::
require_ceo -- CEO only
require_ceo_or_manager -- CEO or Manager
require_approval_roles -- CEO, Manager, or Board Member
require_ceo_or_manager
module-attribute
¶
require_ceo_or_manager = require_roles(CEO, MANAGER)
Guard allowing CEO or Manager roles.
require_approval_roles
module-attribute
¶
require_approval_roles = require_roles(CEO, MANAGER, BOARD_MEMBER)
Guard allowing roles that can approve or reject actions.
has_write_role
¶
Return True if the role grants write access.
Use this for inline role checks instead of importing _WRITE_ROLES
directly. The write set includes CEO, Manager, and Pair Programmer.
Returns:
| Type | Description |
|---|---|
bool
|
|
Source code in src/synthorg/api/guards.py
require_write_access
¶
Guard that allows only write-capable human roles.
Checks connection.user.role for ceo, manager,
or pair_programmer. Board members are excluded (they
may only observe and approve). The system role is
intentionally excluded -- use require_roles() with the
desired roles for endpoints the CLI needs to reach.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
connection
|
ASGIConnection
|
The incoming connection. |
required |
_
|
object
|
Route handler (unused). |
required |
Raises:
| Type | Description |
|---|---|
PermissionDeniedException
|
If the role is not permitted. |
Source code in src/synthorg/api/guards.py
require_read_access
¶
Guard that allows all human roles (excludes SYSTEM).
Checks connection.user.role for any human role
including observer and board_member. The internal
system role is excluded -- use require_roles() for
endpoints the CLI needs to reach.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
connection
|
ASGIConnection
|
The incoming connection. |
required |
_
|
object
|
Route handler (unused). |
required |
Raises:
| Type | Description |
|---|---|
PermissionDeniedException
|
If the role is not permitted. |
Source code in src/synthorg/api/guards.py
require_roles
¶
Create a guard that allows only the specified roles.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*roles
|
HumanRole
|
One or more |
()
|
Returns:
| Type | Description |
|---|---|
Callable[[ASGIConnection, object], None]
|
A guard function compatible with Litestar's guard protocol. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If no roles are provided. |
Source code in src/synthorg/api/guards.py
require_org_mutation
¶
Guard factory for org config mutations.
Access is granted if the user has one of:
OrgRole.OWNER-- always allowedOrgRole.EDITOR-- always allowedOrgRole.DEPARTMENT_ADMIN-- allowed only when the target department (read from the path parameter named department_param) is in the user'sscoped_departments
If the user has no org_roles (empty tuple), falls back to
the existing HumanRole write-access check so legacy
installations without organisation-level roles still resolve.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
department_param
|
str | None
|
Path parameter name containing the target
department (e.g. |
None
|
Returns:
| Type | Description |
|---|---|
Callable[[ASGIConnection, object], None]
|
A guard function compatible with Litestar's guard protocol. |
Raises:
| Type | Description |
|---|---|
PermissionDeniedException
|
Raised on the corresponding failure path. |
Source code in src/synthorg/api/guards.py
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 | |
Middleware¶
middleware
¶
Request middleware and before-send hooks.
Provides ASGI middleware for request logging, and a before_send
hook that injects security headers (CSP, CORP, HSTS, Cache-Control,
etc.) into every HTTP response -- including exception-handler and
unmatched-route (404/405) responses.
Why before_send instead of ASGI middleware?
Litestar's before_send hook wraps the ASGI send callback at
the outermost layer (before the middleware stack), so it fires for
all responses. By contrast, user-defined ASGI middleware only runs
for matched routes -- 404 and 405 responses from the router bypass it.
RequestLoggingMiddleware
¶
ASGI middleware that logs request start and completion.
Uses time.perf_counter() for high-resolution duration
measurement. Only logs HTTP requests (non-HTTP scopes like
WebSocket and lifespan are passed through without logging).
Each HTTP request is also wrapped in an OpenTelemetry span
(http.request) carrying OTel-semconv attributes
(http.request.method, http.route,
http.response.status_code) plus the synthorg.correlation_id
so distributed traces line up with the structured-log stream. When
no tracer provider is configured (default), get_tracer returns
a no-op tracer and the span is essentially free.
Source code in src/synthorg/api/middleware.py
__call__
async
¶
Process an ASGI request, logging start and completion.
Source code in src/synthorg/api/middleware.py
404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 | |
build_docs_csp
¶
Build the relaxed Scalar UI CSP from a list of trusted origins.
Origins are applied uniformly to script-src, style-src,
img-src, font-src and connect-src so operators can
swap the public Scalar hosts for an internally-mirrored CDN with
a single configuration change.
An empty origins list raises ValueError rather than emit a
malformed CSP with trailing whitespace before each ;. CSP
parsers tolerate the trailing space but operators reading the
header back would see an obviously broken policy; the
ApiBridgeConfig validator is the right place to enforce
non-empty (currently only validates pattern), so callers pass
through the bridge-config-validated tuple.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
origins
|
Sequence[str]
|
Origin URLs that Scalar UI assets and proxy requests
may target. Must be non-empty. Each entry must already be
a valid origin (scheme + host); |
required |
Returns:
| Type | Description |
|---|---|
str
|
A CSP header value safe to assign to |
str
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If origins is empty. |
Source code in src/synthorg/api/middleware.py
set_docs_csp_origins
¶
Replace the docs CSP value with one built from origins.
Called once at app startup after resolving
api.csp_docs_external_origins through the settings service.
Reset to the default list with _DOCS_CSP_DEFAULT_ORIGINS for
test isolation.
Calling this outside startup creates a brief eventual-consistency
window for in-flight HTTP responses, since the docs before_send
hook reads the global at request time. The
api.csp_docs_external_origins setting is marked
restart_required=True precisely to keep this single-writer.
Source code in src/synthorg/api/middleware.py
security_headers_hook
async
¶
Inject security headers into every HTTP response.
Registered as a Litestar before_send hook so it fires for
all HTTP responses -- successful, exception-handler, and
router-level 404/405.
Adds static security headers (CORP, HSTS, X-Content-Type-Options,
etc.) and path-aware Content-Security-Policy (strict for API,
relaxed for /docs/ to allow Scalar UI resources) and
Cache-Control (no-store for API, public, max-age=300
for /docs/ since it serves public, non-user-specific content).
Uses __setitem__ (not add) so that if any handler or
middleware already set a header, the known-good value overwrites
it rather than creating a duplicate.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Message
|
ASGI message dict (only |
required |
scope
|
Scope
|
ASGI connection scope. |
required |
Source code in src/synthorg/api/middleware.py
Pagination¶
pagination
¶
Cursor-based pagination helpers.
In-memory helper :func:paginate_cursor slices a tuple and produces a
signed cursor so controllers backed by in-memory collections (config
lists, bus channel names, approval-store filtered views) can return
the same envelope shape as repo-backed endpoints.
The cursor layer is opaque offset encoding today. Repositories that
need seek-based paging (append-only tables) decode the opaque cursor
into a composite (created_at, id) seek tuple internally -- the
wire format stays the same.
CursorLimit
module-attribute
¶
CursorLimit = Annotated[
int,
QueryParameter(
ge=1,
le=MAX_LIMIT,
description=f"Page size (default {DEFAULT_LIMIT}, max {MAX_LIMIT})",
),
]
Query-parameter type for the page size (1-MAX_LIMIT).
HTTP-boundary only: the bounds are enforced by Litestar's
QueryParameter metadata at request parsing. Do not reuse this
alias for in-process validation, where the constraint would silently
not apply.
CursorParam
module-attribute
¶
CursorParam = Annotated[
str | None,
QueryParameter(
max_length=512,
description="Opaque pagination cursor returned by the previous page",
),
]
Query-parameter type for the opaque cursor (max 512 chars).
HTTP-boundary only: the max_length is enforced by Litestar's
QueryParameter metadata at request parsing, not by the type
itself. Do not reuse this alias for in-process validation.
InvalidCursorError
¶
Bases: ValidationError
Raised when a cursor token is malformed, tampered, or unsigned.
Renders as HTTP 422 Unprocessable Entity with a structured
ErrorDetail (error_category=validation,
error_code=VALIDATION_ERROR) via the centralised RFC 9457
dispatch.
Source code in src/synthorg/core/domain_errors.py
cursor_secret_of
¶
Return the wired pagination cursor secret, or raise 503.
The opaque-pagination HMAC secret lives on the api-core state slice
(wired once by create_app). Pagination call sites read it
through this accessor and pass it as secret= to the cursor
encode / decode helpers, so the slice lookup is centralised here
rather than repeated at every controller.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
app_state
|
AppStateSliceMixin
|
The application state (any slice-reader). |
required |
Returns:
| Type | Description |
|---|---|
CursorSecret
|
The wired cursor secret. |
Raises:
| Type | Description |
|---|---|
ServiceUnavailableError
|
When the secret is not yet wired. |
Source code in src/synthorg/api/pagination.py
paginate_cursor
¶
Slice a tuple and produce cursor-based pagination metadata.
Clamps limit to [1, MAX_LIMIT]. A missing cursor starts at
offset 0. Invalid / tampered cursors raise :class:InvalidCursorError
which controllers should surface as HTTP 400.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
items
|
tuple[T, ...]
|
Full collection to paginate (must be already ordered). |
required |
limit
|
int
|
Maximum items to return on this page. |
required |
cursor
|
str | None
|
Opaque cursor from the previous page, or |
required |
secret
|
CursorSecret
|
HMAC secret used to sign / verify cursors. |
required |
Returns:
| Type | Description |
|---|---|
tuple[tuple[T, ...], PaginationMeta]
|
Tuple of (page_items, pagination_meta). |
Raises:
| Type | Description |
|---|---|
InvalidCursorError
|
If |
Source code in src/synthorg/api/pagination.py
encode_repo_seek_meta
¶
Build PaginationMeta for controllers that push limit+offset into the repo.
Centralizes the has_more snapshot-drift guard so the next
pagination bug cannot regress across every version-history
controller one at a time. An empty or short page (page_len ==
0 or offset + page_len == offset) cannot advance the cursor
past the current offset, so the guard refuses to emit a cursor
that would loop the client on the same page when
count_versions disagrees with list_versions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
offset
|
int
|
The decoded cursor offset the current page started at. |
required |
page_len
|
int
|
The number of repo rows consumed ( |
required |
total
|
int
|
The repo's reported total row count. Drives the
|
required |
limit
|
int
|
The page size requested. |
required |
secret
|
CursorSecret
|
HMAC secret used to sign the |
required |
reject_stale_cursor
|
bool
|
When |
True
|
Returns:
| Type | Description |
|---|---|
PaginationMeta
|
|
PaginationMeta
|
fields filled in, safe to wrap in |
Raises:
| Type | Description |
|---|---|
InvalidCursorError
|
When the cursor's decoded offset is past
the repo end. |
Source code in src/synthorg/api/pagination.py
170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | |
encode_countless_seek_meta
¶
Build PaginationMeta for repos that skip the COUNT(*) round-trip.
Counterpart to :func:encode_repo_seek_meta for endpoints that
use the fetch limit+1, detect overflow pattern instead of
issuing a separate count query. The caller fetches up to
limit + 1 rows from the backing store; this helper uses the
overflow to drive has_more and ensures PaginationMeta.total
stays None so clients know the count is unknown (and must
derive display counts from data.length per the frontend
contract in web/CLAUDE.md).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
offset
|
int
|
The decoded cursor offset the current page started at. |
required |
fetched_rows
|
int
|
The number of rows the repo returned when asked
for |
required |
limit
|
int
|
The page size requested. |
required |
secret
|
CursorSecret
|
HMAC secret used to sign the |
required |
Returns:
| Type | Description |
|---|---|
PaginationMeta
|
|
PaginationMeta
|
|
Raises:
| Type | Description |
|---|---|
InvalidCursorError
|
When |
Source code in src/synthorg/api/pagination.py
encode_keyset_meta
¶
Build PaginationMeta for a keyset-paginated read.
Keyset pagination is stable under concurrent inserts and deletes:
the cursor encodes the sort key of the last row returned, and the
next page reads WHERE sort_key > after_key. Out-of-bounds
cursors degrade gracefully -- a cursor pointing past the current
end of the collection just returns an empty page (rather than the
offset-pagination InvalidCursorError for offset > total)
because keyset reads cannot tell whether a cursor is "stale" or
just pointing at a row that has been deleted.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
next_after_key
|
str | None
|
Sort key of the last row on the page that was
just returned, or |
required |
has_more
|
bool
|
Whether the caller observed an overflow row when
fetching |
required |
limit
|
int
|
Page size requested. |
required |
secret
|
CursorSecret
|
HMAC secret used to sign the |
required |
Returns:
| Type | Description |
|---|---|
PaginationMeta
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/synthorg/api/pagination.py
WebSocket Models¶
ws_models
¶
WebSocket event models for real-time feeds.
Defines event types and the WsEvent payload that is
serialised to JSON and pushed to WebSocket subscribers.
WsEventType
¶
Bases: StrEnum
Types of real-time WebSocket events.
WsEvent
pydantic-model
¶
Bases: BaseModel
A real-time event pushed over WebSocket.
Callers must not mutate the payload dict after construction;
the dict is a mutable reference inside a frozen model.
Attributes:
| Name | Type | Description |
|---|---|---|
version |
int
|
Wire-protocol version. Clients MUST ignore events whose
version they do not understand. Bump only when introducing a
breaking change to |
event_type |
WsEventType
|
Classification of the event. |
channel |
NotBlankStr
|
Target channel name. |
timestamp |
AwareDatetime
|
When the event occurred. |
payload |
dict[str, object]
|
Event-specific data. |
Config:
frozen:Trueallow_inf_nan:Falseextra:forbid
Fields:
-
version(int) -
event_type(WsEventType) -
channel(NotBlankStr) -
timestamp(AwareDatetime) -
payload(dict[str, object])
Validators:
-
_deep_copy_payload -
_validate_payload_shape
version
pydantic-field
¶
WS wire-protocol version (clients ignore unknown)
Auth¶
The auth domain types (AuthConfig, User, ApiKey,
AuthenticatedUser, OrgRole, HumanRole, Session,
RefreshRecord) live under
synthorg.core.auth; the HTTP-coupled
service, middleware, and request-scoped user binding live in
synthorg.api.auth.
AuthContextMiddleware (in synthorg.api.auth.context) runs
immediately after ApiAuthMiddleware and binds the authenticated
user into a per-asyncio-Task ContextVar, so controllers and
audit helpers read the user via no-argument accessors
(get_authenticated_user_id, get_authenticated_user,
audit_actor_from_context) without threading a Request.
service
¶
Authentication service -- password hashing, JWT ops, API key hashing.
SecretNotConfiguredError
¶
RefreshRotation
pydantic-model
¶
Bases: BaseModel
Result of a successful refresh-token rotation.
The controller turns this into the session/csrf/refresh cookies
and emits the post-persistence SECURITY_AUTH_REFRESH_CONSUMED
audit event. session_id is the original session id (the
access token rotated in place), not a freshly minted one.
Config:
frozen:Trueextra:forbid
Fields:
-
token(str) -
expires_in(int) -
session_id(str) -
user(User)
AuthService
¶
Immutable authentication operations.
Owns the cryptographic primitives behind login: Argon2id password hashing and verification, JWT mint and decode, HMAC-SHA256 API key hashing, secure API key generation, and refresh-token persistence through the auth-domain boundary.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
AuthConfig
|
Authentication configuration (carries JWT secret). |
required |
Async vs sync. Methods follow a single rule: an operation is
declared async only when it touches an event-loop boundary --
either offloading CPU-bound work via :func:asyncio.to_thread,
or awaiting a repository write. Everything else stays sync.
- :meth:
hash_password_asyncand :meth:verify_password_asyncare async because Argon2id is CPU-bound (3 time-cost iterations over 64MiB of memory by default); :func:asyncio.to_threadkeeps a single login from stalling every concurrent request waiting on the loop. - :meth:
persist_refresh_tokenis async because it awaits a repository write through the auth-domain boundary. - :meth:
create_token, :meth:decode_token, :meth:hash_api_key, and :meth:generate_api_keyare sync: each is either pure CPU with bounded sub-millisecond cost (HMAC,secrets.token_urlsafe) or an in-process JWT codec call with no I/O.
Thread-safety. Instances are safe to share across the
request-handler pool without external locking. After
:meth:__init__, the only state held is _config: AuthConfig
-- itself a Pydantic frozen=True model. The module-global
:class:argon2.PasswordHasher is configured once at import and
treated as a deployment-wide concern (Argon2 parameter selection
is not per-request); the underlying argon2 and jwt
libraries are stateless and thread-safe.
Out of scope. This service does not implement token
revocation (the auth middleware enforces that by checking
pwd_sig on every request), session storage (handled by the
refresh-token repository), or SYSTEM-role token minting
(rejected by :meth:create_token; SYSTEM tokens are minted by
the Go CLI with :data:SYSTEM_ISSUER / :data:SYSTEM_AUDIENCE).
Source code in src/synthorg/api/auth/service.py
hash_password_async
async
¶
Hash a password with Argon2id off the event loop.
Argon2id is CPU-bound; asyncio.to_thread defers the work
to the default thread pool so a single login request cannot
stall every concurrent request waiting on the loop.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
password
|
str
|
Plaintext password. |
required |
Returns:
| Type | Description |
|---|---|
str
|
Argon2id hash string. |
Source code in src/synthorg/api/auth/service.py
verify_password_async
async
¶
Verify a password against an Argon2id hash off the event loop.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
password
|
str
|
Plaintext password to check. |
required |
password_hash
|
str
|
Stored Argon2id hash. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
Raises:
| Type | Description |
|---|---|
VerificationError
|
On non-mismatch verification failures (e.g. unsupported parameters). |
InvalidHashError
|
If the stored hash is corrupted or malformed (data integrity issue). |
Source code in src/synthorg/api/auth/service.py
create_token
¶
Create a JWT for the given human user.
The token includes a pwd_sig claim -- a 16-character
truncated SHA-256 of the stored password hash. This is
plain SHA-256, not HMAC -- the password hash is already a
high-entropy Argon2id output, and the claim is protected
by the JWT signature. The auth middleware validates this
claim on every request so that tokens issued before a
password change are automatically rejected.
A jti (JWT ID) claim is included for per-token session
tracking and revocation.
SYSTEM-role tokens are minted by the Go CLI with
:data:SYSTEM_ISSUER / :data:SYSTEM_AUDIENCE -- never by
this method. Calling create_token with a SYSTEM user
would mint a token bearing :data:USER_ISSUER /
:data:USER_AUDIENCE, which the middleware's
_resolve_jwt_user immediately rejects (per-role iss/aud
enforcement). We fail-fast with ValueError here so a
future caller that accidentally passes a SYSTEM user
surfaces the problem at mint time, not at the next request.
The claim shape is built through :class:JwtClaims so the
encode-side payload is statically typed and the decode-side
boundary helper validates against the same model.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user
|
User
|
Authenticated human user. |
required |
session_id
|
str | None
|
Reuse this session id ( |
None
|
Returns:
| Type | Description |
|---|---|
tuple[str, int, str]
|
Tuple of (encoded JWT, expiry seconds, session ID). |
Raises:
| Type | Description |
|---|---|
SecretNotConfiguredError
|
If the JWT secret is empty. |
ValueError
|
If user has the SYSTEM role -- mint via the CLI's system-token path instead. |
Source code in src/synthorg/api/auth/service.py
206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 | |
decode_token
¶
Decode and validate a JWT into a typed claim set.
Issuer (iss) and audience (aud) verification is
intentionally deferred to the auth middleware's
_resolve_jwt_user: the canonical pair differs by role
(synthorg-cli / synthorg-backend for CLI-minted
SYSTEM tokens vs. synthorg-api / synthorg-api for
API-minted user tokens), and the middleware loads the user
record before deciding which pair to enforce. Both claims are
require-listed here so a missing claim fails decode rather
than reaching the middleware as None.
After PyJWT validates the signature and required claims, the
raw payload is routed through
:func:synthorg.api.boundary.parse_typed so a malformed claim
set (extra keys, type mismatch, iat >= exp) is rejected at
the boundary with a structured api.boundary.validation_failed
log instead of slipping through and surprising a downstream
attribute access.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
token
|
str
|
Encoded JWT string. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
Validated |
JwtClaims
|
class: |
Raises:
| Type | Description |
|---|---|
SecretNotConfiguredError
|
If the JWT secret is empty. |
InvalidTokenError
|
If the token signature, expiry, or required claim set is invalid. |
ValidationError
|
If the decoded claim set does not
conform to :class: |
Source code in src/synthorg/api/auth/service.py
persist_refresh_token
async
¶
Persist a refresh token through the auth-domain boundary.
Centralises the refresh-store write + audit log so callers
(notably make_session_cookies) do not reach into
app_state._refresh_store directly. The repo handle is
passed in rather than held by the service so this stays
compatible with the existing AuthService construction (no
constructor change required).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
store
|
object
|
The :class: |
required |
token_hash
|
str
|
HMAC-SHA256 hex digest of the raw refresh token. |
required |
session_id
|
str
|
Session identifier. |
required |
user_id
|
str
|
User identifier. |
required |
expires_at
|
datetime
|
Refresh token expiry (UTC). |
required |
Raises:
| Type | Description |
|---|---|
QueryError
|
If the underlying repo write fails. |
Source code in src/synthorg/api/auth/service.py
rotate_refresh_token
async
¶
Single-use refresh rotation: consume, validate, re-mint.
The reject matrix lives here (not the controller) so it is
unit-testable without the full app: a missing / replayed /
expired refresh token or a revoked session emits
SECURITY_AUTH_REFRESH_REJECTED (typed reason) and raises
:class:RefreshTokenInvalidError (HTTP 401, code 1005). The
success path re-mints the access token within the consumed
record's session so rotation does not orphan the session or
saturate max_concurrent_sessions.
SECURITY_AUTH_REFRESH_CONSUMED is emitted by the caller
AFTER the rotated refresh row is persisted (state-transition
events log after the write), so it is intentionally not
emitted here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw_refresh_token
|
str
|
The opaque refresh cookie value. |
required |
refresh_store
|
RefreshTokenRepository
|
Repository providing single-use
|
required |
users
|
UserRepository
|
User repository for the post-consume owner lookup. |
required |
is_session_revoked
|
Callable[[str], bool] | None
|
Predicate passed into |
required |
Returns:
| Name | Type | Description |
|---|---|---|
A |
RefreshRotation
|
class: |
RefreshRotation
|
the preserved session id. |
Raises:
| Type | Description |
|---|---|
RefreshTokenInvalidError
|
For any reject path (missing cookie, consume rejection, or owner deleted between issuance and rotation). |
Source code in src/synthorg/api/auth/service.py
379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 | |
hash_api_key
¶
Compute HMAC-SHA256 hex digest of a raw API key.
Uses the server-side JWT secret as the HMAC key so that an attacker with read access to stored hashes cannot brute-force API keys offline.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw_key
|
str
|
The plaintext API key. |
required |
Returns:
| Type | Description |
|---|---|
str
|
Lowercase hex digest. |
Raises:
| Type | Description |
|---|---|
SecretNotConfiguredError
|
If the JWT secret is empty. |
Source code in src/synthorg/api/auth/service.py
generate_api_key
staticmethod
¶
Generate a cryptographically secure API key.
Returns:
| Type | Description |
|---|---|
str
|
URL-safe base64 string sized by |
str
|
(default 32 bytes / 43 base64 chars). |
Source code in src/synthorg/api/auth/service.py
middleware
¶
JWT + API key authentication middleware.
ApiAuthMiddleware
¶
Bases: AbstractAuthenticationMiddleware
Authenticate requests via cookie, JWT header, or API key.
Authentication priority:
- Session cookie: HttpOnly cookie set by login/setup. Primary auth path for browser sessions.
- Authorization header:
Bearer <token>. Tokens with dots are JWTs (system user CLI tokens). Tokens without dots are API keys (HMAC-SHA256 lookup).
Requires auth_service, persistence backend on
app.state["app_state"].
authenticate_request
async
¶
Validate the session cookie or Authorization header.
Tries the session cookie first. Falls back to the Authorization header for API keys and system user JWTs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
connection
|
ASGIConnection[Any, Any, Any, Any]
|
Incoming ASGI connection. |
required |
Returns:
| Type | Description |
|---|---|
AuthenticationResult
|
AuthenticationResult with AuthenticatedUser. |
Raises:
| Type | Description |
|---|---|
NotAuthorizedException
|
If authentication fails. |
Source code in src/synthorg/api/auth/middleware.py
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | |
create_auth_middleware_class
¶
Create a middleware class with excluded paths baked in.
Litestar's AbstractAuthenticationMiddleware.__init__ takes
exclude as a parameter (default None). We create a
subclass whose __init__ forwards the configured exclude
list to super().__init__.
The middleware is restricted to ScopeType.HTTP only;
WebSocket connections use ticket-based auth handled entirely
inside the WS handler (see controllers/ws.py).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
auth_config
|
AuthConfig
|
Auth configuration with exclude_paths. |
required |
Returns:
| Type | Description |
|---|---|
type[ApiAuthMiddleware]
|
Middleware class ready for use in the Litestar middleware stack. |
Source code in src/synthorg/api/auth/middleware.py
context
¶
Request-scoped binding for the authenticated user.
The auth middleware (:class:synthorg.api.auth.middleware.ApiAuthMiddleware)
populates connection.scope["user"] with an
:class:~synthorg.core.auth.models.AuthenticatedUser after authentication.
:class:AuthContextMiddleware runs immediately after auth and binds that
user into the per-:class:asyncio.Task :class:~contextvars.ContextVar
defined here. Controllers and request-coupled helpers then read the
authenticated user via :func:get_authenticated_user_id /
:func:get_authenticated_user without threading a Request argument.
Reading the var while no user is bound raises
:class:AuthContextMissingError (a 500): this surfaces middleware
misconfiguration loudly instead of masking it as "api".
WebSocket scopes use ticket-based authentication
(synthorg.api.controllers.ws) and are not handled by this module;
:class:AuthContextMiddleware is restricted to HTTP scopes.
AuthContextMissingError
¶
Bases: DomainError
Read attempted on the auth ContextVar with no user bound.
Surfacing this as a 500 is intentional: the auth middleware runs
before any controller, so by the time a controller (or helper
invoked from one) calls :func:get_authenticated_user_id the var
must be set. An unset read is therefore a server bug --
exclude_paths misconfiguration, a helper invoked outside the
request lifecycle, or :class:AuthContextMiddleware missing from
the middleware stack -- not a client error.
Source code in src/synthorg/core/domain_errors.py
AuthContextMiddleware
¶
Bases: ASGIMiddleware
Bind scope["user"] into the per-task ContextVar.
Runs immediately after :class:~synthorg.api.auth.middleware.ApiAuthMiddleware
so authenticated handlers, downstream middleware, and helpers can
read the user via :func:get_authenticated_user_id without
threading a Request. Excluded paths (where ApiAuthMiddleware
skipped) leave the var at its default None; helpers reading it
raise :class:AuthContextMissingError, which is the desired
behaviour for endpoints that should never have reached a
user-coupled helper without authentication.
HTTP-only: WebSocket scopes use ticket-based authentication and are
bypassed by the scopes filter on the base class.
handle
async
¶
Bind scope["user"] for the duration of the inner dispatch.
Source code in src/synthorg/api/auth/context.py
get_authenticated_user
¶
Return the user bound to the active request's ContextVar.
Raises:
| Type | Description |
|---|---|
AuthContextMissingError
|
When called outside an authenticated request scope. |
Returns:
| Type | Description |
|---|---|
AuthenticatedUser
|
|
Source code in src/synthorg/api/auth/context.py
get_authenticated_user_id
¶
Return the user_id of the user bound to the current request.
Raises:
| Type | Description |
|---|---|
AuthContextMissingError
|
When called outside an authenticated request scope. |
Returns:
| Type | Description |
|---|---|
str
|
Resulting string. |
Source code in src/synthorg/api/auth/context.py
authenticated_user_scope
async
¶
Bind user to the auth ContextVar for the duration of the block.
Production binding is performed by :class:AuthContextMiddleware.
This helper exists for tests, background tasks, and any caller that
needs to invoke a request-coupled helper outside the HTTP request
path. Mirrors :func:synthorg.providers.cost_recording.cost_recording_scope
-- token-based reset for exception safety, restoring whatever was
active before.
Example (background task that calls a request-coupled helper)::
async def _background_audit(user: AuthenticatedUser) -> None:
async with authenticated_user_scope(user):
# audit_actor_from_context() now returns this user's
# ProviderAuditActor without raising.
actor = audit_actor_from_context()
...