Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,11 @@ an internal-only Container App (ADR 13/15: sessions and rate limits only, never
- Postgres allows public access from any IP (`--public-access 0.0.0.0-255.255.255.255`) rather than
VNet-integrated private access.
- Three domains are still mocked client-side, honestly labelled "Simulated" in the UI: tax lots,
the admin customer directory/detail/KYC-override, and statement export — S8's `/lots`,
`/admin/customers*`, and `/admin/kyc-overrides/<id>` routes don't exist yet
(`docs/superpowers/specs/2026-09-05-frontend-completion-design.md` has the full design for
closing this).
the admin customer directory/detail/KYC-override, and statement export. The backend has since
caught up — `/lots`, `/admin/customers*`, `/admin/kyc-overrides/<id>`, and
`/statements/<period>/export` are all real, registered, tested routes — the frontend just
hasn't been wired to them yet (`docs/superpowers/specs/2026-09-05-frontend-completion-design.md`
has the full design for closing this).
- No real-time SSE push (ADR 20/S12 §6) — every screen refetches on mount instead, the documented
fallback.
- No new automated test coverage was added for this pass (explicit scope cut — ship fast, harden
Expand Down
1 change: 1 addition & 0 deletions backend/app/controllers/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ def session_info() -> Any:


@auth_bp.route("/logout", methods=["POST"])
@limiter.limit("60 per minute")
def logout() -> Any:
logout_user()
flask_session.clear()
Expand Down
12 changes: 8 additions & 4 deletions backend/app/controllers/api/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ def _to_order_response(uow: OrdersUnitOfWork, order: Order) -> OrderResponse:
@orders_bp.route("", methods=["POST"])
@limiter.limit("30 per minute")
def create_order() -> Any:
if not current_user.is_authenticated or current_user.role != "customer":
if not current_user.is_authenticated:
raise UnauthenticatedError("Authentication required")
if current_user.role != "customer":
raise ForbiddenError("Only a customer may place an order")

idempotency_key = request.headers.get("Idempotency-Key")
Expand Down Expand Up @@ -188,7 +190,9 @@ def create_order() -> Any:
@orders_bp.route("/<uuid:order_id>/approve", methods=["POST"])
@limiter.limit("30 per minute")
def approve_order(order_id: uuid.UUID) -> Any:
if not current_user.is_authenticated or current_user.role != "customer":
if not current_user.is_authenticated:
raise UnauthenticatedError("Authentication required")
if current_user.role != "customer":
raise ForbiddenError("Only the owning customer may approve an order")

customer_id = _resolve_customer_id()
Expand All @@ -215,7 +219,7 @@ def approve_order(order_id: uuid.UUID) -> Any:
@limiter.limit("60 per minute")
def list_orders() -> Any:
if not current_user.is_authenticated:
raise ForbiddenError("Authentication required")
raise UnauthenticatedError("Authentication required")
if current_user.role not in ("customer", *_STAFF_ROLES):
raise ForbiddenError(f"role {current_user.role} not authorized for this endpoint")

Expand All @@ -233,7 +237,7 @@ def list_orders() -> Any:
@limiter.limit("60 per minute")
def get_order(order_id: uuid.UUID) -> Any:
if not current_user.is_authenticated:
raise ForbiddenError("Authentication required")
raise UnauthenticatedError("Authentication required")
if current_user.role not in ("customer", *_STAFF_ROLES):
raise ForbiddenError(f"role {current_user.role} not authorized for this endpoint")

Expand Down
7 changes: 6 additions & 1 deletion backend/app/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,12 @@ class ConflictError(AppError):


class RateLimitedError(AppError):
"""The caller has exceeded a configured rate limit (429)."""
"""An application-level rate limit (429) -- e.g. `chat.py`'s daily query cap, always raised
with its own `code=` override. flask-limiter's own per-route limits (`@limiter.limit(...)`)
never reach here: those 429s come from flask-limiter's werkzeug exception instead, caught by
the generic `HTTPException` handler in `app/__init__.py`, which derives its own `code` from
the exception name (`too_many_requests`) -- so the bare `code` below is never what a
flask-limiter throttle actually puts on the wire."""

status = 429
title = "Too Many Requests"
Expand Down
11 changes: 10 additions & 1 deletion backend/tests/api/test_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,4 +452,13 @@ def test_get_order_rejects_another_customers_order(

def test_list_orders_with_no_session_is_rejected(api_client: FlaskClient) -> None:
response = api_client.get("/api/v1/orders")
assert response.status_code == 403
# 401, not 403: no session is an authentication failure, not an authorization one -- a
# frontend that keys "session expired -> re-login" off 401 must actually see it here.
assert response.status_code == 401
assert response.get_json()["code"] == "unauthenticated"


def test_get_order_with_no_session_is_rejected(api_client: FlaskClient) -> None:
response = api_client.get(f"/api/v1/orders/{uuid.uuid4()}")
assert response.status_code == 401
assert response.get_json()["code"] == "unauthenticated"
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// MOCK — no backend endpoint exists yet (S8). Replace with a real fetch call once that spec ships.
// MOCK -- the real backend routes (GET /admin/customers*, POST /admin/kyc-overrides/<id>) exist
// and are tested (backend/app/controllers/admin/), the frontend just hasn't been wired to them
// yet. See docs/superpowers/specs/2026-09-05-frontend-completion-design.md.
import { mockClient } from '../../../services/mockClient.js'

const NAMESPACE = 'admin-customers'
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/features/lots/api/lotsApi.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// MOCK — no backend endpoint exists yet (S5). Replace with a real fetch call once that spec ships.
// MOCK -- the real backend route (GET /lots) exists and is tested
// (backend/app/controllers/api/lots.py), the frontend just hasn't been wired to it yet. See
// docs/superpowers/specs/2026-09-05-frontend-completion-design.md.
import { mockClient } from '../../../services/mockClient.js'

const NAMESPACE = 'lots'
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/features/statements/api/statementsApi.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { apiClient } from '../../../services/apiClient.js'

// `export` isn't wired here yet — no export-as-file endpoint exists (Phase 4 mock domain).
// `export` isn't wired here yet -- the real backend route (GET /statements/<period>/export)
// exists and is tested (backend/app/controllers/api/statements.py), the frontend just hasn't
// been pointed at it. See docs/superpowers/specs/2026-09-05-frontend-completion-design.md.
export const statementsApi = {
list: () => apiClient.get('/statements'),
get: (periodStart, publishWatermark) => {
Expand Down
Loading