# Technical documentation — Click Foundation - LTI Integration Tool

Evidence date: 2026-10-19. Source: `git@bitbucket.org:overdrive/click-foundation-lti-integration-tool.git`, `master` `fbf20a6` (2022-05-30). Only remote branch.

The tree is the OAT TAO LTI 1.3 DevKit (application version parameter 2.4.2) with a local LTI registration labelled Click Learner Portal Platform. Upstream project docs remain in `README.md` and `doc/`. This file records structure, runtime, and configuration. It does not replace those docs, and it does not repeat secret values.

## Stack

| Item | Value | Evidence |
|---|---|---|
| Language | PHP `>=7.2`, extensions ctype, iconv, json | `composer.json` |
| Framework | Symfony 5.3. Flex. `symfony/framework-bundle` v5.3.8 | `composer.json`, `composer.lock` |
| UI | Twig 2.12 or 3, Symfony Form | `composer.json`, `templates/` |
| HTTP | nginx + PHP-FPM, front controller `public/index.php` | `docker/nginx/nginx.conf` |
| Cache | Redis via Predis. App cache adapter `cache.adapter.redis` | `config/packages/cache.yaml` |
| LTI | `oat-sa/bundle-lti1p3` 6.1.0 and the libraries listed below | `composer.lock` |
| Licence | GPL-2.0-only | `composer.json`, `LICENSE` |
| Tests | PHPUnit bridge, Symfony PHPUnit 8.5, one kernel test | `phpunit.xml.dist`, `tests/Unit/CoreTraitsTest.php` |
| Logging | Monolog. Production handler writes to stdout | `config/packages/prod/monolog.yaml` |

Locked OAT packages:

| Package | Lock version |
|---|---|
| oat-sa/bundle-health-check | 2.1.0 |
| oat-sa/bundle-lti1p3 | 6.1.0 |
| oat-sa/lib-lti1p3-core | 6.7.1 |
| oat-sa/lib-lti1p3-ags | 1.3.0 |
| oat-sa/lib-lti1p3-basic-outcome | 4.1.1 |
| oat-sa/lib-lti1p3-deep-linking | 4.0.0 |
| oat-sa/lib-lti1p3-nrps | 7.0.0 |
| oat-sa/lib-lti1p3-proctoring | 0.4.0 |
| oat-sa/lib-lti1p3-submission-review | 0.1.0 |

`vendor/` is committed. A fresh Composer install is still what `doc/installation.md` tells a developer to run.

There is no Doctrine, no SQL database, no Messenger, no mailer, and no queue worker in `composer.json`.

## Application layout

```text
bin/                  Symfony console
config/packages/      Symfony and lti1p3 configuration
config/devkit/        users, claims, deep-linking samples
config/keys/          PEM public and private keys (committed)
config/routes.yaml    Application routes
config/services.yaml  DI, version, LTI service controllers
doc/                  installation, API, CLI, OpenAPI
docker/               nginx, php-fpm, kube Dockerfiles
public/               Front controller and css
src/Action/           HTTP controllers (UI, API, tool AJAX)
src/Ags/              Line item, score, result repositories
src/BasicOutcome/     Basic outcome cache processor
src/Command/          devkit:create:message:launch
src/Nrps/             Membership repository, default factory, service builder
src/Proctoring/       Assessment repository and ACS processor
src/Security/         API key firewall and LTI user authenticator
src/Service/          LtiMessageBuilder
templates/            Twig
tests/Unit/           CoreTraitsTest.php
```

Namespace: `App\` from `src/`. Controllers are invokable services tagged `controller.service_arguments`. Routes are YAML, not attributes.

`Kernel.php` is a custom kernel (it references `RouteCollectionBuilder`). Bundles are enabled from `config/bundles.php`.

## Request map

Application routes are all in `config/routes.yaml`. Bundle routes:

- `config/routes/lti1p3.yaml` imports JWKS, platform message, tool message, and platform service routes from `oat-sa/bundle-lti1p3`.
- `config/routes/health_check.yaml` imports `/health-check` and `/ping`.

Security firewalls (`config/packages/security.yaml`), first match wins:

| Firewall | Pattern | Mechanism |
|---|---|---|
| api_area | `^/api/` | Custom `api_key`, stateless |
| tool_message_area | `^/tool/launch` | `lti1p3_message_tool`, stateless |
| platform_message_area | `^/platform/message/return` | `lti1p3_message_platform`, stateless |
| platform_service_ags_area | `^/platform/service/ags/` | `lti1p3_service` with four AGS scopes |
| platform_service_nrps_area | `^/platform/service/nrps` | NRPS readonly scope |
| platform_service_acs_area | `^/platform/service/acs` | ACS `control.all` |
| platform_service_basic_outcome_area | `^/platform/service/basic-outcome` | Basic Outcome scope |
| dev | profiler and asset prefixes | security disabled |
| main | everything else | anonymous |

The API authenticator is registered by `ApiKeyFactory` (`getKey()` returns `api_key`) as a `pre_auth` factory. `ApiKeyProvider` compares the bearer value to parameter `application_api_key`.

Wiring note, not executed: the factory's child definition `setArgument(0, userProvider)` replaces the provider's first argument. `services.yaml` constructs `ApiKeyProvider` with `@parameter_bag`. The class calls `ParameterBagInterface::get`. If the firewall service really receives the in-memory user provider, authentication would error instead of comparing keys. **Requires Confirmation**.

`UserAuthenticator` is bound to `OAT\Library\Lti1p3Core\Security\User\UserAuthenticatorInterface` and is the login-hint handler for OIDC.

## LTI configuration

File: `config/packages/lti1p3.yaml`.

- Key chains `toolKey` (`toolSet`) and `platformKey` (`platformSet`) both point at `config/keys/public.key` and `config/keys/private.key`. Passphrases are null.
- Platform id `localPlatform`, name `Click Learner Portal Platform`, host `http://127.0.0.1:8000`.
- Tool id `devkitTool`, name `LTI 1.3 DevKit (as tool)`, host `http://127.0.0.1:8001`.
- Registration id `local`, client id `common_client_id_value`, deployment `deploymentId1`, order 1.
- `tool_jwks_url` is unquoted YAML and the parsed value includes a trailing double-quote: `http://127.0.0.1:8001/lti1p3/.well-known/jwks/deliver.json"`. The key set name is `toolSet`, not `deliver`. This was confirmed by reading the compiled dev container dump under `var/cache/dev`, which is present in the tree.

`APP_HOST` (`http://devkit-lti1p3.localhost` in the committed `.env`) is a separate setting. `UrlGenerator` and claim defaults use it. The registration does not.

Devkit overlays, imported from `config/services.yaml`:

- `config/devkit/users.yaml` — 13 fixture users.
- `config/devkit/claims.yaml` — editor claim catalogue.
- `config/devkit/deep_linking.yaml` — five sample resources.

Displayed version is the string parameter `application_version: '2.4.2'`. Changelog section 2.4.3 and the core library lock 6.7.1 are newer than that string. OpenAPI `info.version` is `v2.4.2`.

## Persistence

`config/packages/cache.yaml`:

```yaml
framework:
    cache:
        app: cache.adapter.redis
        default_redis_provider: '%env(resolve:REDIS_CACHE_DSN)%'
```

Committed `.env` sets `REDIS_CACHE_DSN=redis://devkit_lti1p3_redis:6379`, which is the Compose service name. It is not a secret. It will not resolve outside that Docker network unless the environment is changed.

Repositories store PHP objects in a single cache item per aggregate. Keys are listed in `business-rules.md` BR-014. No schema migration exists.

Sessions use the default handler (`handler_id: null` in `framework.yaml`), not Redis.

## Environment variables

Names only. Values of secrets are in the committed `.env` and are not copied here.

| Name | Role |
|---|---|
| APP_ENV | Symfony environment. Committed file sets `dev` |
| APP_HOST | Absolute URL prefix for generated links |
| APP_SECRET | Framework secret |
| APP_API_KEY | Bearer token for `/api/` |
| APP_DEBUG | Set by `config/bootstrap.php` from the environment |
| REDIS_CACHE_DSN | Redis DSN for the app cache |
| REDIS_COMMANDER_URL | Documented commander URL in `.env` |
| LTI1P3_SERVICE_ENCRYPTION_KEY | OAT bundle service encryption key |
| TEST_KEYS_ROOT_DIR | PHPUnit only, points at library test RSA keys |

`.env.test` exists beside `.env`. `.gitignore` does not ignore `.env`. `config/keys/private.key` is tracked.

**Requires Confirmation:** rotate `APP_SECRET`, `APP_API_KEY`, `LTI1P3_SERVICE_ENCRYPTION_KEY`, and the committed key pair if this tree was ever shared, and stop storing them in Git. This document does not judge whether they are production credentials. They are present on `master`.

## Docker and runtime

`doc/installation.md` is the setup guide: Docker and Docker Compose, `docker-compose up -d`, then a Composer container to `composer install`. Documented URL: `http://devkit-lti1p3.localhost`.

Compose services (`docker-compose.yml`):

| Service | Image / build | Ports |
|---|---|---|
| devkit_lti1p3_traefik | traefik:v2.4, API insecure, Docker provider | 80, 8080 |
| devkit_lti1p3_nginx | nginx:stable, host rule `devkit-lti1p3.localhost` | exposed 443, routed by Traefik on 80 |
| devkit_lti1p3_phpfpm | build `docker/phpfpm` (`php:8-fpm`, redis and igbinary PECL, intl, gd, opcache, zip, calendar, sockets) | 9000 |
| devkit_lti1p3_redis | redis:latest, `--appendonly yes` | 6379 |
| devkit_lti1p3_redis_commander | rediscommander/redis-commander | 8081 |

Nginx root is `/var/www/html/public`. `client_max_body_size` is 8m. PHP is reached at `devkit_lti1p3_phpfpm:9000`. The nginx `server_name` is `devkit_lti1p3.docker.localhost`, which differs from the Traefik host rule `devkit-lti1p3.localhost`.

`docker/kube/Dockerfile` is a separate PHP 8 FPM Alpine build with nginx in a later stage (the file continues past the FPM stage). No Kubernetes manifests, Helm chart, or cluster name are in the repository. Deployment target is **Unknown**.

Traefik is started with `--api.insecure=true` and the Docker socket mounted read-only. Provenance: **Implemented** in Compose. Suitable only as a local stack. **Requires Confirmation** before any shared environment uses this file unchanged.

## Logging, health, and errors

Production Monolog: fingers-crossed at info, buffer 50, excluding HTTP 404 and 405, nested handler `php://stdout`. Console handler drops `event` and `doctrine` channels. There is no Doctrine usage; the channel exclusion is the Symfony recipe default.

Dev and test have their own `monolog.yaml` files.

`ErrorHandlerSubscriber` logs every exception and splits JSON (`/api/`) from HTML.

Health routes come from the OAT health-check bundle: `/ping`, `/health-check`. They are not listed in the API key firewall.

`GET /php-info` renders PHP info and is anonymous.

## Auth and crypto

- LTI messages are signed with the PEM files in `config/keys/`. Both platform and tool use the same pair.
- OAuth2 token URL configured for the platform key chain: `http://127.0.0.1:8000/lti1p3/auth/platformKey/token`.
- JWKS URLs are as in the registration. The tool URL looks malformed (see above).
- API authentication is a shared bearer string, not OAuth, for `/api/` only.
- CSRF is disabled in framework config (commented).
- No CAPTCHA, no rate limiter package, and no row-level security. Those controls are not part of this application.

## Background work

The only command owned by this app is `devkit:create:message:launch` (`--type` / `-t`, `--parameters` / `-p`, verbosity `-v`). It is a manual CLI, not a scheduler. No cron, Messenger transport, or worker is configured.

## Testing and quality

`phpunit.xml.dist` bootstraps `tests/bootstrap.php`, forces `APP_ENV=test`, and sets `SYMFONY_PHPUNIT_VERSION` to 8.5. The suite directory is `tests`. The only test, `CoreTraitsTest`, boots the kernel and checks that `OAT\Library\Lti1p3Core\Tests\Traits\DomainTestingTrait` can build a registration and a JWT. It is not a product scenario test.

`composer.json` require-dev is only `symfony/phpunit-bridge`.

No CI configuration (no bitbucket-pipelines file, no GitHub workflow) was found. Sonar properties named in the onboarding inventory are not on `master`.

## Local development notes from the repo

- Installation steps: `doc/installation.md`.
- HTTP API: `doc/api.md` and `doc/openapi/devkit.yaml`.
- CLI examples: `doc/cli.md`. Those examples use registration `devkit` and host `devkit-lti1p3.localhost`. This checkout's registration id is `local` and its audiences are `127.0.0.1` ports 8000 and 8001.
- Windows volume flags for the Composer container are already noted in `doc/installation.md`.

## Security observations for human review

These are discrepancies, not a remediation plan:

1. `.env` is tracked and holds `APP_SECRET`, `APP_API_KEY`, and `LTI1P3_SERVICE_ENCRYPTION_KEY`.
2. `config/keys/private.key` is tracked, and both LTI roles share it.
3. The operator UI, `/php-info`, deep-linking response, and proctoring response are anonymous.
4. Record deletes are HTTP GET.
5. Traefik's insecure API is published on port 8080 in Compose.
6. The tool JWKS URL does not match the tool key set name and includes a stray quote.
7. Client id `common_client_id_value` is a placeholder. If a real platform was registered with that id, that fact is not in the repo.
