diff --git a/.github/workflows/azure-static-web-apps.yml b/.github/workflows/azure-static-web-apps.yml new file mode 100644 index 0000000..79fc38a --- /dev/null +++ b/.github/workflows/azure-static-web-apps.yml @@ -0,0 +1,56 @@ +name: Azure Static Web Apps CI/CD + +on: +# push: +# branches: +# - main +# pull_request: +# types: [opened, synchronize, reopened, closed] +# branches: +# - main + +jobs: + build_and_deploy_job: + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') + runs-on: ubuntu-latest + name: Build and Deploy Job + permissions: + checks: write + contents: read + id-token: write + pull-requests: write + steps: + + - uses: actions/checkout@v3 + with: + submodules: true + lfs: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: deployments/stitch-frontend/package.json + cache: "npm" + cache-dependency-path: deployments/stitch-frontend/package-lock.json + + - name: Build + run: make frontend-build + env: + VITE_API_URL: https://stitch-db-demo-02.politesand-d8480c35.westus2.azurecontainerapps.io/api/v1 + + - name: Install OIDC Client from Core Package + run: npm install @actions/core@1.6.0 @actions/http-client + + - name: Deploy + id: builddeploy + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_DEPLOY_TOKEN }} + repo_token: ${{ secrets.GITHUB_TOKEN }} + action: "upload" + ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### + # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig + skip_app_build: true + app_location: "/deployments/stitch-frontend/dist" # App source code path + skip_api_build: true + ###### End of Repository/Build Configurations ###### diff --git a/deployments/api/README.md b/deployments/api/README.md index e69de29..a3f8812 100644 --- a/deployments/api/README.md +++ b/deployments/api/README.md @@ -0,0 +1,150 @@ +# api + +## Deployment: Azure Container App + +### Prerequisites + +- Working local Docker environment (verify with `docker compose up --build`) +- Azure CLI installed and logged in (`az login`) +- `ACRPush` role on the target Azure Container Registry +- Existing PostgreSQL database provisioned and initialized (see `deployments/db/README.md`) +- Auth0 tenant and application configured + +### Canonical Deployment Values (Example) + +Replace these with environment-appropriate values. + +``` +SUBSCRIPTION=RMI-PROJECT-STITCH-SUB +RESOURCE_GROUP=STITCH-DEV-RG +REGISTRY=testdemostitchacr.azurecr.io +CONTAINER_APP_NAME=stitch-db-demo-02 +IMAGE_NAME=stitch-api +VERSION=0.0.2 +POSTGRES_HOST=stitch-deploy-test.postgres.database.azure.com +AUTH_ISSUER=https://rmi-spd.us.auth0.com/ +AUTH_AUDIENCE=https://stitch-api.local +``` + +--- + +### Build and Push Image + +Build the API image: + +```bash +docker compose build api +``` + +Find the built image: + +```bash +docker image ls +``` + +Tag and push: + +```bash +REGISTRY="testdemostitchacr.azurecr.io" +VERSION="0.0.2" + +docker tag "$REGISTRY/stitch-api:$VERSION" +az acr login -n "$REGISTRY" +docker push "$REGISTRY/stitch-api:$VERSION" +``` + +--- + +### Create Container App (Azure Portal) + +#### Basics + +- Subscription: `RMI-PROJECT-STITCH-SUB` +- Resource Group: `STITCH-DEV-RG` +- Name: `stitch-db-demo-02` +- Optimize for Azure Functions: Unchecked +- Deployment source: Container image +- Container Apps environment: As appropriate + +#### Container + +- Image: `/stitch-api:` + - *Note on Auth*: This deploy assumes that the ACR has an admin user, as a workaround to developers not having permissions to grant `ACRPull` permissions to new resources. +- Workload Profile: Consumption +- CPU/Memory: 0.25 CPU / 0.5 GiB + +Environment Variables: + +``` +LOG_LEVEL=info +POSTGRES_DB=stitch +POSTGRES_HOST= +POSTGRES_PORT=5432 +POSTGRES_USER=stitch_app +POSTGRES_PASSWORD=***** +AUTH_DISABLED=false +AUTH_ISSUER= +AUTH_AUDIENCE= +AUTH_JWKS_URI=.well-known/jwks.json +``` + +#### Ingress + +- Enabled +- Traffic: From anywhere + +--- + +### Verify Deployment State + +Get the "Application URL" from the resource overview. + +Health check: + +``` +https:///api/v1/health +``` + +Expected: + +```json +{"status":"ok"} +``` + +Authenticated endpoint test: + +``` +/api/v1/resources/2 +``` + +Expected (no token): + +```json +{"detail":"Missing Authorization header"} +``` + +This confirms: + +- Container is running +- DB connection works +- Auth middleware is active + +--- + +### Debugging + +#### View Logs + +Portal → Container App → Log Analytics → Logs → Switch to KQL Mode: + +```KQL +ContainerAppConsoleLogs_CL +| project TimeGenerated, RevisionName_s, Stream_s, Log_s +``` + +#### Add New Revision + +Edit container configuration. +Add or update environment variables. +Create revision and wait for rollout. + diff --git a/deployments/api/src/stitch/api/auth.py b/deployments/api/src/stitch/api/auth.py index 0d3ca61..2494d1b 100644 --- a/deployments/api/src/stitch/api/auth.py +++ b/deployments/api/src/stitch/api/auth.py @@ -97,7 +97,8 @@ async def get_token_claims( detail="Invalid or expired token", headers={"WWW-Authenticate": "Bearer"}, ) - except AuthError: + except AuthError as e: + logger.warning("JWT validation failed: %s", e, exc_info=True) raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, detail="Invalid or expired token", @@ -129,8 +130,8 @@ async def get_current_user(claims: Claims, uow: UnitOfWorkDep) -> User: async with session.begin_nested(): user_model = UserModel( sub=claims.sub, - name=claims.name or "", - email=claims.email or "", + name=claims.name, + email=claims.email, ) session.add(user_model) except IntegrityError: diff --git a/deployments/api/tests/test_auth_integration.py b/deployments/api/tests/test_auth_integration.py index 37fe02a..298613b 100644 --- a/deployments/api/tests/test_auth_integration.py +++ b/deployments/api/tests/test_auth_integration.py @@ -2,6 +2,8 @@ import pytest from sqlalchemy import select +from sqlalchemy.exc import NoResultFound + from stitch.auth import TokenClaims @@ -101,7 +103,7 @@ async def test_updates_name_email_on_subsequent_login( assert row.email == "new@example.com" @pytest.mark.anyio - async def test_defaults_empty_string_for_missing_name( + async def test_error_when_missing_claim( self, integration_session_factory, ): @@ -111,10 +113,8 @@ async def test_defaults_empty_string_for_missing_name( ) async with UnitOfWork(integration_session_factory) as uow: - user = await get_current_user(claims, uow) - - assert user.name == "" - assert user.email == "valid@example.com" + with pytest.raises(NoResultFound): + await get_current_user(claims, uow) @pytest.mark.anyio async def test_handles_concurrent_first_login( diff --git a/deployments/db/README.md b/deployments/db/README.md index 8068bc5..c2d8cb2 100644 --- a/deployments/db/README.md +++ b/deployments/db/README.md @@ -30,143 +30,169 @@ This directory contains resources for building and running the development Postg ## Deployment -### Manual Process +### Prerequisites -Create a Resource: "Azure Database for PostgreSQL Flexible Server" +- Azure access to create PostgreSQL Flexible Server +- `psql` tools installed locally +- Docker available (for API connectivity testing) +- Secure location for storing generated passwords (LastPass) -#### Basics +--- -* Subscription: `RMI-PROJECT-STITCH_SUB` -* Resource Group: `STITCH-DB-RG` -* Region: `West US 2` -* PostgreSQL version: `17` -* Workload Type: `Dev/Test` -* Compute + storage: Click "Configure server" - * Cluster options: `Server` - * Compute tier: `Burstable` - * Compute size: `Standard_B1ms` - * Storage type: `Premium SSD` - * Storage size: `32 GiB` - * Performance tier: `P4 (120 iops)` - * Storage autogrow: Unchecked - * Zonal resiliency: Disabled - * Backup retention period: `7 Days` - * Geo-redundancy: Unchecked -* Zonal resiliency: Disabled -* Authentication: PostgreSQL and Microsoft Entra Authentication -* Microsoft Entra administrator: Click "Set admin" - * `Admin_Alex@rmi.org` -* Administrator login: `postgres` -* Password: Set password and note elsewhere -* Confirm password +### Canonical Deployment Values (Example) -#### Networking +``` +SUBSCRIPTION=RMI-PROJECT-STITCH_SUB +RESOURCE_GROUP=STITCH-DEV-RG +REGION=West US 2 +POSTGRES_VERSION=17 +SERVER_NAME=stitch-deploy-test +DB_NAME=stitch +``` -* Connectivity method: `Public access` -* Check "Allow public access to this resource through the internet using a - public IP address" -* Check "Allow public access from any Azure service within Azure to this server" -* Click "Add current client IP address" - * Consider renaming new rule to something like `Alex_IPAddress_2026-1-22_13-26-17` -* *DO NOT CLICK* "Add 0.0.0.0 - 255.255.255.255" -* No Private endpoints +--- -#### Security +### Manual Provisioning (Azure Portal) -* Data encryption key: `Service-managed key` +Create resource: Azure Database for PostgreSQL Flexible Server -#### Tags +#### Basics -* as appropriate +- Subscription: `RMI-PROJECT-STITCH_SUB` +- Resource Group: `STITCH-DB-RG` +- Region: `West US 2` +- PostgreSQL version: `17` +- Workload Type: Dev/Test +- Compute: Burstable / Standard_B1ms + - Cluster options: `Server` + - Compute tier: `Burstable` + - Compute size: `Standard_B1ms` + - Storage type: `Premium SSD` + - Storage size: `32 GiB` + - Performance tier: `P4 (120 iops)` + - Storage autogrow: Unchecked + - Zonal resiliency: Disabled + - Backup retention period: `7 Days` + - Geo-redundancy: Unchecked +- Zonal resiliency: Disabled +- Authentication: PostgreSQL and Microsoft Entra Authentication + +Set: + +- Admin login: `postgres` +- Password: Store securely -#### Review and Create +#### Networking -Click Create, then visit your new DB. +- Connectivity: Public access +- Allow Azure services +- Add current client IP + - Consider renaming new rule to something like `_IPAddress_2026-1-22_13-26-17` +- Do NOT allow 0.0.0.0/0 ("0.0.0.0 - 255.255.255.255") +- No private endpoints -#### After Deploy: +--- +### Post-Deployment Steps -##### Create Database +#### Create Database -In the Web UI, under "Settings"/"Databases" on the left menu, view the existing -databases. -If the `stitch` database does not exist, create it. +Portal → Databases -##### Run Roles init script +Create database named: -test your connection (assuming you have psql tools installed locally): -```bash -pg_isready -d stitch -U postgres -h stitch-deploy-test.postgres.database.azure.com +``` +stitch +``` -psql -c "\\q" -d stitch -U postgres -h stitch-deploy-test.postgres.database.azure.com +#### Verify Connectivity +```bash +pg_isready -d stitch -U postgres -h +psql -d stitch -U postgres -h ``` -Change the host above with the "Endpoint" from the resource main view. +If connection fails, verify firewall rules. -If you cannot connect, check that your client IP address is added to the firewall rules under "Settings"/"Networking" on the left menu. +--- + +#### Initialize Roles -Then run the init script against your new database: ```bash POSTGRES_DB=stitch \ POSTGRES_USER=postgres \ - PGHOST=stitch-deploy-test.postgres.database.azure.com \ + PGHOST= \ STITCH_MIGRATOR_PASSWORD=CHANGE_ME123! \ STITCH_APP_PASSWORD=CHANGE_ME456! \ deployments/db/00-init-roles.sh ``` -Then check that you can connect as the new roles: +Verify: ```bash +psql -d stitch -U stitch_migrator -h +psql -d stitch -U stitch_app -h +``` -psql -c "\\q" -d stitch -U stitch_migrator -h stitch-deploy-test.postgres.database.azure.com -psql -c "\\q" -d stitch -U stitch_app -h stitch-deploy-test.postgres.database.azure.com +--- +### Seed Schema and Data + +```bash +docker run \ + -e LOG_LEVEL='info' \ + -e POSTGRES_HOST= \ + -e POSTGRES_USER=stitch_migrator \ + -e POSTGRES_PASSWORD=CHANGE_ME123! \ + -e POSTGRES_PORT='5432' \ + -e POSTGRES_USER='stitch_migrator' \ + -e POSTGRES_PASSWORD='CHANGE_ME123!' \ + --rm \ + stitch-api:latest python -m stitch.api.db.init_job ``` -##### Connect with local docker containers +Re-run API container. +You should now see seeded dev data. -Assuming you have built the docker container for the API locally (with `docker -compose up api --build` or `docker compose build api`), you should have an image -called `stitch-api`, and be able to attempt connecting with that container to -the public DB. +--- -```bash +## Updating Database -docker run \ - -e LOG_LEVEL='info' \ - -e POSTGRES_DB='stitch' \ - -e POSTGRES_HOST='stitch-deploy-test.postgres.database.azure.com' \ - -e POSTGRES_PORT='5432' \ - -e POSTGRES_USER='stitch_app' \ - -e POSTGRES_PASSWORD='CHANGE_ME456!' \ - --rm \ - -p 8000:8000 \ - stitch-api:latest +⚠ DBA-ONLY WORKFLOW + +This is a manual operational process. +It will eventually be replaced with CI-driven migrations. + +### Local Docker Database +Remove docker volume (`make clean-docker`) and re-run `db-init`. + +### Shared Cloud Database Strategy + +1. Rename existing DB: + +``` +stitch → stitch_old_YYYYMMDD ``` -If you try to hit the API (i.e. visit `http://localhost:8000/api/v1/resources/2`, then you should get an `500 Internal Server Error`, with a sqlalchemy error along the lines of `relation "resources" does not exist`. -This confirms that the API container can successfully connect to the DB, but the DDL operations and seeding have no been done by the `db-init` container. +2. Create new empty `stitch` database. -You can seed the database by connecting with the migrator role, and running the -init command: +3. Re-grant privileges to: -```bash +- stitch_migrator +- stitch_app -docker run \ - -e LOG_LEVEL='info' \ - -e POSTGRES_DB='stitch' \ - -e POSTGRES_HOST='stitch-deploy-test.postgres.database.azure.com' \ - -e POSTGRES_PORT='5432' \ - -e POSTGRES_USER='stitch_migrator' \ - -e POSTGRES_PASSWORD='CHANGE_ME123!' \ - --rm \ - -p 8000:8000 \ - stitch-api:latest python -m stitch.api.db.init_job +(Do not recreate users.) + +4. Update local `.env` to point to cloud host. + +5. Run: +```bash +docker compose up db-init ``` -You can then re-connect with the API container (use the command above), and -should be able to see the seeded dev data through the API. +This should recreate schema and seed data. + +Manual schema diffs are not currently supported. + diff --git a/deployments/stitch-frontend/README.md b/deployments/stitch-frontend/README.md index b3347d1..36bdb0f 100644 --- a/deployments/stitch-frontend/README.md +++ b/deployments/stitch-frontend/README.md @@ -280,3 +280,109 @@ When adding new endpoints: - [TanStack Query Docs: Query Keys](https://tanstack.com/query/latest/docs/react/guides/query-keys) - [Effective React Query Keys](https://tkdodo.eu/blog/effective-react-query-keys) + +## Deployment: Azure Static Web Apps + +### Prerequisites + +- API deployed and reachable +- Auth0 application created +- GitHub repository with Actions enabled + +--- + +### Canonical Values (Example) + +``` +SWA_NAME=stitch-frontend-demo-02 +RESOURCE_GROUP=STITCH-DEV-RG +API_URL=https://stitch-db-demo-02..azurecontainerapps.io +AUTH0_DOMAIN=rmi-spd.us.auth0.com +``` + +--- + +### Create Static Web App (Portal) + +- Subscription: RMI-PROJECT-STITCH-SUB +- Resource Group: STITCH-DEV-RG +- Name: stitch-frontend-demo-02 +- Plan: Free +- Source: Other (CI configured manually) + +Deployment Authorization Policy: Deployment Token + +--- + +### Configure GitHub Secret + +Azure Portal → Manage Deployment Token + +Add token as GitHub repository secret: + +``` +AZURE_STATIC_WEB_APPS_DEPLOY_TOKEN +``` + +Push branch or open PR to trigger workflow. + +--- + +### Expected State Before Auth0 Configuration + +You should see the Stitch login page. + +Attempting login will produce an Auth0 callback error. + +This is expected until callback URLs are configured. + +--- + +### Configure Auth0 Callback URLs + +Auth0 Dashboard → Applications → Settings + +Add your SWA URL to: + +- Allowed Callback URLs +- Allowed Logout URLs +- Allowed Web Origins + +Example: + +``` +https://.azurestaticapps.net/ +https://.azurestaticapps.net/callback +``` + +Save. + +--- + +### Expected State After Auth0 Configuration + +- Login succeeds. +- API calls fail with CORS error. + +This is expected until API CORS is configured. + +--- + +### Configure API CORS + +Portal → Container App → Networking → CORS + +- Enable credentials +- Max Age: 5 +- Allowed Origins: +- Allowed Headers: \* + +Apply changes. + +--- + +### Final Expected State + +- Login succeeds. +- API calls succeed. +- Authenticated resources load properly. diff --git a/deployments/stitch-frontend/package-lock.json b/deployments/stitch-frontend/package-lock.json index 1a23ae5..1f7f55f 100644 --- a/deployments/stitch-frontend/package-lock.json +++ b/deployments/stitch-frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "stitch-frontend", "version": "0.0.0", "dependencies": { + "@auth0/auth0-react": "^2.13.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.16", "react": "^19.2.0", @@ -113,6 +114,41 @@ "dev": true, "license": "MIT" }, + "node_modules/@auth0/auth0-auth-js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-auth-js/-/auth0-auth-js-1.4.0.tgz", + "integrity": "sha512-ShA7KT4KvcBEtxsXZTcrmoNxai5q1JXhB2aEBFnZD1L6LNLzzmiUWiFTtGMsaaITCylr8TJ/onEQk6XZmUHXbg==", + "license": "MIT", + "dependencies": { + "jose": "^6.0.8", + "openid-client": "^6.8.0" + } + }, + "node_modules/@auth0/auth0-react": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.13.0.tgz", + "integrity": "sha512-juIvLE8USttFgXHIndo8+QhPQFxbHYbB6JQl0VmuTl7UXpneZCQ2Jfupl14DG/M6ou6l1EplTKpGEnxP8q4DaA==", + "license": "MIT", + "dependencies": { + "@auth0/auth0-spa-js": "^2.14.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1", + "react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" + } + }, + "node_modules/@auth0/auth0-spa-js": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.15.0.tgz", + "integrity": "sha512-cXv1Isyy4JEc+GxesQPFj3SEbDSnCVQTGiardY9WLSoYTsMMU585Kgm9TJFPJO4dDq3wi+DSJoy3IUcB3rr9nA==", + "license": "MIT", + "dependencies": { + "@auth0/auth0-auth-js": "^1.4.0", + "browser-tabs-lock": "^1.2.15", + "dpop": "^2.1.1", + "es-cookie": "~1.3.2" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -144,6 +180,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -503,6 +540,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -546,6 +584,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1937,8 +1976,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2022,6 +2060,7 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2032,6 +2071,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2197,6 +2237,7 @@ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "fflate": "^0.8.2", @@ -2234,6 +2275,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2388,6 +2430,16 @@ "concat-map": "0.0.1" } }, + "node_modules/browser-tabs-lock": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz", + "integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "lodash": ">=4.17.21" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -2408,6 +2460,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2679,8 +2732,16 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, + "license": "MIT" + }, + "node_modules/dpop": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz", + "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==", "license": "MIT", - "peer": true + "funding": { + "url": "https://github.com/sponsors/panva" + } }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -2729,6 +2790,12 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-cookie": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz", + "integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q==", + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2806,6 +2873,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3481,6 +3549,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3507,6 +3584,7 @@ "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", @@ -3877,6 +3955,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3907,7 +3991,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4051,6 +4134,28 @@ "dev": true, "license": "MIT" }, + "node_modules/oauth4webapi": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.4.tgz", + "integrity": "sha512-EKlVEgav8zH31IXxvhCqjEgQws6S9QmnmJyLXmeV5REf59g7VmqRVa5l/rhGWtUqGm2rLVTNwukn9hla5kJ2WQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz", + "integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==", + "license": "MIT", + "dependencies": { + "jose": "^6.1.3", + "oauth4webapi": "^3.8.4" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4206,6 +4311,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4273,7 +4379,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4289,7 +4394,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -4312,6 +4416,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4321,6 +4426,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4333,8 +4439,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -4943,6 +5048,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5041,6 +5147,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -5361,6 +5468,7 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/deployments/stitch-frontend/package.json b/deployments/stitch-frontend/package.json index 22d99f5..a7f4fac 100644 --- a/deployments/stitch-frontend/package.json +++ b/deployments/stitch-frontend/package.json @@ -16,6 +16,7 @@ "test:coverage": "vitest --coverage" }, "dependencies": { + "@auth0/auth0-react": "^2.13.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.16", "react": "^19.2.0", diff --git a/deployments/stitch-frontend/src/AuthGate.jsx b/deployments/stitch-frontend/src/AuthGate.jsx new file mode 100644 index 0000000..5ff5576 --- /dev/null +++ b/deployments/stitch-frontend/src/AuthGate.jsx @@ -0,0 +1,22 @@ +import { useAuth0 } from "@auth0/auth0-react"; + +export function AuthGate({ children }) { + const { isLoading, isAuthenticated, loginWithRedirect } = useAuth0(); + + if (isLoading) + return ( +
+ Loading… +
+ ); + + if (!isAuthenticated) { + return ( +
+ +
+ ); + } + + return children; +} diff --git a/deployments/stitch-frontend/src/hooks/useResources.js b/deployments/stitch-frontend/src/hooks/useResources.js index 5957e20..249985e 100644 --- a/deployments/stitch-frontend/src/hooks/useResources.js +++ b/deployments/stitch-frontend/src/hooks/useResources.js @@ -1,10 +1,19 @@ +import { useAuth0 } from "@auth0/auth0-react"; import { useQuery } from "@tanstack/react-query"; import { resourceQueries } from "../queries/resources"; export function useResources() { - return useQuery(resourceQueries.list()); + const { getAccessTokenSilently, isAuthenticated } = useAuth0(); + return useQuery({ + ...resourceQueries.list(getAccessTokenSilently), + enabled: isAuthenticated, + }); } export function useResource(id) { - return useQuery(resourceQueries.detail(id)); + const { getAccessTokenSilently, isAuthenticated } = useAuth0(); + return useQuery({ + ...resourceQueries.detail(id, getAccessTokenSilently), + enabled: isAuthenticated && !!id, + }); } diff --git a/deployments/stitch-frontend/src/main.jsx b/deployments/stitch-frontend/src/main.jsx index d489c37..9a86fae 100644 --- a/deployments/stitch-frontend/src/main.jsx +++ b/deployments/stitch-frontend/src/main.jsx @@ -1,8 +1,10 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Auth0Provider } from "@auth0/auth0-react"; import "./index.css"; import App from "./App.jsx"; +import { AuthGate } from "./AuthGate.jsx"; // Set global defaults for QueryClient const queryClient = new QueryClient({ @@ -16,8 +18,20 @@ const queryClient = new QueryClient({ createRoot(document.getElementById("root")).render( - - - + + + + + + + , ); diff --git a/deployments/stitch-frontend/src/queries/api.js b/deployments/stitch-frontend/src/queries/api.js index d585eb5..78cbe55 100644 --- a/deployments/stitch-frontend/src/queries/api.js +++ b/deployments/stitch-frontend/src/queries/api.js @@ -1,24 +1,27 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1"; -export async function getResources() { - const url = `${API_BASE_URL}/resources/`; - const response = await fetch(url); +async function authedFetch(url, getAccessTokenSilently, options = {}) { + const token = await getAccessTokenSilently(); + const headers = new Headers(options.headers || {}); + headers.set("Authorization", `Bearer ${token}`); + + const response = await fetch(url, { ...options, headers }); if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const err = new Error(`HTTP error! status: ${response.status}`); + err.status = response.status; + err.body = await response.text().catch(() => ""); + throw err; } - const data = await response.json(); - return data; + return response.json(); } -export async function getResource(id) { +export function getResources(getAccessTokenSilently) { + const url = `${API_BASE_URL}/resources/`; + return authedFetch(url, getAccessTokenSilently); +} + +export function getResource(id, getAccessTokenSilently) { const url = `${API_BASE_URL}/resources/${id}`; - const response = await fetch(url); - if (!response.ok) { - const error = new Error(`HTTP error! status: ${response.status}`); - error.status = response.status; - throw error; - } - const data = await response.json(); - return data; + return authedFetch(url, getAccessTokenSilently); } diff --git a/deployments/stitch-frontend/src/queries/api.test.js b/deployments/stitch-frontend/src/queries/api.test.js index 54425a3..485cb14 100644 --- a/deployments/stitch-frontend/src/queries/api.test.js +++ b/deployments/stitch-frontend/src/queries/api.test.js @@ -1,6 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { getResources, getResource } from "./api"; +const getAccessTokenSilently = vi.fn(async () => "test-token"); + describe("API Functions", () => { beforeEach(() => { global.fetch = vi.fn(); @@ -19,10 +21,11 @@ describe("API Functions", () => { json: async () => mockResources, }); - const result = await getResources(); + const result = await getResources(getAccessTokenSilently); expect(global.fetch).toHaveBeenCalledWith( "http://localhost:8000/api/v1/resources/", + expect.objectContaining({ headers: expect.any(Headers) }), ); expect(result).toEqual(mockResources); }); @@ -33,13 +36,17 @@ describe("API Functions", () => { status: 500, }); - await expect(getResources()).rejects.toThrow("HTTP error! status: 500"); + await expect(getResources(getAccessTokenSilently)).rejects.toThrow( + "HTTP error! status: 500", + ); }); it("throws error on network failure", async () => { global.fetch.mockRejectedValueOnce(new Error("Network error")); - await expect(getResources()).rejects.toThrow("Network error"); + await expect(getResources(getAccessTokenSilently)).rejects.toThrow( + "Network error", + ); }); }); @@ -53,7 +60,7 @@ describe("API Functions", () => { json: async () => mockResource, }); - const result = await getResource(42); + const result = await getResource(42, getAccessTokenSilently); expect(global.fetch).toHaveBeenCalledWith( "http://localhost:8000/api/v1/resources/42", @@ -68,7 +75,7 @@ describe("API Functions", () => { }); try { - await getResource(999); + await getResource(999, getAccessTokenSilently); expect.fail("Should have thrown an error"); } catch (error) { expect(error.message).toBe("HTTP error! status: 404"); @@ -82,7 +89,9 @@ describe("API Functions", () => { status: 404, }); - await expect(getResource(123)).rejects.toMatchObject({ + await expect( + getResource(123, getAccessTokenSilently), + ).rejects.toMatchObject({ message: "HTTP error! status: 404", status: 404, }); @@ -94,7 +103,9 @@ describe("API Functions", () => { status: 500, }); - await expect(getResource(1)).rejects.toMatchObject({ + await expect( + getResource(1, getAccessTokenSilently), + ).rejects.toMatchObject({ message: "HTTP error! status: 500", status: 500, }); @@ -103,7 +114,9 @@ describe("API Functions", () => { it("throws error on network failure", async () => { global.fetch.mockRejectedValueOnce(new Error("Failed to fetch")); - await expect(getResource(1)).rejects.toThrow("Failed to fetch"); + await expect(getResource(1, getAccessTokenSilently)).rejects.toThrow( + "Failed to fetch", + ); }); }); }); diff --git a/deployments/stitch-frontend/src/queries/resources.js b/deployments/stitch-frontend/src/queries/resources.js index a2b497d..34d7002 100644 --- a/deployments/stitch-frontend/src/queries/resources.js +++ b/deployments/stitch-frontend/src/queries/resources.js @@ -4,22 +4,18 @@ import { getResource, getResources } from "./api"; export const resourceKeys = { all: ["resources"], lists: () => [...resourceKeys.all, "list"], - list: (filters) => [...resourceKeys.lists(), filters], details: () => [...resourceKeys.all, "detail"], detail: (id) => [...resourceKeys.details(), id], }; -// Query definitions export const resourceQueries = { - list: () => ({ + list: (getAccessTokenSilently) => ({ queryKey: resourceKeys.lists(), - queryFn: getResources, - enabled: false, + queryFn: () => getResources(getAccessTokenSilently), }), - detail: (id) => ({ + detail: (id, getAccessTokenSilently) => ({ queryKey: resourceKeys.detail(id), - queryFn: () => getResource(id), - enabled: false, + queryFn: () => getResource(id, getAccessTokenSilently), }), }; diff --git a/deployments/stitch-frontend/src/test/setup.js b/deployments/stitch-frontend/src/test/setup.js index 971941d..1472d70 100644 --- a/deployments/stitch-frontend/src/test/setup.js +++ b/deployments/stitch-frontend/src/test/setup.js @@ -1,6 +1,20 @@ import { expect, afterEach } from "vitest"; import { cleanup } from "@testing-library/react"; import * as matchers from "@testing-library/jest-dom/matchers"; +import { vi } from "vitest"; + +vi.mock("@auth0/auth0-react", () => { + return { + Auth0Provider: ({ children }) => children, + useAuth0: () => ({ + isLoading: false, + isAuthenticated: true, + loginWithRedirect: vi.fn(), + logout: vi.fn(), + getAccessTokenSilently: vi.fn(async () => "test-token"), + }), + }; +}); expect.extend(matchers);