# Technical documentation — click-learner-portal-platform

Evidence date: 2026-10-18.

Source: `git@bitbucket.org:overdrive/click-learner-portal-platform.git`, `master` at `99bde51`. Differences on `origin/Production` (`131cc59`) are marked **Production branch**.

`vendor/` is tracked (6930 paths) and is listed in `.gitignore`, which does not untrack files already committed. It was not checked out for this reading: Windows rejected long paths under `vendor/friendsofphp/proxy-manager-lts/`. Versions below come from `composer.json` constraints, not from a running `composer show`.

Secret and key *values* are not copied. File names and the fact of commitment are.

## Architecture

Symfony 5.4 application. The front controller is `public/index.php`, which boots `App\Kernel` with `APP_ENV` and `APP_DEBUG` from the runtime context. `App\Kernel` is the Symfony micro-kernel (`MicroKernelTrait`).

Custom behaviour is two controllers that build LTI 1.3 resource-link launches with `oat-sa/lib-lti1p3-core` types (pulled in by `oat-sa/bundle-lti1p3`). API Platform exposes one of those controllers as `GET /api/ltiresourcelink/{learnerid}`. The other is a classic annotation route.

Doctrine Bundle, Migrations Bundle, Security Bundle, Twig, Nelmio CORS, and a PostgreSQL Compose service are the Symfony Flex recipe. Application code does not map an entity or run a query.

There is no message bus, no messenger package, no scheduler, and no queue worker in `composer.json`.

## Frameworks and languages

| Item | Evidence |
|---|---|
| PHP `>=8.0.2` | `composer.json` on `master` |
| PHP `>=8.0`, Composer platform `8.2` | `composer.json` on **Production branch** |
| Symfony `5.4.*` | `extra.symfony.require` and the `symfony/*` constraints |
| API Platform `^2.6` | `api-platform/core` |
| LTI 1.3 | `oat-sa/bundle-lti1p3` `^6.1` |
| Doctrine ORM `^2.12`, migrations bundle `^3.2` | `composer.json` |
| Nelmio CORS `^2.2` | `composer.json` |
| Twig bundle | `symfony/twig-bundle` `5.4.*` |
| PSR-7 | `config/packages/nyholm_psr7.yaml` registers Nyholm `Psr17Factory`. The package is not a direct `composer.json` requirement; it is assumed to arrive with the LTI bundle. Not confirmed from a checked-out `vendor/` |
| `laminas/laminas-code` `^4.8` | **Production branch** only |
| License | `proprietary` in `composer.json` |
| Tests | No `require-dev`. No `tests/` path in `git ls-files`. PHPUnit is not configured |

`config/bundles.php` registers Framework, Security, Lti1p3, Twig, Doctrine, DoctrineMigrations, NelmioCors, and ApiPlatform for `all` environments.

## Application structure

```text
appspec.yml
bitbucket-pipelines.yml
composer.json
composer.lock
docker-compose.yml
docker-compose.override.yml
.env
bin/console
config/
  bundles.php
  services.yaml
  packages/          framework, security, doctrine, lti1p3, api_platform, nelmio_cors, twig, …
  routes/            annotations, api_platform (prefix /api), lti1p3 bundle routes
  secrets/dev/       public.key, private.key, new_public_key.pem
public/
  index.php
  .htaccess          Symfony Apache rewrite recipe
migrations/          .gitignore only
scripts/             CodeDeploy hooks
src/
  Kernel.php
  Controller/        DefaultController, LtiRequestController, RegistrationRepository,
                     UserAuthenticator, UserAuthenticationResult
  Entity/ltirequest.php
  Repository/        .gitignore only
templates/base.html.twig
translations/        .gitignore only
```

`src/Controller/.gitignore`, `src/Entity/.gitignore`, and `src/Repository/.gitignore` are empty files from the Flex recipe. They do not ignore the PHP classes beside them.

Annotation routes are loaded from `src/Controller/` (`config/routes.yaml`). API Platform routes use prefix `/api` (`config/routes/api_platform.yaml`). LTI bundle routes are imported with no extra prefix (`config/routes/lti1p3.yaml`).

### Routes in application code

| Method and path | Name | Class | Branch |
|---|---|---|---|
| `GET /default/{learnerId}/{quizId}/{quizEndDate}` | `default_list` | `DefaultController::list` | `master` and Production |
| `GET /api/ltiresourcelink/{learnerid}` | API Platform operation `get_lti_resource_link` | `LtiRequestController` | `master` and Production |
| `GET /` | `default_home` | `DefaultController::home` | **Production branch** only |

`LtiRequestController` is marked `#[AsController]` and is not given its own `@Route`. The path exists because `ltirequest` points the item operation at that class.

### LTI URLs configured for the public platform

From `config/packages/lti1p3.yaml` registration `tao` / platform `ClickLearningPlatform`. These are configuration strings. They were not requested against a live host.

| URL | Role in config |
|---|---|
| `https://clms.clicklearning.org/platform` | Platform audience |
| `https://clms.clicklearning.org/lti1p3/oidc/authentication` | OIDC authentication |
| `https://clms.clicklearning.org/lti1p3/auth/platformKey/token` | OAuth2 access token |
| `https://clms.clicklearning.org/lti1p3/.well-known/jwks/platformSet.json` | Platform JWKS |

Registration `local` repeats those paths on `http://127.0.0.1:8000`. Tool `devkitTool` uses `http://127.0.0.1:8001`. Tool `taoTool` uses:

- audience `https://testrunner-ignite.prod.gcp-eu.taocloud.org/tool`
- OIDC initiation `https://auth-ignite.prod.gcp-eu.taocloud.org/lti1p3/oidc/initiation`
- launch URL `https://testrunner-ignite.prod.gcp-eu.taocloud.org/api/v1/auth/launch-lti-1p3/04fc0ce15e09`
- JWKS `https://auth-ignite.prod.gcp-eu.taocloud.org/.well-known/jwks.json`

`DefaultController` does not call the yaml launch URL. It builds `https://testrunner-ignite.prod.gcp-eu.taocloud.org/api/v1/auth/launch-lti-1p3/{quizId}`.

Key chains `platformKey`, `toolKey`, and `taoToolKey` all point at the same files:

- `config/secrets/dev/public.key`
- `config/secrets/dev/private.key`
- passphrase null

`config/secrets/dev/new_public_key.pem` is also tracked. No yaml path references it.

`RegistrationRepository` indexes registrations by identifier and can find by client id, platform issuer, or tool issuer. `config/services.yaml` does not show a manual wiring of that class onto the bundle. The LTI bundle is expected to construct its own repository from `lti1p3.yaml`. The app class is autowired because it sits under `App\`. Whether the bundle's repository is this class or the bundle's own was not confirmed from `vendor/`. Both controllers type-hint `RegistrationRepositoryInterface` and call `find('tao')` or `find('local')`, which matches identifiers in yaml.

## Databases

| Item | Evidence |
|---|---|
| Driver | `DATABASE_URL` scheme `postgresql` |
| Host and database | `127.0.0.1:5432`, database name `app`, `serverVersion=13` |
| User | `symfony` (Doctrine recipe). Password is the recipe placeholder, not repeated here |
| Compose | Service `database`, image `postgres:${POSTGRES_VERSION:-13}-alpine`, volume `db-data`, published port `5432` in `docker-compose.override.yml` |
| ORM | Mapping directory `src/Entity`, prefix `App\Entity`. `ltirequest` has no `@ORM\Entity`, so it is not a table |
| Migrations | Path `migrations/` namespace `DoctrineMigrations`. No PHP migration is committed |
| Use | No controller or repository queries the connection |

`when@test` adds a database name suffix `_test`. There is no test suite to use it. `when@prod` turns off proxy generation and points Doctrine caches at `cache.app` and `cache.system`.

## Authentication

Two different mechanisms exist, and the TAO launch uses neither of them as a gate.

1. Symfony Security (`config/packages/security.yaml`). Authenticator manager is enabled. The user provider is an empty memory provider. The only firewall that disables security is the profiler/assets pattern. Firewall `main` is lazy and has no authenticator. `access_control` is commented out. Nothing in the app requires a login.

2. LTI user authentication (`UserAuthenticator`, bound to `OAT\Library\Lti1p3Core\Security\User\UserAuthenticatorInterface`). `authenticate` returns success. The user id is the login hint. The name is the literal `Maanie`. `LtiRequestController` does not use the service; it builds `UserAuthenticator(true, true)`, and the second flag returns a null identity (`UserAuthenticationResult::isAnonymous`).

`DefaultController` places `{learnerId}` in the launch message and returns the form. It does not establish a Symfony session user.

Session config is the Flex default: native storage, `cookie_secure: auto`, `cookie_samesite: lax`. The launch actions do not read or write the session.

## Configuration

Committed `.env` keys (values of secrets omitted):

| Key | What the file shows |
|---|---|
| `APP_ENV` | `dev` on `master` and on `Production` `.env` |
| `APP_SECRET` | A committed hex literal. `.env` comments say not to commit production secrets. Value not copied |
| `LTI1P3_SERVICE_ENCRYPTION_KEY` | A committed hex literal. Value not copied |
| `DATABASE_URL` | PostgreSQL recipe URL described above |
| `CORS_ALLOW_ORIGIN` | `^https?://(localhost\|127\.0\.0\.1)(:[0-9]+)?$` |

`APP_DEBUG` is not set in `.env`. Symfony's runtime derives it from the environment. The exact debug flag on a server was not observed.

**.Production branch** also tracks `.env.local.php` (normally gitignored). Its keys are `APP_ENV` (`prod`), `SYMFONY_ENV` (`prod`), `APP_SECRET`, `LTI1P3_SERVICE_ENCRYPTION_KEY`, `DATABASE_URL`, and `CORS_ALLOW_ORIGIN`. The secret and database URL match the committed `.env` shape. Symfony loads this compiled file in preference to `.env` when it is present (the `.env` header documents `composer dump-env prod`).

`framework.yaml` sets `secret` from `APP_SECRET`, disables HTTP method override, and logs PHP errors. CSRF protection is commented out.

`cache.yaml` is the filesystem default. Redis and APCu examples are commented.

No `.env.prod` file is in git. `.gitignore` ignores `.env.local`, `.env.local.php`, and `.env.*.local`, but **Production branch** tracks `.env.local.php` anyway.

## Frontend

There is no JavaScript application and no asset build.

`templates/base.html.twig` is the Symfony skeleton. It calls `encore_entry_link_tags('app')` and `encore_entry_script_tags('app')`. `symfony/webpack-encore-bundle` is not in `composer.json`. No template extends `base.html.twig`. The launch responses are strings built in PHP, not Twig.

`public/.htaccess` is the Symfony Apache rewrite recipe (`DirectoryIndex index.php`, front-controller rewrite). `public/bundles/` is gitignored on `master`. **Production branch** changes files under `public/bundles/apiplatform/` (font and stylesheet bytes), so those built assets are tracked on that branch.

## Deployment and infrastructure

### Pipeline

`bitbucket-pipelines.yml` uses `atlassian/default-image:2`.

| Branch key | Steps |
|---|---|
| `master` | `apt-get install zip`, `zip -r application1.zip .`, CodeDeploy pipe `0.2.5` upload then deploy. Variables `QA_AWS_DEFAULT_REGION`, `QA_AWS_ACCESS_KEY_ID`, `QA_AWS_SECRET_ACCESS_KEY`, `QA_APPLICATION_NAME`, `QA_S3_BUCKET`, `QA_DEPLOYMENT_GROUP_NAME`. `DEBUG: 'TRUE'`. `WAIT: 'true'`. `VERSION_LABEL: 'my-app-1.0.0'`. `IGNORE_APPLICATION_STOP_FAILURES: 'true'`. `FILE_EXISTS_BEHAVIOR: 'OVERWRITE'` |
| `Production` | Same script with `PROD_*` variable names. Not on `master` |

The zip is `.`, so tracked `vendor/`, `config/secrets/dev/`, and `.env` are inside the artifact. On **Production branch** the zip also includes `.env.local.php` and `var/cache/dev/`.

### CodeDeploy

`appspec.yml`:

| Field | Value |
|---|---|
| OS | linux |
| Destination | `/var/www/html/clms.clicklearning.org/` |
| Owner / group | `apache` / `apache` |
| BeforeInstall | `scripts/install_dependencies`, 300s, root |
| ApplicationStop | `scripts/remove_archives.sh`, 300s, root |
| ApplicationStart | `scripts/start_server`, 300s, root |

`install_dependencies` does not install packages (the `yum` line is commented). `start_server` on `master` is `service httpd start`. On **Production branch** it then `export SYMFONY_ENV=prod` in that shell. `stop_server` is not in the appspec. Its assignment `isExistApp = \`pgrep httpd\`` is not valid Bash (spaces around `=`).

`remove_archives.sh` deletes directories under `/opt/codedeploy-agent/deployment-root/` except `deployment-instructions` and the last successful install.

No Dockerfile, no ECS task, no Terraform, and no health-check definition are in the repo. Docker Compose only starts PostgreSQL.

## Security notes (from the tree, not a pentest)

- `config/secrets/dev/private.key` is tracked. Every LTI key chain, including the `clms.clicklearning.org` platform, uses that dev key and a null passphrase.
- `APP_SECRET` and `LTI1P3_SERVICE_ENCRYPTION_KEY` are tracked in `.env`, and again in `.env.local.php` on **Production branch**.
- `GET /default/{learnerId}/{quizId}/{quizEndDate}` is not authenticated. The learner id is in the URL and is copied into the return URL query.
- The API Platform launch is also unauthenticated and is aimed at localhost while still being part of the deployed tree.
- The pipeline turns CodeDeploy pipe debug on and zips the whole repository.
- CORS does not restrict same-host navigation to the launch URL.
- `new_public_key.pem` is unused by the yaml that was read. Whether it was a rotation that never finished is **Unknown**.

Values are intentionally omitted so this document is not a second copy of the secrets.

## Development process

- History runs from 2022-06-03 (`9ce6f9d`, Abdurahmaan Ismail, "Initial commit") to 2022-10-20 on `master` (51 commits) and to 2023-01-11 on `Production` (60 commits). Authors visible in the log include silmaanie, Heinrich du Plessis, heinrichoverdrive, Ettiene, and bitbucket-pipelines.
- No tags. No README. No contribution guide.
- `bin/console` is the Symfony console entry. No project-specific commands were found under `src/`.
- Local URLs hardcoded for a platform on port 8000 and a tool on port 8001 match `symfony serve`'s usual port for the platform side. No `compose` service runs PHP, so the app is not started by `docker compose up` alone.
- Sonar files named in the onboarding scan (`sonar-project.properties`, `sonarqube_scan.bat`) are not in git.

## Monitoring

No application metrics, APM, or log-shipping config is in the repository. `framework.yaml` sets `php_errors.log: true`, which is Symfony's default of logging to the PHP logger. No Monolog bundle is in `composer.json`. Deploy debug is the CodeDeploy pipe flag `DEBUG: 'TRUE'`.

## Related documents

- `project-plan.md` — purpose, branches, milestones, status.
- `prd.md` — workflows and FR-001–FR-011.
- `features.md` — what is on `master`, what is only on `Production`.
- `business-rules.md` — BR-001–BR-034, all inferred from the tree.
- `silicon_ark/ClickPortal/` — a different repository on the `clicklearning.org` domain. No code link was found in either direction.
