# PRD — click-learner-portal-platform

Evidence date: 2026-10-18.

Source: `git@bitbucket.org:overdrive/click-learner-portal-platform.git`, `master` at `99bde51` (2022-10-20). Behaviour that exists only on `origin/Production` (`131cc59`, 2023-01-11) is labelled **Production branch**. `origin/HEAD` is `master`.

LTI config names the platform **Click Learner Portal Platform**. There is no product requirements document in the source repository. Requirements below were read from controllers, config, and deploy files. Provenance is marked on each requirement.

## Users and roles

| Actor | What the code shows | Provenance |
|---|---|---|
| Caller of the launch URL | Any client that can `GET /default/{learnerId}/{quizId}/{quizEndDate}`. Symfony security has no access-control rule for this path. | **Implemented** |
| Learner | The `{learnerId}` path segment is placed in the LTI login hint. The LTI role sent with the launch is the IMS Learner membership role. The app does not load a learner record. | **Implemented** for the claim. That the id is a real learner is **Inferred** |
| LTI platform | This application, issuer `https://clms.clicklearning.org/platform` for the `tao` registration, and `http://127.0.0.1:8000/platform` for the `local` registration. | **Implemented** |
| TAO Ignite tool | Remote tool in the `tao` registration and in the launch URL built by `DefaultController`. | **Implemented** as URLs. The tool's own behaviour is outside this repo |
| Local tool | `http://127.0.0.1:8001`, used only by `LtiRequestController` and the `local` registration. | **Implemented** |
| Return-site | `click-home-learning` on `portaltest.clicklearning.org` (`master`) or `learn.clicklearning.org` (**Production branch**). This app does not serve that page. | **Implemented** as a claim value |

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

## Major workflows

### Launch a TAO quiz

`GET /default/{learnerId}/{quizId}/{quizEndDate}` (`DefaultController::list`, route name `default_list`).

1. Load LTI registration `tao` from `config/packages/lti1p3.yaml`.
2. Build an `LtiResourceLink` whose identifier is the literal `resourceLinkIdentifier` and whose URL is `https://testrunner-ignite.prod.gcp-eu.taocloud.org/api/v1/auth/launch-lti-1p3/` plus the `{quizId}` path segment. The title is `LTI Resource Link`.
3. Build a resource-link launch with login hint `{learnerId}`, deployment id `1`, and role `http://purl.imsglobal.org/vocab/lis/v2/membership#Learner`.
4. Attach claims:
   - `https://purl.imsglobal.org/spec/lti/claim/launch_presentation` → `return_url` = `https://portaltest.clicklearning.org/click-home-learning?learner_id={learnerId}&quiz_id={quizId}` on `master`. On **Production branch** the host is `learn.clicklearning.org` and the path and query are the same.
   - `https://purl.imsglobal.org/spec/lti/claim/custom` → `deliverySettings.closeOn` = `{quizEndDate}` concatenated with `T00:00:00+00:00`.
5. Return `toHtmlRedirectForm()` as the HTTP body.

The method does not check that the three path segments are present beyond Symfony's route match, does not check their format, and does not read a database. A local `PlatformOriginatingLaunchBuilder` is constructed and not used.

Provenance: **Implemented**.

### Local tool launch (API Platform)

`GET /api/ltiresourcelink/{learnerid}`.

`config/routes/api_platform.yaml` sets prefix `/api`. `src/Entity/ltirequest.php` registers one item operation, `get_lti_resource_link`, path `/ltiresourcelink/{learnerid}`, `read: false`, controller `LtiRequestController`.

`LtiRequestController::__invoke`:

1. Loads registration `local`.
2. Builds a resource link whose URL is `http://127.0.0.1:8001/tool/launch` and whose identifier is again the literal `resourceLinkIdentifier`.
3. Builds a launch whose login hint is the literal `loginHint`. The `{learnerid}` argument is not passed into the launch.
4. Runs `OidcInitiator`, then `OidcAuthenticator` with `new UserAuthenticator(true, true)`. The comment says `TESTING ONLY, 2nd param needs to be false`. The second constructor argument is `$withAnonymous`, so this call authenticates with a null user identity.
5. The HTML redirect return is commented out. The response body is `json_encode` of the authentication result URL. The response does not set a JSON content type (Symfony's default is `text/html`).

Provenance: **Implemented**.

### Home page (Production branch only)

`GET /` (`DefaultController::home`, route name `default_home`) returns an HTML document whose visible text is `clms.clicklearning.org`. This method is not on `master`.

Provenance: **Production branch**, **Implemented** on `131cc59`.

### LTI protocol endpoints configured for this platform

`lti1p3.yaml` sets these URLs for `ClickLearningPlatform`. The bundle route files are imported from `config/routes/lti1p3.yaml`. The bundle PHP was not checked out (see technical-documentation.md), so the claim is that these strings are the configured public URLs, not a live probe:

| Configured URL | Config key |
|---|---|
| `https://clms.clicklearning.org/lti1p3/oidc/authentication` | `oidc_authentication_url` |
| `https://clms.clicklearning.org/lti1p3/auth/platformKey/token` | `oauth2_access_token_url` |
| `https://clms.clicklearning.org/lti1p3/.well-known/jwks/platformSet.json` | `platform_jwks_url` on registration `tao` |

The `local` registration repeats the same paths on `http://127.0.0.1:8000`.

`UserAuthenticator` is registered as `UserAuthenticatorInterface` (`config/services.yaml`). Its `authenticate` method ignores the registration, always returns success, and, unless constructed as anonymous, returns a `UserIdentity` whose id is the login hint and whose name is the literal `Maanie`. The method body is the comment `//TODO:` plus that return. The `DefaultController` launch does not call this class. The API Platform action constructs its own anonymous instance instead of the service.

Provenance: **Implemented**.

## Integrations

| System | Direction | Data this repo exchanges | Auth visible here | Failure behaviour in this repo |
|---|---|---|---|---|
| TAO Ignite test runner | Outbound browser POST via the HTML launch form | Resource-link launch to `https://testrunner-ignite.prod.gcp-eu.taocloud.org/api/v1/auth/launch-lti-1p3/{quizId}` | LTI 1.3 registration `tao`, client id `click-learning`, deployment id `1`, platform key chain | No timeout, retry, or error page. A missing registration would surface as a PHP error. Not executed here |
| TAO Ignite auth | Config only | Tool audience, OIDC initiation URL, JWKS URL on `auth-ignite.prod.gcp-eu.taocloud.org` | Tool key chain `taoToolKey` points at the same dev key files as the platform | Not called by `DefaultController`. The controller builds the launch message itself |
| Click Learning return site | Claim only | `return_url` query `learner_id`, `quiz_id` | None in this repo | This app does not call the URL |
| Local LTI tool on port 8001 | Outbound URL from the API Platform action | Launch against `http://127.0.0.1:8001/tool/launch` | Registration `local`, client id literal `common_client_id_value` | Same as the TAO path: no handler in this app |
| AWS CodeDeploy | Outbound from Bitbucket Pipelines | Zip of the working tree, including tracked `vendor/` | `QA_*` variables on `master`; `PROD_*` variables on **Production branch**. Values are not in git | Pipeline sets `WAIT: 'true'` and `IGNORE_APPLICATION_STOP_FAILURES: 'true'`. No other failure branch |
| PostgreSQL | Config and Compose only | `DATABASE_URL` recipe default, database name `app` on `127.0.0.1:5432` | Recipe user `symfony` | No repository calls it |

ClickPortal (`clickportal.git`, documented separately) shares the `clicklearning.org` domain and has its own quiz learner-detail API. This repository does not call it. Relationship: **Unknown / Requires Confirmation**.

## Constraints

- PHP `>=8.0.2` and Symfony `5.4.*` on `master`. **Production branch** allows PHP `>=8.0` and pins Composer platform PHP to `8.2`.
- The launch route is a GET with three path segments. There is no request body and no CSRF check (`framework.yaml` leaves `csrf_protection` commented).
- CORS (`nelmio_cors.yaml`) allows origin regex `^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$` and methods GET, OPTIONS, POST, PUT, PATCH, DELETE. That does not gate same-origin browser navigation to the launch URL.
- `APP_ENV=dev` is committed in `.env` on both branch tips. **Production branch** also commits `.env.local.php` with `APP_ENV` `prod`. Symfony's own comment in `.env` says a compiled `.env.local.php` is how production env is loaded. Which file the server actually uses **Requires Confirmation**.
- Deploy destination is fixed: `/var/www/html/clms.clicklearning.org/`, owner and group `apache`.
- Key material for every LTI key chain is `config/secrets/dev/public.key` and `private.key`, including the registration whose audience is the `clms` HTTPS host. Passphrases are null.

## Requirements discovered from the implementation

### FR-001 — Launch a quiz for a learner

The system accepts `GET /default/{learnerId}/{quizId}/{quizEndDate}` and returns an HTML document produced by `toHtmlRedirectForm()` for an LTI 1.3 resource-link launch.

- Actor: any caller.
- Trigger: HTTP GET.
- Inputs: three path segments, used as strings.
- Output: HTML form body. No `Response` status is set, so Symfony's default 200 applies.
- Validation: route match only.
- Errors: none handled in the action.
- Evidence: `src/Controller/DefaultController.php`.
- Implementation: **Implemented** on `master` and **Production branch**.
- Confidence: High.

### FR-002 — Send the launch to TAO Ignite for that quiz id

The resource-link URL is the TAO test-runner launch path plus the `{quizId}` segment.

The `tao` tool entry in `lti1p3.yaml` has its own `launch_url` ending in a fixed id `04fc0ce15e09`. `DefaultController` does not read that `launch_url`. It builds a new URL from the path segment.

- Evidence: `DefaultController.php`, `config/packages/lti1p3.yaml`.
- Implementation: **Implemented**.
- Confidence: High.
- Requires confirmation: whether the yaml `launch_url` was meant to be the launch target.

### FR-003 — Identify the launch as this platform, deployment 1, learner role

The launch uses registration `tao`: client id `click-learning`, platform `ClickLearningPlatform` (name `Click Learner Portal Platform`, audience `https://clms.clicklearning.org/platform`), tool `taoTool`, deployment id list containing `1`. The launch call passes deployment id `1` and the IMS Learner role URI.

The resource-link identifier is the constant `resourceLinkIdentifier`. A comment in the controller says it "needs to be unique". The code does not make it unique per learner or quiz.

- Evidence: `lti1p3.yaml`, `DefaultController.php`.
- Implementation: **Implemented**.
- Confidence: High.

### FR-004 — Tell the tool where to send the learner afterwards

The launch presentation `return_url` is:

- `master`: `https://portaltest.clicklearning.org/click-home-learning?learner_id={learnerId}&quiz_id={quizId}`
- **Production branch**: the same path and query on `https://learn.clicklearning.org`

This application does not serve `click-home-learning`.

- Evidence: `DefaultController.php` on each branch (`91d9530` is the commit that changes the host).
- Implementation: **Implemented**, and the two branches disagree.
- Confidence: High.
- Requires confirmation: which host is approved.

### FR-005 — Tell the tool when the delivery closes

Custom claim `deliverySettings.closeOn` is `{quizEndDate}` immediately followed by `T00:00:00+00:00`.

There is no check that `{quizEndDate}` is a calendar date. A value that already contains a time would still be concatenated.

- Evidence: `DefaultController.php` comment `Delivery end date`.
- Implementation: **Implemented** concatenation. The expectation that the segment is a date only is **Inferred**.
- Confidence: High for the concatenation. Medium for the intended input shape.

### FR-006 — Expose a localhost LTI launch by learner id

`GET /api/ltiresourcelink/{learnerid}` runs `LtiRequestController` against registration `local` and tool URL `http://127.0.0.1:8001/tool/launch`.

The learner id is not placed in the login hint. The authenticator is constructed as anonymous. The response is a JSON-encoded URL string, not the HTML form (that return is commented out).

- Evidence: `src/Entity/ltirequest.php`, `LtiRequestController.php`, `config/routes/api_platform.yaml`.
- Implementation: **Implemented**. The comment marks it as testing.
- Confidence: High.
- Requires confirmation: whether this route should remain on a deployed host.

### FR-007 — Authenticate an LTI login hint without a user store

When the registered `UserAuthenticator` runs, success is always true and the display name is `Maanie`. The login hint becomes the user id. There is no lookup.

`LtiRequestController` bypasses the service and forces an anonymous identity.

`DefaultController` does not call either authenticator. It puts `{learnerId}` in the launch login hint directly.

- Evidence: `UserAuthenticator.php`, `services.yaml`, both controllers.
- Implementation: **Implemented**.
- Confidence: High.

### FR-008 — Leave the launch routes unauthenticated

`security.yaml` does not require a role for any path. The in-memory provider has no users. No login form, session check, or shared secret is applied to `/default/...` or `/api/ltiresourcelink/...`.

- Evidence: `config/packages/security.yaml`.
- Implementation: **Implemented**.
- Confidence: High.
- Requires confirmation: whether open launch is approved.

### FR-009 — Show a host label at `/` (Production branch)

`GET /` returns HTML centred text `clms.clicklearning.org`. No equivalent route exists on `master`. On `master`, `/` has no application route in the tree that was read.

- Evidence: `DefaultController::home` on `131cc59` only.
- Implementation: **Production branch**.
- Confidence: High.

### FR-010 — Deploy the tree to Apache on the clms host

Pushing `master` runs a Bitbucket Pipeline that zips the repository and uploads and deploys it with `atlassian/aws-code-deploy:0.2.5` using `QA_*` variables. **Production branch** adds the same steps for branch `Production` using `PROD_*` variables.

`appspec.yml` copies `/` to `/var/www/html/clms.clicklearning.org/`, owner `apache`. BeforeInstall runs `scripts/install_dependencies` (a commented `yum` line). ApplicationStop runs `scripts/remove_archives.sh`. ApplicationStart runs `scripts/start_server` (`service httpd start`). Version label in the pipeline is the constant `my-app-1.0.0`.

- Evidence: `bitbucket-pipelines.yml`, `appspec.yml`, `scripts/`.
- Implementation: **Implemented** as pipeline and appspec. That a given push succeeded is not stored in git.
- Confidence: High for the scripts. Unknown for the live result.

### FR-011 — Do not require a database for the launch

Doctrine is configured and Compose can start PostgreSQL 13, but no entity is mapped, no migration class exists, and neither controller injects a repository or connection. The launch workflow does not read or write the database.

- Evidence: `src/Entity/ltirequest.php` (no `@ORM\Entity`), `migrations/`, both controllers, `docker-compose.yml`.
- Implementation: **Implemented** absence of data access. That the database was never required is **Inferred**.
- Confidence: High that current code does not use it.

## Out of scope for this document

- How TAO Ignite validates the launch, stores the attempt, or honours `deliverySettings.closeOn`.
- What `click-home-learning` does with `learner_id` and `quiz_id`.
- ClickPortal's support search, Google sign-in, and quiz learner-detail API.
- Rotating or replacing committed secrets. This document records that they are committed and does not repeat the values.

## Human confirmation

- Which branch and which return URL are the approved behaviour?
- Is an unauthenticated launch URL an approved rule?
- Is the localhost API Platform launch still required?
- Should the LTI user name come from a learner record rather than the literal `Maanie`?
- Is `{quizEndDate}` specified as `YYYY-MM-DD`?
- Should the resource-link identifier be unique per launch?
- Is the yaml tool `launch_url` (fixed quiz id `04fc0ce15e09`) obsolete?
- Are the committed key and secrets still valid, and should they be rotated?
- Is this app still deployed, and does it belong to the same product as ClickPortal?
