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
34 changes: 34 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,37 @@ jobs:

- name: Run full test suite
run: python -m pytest -q

frontend:
name: Frontend lint, test, build
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7
with:
persist-credentials: false

- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5
with:
# Matches frontend/mise.toml's pin (setup-node doesn't parse mise.toml).
node-version: "24"

- name: Enable Corepack
run: corepack enable

- name: Install dependencies
working-directory: frontend
run: pnpm install --frozen-lockfile

- name: Lint
working-directory: frontend
run: pnpm run lint

- name: Test
working-directory: frontend
run: pnpm run test

- name: Build
working-directory: frontend
run: pnpm run build
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ in the same spirit) -- never against real data, per the hard rule above.
against a live local stack (`make up`) and self-skip without one -- see
[README.md](README.md#local-product-stack-docker-compose).

`frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`,
pnpm via Corepack -- do not add a second Node package manager or a
floating Node version):

```bash
cd frontend && pnpm install
pnpm run lint && pnpm run test && pnpm run build
```

## CI gates

`.github/workflows/tests.yml` runs the full suite on every PR to `main`.
Expand Down
24 changes: 24 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,27 @@ allow/deny ABAC boundary against a throwaway migrated Postgres database
excluded from the list and 403s on direct fetch), and proves a forged
token is rejected. `scripts/seed_demo_data.py` populates the docker-compose
stack itself with the same shape of synthetic data for manual/frontend use.
`CORSMiddleware` (`backend/app/main.py`) allows exactly the frontend's
origin(s) (`FRONTEND_ORIGINS`), `GET` only, `Authorization` header only.

### Frontend (`frontend/`)

React + Vite + TypeScript, pinned Node via `mise.toml`, pnpm via Corepack.
`react-oidc-context` drives a real Authorization Code redirect through
Keycloak (`src/main.tsx`'s `AuthProvider`) -- no mocked auth, no static
HTML. `src/api.ts` calls the FastAPI backend directly with the token
Keycloak issued; `src/App.tsx` renders the post list and a detail popup
(the Figma frame `SBpgot7uTvMxEaxUwvoc0S` attachment point -- Event
Lineage / Keyman / Knowledge Graph / LLM chat panels land in Phase 2-4).
Served in `docker compose` via a two-stage build (`frontend/Dockerfile`):
`pnpm run build` then `nginx` serving the static bundle, with `VITE_*`
config baked in at build time from the same `.env` ports every other
service uses (Vite embeds `import.meta.env.VITE_*` at build time, not
runtime, so these are Docker build args, not container env vars).
`src/App.test.tsx` mocks `react-oidc-context`'s `useAuth` to test the
component's own render logic (login button -> `signinRedirect()`; fetch
posts with the token -> render list -> click -> popup shows the fetched
body) -- the real OIDC cryptography is proven elsewhere
(`scripts/smoke_test_oidc.py`, `backend/tests/test_api.py`), so this test
isn't re-proving that, only that the UI wires the pieces together
correctly.
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,39 @@ All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.7.0] - 2026-08-13

### Added

- `frontend/`: React + Vite + TypeScript, pinned Node via `mise.toml`,
pnpm via Corepack -- a real client, not mocked and not static HTML.
`react-oidc-context` drives an actual Authorization Code redirect
through Keycloak; the post list and detail popup call the FastAPI
backend over real `fetch()` with the token Keycloak issued.
`src/App.test.tsx` covers the login-redirect and fetch-then-render
paths (`useAuth` mocked -- the real OIDC round-trip is proven
elsewhere, by `scripts/smoke_test_oidc.py` and `backend/tests/test_api.py`).
- `frontend/Dockerfile` + `nginx.conf`: two-stage build (`pnpm run build`
then nginx serving the static bundle) added as docker-compose's fourth
service, `VITE_*` config baked in at build time from the same `.env`
ports every other service uses. Both stages pin the base image by
digest; the runtime stage declares `USER nginx` and listens on 8080
so the master process does not need root to bind a port.
- `backend/app/main.py`: `CORSMiddleware`, scoped to exactly the
frontend's origin(s) (`FRONTEND_ORIGINS`), `GET` only, `Authorization`
header only -- verified with a real cross-origin preflight + GET against
the live stack, not just unit-tested in isolation.
- `.github/workflows/tests.yml`: added a `frontend` job (lint, test,
build) alongside the existing Python `pytest` job.

### Fixed

- Keycloak's `lineageweave-frontend` client (`docker/keycloak/realm-export.json`)
now allows both the Vite dev-server origin (`:5173`) and the
docker-compose-served frontend's origin (`:15173`) as redirect URIs and
web origins -- the login redirect only worked from one of the two
before this.

## [0.6.0] - 2026-08-13

### Added
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,28 @@ and the deny path against a live Keycloak + throwaway Postgres database,
including that a private post scoped to a *different* corporate entity is
excluded from the list and 403s on direct fetch.

`frontend/` (React + Vite + TypeScript, `docker compose`'s fourth service)
is a real client, not mocked or static: `react-oidc-context` drives an
actual Authorization Code redirect through Keycloak, and the post list /
detail popup call the FastAPI backend over real `fetch()` with the token
Keycloak issued.

```bash
make up
make seed
cd frontend && cp .env.example .env.local && pnpm install && pnpm run dev
# -> http://localhost:5173, click "Log in", redirects through the real
# Keycloak login page for demo.analyst / lineageweave-demo-only
```

`docker compose up` also builds and serves the frontend itself (nginx,
`frontend/Dockerfile`) at `http://localhost:15173` -- the `VITE_*` build
args are wired from the same `.env` ports as every other service.
`frontend/src/App.test.tsx` covers the login-redirect and
fetch-then-render-popup paths (`react-oidc-context`'s `useAuth` mocked --
the *real* OIDC round-trip is what `scripts/smoke_test_oidc.py` and
`backend/tests/test_api.py` already prove against a live Keycloak).

## Modular / standalone

This repo runs standalone (own server, own tests, own CI) and is equally
Expand Down
9 changes: 8 additions & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ class Settings:
# docker-compose the two differ (internal DNS name vs. the
# host-published port a browser actually hits).
keycloak_issuer: str
# Exact browser origins allowed by CORS. Comma-separated FRONTEND_ORIGINS;
# never a wildcard -- the backend only serves the product UI.
frontend_origins: list[str]

@property
def keycloak_jwks_uri(self) -> str:
Expand All @@ -33,7 +36,6 @@ def keycloak_jwks_uri(self) -> str:


def load_settings() -> Settings:
"""Read Settings from the environment, with local-dev defaults only."""
"""Read Settings from the environment, with local-dev defaults only."""
keycloak_base_url = os.environ.get("KEYCLOAK_BASE_URL", "http://localhost:18080")
keycloak_realm = os.environ.get("KEYCLOAK_REALM", "lineageweave-demo")
Expand All @@ -48,4 +50,9 @@ def load_settings() -> Settings:
keycloak_issuer=os.environ.get(
"KEYCLOAK_ISSUER", f"{keycloak_base_url}/realms/{keycloak_realm}"
),
frontend_origins=[
origin.strip()
for origin in os.environ.get("FRONTEND_ORIGINS", "http://localhost:5173").split(",")
if origin.strip()
],
)
7 changes: 7 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

import asyncpg
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware

from backend.app.auth import CurrentAccount, get_current_account
from backend.app.config import load_settings
Expand All @@ -44,6 +45,12 @@ async def lifespan(app: FastAPI):


app = FastAPI(title="LineageWeave API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=load_settings().frontend_origins,
allow_methods=["GET"],
allow_headers=["Authorization"],
)


def _require_post_read(account: CurrentAccount) -> None:
Expand Down
23 changes: 23 additions & 0 deletions backend/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Settings parsing tests that do not need a live Postgres or Keycloak."""

from __future__ import annotations

from backend.app.config import load_settings


def test_frontend_origins_are_parsed_from_comma_separated_env(monkeypatch) -> None:
"""CORS allow-list is an explicit env CSV, never a wildcard default."""
monkeypatch.setenv(
"FRONTEND_ORIGINS",
"http://localhost:5173, http://localhost:15173",
)
settings = load_settings()
assert settings.frontend_origins == [
"http://localhost:5173",
"http://localhost:15173",
]


def test_frontend_origins_drop_blank_entries(monkeypatch) -> None:
monkeypatch.setenv("FRONTEND_ORIGINS", "http://localhost:5173,,")
assert load_settings().frontend_origins == ["http://localhost:5173"]
13 changes: 13 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ services:
KEYCLOAK_ISSUER: http://localhost:${KEYCLOAK_PORT:-18080}/realms/lineageweave-demo
KEYCLOAK_REALM: lineageweave-demo
KEYCLOAK_CLIENT_ID: lineageweave-frontend
FRONTEND_ORIGINS: http://localhost:${FRONTEND_PORT:-15173}
ports:
- "${BACKEND_PORT:-18420}:8000"
depends_on:
Expand All @@ -86,6 +87,18 @@ services:
keycloak:
condition: service_started

frontend:
build:
context: ./frontend
args:
VITE_KEYCLOAK_ISSUER: http://localhost:${KEYCLOAK_PORT:-18080}/realms/lineageweave-demo
VITE_KEYCLOAK_CLIENT_ID: lineageweave-frontend
VITE_BACKEND_BASE_URL: http://localhost:${BACKEND_PORT:-18420}
ports:
- "${FRONTEND_PORT:-15173}:8080"
depends_on:
- backend

volumes:
postgres_data:
valkey_data:
4 changes: 2 additions & 2 deletions docker/keycloak/realm-export.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"redirectUris": ["http://localhost:5173/*"],
"webOrigins": ["http://localhost:5173"],
"redirectUris": ["http://localhost:5173/*", "http://localhost:15173/*"],
"webOrigins": ["http://localhost:5173", "http://localhost:15173"],
"protocolMappers": [
{
"name": "corp-code",
Expand Down
5 changes: 5 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Copy to .env.local to override. Defaults already match this repo's
# docker-compose.yml (see ../.env.example).
VITE_KEYCLOAK_ISSUER=http://localhost:18080/realms/lineageweave-demo
VITE_KEYCLOAK_CLIENT_ID=lineageweave-frontend
VITE_BACKEND_BASE_URL=http://localhost:18420
24 changes: 24 additions & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
8 changes: 8 additions & 0 deletions frontend/.oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
27 changes: 27 additions & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
FROM node:24-slim@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
# Vite bakes VITE_* vars in at build time, not runtime -- build args let
# docker-compose pass through its own (possibly operator-overridden) ports.
ARG VITE_KEYCLOAK_ISSUER
ARG VITE_KEYCLOAK_CLIENT_ID
ARG VITE_BACKEND_BASE_URL
ENV VITE_KEYCLOAK_ISSUER=${VITE_KEYCLOAK_ISSUER} \
VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID} \
VITE_BACKEND_BASE_URL=${VITE_BACKEND_BASE_URL}
RUN pnpm run build

FROM nginx:1.27-alpine@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Official nginx binds :80 as root and writes /var/run/nginx.pid. Move
# both so the declared non-root USER can actually start the master
# process (DS-0002). Compose publishes this 8080, not 80.
RUN sed -i 's,/var/run/nginx.pid,/tmp/nginx.pid,' /etc/nginx/nginx.conf \
&& sed -i '/^user /d' /etc/nginx/nginx.conf \
&& chown -R nginx:nginx /usr/share/nginx/html /var/cache/nginx /var/log/nginx
USER nginx
EXPOSE 8080
32 changes: 32 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# React + TypeScript + Vite

This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.

Currently, two official plugins are available:

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)

## React Compiler

The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).

## Expanding the Oxlint configuration

If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:

```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```

See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
13 changes: 13 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LineageWeave</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
2 changes: 2 additions & 0 deletions frontend/mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
node = "24"
12 changes: 12 additions & 0 deletions frontend/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
server {
listen 8080;
root /usr/share/nginx/html;
index index.html;

# SPA fallback: react-oidc-context's redirect_uri is the app root, and
# client-side routing (added in a later phase) needs every path to
# resolve to index.html.
location / {
try_files $uri /index.html;
}
}
Loading
Loading