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
8 changes: 7 additions & 1 deletion .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:

- name: Lint
working-directory: backend
run: ruff check app tests
run: ruff check app tests scripts

- name: Docstring coverage
working-directory: backend
Expand All @@ -54,3 +54,9 @@ jobs:
- name: Test
working-directory: backend
run: pytest -q

- name: Week 1 real API e2e
working-directory: backend
env:
WORKSTREAM_DATABASE_URL: postgresql+asyncpg://workstream:workstream@localhost:5433/workstream
run: python scripts/week1_api_e2e.py
30 changes: 30 additions & 0 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Frontend

on:
pull_request:
push:
branches:
- main

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "24"
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install frontend
working-directory: frontend
run: npm ci

- name: Build frontend
working-directory: frontend
run: npm run build
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,7 @@ __marimo__/

# Local spreadsheet exports
sheets/

# Frontend local artifacts
frontend/node_modules/
frontend/dist/
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ Workstream turns that operating knowledge into reusable infrastructure.
- [Product Principles](docs/product_principles.md)
- [Product Brief](docs/product_brief.md)
- [First User Flows](docs/product_first_user_flows.md)
- [Architecture Brief PDF](docs/architecture_brief/workstream_architecture_brief.pdf)
- [Architecture Diagrams](docs/diagrams/README.md)
- [System Architecture](docs/architecture_system_architecture.md)
- [Data Model](docs/architecture_data_model.md)
- [Lifecycle State Machine](docs/architecture_lifecycle_state_machine.md)
Expand Down Expand Up @@ -116,6 +118,49 @@ The default local test URL is:
postgresql+asyncpg://workstream:workstream@localhost:5433/workstream
```

## Local Frontend Demo

The team demo UI lives in `frontend/`. It calls the real backend over HTTP through the Vite proxy and uses local Flow-style bearer tokens against the backend `flow` verifier.

Start the backend for the demo:

```bash
cd backend
WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream \
WORKSTREAM_AUTH_PROVIDER=flow \
WORKSTREAM_ENVIRONMENT=local \
WORKSTREAM_FLOW_AUTH_ISSUER=https://auth.flow.local/demo \
WORKSTREAM_FLOW_AUTH_AUDIENCE=workstream-demo \
WORKSTREAM_FLOW_AUTH_LOCAL_HMAC_SECRET=workstream-demo-local-secret \
WORKSTREAM_ENABLE_DEMO_ROUTES=true \
.venv/bin/alembic upgrade head

WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream \
WORKSTREAM_AUTH_PROVIDER=flow \
WORKSTREAM_ENVIRONMENT=local \
WORKSTREAM_FLOW_AUTH_ISSUER=https://auth.flow.local/demo \
WORKSTREAM_FLOW_AUTH_AUDIENCE=workstream-demo \
WORKSTREAM_FLOW_AUTH_LOCAL_HMAC_SECRET=workstream-demo-local-secret \
WORKSTREAM_ENABLE_DEMO_ROUTES=true \
.venv/bin/python -m uvicorn app.main:create_app --factory --host 127.0.0.1 --port 8000
```

Start the frontend:

```bash
cd frontend
npm install
npm run dev -- --port 5173
```

Open:

```text
http://127.0.0.1:5173/
```

The demo runs the Week 1 path from project guide to locked submission using real API calls. The local demo worker-profile route is guarded by `WORKSTREAM_ENABLE_DEMO_ROUTES=true` and local/test environments only.

## Day-30 Success Standard

By day 30, Workstream runs a real internal task cycle with real people:
Expand Down
162 changes: 160 additions & 2 deletions backend/app/adapters/auth/flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,93 @@

from __future__ import annotations

import base64
import binascii
import hashlib
import hmac
import json
from datetime import UTC, datetime
from typing import Any
from uuid import NAMESPACE_URL, uuid5

from app.core.config import Settings
from app.interfaces.auth import AuthVerificationError
from app.schemas.auth import ActorContext

LOCAL_FLOW_AUTH_ENVIRONMENTS = {"local", "dev", "development", "test"}


def actor_id_from_flow_identity(external_issuer: str, external_subject: str) -> str:
"""Build a stable Workstream actor id from Flow issuer and subject.

Args:
external_issuer: Issuer that signed the Flow token.
external_subject: Subject inside the Flow token.

Returns:
Deterministic actor id for the issuer and subject pair.
"""
return str(uuid5(NAMESPACE_URL, f"{external_issuer}:{external_subject}"))


def _decode_base64url(value: str) -> bytes:
"""Decode one unpadded base64url token segment.

Args:
value: JWT segment to decode.

Returns:
Decoded bytes.

Raises:
AuthVerificationError: If the segment is malformed.
"""
try:
padding = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(f"{value}{padding}")
except (binascii.Error, ValueError) as exc:
raise AuthVerificationError("malformed Flow token segment") from exc


def _decode_json_segment(value: str) -> dict[str, Any]:
"""Decode a JSON object from one base64url token segment.

Args:
value: JWT segment containing a JSON object.

Returns:
Decoded JSON object.

Raises:
AuthVerificationError: If the segment is not a JSON object.
"""
try:
decoded = json.loads(_decode_base64url(value))
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise AuthVerificationError("malformed Flow token JSON") from exc
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if not isinstance(decoded, dict):
raise AuthVerificationError("malformed Flow token JSON")
return decoded


def _normalize_roles(value: Any) -> tuple[str, ...]:
"""Normalize Flow role claims into a role tuple.

Args:
value: Role claim supplied as a list, tuple, set, or comma-separated
string.

Returns:
Normalized non-empty role names.
"""
if isinstance(value, str):
raw_roles = value.split(",")
elif isinstance(value, list | tuple | set):
raw_roles = value
else:
raw_roles = ()
return tuple(str(role).strip() for role in raw_roles if str(role).strip())


class FlowAuthVerifier:
"""Production Flow token verifier boundary.
Expand All @@ -21,6 +104,13 @@ def __init__(self, settings: Settings) -> None:
settings: Application settings containing Flow auth configuration.
"""
self._issuer = settings.flow_auth_issuer
self._audience = settings.flow_auth_audience
self._local_hmac_secret = settings.flow_auth_local_hmac_secret
if (
self._local_hmac_secret
and settings.environment.strip().lower() not in LOCAL_FLOW_AUTH_ENVIRONMENTS
):
raise RuntimeError("local Flow auth verifier cannot run outside local/test")

async def verify(self, token: str) -> ActorContext:
"""Verify a Flow bearer token.
Expand All @@ -29,9 +119,77 @@ async def verify(self, token: str) -> ActorContext:
token: Bearer token from the incoming request.

Raises:
AuthVerificationError: Always raised until Flow verification is
configured in a later chunk.
AuthVerificationError: If Flow verification is not configured or
the token is invalid.
"""
if self._local_hmac_secret:
return self._verify_local_hmac_token(token)
raise AuthVerificationError(
f"Flow token verification is not configured for issuer {self._issuer}"
)

def _verify_local_hmac_token(self, token: str) -> ActorContext:
"""Verify a local Flow-compatible HMAC token for real API QA runs.

Args:
token: Bearer token supplied by an HTTP client.

Returns:
Trusted actor context derived from the token claims.

Raises:
AuthVerificationError: If token shape, signature, or claims fail.
"""
parts = token.split(".")
if len(parts) != 3:
raise AuthVerificationError("malformed Flow token")

header_segment, payload_segment, signature_segment = parts
header = _decode_json_segment(header_segment)
if header.get("alg") != "HS256" or header.get("typ") != "JWT":
raise AuthVerificationError("unsupported Flow token header")

signed_content = f"{header_segment}.{payload_segment}".encode()
expected_signature = hmac.new(
self._local_hmac_secret.encode(),
signed_content,
hashlib.sha256,
).digest()
supplied_signature = _decode_base64url(signature_segment)
if not hmac.compare_digest(expected_signature, supplied_signature):
raise AuthVerificationError("invalid Flow token signature")

claims = _decode_json_segment(payload_segment)
subject = claims.get("sub")
issuer = claims.get("iss")
audience = claims.get("aud")
if not isinstance(subject, str) or not subject:
raise AuthVerificationError("Flow token subject is required")
if issuer != self._issuer:
raise AuthVerificationError("Flow token issuer is invalid")
if audience != self._audience:
raise AuthVerificationError("Flow token audience is invalid")

now = datetime.now(UTC).timestamp()
expires_at = claims.get("exp")
not_before = claims.get("nbf")
if not isinstance(expires_at, int | float):
raise AuthVerificationError("Flow token expiry is required")
if expires_at <= now:
raise AuthVerificationError("Flow token is expired")
if not_before is not None and not isinstance(not_before, int | float):
raise AuthVerificationError("Flow token not-before claim is invalid")
if isinstance(not_before, int | float) and not_before > now:
raise AuthVerificationError("Flow token is not active")

return ActorContext(
actor_id=actor_id_from_flow_identity(issuer, subject),
external_subject=subject,
external_issuer=issuer,
email=claims.get("email") if isinstance(claims.get("email"), str) else None,
display_name=claims.get("name") if isinstance(claims.get("name"), str) else None,
roles=_normalize_roles(claims.get("roles", ())),
claim_snapshot=claims,
auth_source="flow",
is_dev_auth=False,
)
2 changes: 2 additions & 0 deletions backend/app/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from fastapi import APIRouter

from app.api.routes.auth import router as auth_router
from app.api.routes.demo import router as demo_router
from app.api.routes.health import router as health_router
from app.modules.projects.router import router as projects_router
from app.modules.tasks.router import router as tasks_router
Expand All @@ -13,5 +14,6 @@
api_router.include_router(health_router)
api_router.include_router(health_router, prefix="/api/v1")
api_router.include_router(auth_router, prefix="/api/v1")
api_router.include_router(demo_router, prefix="/api/v1")
api_router.include_router(projects_router, prefix="/api/v1")
api_router.include_router(tasks_router, prefix="/api/v1")
Loading
Loading