# Technical documentation — Click Foundation - LTI Integration Platform

Evidence date: 2026-10-19. Source: `git@bitbucket.org:overdrive/click-foundation-lti-integration-platform.git`, `master` at `6731ace`.

Secret values that are committed in `.env` and the PEM bodies under `config/secrets/dev/` are intentionally omitted.

## Repository layout

```text
.env
.gitignore
bin/console
composer.json
composer.lock
config/bundles.php
config/packages/*.yaml
config/preload.php
config/routes.yaml
config/routes/annotations.yaml
config/routes/framework.yaml
config/routes/lti1p3.yaml
config/secrets/dev/new_public_key.pem
config/secrets/dev/private.key
config/secrets/dev/public.key
config/services.yaml
public/index.php
src/Controller/DefaultController.php
src/Controller/RegistrationRepository.php
src/Controller/UserAuthenticationResult.php
src/Controller/UserAuthenticator.php
src/Kernel.php
symfony.lock
translations/.gitignore
```

`src/Controller/.gitignore` and `translations/.gitignore` are empty files. There is no `templates/`, `tests/`, `docker-compose.yml`, `Dockerfile`, `README`, `phpunit.xml`, or CI config. `vendor/` is gitignored and is not in the commit.

`sonar-project.properties` and `sonarqube_scan.bat` were listed when the onboarding task was created. They are not in `6731ace`.

## Stack

| Item | Value | Evidence |
|---|---|---|
| Language | PHP `>=8.0.2` | `composer.json` |
| Application framework | Symfony 5.4. Locked `symfony/framework-bundle` v5.4.9, `symfony/runtime` v5.4.8, `symfony/console` v5.4.9 | `composer.json`, `composer.lock` |
| LTI | `oat-sa/bundle-lti1p3` 6.1.1 (`ad5d37fa`), `oat-sa/lib-lti1p3-core` 6.7.1 (`42b06bd`) | `composer.lock` |
| HTTP messages | `nyholm/psr7` 1.5.0, plus the bundle's `symfony/psr-http-message-bridge` | `composer.lock`, `config/packages/nyholm_psr7.yaml` |
| Annotations | `doctrine/annotations` 1.13.2. The only route uses `@Route` | `composer.lock`, `DefaultController` |
| Dump | `symfony/var-dumper` v5.4.9 in `packages`, not `packages-dev` | `composer.lock` (`packages-dev` is `[]`) |
| License field | `proprietary` | `composer.json`. The OAT bundle's own license in the lock is LGPL-2.1 |

`composer.json` `extra.symfony.require` is `5.4.*`. `symfony.lock` still records `symfony/http-kernel`, `symfony/http-foundation`, `symfony/cache`, `symfony/dependency-injection`, and `symfony/runtime` at 6.0.x. `composer.lock` pins `http-kernel`, `http-foundation`, `cache`, and `dependency-injection` to v5.4.9 and `runtime` to v5.4.8. A `composer install` follows `composer.lock`.

Enabled bundles (`config/bundles.php`): FrameworkBundle, SecurityBundle, Lti1p3Bundle, all environments.

## Runtime shape

`public/index.php` returns a `Kernel` built with `APP_ENV` and `APP_DEBUG`. `src/Kernel.php` uses `MicroKernelTrait` and does not override route or container configuration, so Symfony 5.4's default micro-kernel imports apply:

- `config/routes/{env}/*.yaml`
- `config/routes/*.yaml`
- `config/routes.yaml` when that file exists

Both `config/routes.yaml` and `config/routes/annotations.yaml` import `src/Controller/` as annotation controllers and import `src/Kernel.php` the same way. The controller file declares one route, name `default_list`, path `/default`. Loading it twice is expected to fail route compilation with a duplicate route name. Provenance: **Inferred**. Not confirmed by booting.

`config/services.yaml` autowires and autoconfigures `App\` except `DependencyInjection/`, `Entity/`, and `Kernel.php`. Those excluded directories are not in the tree. These classes therefore become services:

| Class | Constructor | Wiring note |
|---|---|---|
| `DefaultController` | `RegistrationRepositoryInterface`, `LtiResourceLinkLaunchRequestBuilder` | Both exist as bundle services. The builder is `public: true` in the bundle. The controller also calls `new` for OIDC classes and does not use the bundle's `OidcInitiator` / `OidcAuthenticator` services. |
| `RegistrationRepository` | `array $registrations` | No binding supplies the array. Symfony cannot autowire a bare `array` argument. **Inferred:** container compile fails on this service even though the action does not inject this class. |
| `UserAuthenticator` | two `bool` arguments, no defaults | No scalar binding. **Inferred:** container compile fails on this service. The action constructs its own instance with `new`. |
| `UserAuthenticationResult` | `bool` plus optional identity, used only via `new` | Still registered. The required `bool` has the same autowire problem. **Inferred.** |

The bundle defines `OAT\Library\Lti1p3Core\Security\User\UserAuthenticatorInterface: ~`, which expects the application to provide that service. This app does not alias it in `services.yaml`. The class that implements the interface cannot be autowired as written. Provenance of the bundle line: locked bundle `services.yaml`. Provenance of the failure: **Inferred**.

`App\Controller\RegistrationRepository` duplicates the find / findByClientId / findByPlatformIssuer / findByToolIssuer methods. The bundle aliases `RegistrationRepositoryInterface` to its own repository, built from yaml by `RegistrationRepositoryBuilder`. The application class is not that alias.

## HTTP surface

Application route:

| Name | Method | Path | Controller |
|---|---|---|---|
| `default_list` | annotation does not set `methods`, so Symfony accepts every method | `/default` | `DefaultController::list` |

The `@Route` line sets the path and the name only. It does not restrict the method to GET. Provenance: **Implemented**.

Bundle routes imported by `config/routes/lti1p3.yaml`, from bundle 6.1.1:

| Name in the bundle file | Methods | Path |
|---|---|---|
| `lti1p3_jwks` | GET | `/lti1p3/.well-known/jwks/{keySetName}.json` |
| `lti1p3_message_oidc_authentication` | GET, POST | `/lti1p3/oidc/authentication` |
| `lti1p3_message_oidc_initiation` | GET, POST | `/lti1p3/oidc/initiation` |
| `lti1p3_service_oauth2_access_token_creation` | POST | `/lti1p3/auth/{keyChainIdentifier}/token` |

Dev-only: `/_error` from FrameworkBundle when `APP_ENV=dev`.

No API Platform, no Twig template directory, no asset build.

## LTI configuration

See `config/packages/lti1p3.yaml`. Summary:

- Key chains `platformKey` (`platformSet`) and `toolKey` (`toolSet`) share `config/secrets/dev/public.key` and `private.key`.
- Platform `localPlatform`: name `Click Learner Portal Platform`, audience `http://127.0.0.1:8000/platform`.
- Tool `devkitTool`: name `LTI 1.3 DevKit (as tool)`, audience `http://127.0.0.1:8001/tool`, `launch_url` `http://127.0.0.1:8001/api/v1/auth/launch-lti-1p3/`.
- Registration `local`: client id `common_client_id_value`, deployment `deploymentId1`, platform JWKS on port 8000 `platformSet.json`, tool JWKS on port 8001 `deliver.json`.

`/default` does not use the yaml `launch_url`. It sets the resource-link URL to `http://127.0.0.1:8001/tool/launch`. Locked `LtiResourceLinkLaunchRequestBuilder` prefers the resource-link URL.

The action then:

1. Builds the platform-originating resource-link message with login hint `loginHint`.
2. Runs `OidcInitiator` (one required constructor argument; nonce generator and payload builder default inside the library) against that message URL.
3. Runs `OidcAuthenticator` with `UserAuthenticator(true, true)`.
4. `dump()`s both results.
5. Returns the authenticator's `toHtmlRedirectForm()`.

`createServerRequest` supports GET query params and POST parsed bodies. Both calls in `list` use GET.

## Security

`config/packages/security.yaml`:

- `enable_authenticator_manager: true`
- Password hasher for `PasswordAuthenticatedUserInterface` is `auto`. No class in `src/` implements that interface. A `when@test` block lowers the hasher cost. There is no test suite.
- Provider `users_in_memory` with `memory: null`
- Firewall `dev` disables security for profiler, web debug toolbar, css, images, and js. Those packages are not in `composer.json`.
- Firewall `main` is `lazy: true` with that provider and no authenticator
- No access_control rules

`Lti1p3Bundle` registers three security listener factories (platform message, tool message, service). This `security.yaml` does not reference them.

Committed secrets, values not reproduced:

| Location | What is committed |
|---|---|
| `.env` | `APP_SECRET` and `LTI1P3_SERVICE_ENCRYPTION_KEY` under the Symfony and OAT section markers. `APP_ENV=dev`. The file header says not to put production secrets in it. |
| `config/secrets/dev/private.key` | A private key PEM. Referenced by both LTI key chains. |
| `config/secrets/dev/public.key` | The matching public key PEM. |
| `config/secrets/dev/new_public_key.pem` | An SSH2 public key (`Comment: "rsa-key-20220530"`). Not referenced. |

`.gitignore` ignores `.env.local`, `.env.*.local`, `config/secrets/prod/prod.decrypt.private.php`, `public/bundles/`, `var/`, and `vendor/`. It does not ignore `.env` or `config/secrets/dev/`.

Session cookies: `cookie_secure: auto`, `cookie_samesite: lax`, handler `null` (native PHP sessions). CSRF protection is commented out in `framework.yaml`. `http_method_override` is false.

## Persistence, jobs, and integrations

- Cache: filesystem `app` cache. Redis URL is commented (`redis://localhost`) and not active.
- Database: none. No Doctrine ORM bundle, no entity directory.
- Queue / workers / cron: none. `bin/console` has no application commands.
- Storage: local key files only.
- Mail, monitoring, error tracking: none. `framework.php_errors.log` is true, which logs PHP errors through Symfony. No logger bundle is configured in `config/packages/`.
- Outbound HTTP from `src/`: none. The HTML form targets the tool URL. Guzzle is a transitive dependency of the LTI core library (locked `guzzlehttp/guzzle` 7.4.2) and is used by the bundle JWKS fetcher and service client, not by an application class.

## Environment

Committed `.env` is the only env file. It documents the usual Symfony overlay order (`.env`, `.env.local`, `.env.$APP_ENV`, `.env.$APP_ENV.local`) in comments. No `.env.prod` or `.env.test` is in Git.

`config/preload.php` requires `var/cache/prod/App_KernelProdContainer.preload.php` when that file exists. Nothing in the repo generates it; that happens after a production cache warmup.

No web server config is committed. The front controller is `public/index.php`. How it is hosted (PHP built-in server, nginx, Apache) is **Unknown**.

## Development and test process

Not described in the repository. There is no README, Makefile, Composer test script, PHPUnit config, or `tests/` directory. Composer scripts run `cache:clear` and `assets:install` after install and update. `symfony/flex` is a dependency. `allow-contrib` is false.

`config/packages/routing.yaml` sets `utf8: true`. In `prod`, `strict_requirements` is null.

## Flex metadata mismatch

`symfony.lock` recipe versions are not the same fact as installed package versions. Examples that matter:

| Package | `symfony.lock` version field | `composer.lock` version |
|---|---|---|
| `symfony/framework-bundle` | `6.0` (recipe version inside the same entry is `5.4`) | v5.4.9 |
| `symfony/runtime` | v6.0.8 | v5.4.8 |
| `symfony/http-kernel` | v6.0.8 | v5.4.9 |
| `symfony/console` | `6.0` | v5.4.9 |
| `symfony/routing` | `6.0` | v5.4.8 |

Provenance: **Implemented** disagreement between the two lock files. It does not by itself change the install.

## Related trees

Do not treat these as modules of this repository:

- `silicon_ark/Click Foundation - LTI Integration Tool` — DevKit snapshot `fbf20a6`, different Git remote.
- `silicon_ark/click-learner-portal-platform` — launcher with a `tao` registration and Ignite URL, different Git remote.

A comment in `DefaultController` mentions the Ignite host. The live launch URL matches the DevKit `/tool/launch` path. Neither link is confirmed by a call from this repository to those repositories.
