# PRD — Click Foundation - LTI Integration Platform

Evidence date: 2026-10-19. Source: `git@bitbucket.org:overdrive/click-foundation-lti-integration-platform.git`, `master` at `6731ace` (2022-05-30 17:46:00 +0200, "Initial commit"). Only remote branch. Parent commit `16e328c` added `.gitignore` only.

There is no PRD, README, or other product document in the source repository. Requirements below were read from controllers, config, and the locked OAT libraries those files call. Provenance is marked on each requirement.

The Bitbucket name says Click Foundation. The application code does not describe a Click Foundation product beyond naming the configured platform "Click Learner Portal Platform". That naming is **Implemented**. A product decision that this repository is the Click learner-portal integration is **Unknown / Requires Confirmation**.

Silicon Ark already documents `Click Foundation - LTI Integration Tool` (the TAO LTI 1.3 DevKit) and `click-learner-portal-platform` (a later launcher with client id `click-learning` and audience `https://clms.clicklearning.org/platform`). This repository is neither tree. Shared display names and localhost ports are not evidence that one process calls the other.

## Product purpose

A caller that can open `/default` receives an HTML form that auto-posts an LTI 1.3 resource-link `id_token` to a tool URL hard-coded in the controller. The `@Route` annotation does not set `methods`, so the route is not limited to GET.

Provenance: **Implemented** (`src/Controller/DefaultController.php`). No document states who that caller is.

## Users and roles

| Actor | What the code shows | Provenance |
|---|---|---|
| Caller of `/default` | Any client that can request `/default`. The route does not restrict the HTTP method. Symfony security has no access-control rule for this path. | **Implemented** |
| Launch user | The login hint is the literal string `loginHint`. `UserAuthenticator` returns success with a null user identity. No learner id, name, or email is loaded. | **Implemented** |
| LTI platform | This application, registration `local`, audience `http://127.0.0.1:8000/platform`, display name Click Learner Portal Platform. | **Implemented** |
| LTI tool | Registration tool `devkitTool`, display name "LTI 1.3 DevKit (as tool)", audience `http://127.0.0.1:8001/tool`. The resource link the action launches is `http://127.0.0.1:8001/tool/launch`. | **Implemented** as URLs. The tool's behaviour is outside this repo |
| IMS role | The live launch call does not pass a roles array. A learner role URI appears only in code after the `return`, which does not run, and in a comment block. | **Implemented** as unused source. No role is attached by the live call |

No staff role, admin role, login screen, or user provider with users exists. `security.yaml` has an empty in-memory user provider and no access-control entries.

## Registration in force

One registration is configured. Identifier: `local`. Client id: the literal `common_client_id_value`. Deployment id: `deploymentId1`.

| Side | Key | Name and URLs |
|---|---|---|
| Platform | `localPlatform` | Click Learner Portal Platform. Audience `http://127.0.0.1:8000/platform`. OIDC authentication `http://127.0.0.1:8000/lti1p3/oidc/authentication`. Token `http://127.0.0.1:8000/lti1p3/auth/platformKey/token`. JWKS `http://127.0.0.1:8000/lti1p3/.well-known/jwks/platformSet.json`. Key chain `platformKey`, key set name `platformSet`. |
| Tool | `devkitTool` | LTI 1.3 DevKit (as tool). Audience `http://127.0.0.1:8001/tool`. OIDC initiation `http://127.0.0.1:8001/lti1p3/oidc/initiation`. `launch_url` `http://127.0.0.1:8001/api/v1/auth/launch-lti-1p3/`. JWKS `http://127.0.0.1:8001/lti1p3/.well-known/jwks/deliver.json`. Key chain `toolKey`, key set name `toolSet`. |

Both key chains read the same files: `config/secrets/dev/public.key` and `config/secrets/dev/private.key`. Passphrase is null. The tool key set name is `toolSet`. The tool JWKS URL path is `deliver.json`, not `toolSet.json`.

`config/secrets/dev/new_public_key.pem` is an SSH2 public key file. No yaml or PHP in this repository references it.

Provenance: **Implemented** (`config/packages/lti1p3.yaml`).

## Major workflows

### Launch a resource link

`/default` (`DefaultController::list`, route name `default_list`). The annotation does not restrict the HTTP method.

1. Take `RegistrationRepositoryInterface` and `LtiResourceLinkLaunchRequestBuilder` from the container. The bundle aliases the repository interface to `OAT\Bundle\Lti1p3Bundle\Repository\RegistrationRepository`, which is built from `lti1p3.yaml`. The action then calls `find('local')`.
2. Build an `LtiResourceLink` whose identifier is the literal `resourceLinkIdentifier`, whose URL is `http://127.0.0.1:8001/tool/launch`, and whose title is `LTI Resource Link`. A comment above the identifier says it needs to be unique. The code always sends the same identifier.
3. Call `buildLtiResourceLinkLaunchRequest` with that link, the registration, and login hint `loginHint`. The call does not pass a deployment id, a roles list, or optional claims.
4. The locked library (`oat-sa/lib-lti1p3-core` 6.7.1, `LtiResourceLinkLaunchRequestBuilder`) uses the resource-link URL when it is set, and uses the registration tool `launch_url` only when the resource-link URL is absent. This action sets the URL, so the yaml value `http://127.0.0.1:8001/api/v1/auth/launch-lti-1p3/` is not the launch target of this request.
5. Construct `new OidcInitiator($this->repo)` and call `initiate` on a PSR-7 request built from the launch message URL. The initiator looks up the registration from the message `iss` and optional `client_id`, requires a tool key chain, and returns a message addressed to the platform OIDC authentication URL.
6. Construct `new UserAuthenticator(true, true)`. The comment on that line says `TESTING ONLY, 2nd param needs to be false`. The constructor parameters are `$withAuthenticationSuccess` and `$withAnonymous`. This call sets both to true.
7. Construct `new OidcAuthenticator($this->repo, $userAuthenticator)` and call `authenticate` on the initiation result URL. The authenticator validates the message hint with the platform public key, calls `UserAuthenticator`, and, because the result is anonymous, does not copy a user identity onto the payload. It returns a message whose parameters are `id_token` and `state`, aimed at the target link URI carried in the hint.
8. Call `dump()` on the initiation result and on the authentication result. `symfony/var-dumper` v5.4.9 is in the production `packages` section of `composer.lock`.
9. Return `new Response($oidcAuthenticationResult->toHtmlRedirectForm())`.

The method does not read a query string, a path parameter, a session, or a database. There is no validation of an external learner id because none is accepted.

Provenance: **Implemented** in `DefaultController`. Steps 4, 5, and 7 follow the locked library source at core `42b06bd` (the `composer.lock` reference). Vendor code is not committed in this repository; those library statements were read from that tag.

### Code that does not run

After the `return`, `DefaultController::list` still contains echo statements and a second resource-link build whose URL is `http://tool.com/resource`, with the IMS learner role and a custom claim `myCustomClaim`. That second build calls `$registrationRepository->find('local')`. `$registrationRepository` is not defined on the live path. None of this runs.

A comment block above the live path builds a registration in PHP (platform name "Click Learner Portal", hosts `http://devkit-lti1p3-master-tool/...`) and a `PlatformOriginatingLaunchBuilder` aimed at `https://testrunner-ignite.prod.gcp-eu.taocloud.org/api/v1/auth/launch-lti-1p3/`. The block is commented out.

Provenance: **Implemented** as source text. It is not behaviour of `/default`.

### LTI protocol endpoints this app exposes

`config/routes/lti1p3.yaml` imports four route files from `oat-sa/bundle-lti1p3` 6.1.1 (lock reference `ad5d37fa`). The paths below are the contents of those files:

| Path | Methods | Bundle controller |
|---|---|---|
| `/lti1p3/.well-known/jwks/{keySetName}.json` | GET | `JwksAction` |
| `/lti1p3/oidc/authentication` | GET, POST | `OidcAuthenticationAction` |
| `/lti1p3/oidc/initiation` | GET, POST | `OidcInitiationAction` |
| `/lti1p3/auth/{keyChainIdentifier}/token` | POST | `OAuth2AccessTokenCreationAction` |

The platform side of the registration points at the authentication, token, and `platformSet` JWKS URLs on `http://127.0.0.1:8000`. The tool side points at initiation and JWKS on port 8001, which is not this process's configured audience host. This application still imports the tool initiation route, so the same paths exist on whichever host serves this Symfony app.

`security.yaml` does not attach the bundle's LTI platform-message, tool-message, or service listener factories. Those factories are registered by `Lti1p3Bundle::configureSecurityExtension` and are unused until a firewall names them. The `main` firewall has no authenticator. What each bundle action still checks internally was not re-tested here.

`GET /` is not declared. No homepage exists.

`config/routes/framework.yaml` loads FrameworkBundle error routes under `/_error` when `APP_ENV` is `dev`. The committed `.env` sets `APP_ENV=dev`.

Provenance: **Implemented**.

## Integrations

| System | Direction | Data this repo exchanges | Auth visible here | Failure behaviour in this repo |
|---|---|---|---|---|
| Tool at `http://127.0.0.1:8001/tool/launch` | Outbound, via the HTML form returned to the caller | Resource-link `id_token` and `state` | Platform signature using `platformKey` | Not handled in this repo. The action does not catch library exceptions. |
| Tool OIDC initiation and JWKS on port 8001 | Configured on the registration. The `/default` path runs initiation in process against the message URL and does not HTTP-call port 8001. | Registration fields | Tool key chain is the same key files as the platform | **Unknown** at runtime. This investigation did not boot the app. |
| TAO Ignite `testrunner-ignite.prod.gcp-eu.taocloud.org` | Named only in a comment | None on the live path | None | Not called |
| OAuth2 clients of this platform | Inbound `POST /lti1p3/auth/{keyChainIdentifier}/token` | Token request and response, implemented by the bundle | Bundle OAuth2 server, encryption key from `LTI1P3_SERVICE_ENCRYPTION_KEY` | Bundle behaviour. Not wrapped by application code. |

No HTTP client call is written in `src/`. Guzzle is present because `oat-sa/lib-lti1p3-core` requires it.

## Constraints

- PHP `>=8.0.2` with `ext-ctype` and `ext-iconv`. The LTI bundle also requires `ext-json`. The core library requires `ext-openssl`. Provenance: **Implemented** (`composer.json`, `composer.lock`).
- Symfony constraint `5.4.*`. Locked framework bundle is v5.4.9.
- License field in `composer.json` is `proprietary`.
- Locales: `framework.default_locale` is `en`. The `translations/` directory contains `.gitignore` only.
- Sessions are enabled (native storage, `cookie_samesite: lax`, `cookie_secure: auto`). `/default` does not read or write the session.
- No database, queue, or object store is configured.

## Error conditions visible in the code

The action has no try/catch. These failures follow from the code and the locked library, and were not executed in this investigation:

| Condition | Where it comes from | Provenance |
|---|---|---|
| `find('local')` returns null | `RegistrationRepository::find` in the bundle returns null for an unknown id. The builder then receives null. | **Inferred** outcome: the request fails inside the library. The application does not map this to a specific HTTP status. |
| OIDC initiation cannot find a registration for `iss` | `OidcInitiator` throws `LtiException` "Cannot find registration for OIDC request" | **Implemented** in locked core 6.7.1. Not caught here. |
| Registration has no tool key chain | `OidcInitiator` throws. This registration sets `tool_key_chain`. | **Implemented** in the library. The yaml supplies a chain. |
| User authentication returns failure | `OidcAuthenticator` throws "User authentication failure". This authenticator is constructed with success `true`, so that branch is not taken by `/default`. | **Implemented** |
| Duplicate route name `default_list` | `config/routes.yaml` and `config/routes/annotations.yaml` both import `src/Controller/` as annotation routes. Symfony 5.4 `MicroKernelTrait` imports both `config/routes.yaml` and `config/routes/*.yaml`. | **Inferred**. The container was not booted. See technical-documentation.md. |
| Container cannot autowire `App\Controller\RegistrationRepository` or `App\Controller\UserAuthenticator` | Both are under the `App\` resource in `config/services.yaml`. The first constructor argument is an untyped-bound `array`. The second requires two `bool` arguments with no defaults. | **Inferred** from Symfony autowiring rules. Not confirmed by a compile. |

## Assumptions not promoted to requirements

- That port 8001 is the Click Foundation DevKit repository. The URL shape matches. The repositories are separate. **Requires Confirmation**.
- That a real learner is represented by `loginHint`. The code uses that fixed string and an anonymous identity. **Implemented** as the fixed string. A learner mapping is **not** implemented.
- That this application is deployed at `clms.clicklearning.org` or any other public host. No such host is configured. **Unknown**.
