diff --git a/.env.example b/.env.example index 9a03fb7d..05ed8d5f 100644 --- a/.env.example +++ b/.env.example @@ -109,8 +109,8 @@ CANVAS_CLIENT_SECRET=your-canvas-client-secret-here CANVAS_REDIRECT_URI=http://localhost:8051/api/create/canvas/oauth/callback LTI_CLIENT_ID=10000000000002 LTI_KEY=replace-with-a-long-random-secret -LTI_PORT=7737 -LTI_PUBLIC_URL=http://localhost:7737 +# The LTI tool is served by the main server under /lti; no separate port. +LTI_PUBLIC_URL=http://localhost:8051/lti # Public origin of the main CREATE server used by the Canvas LTI player to load # H5P core, library, font and media assets. Set this when Canvas cannot reach # the API through localhost (for example, a hosted or container deployment). diff --git a/AGENTS.md b/AGENTS.md index 97a8f376..f3cd559f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,7 +281,7 @@ Defaults come from `.env.example`: - Qdrant: `http://localhost:6333`, collection `quiz-materials` - Local SimpleSAMLphp IdP: `http://localhost:6122` - Ollama fallback: `http://localhost:11434` -- Optional Canvas: `http://localhost`; optional LTI public runtime on port 7737 +- Optional Canvas: `http://localhost`; optional LTI tool under `/lti` on the backend port The app can use OpenAI-compatible endpoints or Ollama. `llmService.js` resolves the effective provider/model/key and supports per-user credentials. Redis is diff --git a/canvas-lti-local-setup.md b/canvas-lti-local-setup.md index c72fd6cd..257dfe1b 100644 --- a/canvas-lti-local-setup.md +++ b/canvas-lti-local-setup.md @@ -2,6 +2,8 @@ This document records the errors encountered when setting up the TLEF-CREATE LTI 1.3 integration with a local Canvas (`canvas-lms-docker`) instance, and the exact fix for each one. +> **Note:** the LTI tool used to run on its own port (7737). It is now served by the main server under `/lti`, so `http://localhost:7737/…` in the history below corresponds to `http://localhost:8051/lti/…` today. The Final Working Configuration section uses the current URLs. + --- ## Overview of the Flow @@ -131,7 +133,7 @@ Canvas responds with the installed tool's `id`, which is then used for creating **Fix:** Switch the Developer Key's JWK method from **Public JWK URL** to **Paste Public JWK**, and paste the static JWK JSON directly. Retrieve the JWK from our server: ```bash -curl http://localhost:7737/keys +curl http://localhost:8051/lti/keys ``` Copy the result and paste it into the Canvas Developer Key editor under **Paste Public JWK**. @@ -143,7 +145,7 @@ Copy the result and paste it into the Canvas Developer Key editor under **Paste ### `.env` (tlef-create) ```env LTI_CLIENT_ID=10000000000002 -LTI_PUBLIC_URL=http://localhost:7737 +LTI_PUBLIC_URL=http://localhost:8051/lti CANVAS_BASE_URL=http://localhost CANVAS_CLIENT_ID=10000000000001 CANVAS_CLIENT_SECRET= @@ -162,12 +164,12 @@ default: &default - **Key type:** LTI Key - **Redirect URIs:** ``` - http://localhost:7737/ - http://localhost:7737/login + http://localhost:8051/lti/ + http://localhost:8051/lti/login ``` -- **Target Link URI:** `http://localhost:7737/` -- **OpenID Connect Initiation URL:** `http://localhost:7737/login` -- **JWK Method:** Paste Public JWK (from `curl http://localhost:7737/keys`) +- **Target Link URI:** `http://localhost:8051/lti/` +- **OpenID Connect Initiation URL:** `http://localhost:8051/lti/login` +- **JWK Method:** Paste Public JWK (from `curl http://localhost:8051/lti/keys`) ### After any Canvas reset If you reset or re-create the Canvas Docker environment, run these steps: diff --git a/docs/canvas-toolkit-integration.md b/docs/canvas-toolkit-integration.md index 27a033f6..54329598 100644 --- a/docs/canvas-toolkit-integration.md +++ b/docs/canvas-toolkit-integration.md @@ -66,7 +66,7 @@ A local Canvas instance can continue to use `CANVAS_BASE_URL=http://localhost` a 4. Exporting a playable CREATE activity also requires a separate **LTI Developer Key and tool installation**. Configure `LTI_CLIENT_ID`, `LTI_PUBLIC_URL`, and the other LTI settings. The OAuth client ID and LTI client ID serve different purposes. Canvas and learners' browsers must be able to reach the public HTTPS address; do not use localhost in a hosted deployment. - The LTI server listens on `LTI_PORT` (7737 by default). In a hosted deployment, reverse proxy a public HTTPS LTI host to that port. Set the Canvas LTI Key's OpenID Connect initiation URL, Target Link URI, Redirect URI, and Public JWK URL to `/login`, `/`, `/`, and `/keys`, respectively. Set a random `LTI_KEY` and set `H5P_ASSETS_URL` to the public origin of CREATE's main API. The public `/keys` endpoint must return JSON with a `keys` array, not CREATE's frontend HTML. + The LTI tool is served by the main CREATE server under `/lti`, so it needs no separate port or hostname: set `LTI_PUBLIC_URL` to `/lti` (for example `https://create.apps.ltic.ubc.ca/lti`, or `http://localhost:8051/lti` locally). Set the Canvas LTI Key's OpenID Connect initiation URL, Target Link URI, Redirect URI, and Public JWK URL to `/login`, `/`, `/`, and `/keys`, respectively. Set a random `LTI_KEY` and set `H5P_ASSETS_URL` to the public origin of CREATE's main API. The public `/keys` endpoint must return JSON with a `keys` array, not CREATE's frontend HTML. For Instructure-hosted Canvas, OAuth and REST APIs continue to use the institution's Canvas domain. Configure LTI registration with Canvas's shared issuer and SSO/JWKS endpoints. Self-hosted Canvas uses its own instance endpoints. diff --git a/routes/create/controllers/canvasController.js b/routes/create/controllers/canvasController.js index 7f1295af..0176fc19 100644 --- a/routes/create/controllers/canvasController.js +++ b/routes/create/controllers/canvasController.js @@ -227,7 +227,7 @@ router.post('/export/:quizId', asyncHandler(async (req, res) => { const externalToolId = await canvasApiService.ensureLtiToolInstalled(req.user.id, courseId); // Step 5: Build LTI launch URL with custom param to identify the quiz - const ltiPublicUrl = process.env.LTI_PUBLIC_URL || `http://localhost:${process.env.LTI_PORT || 7737}`; + const ltiPublicUrl = process.env.LTI_PUBLIC_URL || `http://localhost:${process.env.PORT || 8051}/lti`; const launchUrl = `${ltiPublicUrl}/?quizExportId=${resourceLinkId}`; // Step 6: Create ExternalTool module item (Canvas launches via LTI) diff --git a/routes/create/services/ltiService.js b/routes/create/services/ltiService.js index a5f069bc..606e57f2 100644 --- a/routes/create/services/ltiService.js +++ b/routes/create/services/ltiService.js @@ -13,13 +13,14 @@ const require = createRequire(import.meta.url); const lti = require('ltijs').Provider; const LTI_KEY = process.env.LTI_KEY || 'LTIKEY-default-change-in-production'; -const LTI_PORT = parseInt(process.env.LTI_PORT || '7737', 10); const MONGODB_URI = process.env.MONGODB_URI; -// LTI_PUBLIC_URL must be reachable from Canvas. If Canvas is in Docker, use host.docker.internal -const LTI_PUBLIC_URL = process.env.LTI_PUBLIC_URL || `http://localhost:${LTI_PORT}`; +// LTI_PUBLIC_URL must be reachable from Canvas: the main server's origin plus +// the /lti mount path. If Canvas is in Docker, use host.docker.internal +const LTI_PUBLIC_URL = process.env.LTI_PUBLIC_URL || `http://localhost:${process.env.PORT || 8051}/lti`; /** - * Start the LTI 1.3 server on a separate port + * Set up the LTI 1.3 tool and return its Express app, which server.js mounts + * at /lti. Links in pages it renders use req.baseUrl so they keep that prefix. */ export async function startLtiServer() { if (!MONGODB_URI) { @@ -83,7 +84,7 @@ export async function startLtiServer() { const snapshot = exportRecord.canvasExport.mixedActivitySnapshot; const items = (snapshot?.questions || []).map((question, index) => ({ title: `Question ${index + 1}`, - url: `/h5p/mixed/${encodeURIComponent(quizExportId)}/${encodeURIComponent(String(question._id))}?token=${encodeURIComponent(createH5PPreviewToken({ + url: `${req.baseUrl}/h5p/mixed/${encodeURIComponent(quizExportId)}/${encodeURIComponent(String(question._id))}?token=${encodeURIComponent(createH5PPreviewToken({ userId: quiz.createdBy, quizId: quiz._id, questionId: question._id @@ -96,8 +97,8 @@ export async function startLtiServer() { title: snapshot.title || quiz.name, items, scoreEndpoint: typeof req.query?.ltik === 'string' - ? `/score?ltik=${encodeURIComponent(req.query.ltik)}` - : '/score' + ? `${req.baseUrl}/score?ltik=${encodeURIComponent(req.query.ltik)}` + : `${req.baseUrl}/score` })); } @@ -178,15 +179,17 @@ export async function startLtiServer() { } }); - // Deploy on separate port - await lti.deploy({ port: LTI_PORT }); + // Serverless: no listener of its own; the main server mounts lti.app + await lti.deploy({ serverless: true, silent: true }); // Register Canvas platform (if not already registered) await registerCanvasPlatform(); - console.log(`✅ LTI 1.3 server running on port ${LTI_PORT}`); + console.log(`✅ LTI 1.3 tool ready at ${LTI_PUBLIC_URL}`); console.log(` JWKS: ${LTI_PUBLIC_URL}/keys`); console.log(` Launch: ${LTI_PUBLIC_URL}/`); + + return lti.app; } /** diff --git a/routes/create/services/lumiService.js b/routes/create/services/lumiService.js index d7193f18..ac1cd579 100644 --- a/routes/create/services/lumiService.js +++ b/routes/create/services/lumiService.js @@ -342,7 +342,7 @@ export async function renderContent(contentId, user = systemUser, options) { let html = await readyPlayer.render(contentId, user, 'en', options); if (typeof html === 'string') { - // When rendered inside Canvas LTI iframe, relative paths go to :7737 instead of :8051 + // The Canvas LTI iframe may be served from a different origin than the API // Convert all /api/create/h5p/ paths to absolute URLs pointing to the main server const mainServerUrl = process.env.H5P_ASSETS_URL || `http://localhost:${process.env.PORT || 8051}`; html = html.replace(/(['"])(\/api\/create\/h5p\/)/g, `$1${mainServerUrl}/api/create/h5p/`); diff --git a/server.js b/server.js index 63e108ac..d0cd0eee 100644 --- a/server.js +++ b/server.js @@ -75,11 +75,13 @@ import('./routes/create/services/lumiService.js') console.error('💡 Canvas H5P export may not work properly'); }); -// Start LTI 1.3 server (separate port) — only if configured +// Start LTI 1.3 tool (mounted at /lti below) — only if configured +let ltiApp = null; if (process.env.LTI_CLIENT_ID) { console.log('🔧 Starting LTI 1.3 server...'); import('./routes/create/services/ltiService.js') .then(module => module.startLtiServer()) + .then(app => { ltiApp = app; }) .catch(err => { console.error('❌ Failed to start LTI server:', err.message); console.error('💡 Canvas LTI integration may not work properly'); @@ -91,6 +93,16 @@ if (process.env.LTI_CLIENT_ID) { const app = express(); const PORT = process.env.PORT || 7736; +// Canvas reaches the LTI tool under /lti. It must run ahead of the app's own +// body, cookie and session middleware: ltijs signs cookies with its own key, +// and cookie-parser skips requests whose cookies were already parsed. +if (process.env.LTI_CLIENT_ID) { + app.use('/lti', (req, res, next) => { + if (!ltiApp) return res.status(503).send('LTI tool is starting, please retry'); + return ltiApp(req, res, next); + }); +} + // CORS configuration for frontend integration // In production, strip port numbers from FRONTEND_URL as they shouldn't be in browser requests let corsOrigin;