From ee136c4e58f144eff5dce444c76e784605b5659c Mon Sep 17 00:00:00 2001 From: Sam Ottenhoff Date: Wed, 8 Jul 2026 10:19:24 -0400 Subject: [PATCH 01/12] Adopt lti-tool and simplify architecture --- README.md | 3 + deno.json | 17 +- deno.lock | 61 +- src/admin/app_writer_run_page.ts | 546 +------ src/admin/app_writer_run_sections.ts | 543 +++++++ src/admin/deployment_detail_ops_support.ts | 2 - src/admin/deployment_detail_roster_test.ts | 292 +--- src/admin/package_detail.ts | 196 +-- src/admin/package_detail_support.ts | 171 ++ src/admin/package_navigation.ts | 16 +- src/admin/package_version_history.ts | 28 + src/app_admin_app_writer_routes.ts | 83 +- src/app_admin_certification_recording_test.ts | 2 +- src/app_admin_control_plane_test.ts | 5 +- ...app_admin_deployment_detail_form_routes.ts | 7 + ...n_deployment_detail_registration_routes.ts | 109 +- ...min_deployment_moodle_registration_test.ts | 174 +- src/app_admin_deployment_registration_test.ts | 114 +- src/app_admin_deployment_retry_routes.ts | 24 +- src/app_admin_deployment_roster_route.ts | 70 +- ...dmin_deployment_sakai_registration_test.ts | 91 +- src/app_admin_grade_smoke_moodle_test.ts | 34 +- src/app_admin_grade_smoke_route.ts | 21 +- src/app_admin_grade_smoke_sakai_test.ts | 6 + src/app_admin_grade_smoke_support.ts | 148 +- src/app_admin_grade_smoke_test.ts | 501 +----- src/app_admin_grade_smoke_test_support.ts | 2 + src/app_admin_inventory_routes.ts | 239 +-- src/app_admin_package_import_support.ts | 103 ++ src/app_admin_retry_grade_publish_test.ts | 13 + src/app_admin_revision_support.ts | 53 + src/app_basic_routes.ts | 33 +- ...app_deep_linking_authoring_preview_test.ts | 78 +- src/app_deep_linking_request_test.ts | 378 ++--- src/app_deep_linking_routes.ts | 152 +- src/app_deep_linking_submit_routes.ts | 42 +- src/app_deep_linking_submit_test.ts | 86 +- src/app_deployment_support.ts | 10 +- src/app_launch_routes.ts | 270 +++- src/app_lti_config_login_test.ts | 178 ++- src/app_lti_launch_authoring_test.ts | 123 +- src/app_lti_launch_edge_test.ts | 209 ++- src/app_lti_launch_multilms_test.ts | 69 +- src/app_lti_launch_test.ts | 95 +- src/app_lti_support.ts | 183 ++- src/app_origin.ts | 13 + src/app_request_support.ts | 2 +- src/app_runtime_finalize_test.ts | 42 +- src/app_runtime_routes.ts | 143 +- src/app_services.ts | 43 +- src/app_status_support.ts | 20 +- src/app_test_support.ts | 21 +- src/app_worker_services.ts | 36 +- src/app_worker_services_test.ts | 55 +- src/app_writer/background_execution.ts | 75 + src/app_writer/html_inspection.ts | 13 + src/app_writer/html_inspection_test.ts | 20 + src/app_writer/repository_d1.ts | 278 +--- src/app_writer/repository_d1_mapping.ts | 241 +++ src/app_writer/repository_d1_test.ts | 43 + src/app_writer/service_core.ts | 422 +---- src/app_writer/service_finish.ts | 2 +- src/app_writer/service_preview.ts | 2 +- src/app_writer/service_revision.ts | 225 +-- src/app_writer/service_revision_source.ts | 214 +++ src/app_writer/service_run_records.ts | 49 + src/app_writer/service_run_types.ts | 97 ++ src/app_writer/service_save.ts | 2 +- src/app_writer/service_typescript.ts | 2 +- src/app_writer/service_workflow_steps.ts | 282 ++++ src/app_writer/typescript_source_compiler.ts | 4 +- src/app_writer/validation.ts | 617 +------ src/app_writer/validation_files.ts | 248 +++ src/app_writer/validation_plan_alignment.ts | 193 +++ src/app_writer/validation_policy.ts | 210 +++ src/db/d1.ts | 19 +- src/db/d1_migrations/0001_initial_schema.sql | 83 +- src/db/d1_migrations/0007_lti_tool_tables.sql | 55 + .../0008_runtime_session_lti_tool_session.sql | 1 + ..._deep_linking_session_lti_tool_session.sql | 2 + src/db/d1_row.ts | 99 ++ src/db/d1_row_test.ts | 34 + src/lti/ags_session.ts | 18 + src/lti/canvas_dynamic_registration.ts | 205 --- src/lti/config.ts | 4 +- src/lti/config_test.ts | 10 +- src/lti/context_factory.ts | 26 + src/lti/deep_linking.ts | 1 - src/lti/deep_linking_content_item.ts | 103 ++ src/lti/deep_linking_launch_settings.ts | 72 + src/lti/deep_linking_request.ts | 711 --------- src/lti/deep_linking_request_test.ts | 760 --------- src/lti/deep_linking_response.ts | 152 -- src/lti/deep_linking_response_test.ts | 241 --- src/lti/deep_linking_response_tool.ts | 36 + src/lti/deep_linking_session.ts | 1 + src/lti/deep_linking_test_helpers.ts | 30 - src/lti/dynamic_registration_tool.ts | 154 ++ .../dynamic_registration_validation_test.ts | 242 --- src/lti/hono_handler_adapter.ts | 12 + src/lti/id_token_verification.ts | 45 - src/lti/launch.ts | 2 - src/lti/launch_audit.ts | 94 ++ src/lti/launch_mapping.ts | 207 +++ src/lti/launch_placement_default_path_test.ts | 56 - src/lti/launch_placement_test.ts | 272 ---- src/lti/launch_runtime_session_test.ts | 2 +- src/lti/launch_service_claims.ts | 60 - src/lti/launch_test_support.ts | 9 - src/lti/launch_validation.ts | 295 ---- src/lti/launch_validation_multilms_test.ts | 137 -- src/lti/launch_validation_test.ts | 421 ----- src/lti/login.ts | 297 ---- src/lti/login_policy.ts | 81 + src/lti/login_request.ts | 13 + src/lti/login_test.ts | 357 ----- src/lti/lti_tool_session.ts | 27 + src/lti/moodle_dynamic_registration.ts | 241 --- src/lti/nrps_roster_verification.ts | 34 + src/lti/platform_binding.ts | 10 +- src/lti/platform_binding_lookup_test.ts | 87 - src/lti/runtime_session.ts | 2 + src/lti/sakai_dynamic_registration.ts | 200 --- src/lti/service_memberships.ts | 59 - src/lti/service_support.ts | 196 --- src/lti/services.ts | 290 ---- src/lti/services_nrps_test.ts | 130 -- src/lti/services_test.ts | 332 ---- src/lti/tool_key.ts | 9 +- src/lti/tool_provider.ts | 153 ++ src/lti/types.ts | 19 +- src/ops/repository_d1.ts | 1415 +---------------- src/ops/repository_d1_broker.ts | 193 +++ src/ops/repository_d1_detail.ts | 364 +++++ src/ops/repository_d1_mapping.ts | 592 +++++++ src/ops/repository_d1_queries.ts | 324 ++++ src/ops/repository_retry_mapping.ts | 1 + src/ops/repository_types.ts | 2 +- src/ops/retry.ts | 149 +- src/ops/service.ts | 7 +- src/ops/service_test.ts | 449 +----- src/ops/types.ts | 1 + src/package_review/repository.ts | 6 +- src/package_review/repository_authoring_d1.ts | 108 +- .../repository_deep_linking_d1.ts | 222 +-- .../repository_deep_linking_d1_mapping.ts | 193 +++ .../repository_deep_linking_d1_test.ts | 5 +- .../repository_deployments_d1.ts | 173 +- .../repository_deployments_d1_mapping.ts | 126 ++ .../repository_deployments_d1_test.ts | 14 + src/package_review/repository_grading_d1.ts | 367 +---- .../repository_grading_d1_mapping.ts | 161 ++ .../repository_grading_d1_test.ts | 71 +- .../repository_launch_state_d1.ts | 548 +------ .../repository_launch_state_d1_mapping.ts | 337 ++++ .../repository_mappers_package.ts | 45 +- .../repository_mappers_sessions.ts | 2 + .../repository_mappers_sessions_test.ts | 1 + .../repository_package_versions_d1.ts | 235 +-- src/package_review/repository_preview_d1.ts | 271 ++-- .../repository_preview_d1_test.ts | 52 +- src/package_review/repository_row_types.ts | 20 +- src/preview/service.ts | 1 + src/runtime/gateway.ts | 17 +- .../gateway_finalize_edge_cases_test.ts | 13 +- src/runtime/gateway_finalize_test.ts | 15 + src/runtime/gateway_publication.ts | 325 ++-- src/runtime/gateway_publication_support.ts | 158 -- src/runtime/gateway_types.ts | 21 - src/runtime/route_commands.ts | 204 +++ src/test_helpers/lti_identity_builders.ts | 35 +- src/test_helpers/lti_session_builders.ts | 3 + src/test_helpers/lti_signing.ts | 42 +- src/test_helpers/lti_tool_context.ts | 148 ++ .../package_review_in_memory_deployments.ts | 9 +- .../package_review_in_memory_ops.ts | 7 +- .../package_review_in_memory_sessions.ts | 41 - .../package_review_in_memory_shared.ts | 4 - .../package_review_test_builder_ops.ts | 1 + src/worker.ts | 56 + tools/local_init.ts | 2 + tools/local_support.ts | 17 + tools/local_support_test.ts | 12 +- 183 files changed, 9460 insertions(+), 14527 deletions(-) create mode 100644 src/admin/app_writer_run_sections.ts create mode 100644 src/admin/package_detail_support.ts create mode 100644 src/admin/package_version_history.ts create mode 100644 src/app_admin_package_import_support.ts create mode 100644 src/app_admin_revision_support.ts create mode 100644 src/app_origin.ts create mode 100644 src/app_writer/background_execution.ts create mode 100644 src/app_writer/html_inspection.ts create mode 100644 src/app_writer/html_inspection_test.ts create mode 100644 src/app_writer/repository_d1_mapping.ts create mode 100644 src/app_writer/service_revision_source.ts create mode 100644 src/app_writer/service_run_records.ts create mode 100644 src/app_writer/service_run_types.ts create mode 100644 src/app_writer/service_workflow_steps.ts create mode 100644 src/app_writer/validation_files.ts create mode 100644 src/app_writer/validation_plan_alignment.ts create mode 100644 src/app_writer/validation_policy.ts create mode 100644 src/db/d1_migrations/0007_lti_tool_tables.sql create mode 100644 src/db/d1_migrations/0008_runtime_session_lti_tool_session.sql create mode 100644 src/db/d1_migrations/0009_deep_linking_session_lti_tool_session.sql create mode 100644 src/db/d1_row.ts create mode 100644 src/db/d1_row_test.ts create mode 100644 src/lti/ags_session.ts delete mode 100644 src/lti/canvas_dynamic_registration.ts create mode 100644 src/lti/context_factory.ts create mode 100644 src/lti/deep_linking_content_item.ts create mode 100644 src/lti/deep_linking_launch_settings.ts delete mode 100644 src/lti/deep_linking_request.ts delete mode 100644 src/lti/deep_linking_request_test.ts delete mode 100644 src/lti/deep_linking_response.ts delete mode 100644 src/lti/deep_linking_response_test.ts create mode 100644 src/lti/deep_linking_response_tool.ts delete mode 100644 src/lti/deep_linking_test_helpers.ts create mode 100644 src/lti/dynamic_registration_tool.ts delete mode 100644 src/lti/dynamic_registration_validation_test.ts create mode 100644 src/lti/hono_handler_adapter.ts delete mode 100644 src/lti/id_token_verification.ts delete mode 100644 src/lti/launch.ts create mode 100644 src/lti/launch_audit.ts create mode 100644 src/lti/launch_mapping.ts delete mode 100644 src/lti/launch_placement_default_path_test.ts delete mode 100644 src/lti/launch_placement_test.ts delete mode 100644 src/lti/launch_service_claims.ts delete mode 100644 src/lti/launch_test_support.ts delete mode 100644 src/lti/launch_validation.ts delete mode 100644 src/lti/launch_validation_multilms_test.ts delete mode 100644 src/lti/launch_validation_test.ts delete mode 100644 src/lti/login.ts create mode 100644 src/lti/login_policy.ts create mode 100644 src/lti/login_request.ts delete mode 100644 src/lti/login_test.ts create mode 100644 src/lti/lti_tool_session.ts delete mode 100644 src/lti/moodle_dynamic_registration.ts create mode 100644 src/lti/nrps_roster_verification.ts delete mode 100644 src/lti/platform_binding_lookup_test.ts delete mode 100644 src/lti/sakai_dynamic_registration.ts delete mode 100644 src/lti/service_memberships.ts delete mode 100644 src/lti/service_support.ts delete mode 100644 src/lti/services.ts delete mode 100644 src/lti/services_nrps_test.ts delete mode 100644 src/lti/services_test.ts create mode 100644 src/lti/tool_provider.ts create mode 100644 src/ops/repository_d1_broker.ts create mode 100644 src/ops/repository_d1_detail.ts create mode 100644 src/ops/repository_d1_mapping.ts create mode 100644 src/ops/repository_d1_queries.ts create mode 100644 src/package_review/repository_deep_linking_d1_mapping.ts create mode 100644 src/package_review/repository_deployments_d1_mapping.ts create mode 100644 src/package_review/repository_grading_d1_mapping.ts create mode 100644 src/package_review/repository_launch_state_d1_mapping.ts create mode 100644 src/runtime/route_commands.ts create mode 100644 src/test_helpers/lti_tool_context.ts diff --git a/README.md b/README.md index 99b43ab..38ce06d 100644 --- a/README.md +++ b/README.md @@ -341,10 +341,13 @@ Generated local environment: - `APP_ORIGIN`: defaults to `http://localhost:8417` - `APP_RUNTIME_ORIGIN`: defaults to `http://localhost:8417` so local preview and reviewed runtime stay on one origin +- `LTI_STATE_SECRET`: generated automatically for local development - `LTI_TOOL_PRIVATE_JWK`: generated automatically for local development Manual minimum environment if you do not use `local:init`: +- `LTI_STATE_SECRET`: one stable secret with at least 32 UTF-8 bytes used for + LTI OIDC state signing - `LTI_TOOL_PRIVATE_JWK`: one RSA private JWK used to publish Lantern's public JWKS and sign LTI assertions - `APP_ORIGIN`: recommended for stable local config and required anywhere diff --git a/deno.json b/deno.json index e462d72..bb33c02 100644 --- a/deno.json +++ b/deno.json @@ -5,8 +5,8 @@ "tasks": { "dev": "deno run --unsafely-ignore-certificate-errors --watch --allow-read --allow-write --allow-env --allow-net main.ts", "start": "deno run --allow-read --allow-write --allow-env --allow-net main.ts", - "db:migrate": "npx wrangler d1 migrations apply lantern --local", - "db:migrate:remote": "npx wrangler d1 migrations apply lantern --remote", + "db:migrate": "npx wrangler@4.105.0 d1 migrations apply lantern --local", + "db:migrate:remote": "npx wrangler@4.105.0 d1 migrations apply lantern --remote", "app:validate": "deno run --allow-read tools/app_validate.ts", "app:preview": "deno run --allow-read --allow-net tools/app_preview.ts", "app:dev": "deno run --allow-read --allow-net --allow-env=WS_NO_BUFFER_UTIL,WS_NO_UTF_8_VALIDATE tools/app_dev.ts", @@ -16,10 +16,10 @@ "local:init": "deno run --allow-read --allow-write tools/local_init.ts", "local:migrate": "deno task db:migrate", "local:bootstrap": "deno task local:migrate", - "local:start": "npx wrangler dev", - "local:dev": "npx wrangler dev", + "local:start": "npx wrangler@4.105.0 dev", + "local:dev": "npx wrangler@4.105.0 dev", "build:app-writer-platform": "deno run --allow-read --allow-write --allow-run tools/build_app_writer_platform_bundle.ts", - "deploy:app-writer-platform": "npx wrangler deploy --config wrangler.app-writer-platform.jsonc", + "deploy:app-writer-platform": "npx wrangler@4.105.0 deploy --config wrangler.app-writer-platform.jsonc", "build:worker": "deno run --allow-read --allow-write --allow-run tools/build_worker_bundle.ts", "check:worker": "deno task build:worker && deno run --allow-read tools/check_worker_bundle.ts && deno run --allow-read tools/check_worker_config.ts", "fmt": "deno fmt", @@ -51,13 +51,16 @@ "imports": { "@ajv2020": "npm:ajv@8.20.0/dist/2020.js", "@cloudflare/puppeteer": "npm:@cloudflare/puppeteer@1.1.0", - "@happy-dom": "npm:happy-dom@20.9.0", + "@happy-dom": "npm:happy-dom@20.10.6", "@hono/hono": "jsr:@hono/hono@^4.12.18", + "@longsightgroup/lti-tool": "npm:@longsightgroup/lti-tool@0.1.3", + "drizzle-orm": "npm:drizzle-orm@0.45.2", + "hono": "npm:hono@4.12.27", "@std/assert": "jsr:@std/assert@^1.0.19", "@std/semver": "jsr:@std/semver@1.0.8", "jose": "npm:jose@6.2.3", "oauth4webapi": "npm:oauth4webapi@3.8.6", - "typescript": "npm:typescript@5.9.3" + "typescript": "npm:typescript@6.0.3" }, "compilerOptions": { "strict": true, diff --git a/deno.lock b/deno.lock index df1588f..a4e3d82 100644 --- a/deno.lock +++ b/deno.lock @@ -1,21 +1,24 @@ { "version": "5", "specifiers": { - "jsr:@hono/hono@^4.12.18": "4.12.18", + "jsr:@hono/hono@^4.12.18": "4.12.27", "jsr:@std/assert@^1.0.19": "1.0.19", "jsr:@std/internal@^1.0.12": "1.0.13", "jsr:@std/semver@1.0.8": "1.0.8", "npm:@cloudflare/puppeteer@1.1.0": "1.1.0", "npm:@cloudflare/puppeteer@latest": "1.1.0", + "npm:@longsightgroup/lti-tool@0.1.3": "0.1.3_drizzle-orm@0.45.2_hono@4.12.27", "npm:ajv@8.20.0": "8.20.0", - "npm:happy-dom@20.9.0": "20.9.0", + "npm:drizzle-orm@0.45.2": "0.45.2", + "npm:happy-dom@20.10.6": "20.10.6", + "npm:hono@4.12.27": "4.12.27", "npm:jose@6.2.3": "6.2.3", "npm:oauth4webapi@3.8.6": "3.8.6", - "npm:typescript@5.9.3": "5.9.3" + "npm:typescript@6.0.3": "6.0.3" }, "jsr": { - "@hono/hono@4.12.18": { - "integrity": "854fd0628abd7703a1583807f1ae8f99399604b9452b6a2a777fd3e097977b8c" + "@hono/hono@4.12.27": { + "integrity": "3acc640592008e43f7048e8e53275203e0da84fbcd50201121195da7560583dc" }, "@std/assert@1.0.19": { "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", @@ -40,6 +43,19 @@ "ws" ] }, + "@longsightgroup/lti-tool@0.1.3_drizzle-orm@0.45.2_hono@4.12.27": { + "integrity": "sha512-18FHLfLhfj/cacVq81i9cr+kdIayg8JAzGS8CNdDw3Kl0pBDAIknHBtsJ9dPkX8dbnjFnw3yCist3ELZkrkHsQ==", + "dependencies": [ + "drizzle-orm", + "hono", + "jose", + "zod" + ], + "optionalPeers": [ + "drizzle-orm", + "hono" + ] + }, "@puppeteer/browsers@2.2.4": { "integrity": "sha512-BdG2qiI1dn89OTUUsx2GZSpUzW+DRffR1wlMJyKxVHYrhnKoELSDxDd+2XImUkuWPEKk76H5FcM/gPFrEK1Tfw==", "dependencies": [ @@ -156,6 +172,12 @@ "buffer-crc32@0.2.13": { "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==" }, + "buffer-image-size@0.6.4": { + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dependencies": [ + "@types/node" + ] + }, "buffer@5.7.1": { "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "dependencies": [ @@ -200,6 +222,9 @@ "devtools-protocol@0.0.1299070": { "integrity": "sha512-+qtL3eX50qsJ7c+qVyagqi7AWMoQCBGNfoyJZMwm/NSXVqLYbuitrWEEIzxfUmTNy7//Xe8yhMmQ+elj3uAqSg==" }, + "drizzle-orm@0.45.2": { + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==" + }, "emoji-regex@8.0.0": { "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, @@ -287,17 +312,21 @@ "debug" ] }, - "happy-dom@20.9.0": { - "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", + "happy-dom@20.10.6": { + "integrity": "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==", "dependencies": [ "@types/node", "@types/whatwg-mimetype", "@types/ws", + "buffer-image-size", "entities", "whatwg-mimetype", "ws" ] }, + "hono@4.12.27": { + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==" + }, "http-proxy-agent@7.0.2": { "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dependencies": [ @@ -485,8 +514,8 @@ "tslib@2.8.1": { "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, - "typescript@5.9.3": { - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "typescript@6.0.3": { + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "bin": true }, "unbzip2-stream@1.4.3": { @@ -513,8 +542,8 @@ "wrappy@1.0.2": { "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, - "ws@8.20.1": { - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==" + "ws@8.21.0": { + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==" }, "y18n@5.0.8": { "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" @@ -540,6 +569,9 @@ "buffer-crc32", "fd-slicer" ] + }, + "zod@4.4.3": { + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==" } }, "remote": { @@ -552,11 +584,14 @@ "jsr:@std/assert@^1.0.19", "jsr:@std/semver@1.0.8", "npm:@cloudflare/puppeteer@1.1.0", + "npm:@longsightgroup/lti-tool@0.1.3", "npm:ajv@8.20.0", - "npm:happy-dom@20.9.0", + "npm:drizzle-orm@0.45.2", + "npm:happy-dom@20.10.6", + "npm:hono@4.12.27", "npm:jose@6.2.3", "npm:oauth4webapi@3.8.6", - "npm:typescript@5.9.3" + "npm:typescript@6.0.3" ] } } diff --git a/src/admin/app_writer_run_page.ts b/src/admin/app_writer_run_page.ts index c3a915f..3b95464 100644 --- a/src/admin/app_writer_run_page.ts +++ b/src/admin/app_writer_run_page.ts @@ -1,8 +1,4 @@ -import type { - AppGenerationRunRecord, - AppGenerationStatus, - AppGenerationWorkspaceRecord, -} from '../app_writer/types.ts'; +import type { AppGenerationRunRecord, AppGenerationWorkspaceRecord } from '../app_writer/types.ts'; import type { AuditEventRecord, PackageVersionRecord, @@ -11,27 +7,22 @@ import type { } from '../package_review/types.ts'; import { type AdminNotice, escapeHtml, formatDateTime, renderAdminLayout } from './layout.ts'; import { - formatNullableNumber, - formatProgressStage, - formatResultValue, formatSelectedContext, formatStatus, - formatWorkspaceFileRole, isActiveGenerationStatus, - progressIndexForStatus, - readAuditString, - readPreviewStepResult, - readUnknownNumber, - selectCurrentGenerationPlanStep, - statusClassForModelOutcome, - statusClassForPlanStep, } from './app_writer_run_view_model.ts'; import { - readManifestRecord, - readManifestString, - readManifestStringArray, - readUnknownString, -} from '../package_review/manifest_view.ts'; + renderActivity, + renderFindings, + renderGeneratedFilesSummary, + renderGeneratedPackageRuntimeLog, + renderGenerationPlan, + renderGenerationProgress, + renderGenerationRefreshScript, + renderLiveProgress, + renderModelRequestMetadata, + renderPreviewSummary, +} from './app_writer_run_sections.ts'; export function renderAppGenerationRunPage(input: { run: AppGenerationRunRecord; @@ -151,7 +142,7 @@ export function renderAppGenerationRunPage(input: {
- + ${ run.appPlan === null ? '

No plan was recorded.

' @@ -214,514 +205,3 @@ export function renderAppGenerationRunPage(input: { ${refreshWhileRunning ? renderGenerationRefreshScript(run.generationId) : ''}`, }); } - -function renderGenerationProgress(status: AppGenerationStatus): string { - const steps: Array<{ - status: AppGenerationStatus; - label: string; - }> = [ - { status: 'started', label: 'Started' }, - { status: 'initializing', label: 'Initializing' }, - { status: 'planning', label: 'Planning' }, - { status: 'generating_package', label: 'Generating' }, - { status: 'validating', label: 'Validating' }, - { status: 'previewing', label: 'Previewing' }, - { status: 'saved_pending_version', label: 'Saved' }, - ]; - const activeIndex = progressIndexForStatus(status); - - return `
    - ${ - steps - .map((step, index) => { - const stateClass = index < activeIndex - ? 'is-complete' - : index === activeIndex - ? 'is-current' - : 'is-pending'; - - return `
  1. - - ${escapeHtml(step.label)} -
  2. `; - }) - .join('') - } -
`; -} - -function renderLiveProgress(input: { - run: AppGenerationRunRecord; - workspace: AppGenerationWorkspaceRecord | null; -}): string { - const currentStep = selectCurrentGenerationPlanStep(input.workspace); - const modelAttempt = input.run.modelRequestMetadata.at(-1); - - return `
-
-

Live progress ${ - escapeHtml( - formatStatus(input.run.status), - ) - }

-

${ - escapeHtml( - currentStep === null - ? 'No generation plan step is active yet.' - : `${formatProgressStage(currentStep.id)}: ${currentStep.summary}`, - ) - }

-

${ - escapeHtml( - [ - `Repairs ${input.run.repairAttemptCount}`, - `Findings ${input.run.validationFindings.length}`, - modelAttempt === undefined - ? 'Model attempt unknown' - : `Model ${modelAttempt.stage} attempt ${modelAttempt.attempt} ${modelAttempt.outcome}`, - ].join(' · '), - ) - }

-
-
`; -} - -function renderGenerationRefreshScript(generationId: string): string { - const eventUrl = `/admin/app-writer/runs/${escapeHtml(generationId)}/events`; - - return ``; -} - -function renderGeneratedFilesSummary(input: { - workspace: AppGenerationWorkspaceRecord | null; - packageVersion: PackageVersionRecord | null; -}): string { - if (input.workspace !== null) { - const fileRows = input.workspace.files.map((file) => ({ - label: file.path, - path: `${formatWorkspaceFileRole(file.role)} · ${file.contents.length} characters`, - })); - - return `
- ${ - fileRows - .map( - (row) => - `
-

${escapeHtml(row.label)}

-

${escapeHtml(row.path)}

-
`, - ) - .join('') - } -
-

Workspace captured after repair attempt ${ - escapeHtml( - String(input.workspace.repairAttemptCount), - ) - }; ${ - escapeHtml( - String(input.workspace.validationFindings.length), - ) - } validation or preview findings currently attached.

- ${ - input.packageVersion === null - ? '

No immutable package version has been saved yet.

' - : `

Saved package checksum ${ - escapeHtml( - input.packageVersion.artifact.digest, - ) - }.

` - }`; - } - - if (input.packageVersion === null) { - return '

No generated workspace files were recorded yet.

'; - } - - const contentFiles = readManifestStringArray(input.packageVersion.manifestJson, 'content_files'); - const preview = readManifestRecord(input.packageVersion.manifestJson, 'preview'); - const previewFiles = [ - readManifestString(preview, 'fixtures_file'), - readManifestString(preview, 'tests_file'), - ].filter((path): path is string => path !== null); - const fileRows: Array<{ label: string; path: string }> = [ - { label: 'Manifest', path: input.packageVersion.artifact.manifestPath }, - { label: 'Entrypoint', path: input.packageVersion.entrypoint }, - ...contentFiles.map((path) => ({ label: 'Content', path })), - ...previewFiles.map((path) => ({ label: 'Preview', path })), - { - label: 'Artifact root', - path: input.packageVersion.artifact.snapshotRoot, - }, - ]; - - return `
- ${ - fileRows - .map( - (row) => - `
-

${escapeHtml(row.label)}

-

${escapeHtml(row.path)}

-
`, - ) - .join('') - } -
-

Checksum ${escapeHtml(input.packageVersion.artifact.digest)}.

`; -} - -function renderGenerationPlan(workspace: AppGenerationWorkspaceRecord | null): string { - if (workspace === null || workspace.generationPlan.length === 0) { - return '

No generation plan has been recorded yet.

'; - } - - return `
- ${ - workspace.generationPlan - .map( - (step) => - `
-

${ - escapeHtml( - formatProgressStage(step.id), - ) - } ${escapeHtml(step.status)}

-

${escapeHtml(step.summary)}

-

${escapeHtml(renderPlanStepTiming(step))} · ${ - escapeHtml( - String(step.diagnosticCount), - ) - } diagnostics.

- ${renderPlanStepResult(step)} -
`, - ) - .join('') - } -
`; -} - -function renderPreviewSummary(workspace: AppGenerationWorkspaceRecord | null): string { - const previewResult = readPreviewStepResult(workspace); - - if (previewResult === null) { - return '

No preview assertion summary has been recorded yet.

'; - } - - return `
-
-

Preview summary

-

${escapeHtml(previewResult.summary)}

-

${ - escapeHtml( - `${previewResult.passedAssertionCount}/${previewResult.assertionCount} assertions passed · ${previewResult.runtimeLogCount} runtime log entries`, - ) - }

-
-
`; -} - -function renderPlanStepTiming( - step: AppGenerationWorkspaceRecord['generationPlan'][number], -): string { - const started = step.startedAt === null - ? 'not started' - : `started ${formatDateTime(step.startedAt)}`; - const completed = step.completedAt === null - ? 'not completed' - : `completed ${formatDateTime(step.completedAt)}`; - - return `${started}; ${completed}`; -} - -function renderPlanStepResult( - step: AppGenerationWorkspaceRecord['generationPlan'][number], -): string { - const summary = readUnknownString(step.result.summary); - - if (typeof summary === 'string') { - const assertionCount = readUnknownNumber(step.result.assertionCount); - const passedAssertionCount = readUnknownNumber(step.result.passedAssertionCount); - - return `

${ - escapeHtml( - assertionCount === null || passedAssertionCount === null - ? summary - : `${summary} Assertions ${passedAssertionCount}/${assertionCount}.`, - ) - }

`; - } - - const keys = Object.keys(step.result); - - if (keys.length === 0) { - return ''; - } - - return `

Result: ${ - escapeHtml( - keys - .filter((key) => key !== 'runtimeLog') - .slice(0, 4) - .map((key) => `${key}=${formatResultValue(step.result[key])}`) - .join(', '), - ) - }

`; -} - -function renderModelRequestMetadata(run: AppGenerationRunRecord): string { - if (run.modelRequestMetadata.length === 0) { - return '

No model request metadata was recorded for this run.

'; - } - - return `
- ${ - run.modelRequestMetadata - .map( - (metadata) => - `
-

${escapeHtml(metadata.stage)} attempt ${ - escapeHtml( - String(metadata.attempt), - ) - } ${escapeHtml(metadata.outcome)}

-

${escapeHtml(metadata.provider)}${ - metadata.model === null ? '' : ` · ${escapeHtml(metadata.model)}` - }

-

Request ${ - escapeHtml( - metadata.requestId ?? 'not provided', - ) - }; ${ - escapeHtml( - formatNullableNumber(metadata.responseCharacters), - ) - } characters; ${escapeHtml(formatNullableNumber(metadata.durationMs))} ms${ - metadata.errorCode === null ? '' : `; ${escapeHtml(metadata.errorCode)}` - }.

-
`, - ) - .join('') - } -
`; -} - -function renderFindings(run: AppGenerationRunRecord): string { - if (run.validationFindings.length === 0) { - return '

No validation or preview findings were recorded.

'; - } - - return `
- ${ - run.validationFindings - .map( - (finding) => - `
-

${escapeHtml(finding.code)}

-

${escapeHtml(finding.message)}

- ${renderFindingDetail(finding)} - ${ - finding.fix === null ? '' : `

Fix: ${escapeHtml(finding.fix)}

` - } -
`, - ) - .join('') - } -
`; -} - -function renderFindingDetail( - finding: AppGenerationRunRecord['validationFindings'][number], -): string { - const harnessError = readUnknownString(finding.detail.harnessError); - const modelRequestCount = readUnknownNumber(finding.detail.modelRequestCount); - - if (harnessError === null && modelRequestCount === null) { - return ''; - } - - return `

Detail: ${ - escapeHtml( - [ - harnessError === null ? null : `harness=${harnessError}`, - modelRequestCount === null ? null : `model requests=${modelRequestCount}`, - ] - .filter((part): part is string => part !== null) - .join(', '), - ) - }

`; -} - -function renderActivity(run: AppGenerationRunRecord, events: AuditEventRecord[]): string { - if (events.length === 0) { - const emptyStateEvents = [ - `Created run ${run.generationId}`, - `Selected ${run.selectedStarterId ?? 'no'} starter`, - `Current status is ${formatStatus(run.status)}`, - run.packageVersionId === null - ? 'No package version saved yet' - : `Saved pending package version ${run.packageVersionId}`, - ]; - - return `
    - ${emptyStateEvents.map((event) => `
  1. ${escapeHtml(event)}
  2. `).join('')} -
`; - } - - return `
    - ${events.map((event) => `
  1. ${renderActivityEvent(event)}
  2. `).join('')} -
`; -} - -function renderActivityEvent(event: AuditEventRecord): string { - const progressStage = readAuditString(event.detail, 'modelProgressStage'); - const progressLabel = progressStage === null - ? '' - : ` ${ - escapeHtml( - formatProgressStage(progressStage), - ) - }`; - - return `${ - escapeHtml( - formatDateTime(event.occurredAt), - ) - }${progressLabel} ${escapeHtml(event.summary)}`; -} - -function renderGeneratedPackageRuntimeLog(input: { - packageVersion: PackageVersionRecord | null; - workspace: AppGenerationWorkspaceRecord | null; - latestPreviewSession: PreviewSessionRecord | null; - previewEvidence: PreviewEvidenceRecord[]; -}): string { - if (input.packageVersion === null) { - return ''; - } - - const previewHref = `/admin/packages/${ - escapeHtml( - input.packageVersion.appId, - ) - }/versions/${escapeHtml(input.packageVersion.version)}/preview`; - - return `
-
- - ${ - input.latestPreviewSession === null - ? `

${ - escapeHtml( - readPreviewStepResult(input.workspace) === null - ? 'No manual runtime preview session has been recorded for this generated package yet.' - : `Generation preview completed: ${ - readPreviewStepResult(input.workspace)?.summary ?? '' - } No manual test launch has been opened yet.`, - ) - }

- ` - : `

Latest test launch ${ - escapeHtml( - input.latestPreviewSession.sessionId, - ) - } ran as ${ - escapeHtml( - input.latestPreviewSession.launch.userRole, - ) - } in course ${ - escapeHtml( - input.latestPreviewSession.launch.courseId, - ) - }.

- ${renderPreviewEvidence(input.previewEvidence)} - ` - } -
-
`; -} - -function renderPreviewEvidence(records: PreviewEvidenceRecord[]): string { - if (records.length === 0) { - return '

No runtime gateway events have been recorded for this test launch yet.

'; - } - - return `
- ${ - records - .map( - (record) => - `
-

${escapeHtml(record.eventType)}${ - record.capability === null - ? '' - : ` ${escapeHtml(record.capability)}` - }

-

${escapeHtml(formatDateTime(record.occurredAt))}

-

${escapeHtml(record.summary)}

-
`, - ) - .join('') - } -
`; -} diff --git a/src/admin/app_writer_run_sections.ts b/src/admin/app_writer_run_sections.ts new file mode 100644 index 0000000..e62f7b8 --- /dev/null +++ b/src/admin/app_writer_run_sections.ts @@ -0,0 +1,543 @@ +import type { + AppGenerationRunRecord, + AppGenerationStatus, + AppGenerationWorkspaceRecord, +} from '../app_writer/types.ts'; +import type { + AuditEventRecord, + PackageVersionRecord, + PreviewEvidenceRecord, + PreviewSessionRecord, +} from '../package_review/types.ts'; +import { escapeHtml, formatDateTime } from './layout.ts'; +import { + formatNullableNumber, + formatProgressStage, + formatResultValue, + formatStatus, + formatWorkspaceFileRole, + progressIndexForStatus, + readAuditString, + readPreviewStepResult, + readUnknownNumber, + selectCurrentGenerationPlanStep, + statusClassForModelOutcome, + statusClassForPlanStep, +} from './app_writer_run_view_model.ts'; +import { + readManifestRecord, + readManifestString, + readManifestStringArray, + readUnknownString, +} from '../package_review/manifest_view.ts'; + +export function renderGenerationProgress(status: AppGenerationStatus): string { + const steps: Array<{ + status: AppGenerationStatus; + label: string; + }> = [ + { status: 'started', label: 'Started' }, + { status: 'initializing', label: 'Initializing' }, + { status: 'planning', label: 'Planning' }, + { status: 'generating_package', label: 'Generating' }, + { status: 'validating', label: 'Validating' }, + { status: 'previewing', label: 'Previewing' }, + { status: 'saved_pending_version', label: 'Saved' }, + ]; + const activeIndex = progressIndexForStatus(status); + + return `
    + ${ + steps + .map((step, index) => { + const stateClass = index < activeIndex + ? 'is-complete' + : index === activeIndex + ? 'is-current' + : 'is-pending'; + + return `
  1. + + ${escapeHtml(step.label)} +
  2. `; + }) + .join('') + } +
`; +} + +export function renderLiveProgress(input: { + run: AppGenerationRunRecord; + workspace: AppGenerationWorkspaceRecord | null; +}): string { + const currentStep = selectCurrentGenerationPlanStep(input.workspace); + const modelAttempt = input.run.modelRequestMetadata.at(-1); + + return `
+
+

Live progress ${ + escapeHtml( + formatStatus(input.run.status), + ) + }

+

${ + escapeHtml( + currentStep === null + ? 'No generation plan step is active yet.' + : `${formatProgressStage(currentStep.id)}: ${currentStep.summary}`, + ) + }

+

${ + escapeHtml( + [ + `Repairs ${input.run.repairAttemptCount}`, + `Findings ${input.run.validationFindings.length}`, + modelAttempt === undefined + ? 'Model attempt unknown' + : `Model ${modelAttempt.stage} attempt ${modelAttempt.attempt} ${modelAttempt.outcome}`, + ].join(' · '), + ) + }

+
+
`; +} + +export function renderGenerationRefreshScript(generationId: string): string { + const eventUrl = `/admin/app-writer/runs/${escapeHtml(generationId)}/events`; + + return ``; +} + +export function renderGeneratedFilesSummary(input: { + workspace: AppGenerationWorkspaceRecord | null; + packageVersion: PackageVersionRecord | null; +}): string { + if (input.workspace !== null) { + const fileRows = input.workspace.files.map((file) => ({ + label: file.path, + path: `${formatWorkspaceFileRole(file.role)} · ${file.contents.length} characters`, + })); + + return `
+ ${ + fileRows + .map( + (row) => + `
+

${escapeHtml(row.label)}

+

${escapeHtml(row.path)}

+
`, + ) + .join('') + } +
+

Workspace captured after repair attempt ${ + escapeHtml( + String(input.workspace.repairAttemptCount), + ) + }; ${ + escapeHtml( + String(input.workspace.validationFindings.length), + ) + } validation or preview findings currently attached.

+ ${ + input.packageVersion === null + ? '

No immutable package version has been saved yet.

' + : `

Saved package checksum ${ + escapeHtml( + input.packageVersion.artifact.digest, + ) + }.

` + }`; + } + + if (input.packageVersion === null) { + return '

No generated workspace files were recorded yet.

'; + } + + const contentFiles = readManifestStringArray(input.packageVersion.manifestJson, 'content_files'); + const preview = readManifestRecord(input.packageVersion.manifestJson, 'preview'); + const previewFiles = [ + readManifestString(preview, 'fixtures_file'), + readManifestString(preview, 'tests_file'), + ].filter((path): path is string => path !== null); + const fileRows: Array<{ label: string; path: string }> = [ + { label: 'Manifest', path: input.packageVersion.artifact.manifestPath }, + { label: 'Entrypoint', path: input.packageVersion.entrypoint }, + ...contentFiles.map((path) => ({ label: 'Content', path })), + ...previewFiles.map((path) => ({ label: 'Preview', path })), + { + label: 'Artifact root', + path: input.packageVersion.artifact.snapshotRoot, + }, + ]; + + return `
+ ${ + fileRows + .map( + (row) => + `
+

${escapeHtml(row.label)}

+

${escapeHtml(row.path)}

+
`, + ) + .join('') + } +
+

Checksum ${escapeHtml(input.packageVersion.artifact.digest)}.

`; +} + +export function renderGenerationPlan(workspace: AppGenerationWorkspaceRecord | null): string { + if (workspace === null || workspace.generationPlan.length === 0) { + return '

No generation plan has been recorded yet.

'; + } + + return `
+ ${ + workspace.generationPlan + .map( + (step) => + `
+

${ + escapeHtml( + formatProgressStage(step.id), + ) + } ${escapeHtml(step.status)}

+

${escapeHtml(step.summary)}

+

${escapeHtml(renderPlanStepTiming(step))} · ${ + escapeHtml( + String(step.diagnosticCount), + ) + } diagnostics.

+ ${renderPlanStepResult(step)} +
`, + ) + .join('') + } +
`; +} + +export function renderPreviewSummary(workspace: AppGenerationWorkspaceRecord | null): string { + const previewResult = readPreviewStepResult(workspace); + + if (previewResult === null) { + return '

No preview assertion summary has been recorded yet.

'; + } + + return `
+
+

Preview summary

+

${escapeHtml(previewResult.summary)}

+

${ + escapeHtml( + `${previewResult.passedAssertionCount}/${previewResult.assertionCount} assertions passed · ${previewResult.runtimeLogCount} runtime log entries`, + ) + }

+
+
`; +} + +export function renderModelRequestMetadata(run: AppGenerationRunRecord): string { + if (run.modelRequestMetadata.length === 0) { + return '

No model request metadata was recorded for this run.

'; + } + + return `
+ ${ + run.modelRequestMetadata + .map( + (metadata) => + `
+

${escapeHtml(metadata.stage)} attempt ${ + escapeHtml( + String(metadata.attempt), + ) + } ${escapeHtml(metadata.outcome)}

+

${escapeHtml(metadata.provider)}${ + metadata.model === null ? '' : ` · ${escapeHtml(metadata.model)}` + }

+

Request ${ + escapeHtml( + metadata.requestId ?? 'not provided', + ) + }; ${ + escapeHtml( + formatNullableNumber(metadata.responseCharacters), + ) + } characters; ${escapeHtml(formatNullableNumber(metadata.durationMs))} ms${ + metadata.errorCode === null ? '' : `; ${escapeHtml(metadata.errorCode)}` + }.

+
`, + ) + .join('') + } +
`; +} + +export function renderFindings(run: AppGenerationRunRecord): string { + if (run.validationFindings.length === 0) { + return '

No validation or preview findings were recorded.

'; + } + + return `
+ ${ + run.validationFindings + .map( + (finding) => + `
+

${escapeHtml(finding.code)}

+

${escapeHtml(finding.message)}

+ ${renderFindingDetail(finding)} + ${ + finding.fix === null ? '' : `

Fix: ${escapeHtml(finding.fix)}

` + } +
`, + ) + .join('') + } +
`; +} + +export function renderActivity(run: AppGenerationRunRecord, events: AuditEventRecord[]): string { + if (events.length === 0) { + const emptyStateEvents = [ + `Created run ${run.generationId}`, + `Selected ${run.selectedStarterId ?? 'no'} starter`, + `Current status is ${formatStatus(run.status)}`, + run.packageVersionId === null + ? 'No package version saved yet' + : `Saved pending package version ${run.packageVersionId}`, + ]; + + return `
    + ${emptyStateEvents.map((event) => `
  1. ${escapeHtml(event)}
  2. `).join('')} +
`; + } + + return `
    + ${events.map((event) => `
  1. ${renderActivityEvent(event)}
  2. `).join('')} +
`; +} + +export function renderGeneratedPackageRuntimeLog(input: { + packageVersion: PackageVersionRecord | null; + workspace: AppGenerationWorkspaceRecord | null; + latestPreviewSession: PreviewSessionRecord | null; + previewEvidence: PreviewEvidenceRecord[]; +}): string { + if (input.packageVersion === null) { + return ''; + } + + const previewHref = `/admin/packages/${ + escapeHtml( + input.packageVersion.appId, + ) + }/versions/${escapeHtml(input.packageVersion.version)}/preview`; + + return `
+
+ + ${ + input.latestPreviewSession === null + ? `

${ + escapeHtml( + readPreviewStepResult(input.workspace) === null + ? 'No manual runtime preview session has been recorded for this generated package yet.' + : `Generation preview completed: ${ + readPreviewStepResult(input.workspace)?.summary ?? '' + } No manual test launch has been opened yet.`, + ) + }

+ ` + : `

Latest test launch ${ + escapeHtml( + input.latestPreviewSession.sessionId, + ) + } ran as ${ + escapeHtml( + input.latestPreviewSession.launch.userRole, + ) + } in course ${ + escapeHtml( + input.latestPreviewSession.launch.courseId, + ) + }.

+ ${renderPreviewEvidence(input.previewEvidence)} + ` + } +
+
`; +} + +function renderPlanStepTiming( + step: AppGenerationWorkspaceRecord['generationPlan'][number], +): string { + const started = step.startedAt === null + ? 'not started' + : `started ${formatDateTime(step.startedAt)}`; + const completed = step.completedAt === null + ? 'not completed' + : `completed ${formatDateTime(step.completedAt)}`; + + return `${started}; ${completed}`; +} + +function renderPlanStepResult( + step: AppGenerationWorkspaceRecord['generationPlan'][number], +): string { + const summary = readUnknownString(step.result.summary); + + if (typeof summary === 'string') { + const assertionCount = readUnknownNumber(step.result.assertionCount); + const passedAssertionCount = readUnknownNumber(step.result.passedAssertionCount); + + return `

${ + escapeHtml( + assertionCount === null || passedAssertionCount === null + ? summary + : `${summary} Assertions ${passedAssertionCount}/${assertionCount}.`, + ) + }

`; + } + + const keys = Object.keys(step.result); + + if (keys.length === 0) { + return ''; + } + + return `

Result: ${ + escapeHtml( + keys + .filter((key) => key !== 'runtimeLog') + .slice(0, 4) + .map((key) => `${key}=${formatResultValue(step.result[key])}`) + .join(', '), + ) + }

`; +} + +function renderFindingDetail( + finding: AppGenerationRunRecord['validationFindings'][number], +): string { + const harnessError = readUnknownString(finding.detail.harnessError); + const modelRequestCount = readUnknownNumber(finding.detail.modelRequestCount); + + if (harnessError === null && modelRequestCount === null) { + return ''; + } + + return `

Detail: ${ + escapeHtml( + [ + harnessError === null ? null : `harness=${harnessError}`, + modelRequestCount === null ? null : `model requests=${modelRequestCount}`, + ] + .filter((part): part is string => part !== null) + .join(', '), + ) + }

`; +} + +function renderActivityEvent(event: AuditEventRecord): string { + const progressStage = readAuditString(event.detail, 'modelProgressStage'); + const progressLabel = progressStage === null + ? '' + : ` ${ + escapeHtml( + formatProgressStage(progressStage), + ) + }`; + + return `${ + escapeHtml( + formatDateTime(event.occurredAt), + ) + }${progressLabel} ${escapeHtml(event.summary)}`; +} + +function renderPreviewEvidence(records: PreviewEvidenceRecord[]): string { + if (records.length === 0) { + return '

No runtime gateway events have been recorded for this test launch yet.

'; + } + + return `
+ ${ + records + .map( + (record) => + `
+

${escapeHtml(record.eventType)}${ + record.capability === null + ? '' + : ` ${escapeHtml(record.capability)}` + }

+

${escapeHtml(formatDateTime(record.occurredAt))}

+

${escapeHtml(record.summary)}

+
`, + ) + .join('') + } +
`; +} diff --git a/src/admin/deployment_detail_ops_support.ts b/src/admin/deployment_detail_ops_support.ts index 5a66510..d2debfa 100644 --- a/src/admin/deployment_detail_ops_support.ts +++ b/src/admin/deployment_detail_ops_support.ts @@ -398,8 +398,6 @@ export function describeCompatibilityPathSummary( return 'Lantern last retried Deep Linking JWKS lookup on the saved deployment path.'; case 'deep_linking:target_link_uri_drift': return 'Lantern last used Deep Linking target drift tolerance on the saved deployment path.'; - case 'service:service_401_retry': - return 'Lantern last retried an LMS service request after a 401 on the saved deployment path.'; default: return snapshot.summary; } diff --git a/src/admin/deployment_detail_roster_test.ts b/src/admin/deployment_detail_roster_test.ts index 63f2364..6183350 100644 --- a/src/admin/deployment_detail_roster_test.ts +++ b/src/admin/deployment_detail_roster_test.ts @@ -5,15 +5,14 @@ import { buildPackageVersionRecord, createInMemoryPackageReviewRepository, } from '../test_helpers/package_review.ts'; -import { - buildDeploymentBinding, - buildRuntimeSessionRecord, - getTestToolPrivateJwkEnvValue, -} from '../test_helpers/lti.ts'; -import { restoreEnv, withFetchStub } from './deployment_detail_test_helpers.ts'; +import { buildDeploymentBinding, buildRuntimeSessionRecord } from '../test_helpers/lti.ts'; +import { createTestLtiToolContext } from '../test_helpers/lti_tool_context.ts'; -Deno.test('POST /admin/packages/:appId/deployment/verify-roster stores a deployment-level NRPS verification summary', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); +Deno.test('POST /admin/packages/:appId/deployment/verify-roster stores a deployment-level NRPS verification summary from lti-tool', async () => { + const ltiToolContext = createTestLtiToolContext({ + sessionId: 'lti-tool-session-123', + members: [{ userId: 'learner-1' }], + }); const repository = createInMemoryPackageReviewRepository({ packageVersions: [ buildPackageVersionRecord({ @@ -35,89 +34,35 @@ Deno.test('POST /admin/packages/:appId/deployment/verify-roster stores a deploym }, runtimeSessions: [ buildRuntimeSessionRecord({ + ltiToolSessionId: 'lti-tool-session-123', expiresAt: '2026-03-25T02:45:00Z', }), ], }); - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - await withFetchStub( - (input, init) => { - const url = String(input); - const headers = new Headers(init?.headers); - - if (url === 'https://sso.canvaslms.com/login/oauth2/token') { - const body = typeof init?.body === 'string' - ? init.body - : init?.body instanceof URLSearchParams - ? init.body.toString() - : ''; - - assertStringIncludes(body, 'deployment_id=deployment-123'); - - return new Response( - JSON.stringify({ - access_token: 'canvas-access-token', - token_type: 'bearer', - }), - { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }, - ); - } - - assertEquals(headers.get('user-agent'), 'Lantern-LTI-Service/1.0'); - - return new Response( - JSON.stringify({ - members: [ - { - user_id: 'canvas-user-123', - roles: ['Learner'], - }, - ], - }), - { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }, - ); - }, - async () => { - const response = await createApp({ - getRepository: () => repository, - }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment/verify-roster', { - method: 'POST', - headers: { - Origin: 'http://localhost', - }, - }); + const response = await createApp({ + getRepository: () => repository, + getLtiToolContext: () => Promise.resolve(ltiToolContext.context), + }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment/verify-roster', { + method: 'POST', + headers: { + Origin: 'http://localhost', + }, + }); - assertEquals(response.status, 303); - assertEquals( - response.headers.get('location'), - '/admin/packages/chapter-4-asteroids/deployment', - ); + assertEquals(response.status, 303); + assertEquals( + response.headers.get('location'), + '/admin/packages/chapter-4-asteroids/deployment', + ); - const auditEvents = await repository.listAuditEventsByEventType('deployment.nrps_verified'); + const auditEvents = await repository.listAuditEventsByEventType('deployment.nrps_verified'); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.status, 'succeeded'); - assertEquals(auditEvents[0]?.detail.memberCount, 1); - assertEquals(auditEvents[0]?.detail.ltiProfileId, 'certification'); - assertEquals(auditEvents[0]?.detail.ltiProfileSource, 'lanternDefault'); - }, - ); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } + assertEquals(auditEvents.length, 1); + assertEquals(auditEvents[0]?.status, 'succeeded'); + assertEquals(auditEvents[0]?.detail.memberCount, 1); + assertEquals(auditEvents[0]?.detail.ltiProfileId, 'certification'); + assertEquals(auditEvents[0]?.detail.ltiProfileSource, 'lanternDefault'); }); Deno.test('deployment roster verification fails clearly when no launch service context exists yet', async () => { @@ -155,8 +100,7 @@ Deno.test('deployment roster verification fails clearly when no launch service c ); }); -Deno.test('POST /admin/packages/:appId/deployment/verify-roster retries one Canvas NRPS 401 when governed compatibility allows the saved service retry path', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); +Deno.test('deployment roster verification requires a verified lti-tool session', async () => { const repository = createInMemoryPackageReviewRepository({ packageVersions: [ buildPackageVersionRecord({ @@ -172,182 +116,24 @@ Deno.test('POST /admin/packages/:appId/deployment/verify-roster retries one Canv binding: buildDeploymentBinding(), }), ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'governedCompatibility', - updatedAt: '2026-03-24T12:25:00Z', - }, runtimeSessions: [ buildRuntimeSessionRecord({ + ltiToolSessionId: null, expiresAt: '2026-03-25T02:45:00Z', }), ], }); - const nrpsAuthorizations: string[] = []; - let tokenRequests = 0; - - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - await withFetchStub( - (input, init) => { - const url = String(input); - const headers = new Headers(init?.headers); - - if (url === 'https://sso.canvaslms.com/login/oauth2/token') { - tokenRequests += 1; - - return new Response( - JSON.stringify({ - access_token: `canvas-access-token-${tokenRequests}`, - token_type: 'bearer', - }), - { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }, - ); - } - - nrpsAuthorizations.push(headers.get('authorization') ?? ''); - - if (headers.get('authorization') === 'Bearer canvas-access-token-1') { - return new Response(null, { status: 401 }); - } - return new Response( - JSON.stringify({ - members: [ - { - user_id: 'canvas-user-123', - roles: ['Learner'], - }, - ], - }), - { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }, - ); - }, - async () => { - const response = await createApp({ - getRepository: () => repository, - }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment/verify-roster', { - method: 'POST', - headers: { - Origin: 'http://localhost', - }, - }); - - assertEquals(response.status, 303); - assertEquals( - response.headers.get('location'), - '/admin/packages/chapter-4-asteroids/deployment', - ); - }, - ); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } - - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - - assertEquals(tokenRequests, 2); - assertEquals(nrpsAuthorizations, [ - 'Bearer canvas-access-token-1', - 'Bearer canvas-access-token-2', - ]); - assertEquals(interopEvents.length, 1); - assertEquals(interopEvents[0]?.detail.path, 'service_401_retry'); - assertEquals(interopEvents[0]?.detail.ltiProfileId, 'governedCompatibility'); -}); - -Deno.test('POST /admin/packages/:appId/deployment/verify-roster fails on Canvas NRPS 401 without retry when certification disables the saved service retry path', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-24T12:25:00Z', + const response = await createApp({ + getRepository: () => repository, + }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment/verify-roster', { + method: 'POST', + headers: { + Origin: 'http://localhost', }, - runtimeSessions: [ - buildRuntimeSessionRecord({ - expiresAt: '2026-03-25T02:45:00Z', - }), - ], }); - let tokenRequests = 0; - let nrpsRequests = 0; - - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - await withFetchStub( - (input) => { - const url = String(input); - - if (url === 'https://sso.canvaslms.com/login/oauth2/token') { - tokenRequests += 1; - - return new Response( - JSON.stringify({ - access_token: 'canvas-access-token-1', - token_type: 'bearer', - }), - { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }, - ); - } - - nrpsRequests += 1; - return new Response(null, { status: 401 }); - }, - async () => { - const response = await createApp({ - getRepository: () => repository, - }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment/verify-roster', { - method: 'POST', - headers: { - Origin: 'http://localhost', - }, - }); - const body = await response.text(); - - assertEquals(response.status, 409); - assertStringIncludes(body, 'Canvas NRPS memberships read failed. Status 401.'); - }, - ); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } - - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - const auditEvents = await repository.listAuditEventsByEventType('deployment.nrps_verified'); + const body = await response.text(); - assertEquals(tokenRequests, 1); - assertEquals(nrpsRequests, 1); - assertEquals(interopEvents.length, 0); - assertEquals(auditEvents[0]?.status, 'failed'); - assertEquals(auditEvents[0]?.detail.ltiProfileId, 'certification'); + assertEquals(response.status, 409); + assertStringIncludes(body, 'The verified LTI session for roster verification is required.'); }); diff --git a/src/admin/package_detail.ts b/src/admin/package_detail.ts index 9dd7156..8b385ed 100644 --- a/src/admin/package_detail.ts +++ b/src/admin/package_detail.ts @@ -20,6 +20,18 @@ import type { import { type AdminNotice, escapeHtml, formatDateTime, renderAdminLayout } from './layout.ts'; import { renderPackagePageNav } from './package_navigation.ts'; import { renderDecisionSection, renderHistoryRow } from './package_detail_sections.ts'; +import { + formatCapabilityNames, + formatChangeSummary, + formatPreviewEvidenceLabel, + formatPreviewSessionSummary, + formatRuntimeLogSummary, + renderCapabilityChangeSummary, + resolvePreviousApprovedVersion, + resolvePreviousVersion, + summarizeCapabilityChanges, + versionPreviewHref, +} from './package_detail_support.ts'; export function renderPackageDetailPage(input: { packageVersion: PackageVersionRecord; @@ -577,187 +589,3 @@ function renderGenerationActivitySection(events: AuditEventRecord[]): string { function formatInstallScope(scope: PackageVersionRecord['installScope']): string { return scope === 'assignment' ? 'Assignment placement' : 'Course placement'; } - -function resolvePreviousVersion( - history: readonly PackageVersionRecord[], - packageVersion: PackageVersionRecord, -): PackageVersionRecord | null { - const currentIndex = history.findIndex((version) => version.id === packageVersion.id); - - if (currentIndex < 0) { - return null; - } - - return history[currentIndex + 1] ?? null; -} - -function resolvePreviousApprovedVersion( - history: readonly PackageVersionRecord[], - packageVersion: PackageVersionRecord, -): PackageVersionRecord | null { - const currentIndex = history.findIndex((version) => version.id === packageVersion.id); - - if (currentIndex === -1) { - return null; - } - - return history.slice(currentIndex + 1).find((version) => version.approvalStatus === 'approved') ?? - null; -} - -interface CapabilityChangeSummary { - previousApprovedVersion: PackageVersionRecord | null; - added: CapabilitySummary[]; - removed: CapabilitySummary[]; - unchanged: CapabilitySummary[]; -} - -function summarizeCapabilityChanges(input: { - current: CapabilitySummary[]; - previousApprovedVersion: PackageVersionRecord | null; -}): CapabilityChangeSummary { - if (input.previousApprovedVersion === null) { - return { - previousApprovedVersion: null, - added: input.current, - removed: [], - unchanged: [], - }; - } - - const currentIds = new Set(input.current.map((capability) => capability.id)); - const previous = summarizeCapabilities(input.previousApprovedVersion.capabilities); - const previousIds = new Set(previous.map((capability) => capability.id)); - - return { - previousApprovedVersion: input.previousApprovedVersion, - added: input.current.filter((capability) => !previousIds.has(capability.id)), - removed: previous.filter((capability) => !currentIds.has(capability.id)), - unchanged: input.current.filter((capability) => previousIds.has(capability.id)), - }; -} - -function renderCapabilityChangeSummary(summary: CapabilityChangeSummary): string { - const addedSensitive = summary.added.filter((capability) => capability.flagged); - - if (summary.previousApprovedVersion === null) { - return `
-

Capability baseline: no previously approved version exists. Review every declared runtime capability before approval.

- ${renderCapabilityChangeGroup('Declared for first approval', summary.added)} -
`; - } - - if (summary.added.length === 0 && summary.removed.length === 0) { - return `
-

Capability changes since approved version ${ - escapeHtml(summary.previousApprovedVersion.version) - }: no capability changes.

- ${renderCapabilityChangeGroup('Unchanged', summary.unchanged)} -
`; - } - - return `
-

Capability changes since approved version ${ - escapeHtml(summary.previousApprovedVersion.version) - }.

- ${renderCapabilityChangeGroup('Added', summary.added)} - ${renderCapabilityChangeGroup('Removed', summary.removed)} - ${renderCapabilityChangeGroup('Unchanged', summary.unchanged)} - ${ - addedSensitive.length === 0 - ? '' - : `

Review impact: this version newly requests ${ - escapeHtml(formatCapabilityNames(addedSensitive)) - }. Confirm the assignment purpose, evidence or grading flow, and latest preview evidence before approval.

` - } -
`; -} - -function renderCapabilityChangeGroup(label: string, capabilities: CapabilitySummary[]): string { - if (capabilities.length === 0) { - return `

${escapeHtml(label)}: none.

`; - } - - return `
-

${escapeHtml(label)}

-
    - ${ - capabilities - .map((capability) => - `
  • ${escapeHtml(capability.label)}: ${escapeHtml(capability.detail)}${ - capability.flagged - ? ` ${escapeHtml(capability.sensitivityLabel)}` - : '' - }
  • ` - ) - .join('') - } -
-
`; -} - -function formatChangeSummary( - packageVersion: PackageVersionRecord, - previousVersion: PackageVersionRecord | null, -): string { - if (previousVersion === null) { - return `Version ${packageVersion.version} is the first saved version of this app.`; - } - - return `Version ${packageVersion.version} is pending review against previous version ${previousVersion.version}.`; -} - -function formatCapabilityNames(capabilities: CapabilitySummary[]): string { - return capabilities.map((capability) => capability.label).join(', '); -} - -function formatPreviewSessionSummary(session: PreviewSessionRecord | null): string { - if (session === null) { - return 'No review test launch has been recorded yet.'; - } - - return `Latest review test launch ${session.sessionId} ran as ${ - formatPreviewRole( - session.launch.userRole, - ) - } in course ${session.launch.courseId}.`; -} - -function formatRuntimeLogSummary(records: PreviewEvidenceRecord[]): string { - if (records.length === 0) { - return 'No runtime events have been recorded yet. Start a review test launch to see gateway activity here.'; - } - - return `${records.length} runtime event${ - records.length === 1 ? '' : 's' - } recorded from the latest review test launch.`; -} - -function formatPreviewEvidenceLabel(eventType: string): string { - switch (eventType) { - case 'preview.launch': - return 'Started test launch'; - case 'preview.content_read': - return 'Loaded app content'; - case 'preview.attempt_event': - return 'Received app progress update'; - case 'preview.evidence_artifact': - return 'Stored anonymous evidence'; - case 'preview.finalize': - return 'Finished test attempt'; - default: - return eventType; - } -} - -function formatPreviewRole(role: string): string { - return role === 'learner' ? 'Student' : role === 'instructor' ? 'Instructor' : role; -} - -function versionPreviewHref(packageVersion: PackageVersionRecord): string { - return `/admin/packages/${escapeHtml(packageVersion.appId)}/versions/${ - escapeHtml( - packageVersion.version, - ) - }/preview`; -} diff --git a/src/admin/package_detail_support.ts b/src/admin/package_detail_support.ts new file mode 100644 index 0000000..4b420bd --- /dev/null +++ b/src/admin/package_detail_support.ts @@ -0,0 +1,171 @@ +import { type CapabilitySummary, summarizeCapabilities } from '../package_review/summary.ts'; +import type { + PackageVersionRecord, + PreviewEvidenceRecord, + PreviewSessionRecord, +} from '../package_review/types.ts'; +import { escapeHtml } from './layout.ts'; +export { + resolvePreviousApprovedPackageVersion as resolvePreviousApprovedVersion, + resolvePreviousPackageVersion as resolvePreviousVersion, +} from './package_version_history.ts'; + +export interface CapabilityChangeSummary { + previousApprovedVersion: PackageVersionRecord | null; + added: CapabilitySummary[]; + removed: CapabilitySummary[]; + unchanged: CapabilitySummary[]; +} + +export function summarizeCapabilityChanges(input: { + current: CapabilitySummary[]; + previousApprovedVersion: PackageVersionRecord | null; +}): CapabilityChangeSummary { + if (input.previousApprovedVersion === null) { + return { + previousApprovedVersion: null, + added: input.current, + removed: [], + unchanged: [], + }; + } + + const currentIds = new Set(input.current.map((capability) => capability.id)); + const previous = summarizeCapabilities(input.previousApprovedVersion.capabilities); + const previousIds = new Set(previous.map((capability) => capability.id)); + + return { + previousApprovedVersion: input.previousApprovedVersion, + added: input.current.filter((capability) => !previousIds.has(capability.id)), + removed: previous.filter((capability) => !currentIds.has(capability.id)), + unchanged: input.current.filter((capability) => previousIds.has(capability.id)), + }; +} + +export function renderCapabilityChangeSummary(summary: CapabilityChangeSummary): string { + const addedSensitive = summary.added.filter((capability) => capability.flagged); + + if (summary.previousApprovedVersion === null) { + return `
+

Capability baseline: no previously approved version exists. Review every declared runtime capability before approval.

+ ${renderCapabilityChangeGroup('Declared for first approval', summary.added)} +
`; + } + + if (summary.added.length === 0 && summary.removed.length === 0) { + return `
+

Capability changes since approved version ${ + escapeHtml(summary.previousApprovedVersion.version) + }: no capability changes.

+ ${renderCapabilityChangeGroup('Unchanged', summary.unchanged)} +
`; + } + + return `
+

Capability changes since approved version ${ + escapeHtml(summary.previousApprovedVersion.version) + }.

+ ${renderCapabilityChangeGroup('Added', summary.added)} + ${renderCapabilityChangeGroup('Removed', summary.removed)} + ${renderCapabilityChangeGroup('Unchanged', summary.unchanged)} + ${ + addedSensitive.length === 0 + ? '' + : `

Review impact: this version newly requests ${ + escapeHtml(formatCapabilityNames(addedSensitive)) + }. Confirm the assignment purpose, evidence or grading flow, and latest preview evidence before approval.

` + } +
`; +} + +export function renderCapabilityChangeGroup( + label: string, + capabilities: CapabilitySummary[], +): string { + if (capabilities.length === 0) { + return `

${escapeHtml(label)}: none.

`; + } + + return `
+

${escapeHtml(label)}

+
    + ${ + capabilities + .map((capability) => + `
  • ${escapeHtml(capability.label)}: ${escapeHtml(capability.detail)}${ + capability.flagged + ? ` ${escapeHtml(capability.sensitivityLabel)}` + : '' + }
  • ` + ) + .join('') + } +
+
`; +} + +export function formatChangeSummary( + packageVersion: PackageVersionRecord, + previousVersion: PackageVersionRecord | null, +): string { + if (previousVersion === null) { + return `Version ${packageVersion.version} is the first saved version of this app.`; + } + + return `Version ${packageVersion.version} is pending review against previous version ${previousVersion.version}.`; +} + +export function formatCapabilityNames(capabilities: CapabilitySummary[]): string { + return capabilities.map((capability) => capability.label).join(', '); +} + +export function formatPreviewSessionSummary(session: PreviewSessionRecord | null): string { + if (session === null) { + return 'No review test launch has been recorded yet.'; + } + + return `Latest review test launch ${session.sessionId} ran as ${ + formatPreviewRole( + session.launch.userRole, + ) + } in course ${session.launch.courseId}.`; +} + +export function formatRuntimeLogSummary(records: PreviewEvidenceRecord[]): string { + if (records.length === 0) { + return 'No runtime events have been recorded yet. Start a review test launch to see gateway activity here.'; + } + + return `${records.length} runtime event${ + records.length === 1 ? '' : 's' + } recorded from the latest review test launch.`; +} + +export function formatPreviewEvidenceLabel(eventType: string): string { + switch (eventType) { + case 'preview.launch': + return 'Started test launch'; + case 'preview.content_read': + return 'Loaded app content'; + case 'preview.attempt_event': + return 'Received app progress update'; + case 'preview.evidence_artifact': + return 'Stored anonymous evidence'; + case 'preview.finalize': + return 'Finished test attempt'; + default: + return eventType; + } +} + +export function formatPreviewRole(role: string): string { + return role === 'learner' ? 'Student' : role === 'instructor' ? 'Instructor' : role; +} + +export function versionPreviewHref(packageVersion: PackageVersionRecord): string { + return `/admin/packages/${escapeHtml(packageVersion.appId)}/versions/${ + escapeHtml( + packageVersion.version, + ) + }/preview`; +} diff --git a/src/admin/package_navigation.ts b/src/admin/package_navigation.ts index 8a015a1..07488fa 100644 --- a/src/admin/package_navigation.ts +++ b/src/admin/package_navigation.ts @@ -1,5 +1,6 @@ import type { PackageVersionRecord } from '../package_review/types.ts'; import { escapeHtml } from './layout.ts'; +import { resolvePreviousPackageVersion } from './package_version_history.ts'; export type PackagePageSection = | 'overview' @@ -22,7 +23,7 @@ export function renderPackagePageNav(input: { const authoringVersion = resolveAuthoringVersion(input.history, input.currentVersion ?? null); const diffBaseVersion = currentVersion === null ? null - : resolvePreviousVersion(input.history, currentVersion); + : resolvePreviousPackageVersion(input.history, currentVersion); const links = [ renderNavLink({ label: 'Overview', @@ -128,19 +129,6 @@ function resolveAuthoringVersion( ); } -function resolvePreviousVersion( - history: PackageVersionRecord[], - currentVersion: PackageVersionRecord, -): PackageVersionRecord | null { - const currentIndex = history.findIndex((version) => version.id === currentVersion.id); - - if (currentIndex < 0) { - return null; - } - - return history[currentIndex + 1] ?? null; -} - function renderNavLink(input: { label: string; href: string; current: boolean }): string { return `; - run: Awaited>['run']; - workflowInstanceId: string | null; -}): Promise { - await input.repository.recordAuditEvent({ - eventType: 'app_generation.generating', - actorType: 'user', - actorId: input.run.ownerId, - deploymentRecordId: null, - packageVersionId: input.run.packageVersionId, - attemptId: null, - lineItemBindingId: null, - status: 'accepted', - summary: 'Queued app writer revision in Cloudflare Workflow.', - detail: { - generationId: input.run.generationId, - generationStatus: input.run.status, - requestedAppId: input.run.requestedAppId, - generatedAppId: input.run.generatedAppId, - selectedStarterId: input.run.selectedStarterId, - repairAttemptCount: input.run.repairAttemptCount, - findingCount: input.run.validationFindings.length, - workflowInstanceId: input.workflowInstanceId, - backgroundRunner: 'workflow', - }, - occurredAt: input.run.updatedAt, - }); -} - -function scheduleRevisionContinuation( - context: unknown, - continuation: Promise, - generationId: string, -): void { - const handled = continuation - .then(() => {}) - .catch((error) => { - console.error(`App writer revision ${generationId} failed in background.`, error); - }); - const executionContext = readExecutionContext(context); - - if (executionContext !== null) { - executionContext.waitUntil(handled); - return; - } - - void handled; -} - -function readExecutionContext( - context: unknown, -): { waitUntil(promise: Promise): void } | null { - try { - const candidate = (context as { executionCtx?: unknown }).executionCtx; - - if ( - candidate !== null && - typeof candidate === 'object' && - typeof (candidate as { waitUntil?: unknown }).waitUntil === 'function' - ) { - return candidate as { waitUntil(promise: Promise): void }; - } - } catch { - return null; - } - - return null; -} - -function requireVersionString(value: FormDataEntryValue | null): string { - const version = requireTrimmedFormValue(value, 'Choose a target version for the revision.'); - - if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) { - throw new Error('Revision target version must be a semantic version like 0.2.0.'); - } - - return version; -} - -function nextRevisionVersion( - sourceVersion: string, - history: readonly { version: string }[], -): string { - let candidate = incrementMinorVersion(sourceVersion); - const existingVersions = new Set(history.map((version) => version.version)); - - while (existingVersions.has(candidate)) { - candidate = incrementMinorVersion(candidate); - } - - return candidate; -} - -function incrementMinorVersion(version: string): string { - const match = version.match(/^([0-9]+)\.([0-9]+)\.[0-9]+/); - - if (match === null) { - return '0.2.0'; - } - - return `${match[1]}.${Number(match[2]) + 1}.0`; -} - -function resolvePreviousPackageVersion( - history: readonly PackageVersionRecord[], - packageVersion: PackageVersionRecord, -): PackageVersionRecord | null { - const currentIndex = history.findIndex((version) => version.id === packageVersion.id); - - if (currentIndex < 0) { - return null; - } - - return history[currentIndex + 1] ?? null; -} - -async function collectUploadedPackageFiles(formData: FormData): Promise { - const uploadedFiles = formData.getAll('packageFiles'); - - if (uploadedFiles.length === 0) { - throw new Error('Choose one reviewed package directory.'); - } - - const files = await Promise.all( - uploadedFiles.map(async (entry) => { - if (!(entry instanceof File)) { - throw new TypeError('Choose one reviewed package directory.'); - } - - return { - relativePath: readUploadedRelativePath(entry), - bytes: new Uint8Array(await entry.arrayBuffer()), - }; - }), - ); - - return normalizeUploadedPackageFiles(files); -} - -function normalizeUploadedPackageFiles( - files: MemoryPackageSourceFile[], -): MemoryPackageSourceFile[] { - const normalizedFiles = files.map((file) => ({ - ...file, - relativePath: file.relativePath.replaceAll('\\', '/').replace(/^\/+/, ''), - })); - - if (normalizedFiles.some((file) => file.relativePath === 'manifest.json')) { - return normalizedFiles; - } - - const sharedRoot = getSharedUploadRoot(normalizedFiles); - - if (sharedRoot === null) { - return normalizedFiles; - } - - if (!normalizedFiles.some((file) => file.relativePath === `${sharedRoot}/manifest.json`)) { - return normalizedFiles; - } - - return normalizedFiles.map((file) => ({ - ...file, - relativePath: file.relativePath.slice(sharedRoot.length + 1), - })); -} - -function getSharedUploadRoot(files: MemoryPackageSourceFile[]): string | null { - const roots = new Set(); - - for (const file of files) { - const separatorIndex = file.relativePath.indexOf('/'); - - if (separatorIndex < 0) { - return null; - } - - roots.add(file.relativePath.slice(0, separatorIndex)); - } - - return roots.size === 1 ? ([...roots][0] ?? null) : null; -} - -function readUploadedRelativePath(file: File): string { - const webkitRelativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath ?? - null; - - return webkitRelativePath && webkitRelativePath !== '' ? webkitRelativePath : file.name; -} - -function statusForPackageImportError(error: unknown): 409 | 500 { - if (error instanceof Error && error.message.includes('Package source file')) { - return 409; - } - - return statusForError(error); -} diff --git a/src/app_admin_package_import_support.ts b/src/app_admin_package_import_support.ts new file mode 100644 index 0000000..83cf002 --- /dev/null +++ b/src/app_admin_package_import_support.ts @@ -0,0 +1,103 @@ +import { requireTrimmedFormValue } from './app_request_support.ts'; +import { statusForError } from './app_status_support.ts'; +import { isReferencePackageId } from './package_review/intake.ts'; +import { + createMemoryPackageSource, + type MemoryPackageSourceFile, +} from './package_review/package_source.ts'; + +export function requireSupportedReferencePackageId(value: FormDataEntryValue | null): string { + const appId = requireTrimmedFormValue(value, "Choose one of Lantern's shipped reference apps."); + + if (!isReferencePackageId(appId)) { + throw new Error("Choose one of Lantern's shipped reference apps."); + } + + return appId; +} + +export async function createUploadedPackageSource(formData: FormData) { + return createMemoryPackageSource(await collectUploadedPackageFiles(formData)); +} + +export function statusForPackageImportError(error: unknown): 409 | 500 { + if (error instanceof Error && error.message.includes('Package source file')) { + return 409; + } + + return statusForError(error); +} + +async function collectUploadedPackageFiles(formData: FormData): Promise { + const uploadedFiles = formData.getAll('packageFiles'); + + if (uploadedFiles.length === 0) { + throw new Error('Choose one reviewed package directory.'); + } + + const files = await Promise.all( + uploadedFiles.map(async (entry) => { + if (!(entry instanceof File)) { + throw new TypeError('Choose one reviewed package directory.'); + } + + return { + relativePath: readUploadedRelativePath(entry), + bytes: new Uint8Array(await entry.arrayBuffer()), + }; + }), + ); + + return normalizeUploadedPackageFiles(files); +} + +function normalizeUploadedPackageFiles( + files: MemoryPackageSourceFile[], +): MemoryPackageSourceFile[] { + const normalizedFiles = files.map((file) => ({ + ...file, + relativePath: file.relativePath.replaceAll('\\', '/').replace(/^\/+/, ''), + })); + + if (normalizedFiles.some((file) => file.relativePath === 'manifest.json')) { + return normalizedFiles; + } + + const sharedRoot = getSharedUploadRoot(normalizedFiles); + + if (sharedRoot === null) { + return normalizedFiles; + } + + if (!normalizedFiles.some((file) => file.relativePath === `${sharedRoot}/manifest.json`)) { + return normalizedFiles; + } + + return normalizedFiles.map((file) => ({ + ...file, + relativePath: file.relativePath.slice(sharedRoot.length + 1), + })); +} + +function getSharedUploadRoot(files: MemoryPackageSourceFile[]): string | null { + const roots = new Set(); + + for (const file of files) { + const separatorIndex = file.relativePath.indexOf('/'); + + if (separatorIndex < 0) { + return null; + } + + roots.add(file.relativePath.slice(0, separatorIndex)); + } + + return roots.size === 1 ? ([...roots][0] ?? null) : null; +} + +function readUploadedRelativePath(file: File): string { + const webkitRelativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath ?? + null; + + return webkitRelativePath && webkitRelativePath !== '' ? webkitRelativePath : file.name; +} diff --git a/src/app_admin_retry_grade_publish_test.ts b/src/app_admin_retry_grade_publish_test.ts index 7913de1..9b5af33 100644 --- a/src/app_admin_retry_grade_publish_test.ts +++ b/src/app_admin_retry_grade_publish_test.ts @@ -17,6 +17,7 @@ import { buildRuntimeSessionRecord, getTestToolPrivateJwkEnvValue, } from './test_helpers/lti.ts'; +import { createTestLtiToolContext } from './test_helpers/lti_tool_context.ts'; Deno.test('POST /admin/packages/:appId/deployment/retry-grade-publish retries the failed grade publish through the SSR control plane', async () => { const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); @@ -56,6 +57,7 @@ Deno.test('POST /admin/packages/:appId/deployment/retry-grade-publish retries th ], runtimeSessions: [ buildRuntimeSessionRecord({ + ltiToolSessionId: 'lti-tool-session-123', attemptId: 'attempt-123', deploymentRecordId: 3, packageVersionId: 5, @@ -89,6 +91,9 @@ Deno.test('POST /admin/packages/:appId/deployment/retry-grade-publish retries th }), ], }); + const ltiToolContext = createTestLtiToolContext({ + sessionId: 'lti-tool-session-123', + }); const formData = new FormData(); Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); @@ -117,6 +122,7 @@ Deno.test('POST /admin/packages/:appId/deployment/retry-grade-publish retries th async () => { const response = await createApp({ getRepository: () => repository, + getLtiToolContext: () => Promise.resolve(ltiToolContext.context), }).request( 'http://localhost/admin/packages/chapter-4-asteroids/deployment/retry-grade-publish', { @@ -189,6 +195,7 @@ Deno.test('POST /admin/packages/:appId/deployment/retry-grade-publish records bo ], runtimeSessions: [ buildRuntimeSessionRecord({ + ltiToolSessionId: 'lti-tool-session-123', attemptId: 'attempt-123', deploymentRecordId: 3, packageVersionId: 5, @@ -222,6 +229,11 @@ Deno.test('POST /admin/packages/:appId/deployment/retry-grade-publish records bo }), ], }); + const ltiToolContext = createTestLtiToolContext({ + sessionId: 'lti-tool-session-123', + submitScoreSuccess: false, + submitScoreMessage: 'simulated retry failure', + }); const formData = new FormData(); Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); @@ -237,6 +249,7 @@ Deno.test('POST /admin/packages/:appId/deployment/retry-grade-publish records bo async () => { const response = await createApp({ getRepository: () => repository, + getLtiToolContext: () => Promise.resolve(ltiToolContext.context), }).request( 'http://localhost/admin/packages/chapter-4-asteroids/deployment/retry-grade-publish', { diff --git a/src/app_admin_revision_support.ts b/src/app_admin_revision_support.ts new file mode 100644 index 0000000..440cee7 --- /dev/null +++ b/src/app_admin_revision_support.ts @@ -0,0 +1,53 @@ +import { recordAppWriterWorkflowQueuedEvent } from './app_writer/background_execution.ts'; +import { requireTrimmedFormValue } from './app_request_support.ts'; +import type { PackageReviewRepository } from './package_review/repository.ts'; +import type { AppGenerationRunRecord } from './app_writer/types.ts'; + +export { resolvePreviousPackageVersion } from './admin/package_version_history.ts'; + +export async function recordRevisionWorkflowQueuedEvent(input: { + repository: PackageReviewRepository; + run: AppGenerationRunRecord; + workflowInstanceId: string | null; +}): Promise { + await recordAppWriterWorkflowQueuedEvent({ + repository: input.repository, + run: input.run, + workflowInstanceId: input.workflowInstanceId, + summary: 'Queued app writer revision in Cloudflare Workflow.', + }); +} + +export function requireVersionString(value: FormDataEntryValue | null): string { + const version = requireTrimmedFormValue(value, 'Choose a target version for the revision.'); + + if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) { + throw new Error('Revision target version must be a semantic version like 0.2.0.'); + } + + return version; +} + +export function nextRevisionVersion( + sourceVersion: string, + history: readonly { version: string }[], +): string { + let candidate = incrementMinorVersion(sourceVersion); + const existingVersions = new Set(history.map((version) => version.version)); + + while (existingVersions.has(candidate)) { + candidate = incrementMinorVersion(candidate); + } + + return candidate; +} + +function incrementMinorVersion(version: string): string { + const match = version.match(/^([0-9]+)\.([0-9]+)\.[0-9]+/); + + if (match === null) { + return '0.2.0'; + } + + return `${match[1]}.${Number(match[2]) + 1}.0`; +} diff --git a/src/app_basic_routes.ts b/src/app_basic_routes.ts index ae3cf31..7661be5 100644 --- a/src/app_basic_routes.ts +++ b/src/app_basic_routes.ts @@ -2,10 +2,8 @@ import type { Hono } from '@hono/hono'; import type { AppServices } from './app_services.ts'; import { renderHomePage } from './pages/home.ts'; import { buildCanvasConfigDocument } from './lti/config.ts'; -import { getPublicJwkSet } from './lti/tool_key.ts'; import { statusForError } from './app_status_support.ts'; -import { readEnv } from './platform/env.ts'; -import { resolveConfiguredPublicOrigin } from './public_origin.ts'; +import { resolveRequestAppOrigin } from './app_origin.ts'; const APP_WRITER_HARNESS = 'workspace-runner-v1'; @@ -22,13 +20,7 @@ export function registerBasicRoutes(app: Hono, services: AppServices): void { try { return context.json( await buildCanvasConfigDocument( - resolveConfiguredPublicOrigin({ - requestUrl: context.req.url, - forwardedHeader: context.req.header('forwarded') ?? null, - xForwardedHost: context.req.header('x-forwarded-host') ?? null, - xForwardedProto: context.req.header('x-forwarded-proto') ?? null, - configuredOrigin: readEnv('APP_ORIGIN', services.env), - }), + resolveRequestAppOrigin(context, services.env), services.env, ), ); @@ -42,9 +34,28 @@ export function registerBasicRoutes(app: Hono, services: AppServices): void { } }); + app.get('/lti/jwks', async (context) => { + try { + const ltiContext = await services.getLtiToolContext( + resolveRequestAppOrigin(context, services.env), + ); + + return context.json(await ltiContext.tool.getJWKS()); + } catch (error) { + return context.json( + { error: error instanceof Error ? error.message : 'JWKS unavailable.' }, + statusForError(error), + ); + } + }); + app.get('/lti/jwks.json', async (context) => { try { - return context.json(await getPublicJwkSet(services.env)); + const ltiContext = await services.getLtiToolContext( + resolveRequestAppOrigin(context, services.env), + ); + + return context.json(await ltiContext.tool.getJWKS()); } catch (error) { return context.json( { error: error instanceof Error ? error.message : 'JWKS unavailable.' }, diff --git a/src/app_deep_linking_authoring_preview_test.ts b/src/app_deep_linking_authoring_preview_test.ts index bcc461f..0476e3f 100644 --- a/src/app_deep_linking_authoring_preview_test.ts +++ b/src/app_deep_linking_authoring_preview_test.ts @@ -1,13 +1,7 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; import { createApp } from './app.ts'; -import { - extractHiddenInputValue, - verifyDeepLinkingResponseJwt, - withCanvasReturnEnv, - withRuntimeOriginEnv, -} from './app_test_support.ts'; +import { withCanvasReturnEnv, withRuntimeOriginEnv } from './app_test_support.ts'; import { buildDeepLinkingSelectionValue } from './lti/deep_linking.ts'; -import { LANTERN_PLACEMENT_CUSTOM_KEY } from './lti/types.ts'; import { buildDeepLinkingResourceOption, buildDeepLinkingResourceSelection, @@ -15,7 +9,11 @@ import { buildPackageVersionRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; -import { buildDeepLinkingSessionRecord, buildDeploymentBinding } from './test_helpers/lti.ts'; +import { + buildDeepLinkingSessionRecord, + buildDeploymentBinding, + createSyncedTestLtiStorage, +} from './test_helpers/lti.ts'; Deno.test('Deep Linking picker shows preview only after a reviewed selection is saved and posts it in a new tab', async () => { const repository = createInMemoryPackageReviewRepository({ @@ -220,6 +218,47 @@ Deno.test('POST /lti/deep-linking/sessions/:id/preview launches a governed runti Deno.test('POST /lti/deep-linking/sessions/:id/preview does not consume the Deep Linking session or break the later LMS return', async () => { await withRuntimeOriginEnv(async () => { await withAuthoringPreviewSnapshot(async (snapshotRoot) => { + const binding = buildDeploymentBinding(); + const ltiStorage = await createSyncedTestLtiStorage({ binding }); + await ltiStorage.addSession({ + id: 'lti-tool-session-preview-submit', + jwtPayload: {}, + user: { + id: 'canvas-user-123', + roles: ['http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'], + }, + context: { + id: 'course-42', + label: 'Physics 101', + title: 'Physics 101', + }, + platform: { + issuer: binding.issuer, + clientId: binding.clientId, + deploymentId: binding.deploymentId, + name: 'Canvas', + }, + launch: { + target: 'https://lantern.example/lti/deep-linking', + }, + services: { + deepLinking: { + returnUrl: 'https://canvas.example/courses/42/deep_link_return', + acceptTypes: ['ltiResourceLink'], + acceptPresentationDocumentTargets: ['iframe'], + acceptMultiple: false, + autoCreate: false, + data: 'deep-linking-state-token', + }, + }, + customParameters: {}, + isAdmin: false, + isInstructor: true, + isStudent: false, + isAssignmentAndGradesAvailable: false, + isDeepLinkingAvailable: true, + isNameAndRolesAvailable: false, + }); const repository = createInMemoryPackageReviewRepository({ packageVersions: [ buildAuthoringPreviewPackageVersion({ @@ -233,13 +272,14 @@ Deno.test('POST /lti/deep-linking/sessions/:id/preview does not consume the Deep id: 1, enabledPackageVersionId: 8, enabledPackageVersion: '0.8.0', - binding: buildDeploymentBinding(), + binding, }), ], deepLinkingSessions: [ buildDeepLinkingSessionRecord({ sessionId: 'deep-linking-session-preview-submit', sessionToken: 'deep-linking-token-preview-submit', + ltiToolSessionId: 'lti-tool-session-preview-submit', selection: buildDeepLinkingResourceSelection({ packageVersionId: 8, packageVersion: '0.8.0', @@ -255,7 +295,10 @@ Deno.test('POST /lti/deep-linking/sessions/:id/preview does not consume the Deep packageVersion: '0.8.0', }), }); - const app = createApp({ getRepository: () => repository }); + const app = createApp({ + getRepository: () => repository, + getLtiToolStorage: () => ltiStorage, + }); const formData = new FormData(); formData.set('token', 'deep-linking-token-preview-submit'); @@ -289,21 +332,16 @@ Deno.test('POST /lti/deep-linking/sessions/:id/preview does not consume the Deep assertEquals(submitResponse.status, 200); const body = await submitResponse.text(); - const responseJwt = extractHiddenInputValue(body, 'JWT'); - const verified = await verifyDeepLinkingResponseJwt(responseJwt); - const contentItems = verified.payload[ - 'https://purl.imsglobal.org/spec/lti-dl/claim/content_items' - ] as Array>; - const contentItem = contentItems[0] ?? {}; - const placementId = (contentItem.custom as Record)[ - LANTERN_PLACEMENT_CUSTOM_KEY - ] as string; + const auditEvents = await repository.listAuditEventsByEventType( + 'deep_linking.placement.created', + ); + const placementId = String(auditEvents[0]?.detail.placementId ?? ''); const savedPlacement = await repository.getReviewedPlacementById(placementId); const sessionAfterSubmit = await repository.getDeepLinkingSessionById( 'deep-linking-session-preview-submit', ); - assertStringIncludes(body, 'Returning assignment resource to LMS'); + assertStringIncludes(body, 'name="JWT"'); assertEquals(savedPlacement?.contentPath, '/content/bonus.json'); assertEquals(savedPlacement?.activityId, '/content/bonus.json'); assertEquals(sessionAfterSubmit?.usedAt !== null, true); diff --git a/src/app_deep_linking_request_test.ts b/src/app_deep_linking_request_test.ts index 183f086..4ae6409 100644 --- a/src/app_deep_linking_request_test.ts +++ b/src/app_deep_linking_request_test.ts @@ -1,4 +1,4 @@ -import { assertEquals, assertObjectMatch, assertStringIncludes } from '@std/assert'; +import { assertEquals, assertStringIncludes } from '@std/assert'; import { createApp } from './app.ts'; import { LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE } from './lti/types.ts'; import { @@ -8,13 +8,16 @@ import { } from './test_helpers/package_review.ts'; import { buildDeploymentBinding, - buildLoginStateRecord, buildMoodleDeploymentBinding, + createSyncedTestLtiStorage, getTestCanvasJwks, signCanvasIdToken, + signLtiToolState, } from './test_helpers/lti.ts'; +import { withFetchStub, withRuntimeOriginEnv } from './app_test_support.ts'; -Deno.test('POST /lti/deep-linking accepts assignment-selection launches and redirects to a Lantern-owned picker session', async () => { +Deno.test('POST /lti/deep-linking accepts lti-tool verified Canvas Deep Linking launches', async () => { + const binding = buildDeploymentBinding(); const repository = createInMemoryPackageReviewRepository({ packageVersions: [ buildPackageVersionRecord({ @@ -29,284 +32,147 @@ Deno.test('POST /lti/deep-linking accepts assignment-selection launches and redi id: 7, enabledPackageVersionId: 1, enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-24T16:00:00Z', - }, - loginStates: [ - buildLoginStateRecord({ - state: 'state-deep-linking', - nonce: 'nonce-deep-linking', - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - createdAt: '2026-03-24T16:10:00Z', - expiresAt: '2030-03-25T16:20:00Z', + binding, }), ], }); + const ltiStorage = await createSyncedTestLtiStorage({ binding }); const formData = new FormData(); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-deep-linking', - subject: null, - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', - deepLinkData: 'dl-state-123', - }); - formData.set('state', 'state-deep-linking'); - formData.set('id_token', idToken); - - const response = await createApp({ - getRepository: () => repository, - loadCanvasJwks: () => Promise.resolve(getTestCanvasJwks()), - }).request('http://localhost/lti/deep-linking', { - method: 'POST', - headers: { - 'user-agent': 'Canvas-DeepLink/1.0', - 'cf-connecting-ip': '198.51.100.27', - 'cf-ray': 'deeplink-ray-IAD', - }, - body: formData, - }); - const location = response.headers.get('location') ?? ''; - const sessionLocation = new URL(`http://localhost${location}`); - const sessionId = sessionLocation.pathname.split('/').at(-1) ?? ''; - const savedSession = await repository.getDeepLinkingSessionById(sessionId); - const runtimeSession = await repository.getLatestRuntimeSessionByDeploymentId(7); - const auditEvents = await repository.listAuditEventsByEventType('deep_linking.request.accepted'); - - assertEquals(response.status, 303); - assertStringIncludes(location, '/lti/deep-linking/sessions/'); - assertEquals(savedSession?.deepLinkReturnUrl.includes('deep_link_return'), true); - assertEquals(savedSession?.sessionToken, sessionLocation.searchParams.get('token')); - assertEquals(runtimeSession, null); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.deploymentRecordId, 7); - assertEquals(auditEvents[0]?.packageVersionId, 1); - assertEquals( - String(auditEvents[0]?.detail.internalDeploymentSlug ?? ''), - 'chapter-4-asteroids-pilot', + formData.set( + 'state', + await signLtiToolState({ + nonce: 'nonce-canvas-deep-linking', + issuer: binding.issuer, + clientId: binding.clientId, + targetLinkUri: 'http://localhost/lti/deep-linking?placement=assignment_selection', + }), + ); + formData.set( + 'id_token', + await signCanvasIdToken({ + nonce: 'nonce-canvas-deep-linking', + subject: 'canvas-author-123', + messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, + targetLinkUri: 'http://localhost/lti/deep-linking?placement=assignment_selection', + deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', + deepLinkData: 'dl-state-123', + deepLinkAcceptLineItem: true, + }), ); - assertEquals(auditEvents[0]?.detail.ltiProfileId, 'certification'); - assertEquals(auditEvents[0]?.detail.ltiProfileSource, 'lanternDefault'); -}); - -Deno.test('POST /lti/deep-linking rejects unsupported Deep Linking payloads before any picker handoff', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - installScope: 'assignment', - approvalStatus: 'approved', - reviewedAt: '2026-03-24T16:15:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - loginStates: [ - buildLoginStateRecord({ - state: 'state-deep-linking-error', - nonce: 'nonce-deep-linking-error', - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - createdAt: '2026-03-24T16:10:00Z', - expiresAt: '2030-03-25T16:20:00Z', - }), - ], - }); - const formData = new FormData(); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-deep-linking-error', - subject: null, - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', - deepLinkAcceptTypes: ['html'], - }); - - formData.set('state', 'state-deep-linking-error'); - formData.set('id_token', idToken); - - const response = await createApp({ - getRepository: () => repository, - loadCanvasJwks: () => Promise.resolve(getTestCanvasJwks()), - }).request('http://localhost/lti/deep-linking', { - method: 'POST', - headers: { - 'x-real-ip': '198.51.100.27', - }, - body: formData, - }); - const savedState = await repository.getLoginStateByState('state-deep-linking-error'); - const runtimeSession = await repository.getLatestRuntimeSessionByDeploymentId(7); - const auditEvents = await repository.listAuditEventsByEventType('deep_linking.request.rejected'); - - assertEquals(response.status, 400); - assertStringIncludes(await response.text(), 'Unsupported'); - assertEquals(savedState?.usedAt, null); - assertEquals(runtimeSession, null); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.detail.category, 'specInvalid'); - assertEquals(auditEvents[0]?.detail.code, 'unsupported_deep_linking_accept_type'); - assertEquals(auditEvents[0]?.detail.ltiProfileId, 'governedCompatibility'); - assertObjectMatch(auditEvents[0]?.detail.request ?? {}, { - method: 'POST', - path: '/lti/deep-linking', - formKeys: ['id_token', 'state'], - queryKeys: [], - clientIpMasked: '198.51.100.x', - }); -}); - -Deno.test('POST /lti/deep-linking returns 409 and records a typed policy denial when certification blocks the nonce bridge', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - installScope: 'assignment', - approvalStatus: 'approved', - reviewedAt: '2026-03-24T16:15:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-24T16:00:00Z', - }, - loginStates: [ - buildLoginStateRecord({ - state: 'state-deep-linking-policy-denial', - nonce: 'nonce-deep-linking-policy-denial', - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - createdAt: '2026-03-24T16:10:00Z', - expiresAt: '2030-03-25T16:20:00Z', - }), - ], - }); - const formData = new FormData(); - const idToken = await signCanvasIdToken({ - nonce: null, - jwtId: 'nonce-deep-linking-policy-denial', - subject: null, - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', - }); - - formData.set('state', 'state-deep-linking-policy-denial'); - formData.set('id_token', idToken); - - const response = await createApp({ - getRepository: () => repository, - loadCanvasJwks: () => Promise.resolve(getTestCanvasJwks()), - }).request('http://localhost/lti/deep-linking', { - method: 'POST', - headers: { - 'x-real-ip': '198.51.100.28', - }, - body: formData, - }); - const savedState = await repository.getLoginStateByState('state-deep-linking-policy-denial'); - const auditEvents = await repository.listAuditEventsByEventType('deep_linking.request.rejected'); - assertEquals(response.status, 409); - assertStringIncludes(await response.text(), 'nonce'); - assertEquals(savedState?.usedAt, null); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.detail.category, 'policyDenied'); - assertEquals(auditEvents[0]?.detail.code, 'nonce_bridge_not_allowed'); - assertEquals(auditEvents[0]?.detail.ltiProfileId, 'certification'); - assertObjectMatch(auditEvents[0]?.detail.request ?? {}, { - method: 'POST', - path: '/lti/deep-linking', - formKeys: ['id_token', 'state'], - clientIpMasked: '198.51.100.x', + await withRuntimeOriginEnv(async () => { + await withFetchStub( + () => + Promise.resolve( + new Response(JSON.stringify(getTestCanvasJwks()), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + async () => { + const response = await createApp({ + getRepository: () => repository, + getLtiToolStorage: () => ltiStorage, + }).request('http://localhost/lti/deep-linking', { + method: 'POST', + body: formData, + }); + const location = response.headers.get('location') ?? ''; + const sessionLocation = new URL(`http://localhost${location}`); + const sessionId = sessionLocation.pathname.split('/').at(-1) ?? ''; + const savedSession = await repository.getDeepLinkingSessionById(sessionId); + const runtimeSession = await repository.getLatestRuntimeSessionByDeploymentId(7); + + assertEquals(response.status, 303, await response.clone().text()); + assertStringIncludes(location, '/lti/deep-linking/sessions/'); + assertEquals(savedSession?.deepLinkReturnUrl.includes('deep_link_return'), true); + assertEquals(savedSession?.sessionToken, sessionLocation.searchParams.get('token')); + assertEquals(savedSession?.ltiToolSessionId !== null, true); + assertEquals(savedSession?.placement, 'assignment_selection'); + assertEquals(savedSession?.acceptLineItem, true); + assertEquals(runtimeSession, null); + }, + ); }); }); -Deno.test('POST /lti/deep-linking accepts Moodle Deep Linking launches through the generic route', async () => { +Deno.test('POST /lti/deep-linking accepts lti-tool verified Moodle Deep Linking launches', async () => { + const binding = buildMoodleDeploymentBinding(); const repository = createInMemoryPackageReviewRepository({ packageVersions: [ buildPackageVersionRecord({ - id: 1, - installScope: 'assignment', + id: 5, + installScope: 'course', approvalStatus: 'approved', reviewedAt: '2026-03-24T16:15:00Z', }), ], deployments: [ buildDeploymentRecord({ - id: 8, - enabledPackageVersionId: 1, + id: 12, + enabledPackageVersionId: 5, enabledPackageVersion: '0.1.0', - binding: buildMoodleDeploymentBinding({ - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - }), - }), - ], - loginStates: [ - buildLoginStateRecord({ - lms: 'moodle', - canvasEnvironment: null, - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - state: 'state-deep-linking-moodle', - nonce: 'nonce-deep-linking-moodle', - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - createdAt: '2026-03-24T16:10:00Z', - expiresAt: '2030-03-25T16:20:00Z', + binding, }), ], }); + const ltiStorage = await createSyncedTestLtiStorage({ binding }); const formData = new FormData(); - const idToken = await signCanvasIdToken({ - deploymentBinding: { - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - }, - audience: 'moodle-client-123', - nonce: 'nonce-deep-linking-moodle', - subject: null, - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - deepLinkReturnUrl: 'https://moodle.example/mod/lti/deep_link_return.php', - }); - formData.set('state', 'state-deep-linking-moodle'); - formData.set('id_token', idToken); + formData.set( + 'state', + await signLtiToolState({ + nonce: 'nonce-moodle-deep-linking', + issuer: binding.issuer, + clientId: binding.clientId, + targetLinkUri: 'http://localhost/lti/deep-linking?placement=resource_selection', + }), + ); + formData.set( + 'id_token', + await signCanvasIdToken({ + deploymentBinding: { + issuer: binding.issuer, + clientId: binding.clientId, + deploymentId: binding.deploymentId, + }, + audience: binding.clientId, + nonce: 'nonce-moodle-deep-linking', + subject: 'moodle-user-9', + messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, + targetLinkUri: 'http://localhost/lti/deep-linking?placement=resource_selection', + deepLinkReturnUrl: 'https://moodle.example/mod/lti/deep_link_return', + deepLinkData: 'moodle-state', + }), + ); - const response = await createApp({ - getRepository: () => repository, - loadCanvasJwks: () => Promise.resolve(getTestCanvasJwks()), - }).request('http://localhost/lti/deep-linking', { - method: 'POST', - body: formData, + await withRuntimeOriginEnv(async () => { + await withFetchStub( + () => + Promise.resolve( + new Response(JSON.stringify(getTestCanvasJwks()), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + async () => { + const response = await createApp({ + getRepository: () => repository, + getLtiToolStorage: () => ltiStorage, + }).request('http://localhost/lti/deep-linking', { + method: 'POST', + body: formData, + }); + const location = response.headers.get('location') ?? ''; + const sessionId = new URL(`http://localhost${location}`).pathname.split('/').at(-1) ?? ''; + const savedSession = await repository.getDeepLinkingSessionById(sessionId); + + assertEquals(response.status, 303); + assertEquals(savedSession?.deploymentRecordId, 12); + assertEquals(savedSession?.placement, 'resource_selection'); + assertEquals(savedSession?.userId, 'moodle-user-9'); + }, + ); }); - const location = response.headers.get('location') ?? ''; - const auditEvents = await repository.listAuditEventsByEventType('deep_linking.request.accepted'); - - assertEquals(response.status, 303); - assertStringIncludes(location, '/lti/deep-linking/sessions/'); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.deploymentRecordId, 8); - assertEquals(auditEvents[0]?.detail.lms, 'moodle'); }); diff --git a/src/app_deep_linking_routes.ts b/src/app_deep_linking_routes.ts index a90aacd..c0125bf 100644 --- a/src/app_deep_linking_routes.ts +++ b/src/app_deep_linking_routes.ts @@ -1,106 +1,19 @@ import type { Hono } from '@hono/hono'; import { renderDeepLinkingPickerResponse } from './app_deep_linking_views.ts'; -import { - normalizeOptionalString, - requireTrimmedFormValue, - requireTrimmedString, -} from './app_request_support.ts'; -import { - errorMessage, - statusForDeepLinkingError, - statusForDeepLinkingSessionError, -} from './app_status_support.ts'; +import { requireTrimmedFormValue, requireTrimmedString } from './app_request_support.ts'; +import { errorMessage, statusForDeepLinkingSessionError } from './app_status_support.ts'; import type { AppServices } from './app_services.ts'; -import type { PackageReviewRepository } from './package_review/repository.ts'; import { readEnv } from './platform/env.ts'; import { launchPreviewRuntimeSession } from './preview/service.ts'; import { buildRuntimeSessionUrl, requireConfiguredRuntimeOrigin } from './runtime_origin.ts'; import { - createDeepLinkingSession, listDeepLinkingResources, requireAuthorizedDeepLinkingSession, resolveDeepLinkingSelection, saveDeepLinkingSessionSelection, - validateDeepLinkingRequest, } from './lti/deep_linking.ts'; -import { isLtiBoundaryDenialError } from './lti/launch_rejection.ts'; -import { formatLmsLabel } from './lti/platform_binding.ts'; -import { - buildResolvedLtiProfileDetail, - resolveLtiProfileForDeployment, -} from './lti/profile_resolution.ts'; -import { buildRequestAuditEnvelope, type RequestAuditEnvelope } from './request_audit.ts'; export function registerDeepLinkingRoutes(app: Hono, services: AppServices): void { - app.post('/lti/deep-linking', async (context) => { - const repository = services.getRepository(); - const formData = await context.req.formData(); - const state = normalizeOptionalString(formData.get('state')); - const idToken = normalizeOptionalString(formData.get('id_token')); - const request = buildRequestAuditEnvelope({ - context, - formData, - }); - - try { - const request = await validateDeepLinkingRequest({ - repository, - state: state ?? '', - idToken: idToken ?? '', - loadJwks: services.loadCanvasJwks, - }); - const session = await createDeepLinkingSession({ - repository, - request, - }); - const deployment = await repository.getDeploymentBySlug(request.internalDeploymentSlug); - const ltiProfile = deployment === null ? null : await resolveLtiProfileForDeployment({ - repository, - deployment, - }); - await repository.recordAuditEvent({ - eventType: 'deep_linking.request.accepted', - actorType: 'platform', - actorId: request.userId, - deploymentRecordId: request.internalDeploymentId, - packageVersionId: deployment?.enabledPackageVersionId ?? null, - attemptId: null, - lineItemBindingId: null, - status: 'accepted', - summary: `Accepted a ${formatLmsLabel(request.lms)} Deep Linking request.`, - detail: { - lms: request.lms, - deepLinkingSessionId: session.sessionId, - internalDeploymentSlug: request.internalDeploymentSlug, - issuer: request.issuer, - clientId: request.clientId, - deploymentId: request.deploymentId, - contextId: request.contextId, - placement: request.placement, - ...(ltiProfile === null ? {} : buildResolvedLtiProfileDetail(ltiProfile)), - }, - occurredAt: new Date().toISOString(), - }); - - return context.redirect( - `/lti/deep-linking/sessions/${session.sessionId}?token=${ - encodeURIComponent( - session.sessionToken, - ) - }`, - 303, - ); - } catch (error) { - await recordRejectedDeepLinkingRequestAudit({ - repository, - state, - error, - request, - }); - return context.text(errorMessage(error), statusForDeepLinkingError(error)); - } - }); - app.get('/lti/deep-linking/sessions/:sessionId', async (context) => { try { const repository = services.getRepository(); @@ -280,64 +193,3 @@ function buildDeepLinkingPreviewMissingSelectionDetail( return `Save one reviewed ${resource} before previewing it in Lantern.`; } - -async function recordRejectedDeepLinkingRequestAudit(input: { - repository: PackageReviewRepository; - state: string | null; - error: unknown; - request: RequestAuditEnvelope; -}): Promise { - if (!isLtiBoundaryDenialError(input.error)) { - return; - } - - const loginState = input.state === null - ? null - : await input.repository.getLoginStateByState(input.state).catch(() => null); - const deployment = loginState === null ? null : await input.repository - .getDeploymentByBinding({ - lms: loginState.lms, - issuer: loginState.issuer, - clientId: loginState.clientId, - deploymentId: loginState.deploymentId, - }) - .catch(() => null); - const ltiProfile = deployment === null ? null : await resolveLtiProfileForDeployment({ - repository: input.repository, - deployment, - }); - - await input.repository.recordAuditEvent({ - eventType: 'deep_linking.request.rejected', - actorType: 'platform', - actorId: null, - deploymentRecordId: deployment?.id ?? null, - packageVersionId: deployment?.enabledPackageVersionId ?? null, - attemptId: null, - lineItemBindingId: null, - status: 'failed', - summary: loginState === null - ? 'Rejected a Deep Linking request before Lantern could match the saved login state.' - : `Rejected a ${ - formatLmsLabel( - loginState.lms, - ) - } Deep Linking request before picker handoff.`, - detail: { - lms: loginState?.lms ?? null, - category: input.error.category, - code: input.error.code, - message: input.error.message, - request: input.request, - ...input.error.detail, - issuer: loginState?.issuer ?? null, - clientId: loginState?.clientId ?? null, - deploymentId: loginState?.deploymentId ?? null, - targetLinkUri: loginState?.targetLinkUri ?? null, - internalDeploymentSlug: deployment?.slug ?? null, - appId: deployment?.appId ?? null, - ...(ltiProfile === null ? {} : buildResolvedLtiProfileDetail(ltiProfile)), - }, - occurredAt: new Date().toISOString(), - }); -} diff --git a/src/app_deep_linking_submit_routes.ts b/src/app_deep_linking_submit_routes.ts index 388e95c..e9bd6f2 100644 --- a/src/app_deep_linking_submit_routes.ts +++ b/src/app_deep_linking_submit_routes.ts @@ -6,7 +6,7 @@ import { import { normalizeOptionalString } from './app_request_support.ts'; import { deepLinkingReturnErrorMessage } from './app_status_support.ts'; import type { AppServices } from './app_services.ts'; -import { buildDeepLinkingResponseSubmission } from './lti/deep_linking_response.ts'; +import { createDeepLinkingReturnResponseWithLtiTool } from './lti/deep_linking_response_tool.ts'; import { authorizeDeepLinkingSession, createReviewedPlacementFromDeepLinkingSession, @@ -14,8 +14,7 @@ import { resolveDeepLinkingSelection, } from './lti/deep_linking.ts'; import type { DeepLinkingSessionRecord, LtiPlacement } from './lti/types.ts'; -import { readEnv } from './platform/env.ts'; -import { resolveConfiguredPublicOrigin } from './public_origin.ts'; +import { resolveRequestAppOrigin } from './app_origin.ts'; const sessionVerificationFailureDetail = 'Lantern could not verify this Deep Linking session. Reopen Deep Linking authoring from the LMS and try again.'; @@ -145,31 +144,16 @@ export function registerDeepLinkingSubmitRoutes(app: Hono, services: AppServices }, occurredAt: new Date().toISOString(), }); - const submission = await buildDeepLinkingResponseSubmission({ + const appOrigin = resolveRequestAppOrigin(context, services.env); + const response = await createDeepLinkingReturnResponseWithLtiTool({ + ltiContext: await services.getLtiToolContext(appOrigin), session, - deployment, placement, packageVersion, - appOrigin: resolveConfiguredPublicOrigin({ - requestUrl: context.req.url, - forwardedHeader: context.req.header('forwarded') ?? null, - xForwardedHost: context.req.header('x-forwarded-host') ?? null, - xForwardedProto: context.req.header('x-forwarded-proto') ?? null, - configuredOrigin: readEnv('APP_ORIGIN', services.env), - }), - env: services.env, + appOrigin, }); - return context.html( - renderDeepLinkingSubmitStatusPage({ - tone: 'success', - title: buildDeepLinkingReturnTitle(session.placement, 'returning'), - detail: buildDeepLinkingSuccessDetail(session.placement), - session, - selection, - submission, - }), - ); + return response; } catch (error) { return context.html( renderDeepLinkingSubmitStatusPage({ @@ -204,7 +188,7 @@ function renderSessionVerificationFailure(context: Context, status: 404 | 409) { function buildDeepLinkingReturnTitle( placement: LtiPlacement, - state: 'alreadyUsed' | 'failed' | 'returning', + state: 'alreadyUsed' | 'failed', ): string { const resourceLabel = describeDeepLinkingPlacement(placement).capitalized; @@ -213,8 +197,6 @@ function buildDeepLinkingReturnTitle( return `${resourceLabel} return already used`; case 'failed': return `${resourceLabel} return failed`; - case 'returning': - return `Returning ${describeDeepLinkingPlacement(placement).resource} to LMS`; } } @@ -234,14 +216,6 @@ function buildDeepLinkingMissingSelectionDetail(placement: LtiPlacement): string ); } -function buildDeepLinkingSuccessDetail(placement: LtiPlacement): string { - return ( - 'Lantern created the reviewed placement and is posting this reviewed ' + - describeDeepLinkingPlacement(placement).resource + - ' back to the LMS.' - ); -} - function describeDeepLinkingPlacement( placement: Pick['placement'], ): { diff --git a/src/app_deep_linking_submit_test.ts b/src/app_deep_linking_submit_test.ts index fe9cf7b..f7f1681 100644 --- a/src/app_deep_linking_submit_test.ts +++ b/src/app_deep_linking_submit_test.ts @@ -1,9 +1,5 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; import { createApp } from './app.ts'; -import { - LANTERN_PLACEMENT_CUSTOM_KEY, - LTI_DEEP_LINKING_RESPONSE_MESSAGE_TYPE, -} from './lti/types.ts'; import { buildDeepLinkingResourceOption, buildDeepLinkingResourceSelection, @@ -11,14 +7,59 @@ import { buildPackageVersionRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; -import { buildDeepLinkingSessionRecord, buildDeploymentBinding } from './test_helpers/lti.ts'; +import { + buildDeepLinkingSessionRecord, + buildDeploymentBinding, + createSyncedTestLtiStorage, +} from './test_helpers/lti.ts'; import { extractHiddenInputValue, verifyDeepLinkingResponseJwt, withCanvasReturnEnv, } from './app_test_support.ts'; -Deno.test('POST /lti/deep-linking/sessions/:id/submit creates one reviewed placement and returns an auto-post LMS form', async () => { +Deno.test('POST /lti/deep-linking/sessions/:id/submit creates one reviewed placement and returns lti-tool auto-post HTML', async () => { + const binding = buildDeploymentBinding(); + const ltiStorage = await createSyncedTestLtiStorage({ binding }); + await ltiStorage.addSession({ + id: 'lti-tool-session-submit', + jwtPayload: {}, + user: { + id: 'canvas-user-123', + roles: ['http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'], + }, + context: { + id: 'course-42', + label: 'Physics 101', + title: 'Physics 101', + }, + platform: { + issuer: binding.issuer, + clientId: binding.clientId, + deploymentId: binding.deploymentId, + name: 'Canvas', + }, + launch: { + target: 'https://lantern.example/lti/deep-linking', + }, + services: { + deepLinking: { + returnUrl: 'https://canvas.example/courses/42/deep_link_return', + acceptTypes: ['ltiResourceLink'], + acceptPresentationDocumentTargets: ['iframe'], + acceptMultiple: false, + autoCreate: false, + data: 'deep-linking-state-token', + }, + }, + customParameters: {}, + isAdmin: false, + isInstructor: true, + isStudent: false, + isAssignmentAndGradesAvailable: false, + isDeepLinkingAvailable: true, + isNameAndRolesAvailable: false, + }); const repository = createInMemoryPackageReviewRepository({ packageVersions: [ buildPackageVersionRecord({ @@ -37,13 +78,15 @@ Deno.test('POST /lti/deep-linking/sessions/:id/submit creates one reviewed place id: 1, enabledPackageVersionId: 2, enabledPackageVersion: '0.2.0', - binding: buildDeploymentBinding(), + binding, }), ], deepLinkingSessions: [ buildDeepLinkingSessionRecord({ sessionId: 'deep-linking-session-submit', sessionToken: 'deep-linking-token-submit', + ltiToolSessionId: 'lti-tool-session-submit', + acceptLineItem: true, selection: { packageVersionId: 2, packageVersion: '0.2.0', @@ -71,6 +114,7 @@ Deno.test('POST /lti/deep-linking/sessions/:id/submit creates one reviewed place await withCanvasReturnEnv(async () => { const response = await createApp({ getRepository: () => repository, + getLtiToolStorage: () => ltiStorage, }).request('http://localhost/lti/deep-linking/sessions/deep-linking-session-submit/submit', { method: 'POST', body: formData, @@ -85,38 +129,32 @@ Deno.test('POST /lti/deep-linking/sessions/:id/submit creates one reviewed place 'https://purl.imsglobal.org/spec/lti-dl/claim/content_items' ] as Array>; const contentItem = contentItems[0] ?? {}; - const placementId = (contentItem.custom as Record)[ - LANTERN_PLACEMENT_CUSTOM_KEY - ] as string; - const savedPlacement = await repository.getReviewedPlacementById(placementId); + const lineItem = contentItem.lineItem as Record | undefined; + const presentation = contentItem.presentation as Record | undefined; const savedSession = await repository.getDeepLinkingSessionById('deep-linking-session-submit'); const auditEvents = await repository.listAuditEventsByEventType( 'deep_linking.placement.created', ); + const placementId = String(auditEvents[0]?.detail.placementId ?? ''); + const savedPlacement = await repository.getReviewedPlacementById(placementId); - assertStringIncludes(body, ''); + assertStringIncludes(body, ''); assertStringIncludes(body, 'action="https://canvas.example/courses/42/deep_link_return"'); - assertStringIncludes(body, 'id="lms-return-form"'); - assertStringIncludes(body, 'Returning assignment resource to LMS'); - assertStringIncludes( - body, - 'Lantern created the reviewed placement and is posting this reviewed assignment resource back to the LMS.', - ); - assertEquals( - verified.payload['https://purl.imsglobal.org/spec/lti/claim/message_type'], - LTI_DEEP_LINKING_RESPONSE_MESSAGE_TYPE, - ); - assertEquals(contentItem.type, 'ltiResourceLink'); + assertStringIncludes(body, 'name="JWT"'); + assertEquals(lineItem?.scoreMaximum, 25); + assertEquals(lineItem?.tag, 'final-grade'); + assertEquals(presentation?.documentTarget, 'iframe'); assertEquals(savedPlacement?.packageVersionId, 2); assertEquals(savedPlacement?.contentPath, '/content/bonus.json'); assertEquals(savedPlacement?.resourceLinkId, null); assertEquals(savedSession?.usedAt !== null, true); assertEquals(auditEvents.length, 1); - assertEquals(String(auditEvents[0]?.detail.placementId ?? ''), placementId); + assertEquals(String(auditEvents[0]?.detail.placementId ?? ''), savedPlacement?.placementId); assertEquals(String(auditEvents[0]?.detail.contentPath ?? ''), '/content/bonus.json'); const replay = await createApp({ getRepository: () => repository, + getLtiToolStorage: () => ltiStorage, }).request('http://localhost/lti/deep-linking/sessions/deep-linking-session-submit/submit', { method: 'POST', body: formData, diff --git a/src/app_deployment_support.ts b/src/app_deployment_support.ts index de6e903..91fecda 100644 --- a/src/app_deployment_support.ts +++ b/src/app_deployment_support.ts @@ -8,10 +8,8 @@ import { } from './admin/deployment_detail.ts'; import { getCanvasConfigUrlNoticeSafe } from './app_notice_support.ts'; import { requireAppOrigin } from './lti/config.ts'; -import { buildCanvasDynamicRegistrationUrl } from './lti/canvas_dynamic_registration.ts'; import { createDynamicRegistrationState } from './lti/dynamic_registration_state.ts'; -import { buildMoodleDynamicRegistrationUrl } from './lti/moodle_dynamic_registration.ts'; -import { buildSakaiDynamicRegistrationUrl } from './lti/sakai_dynamic_registration.ts'; +import { buildDynamicRegistrationUrl } from './lti/dynamic_registration_tool.ts'; import type { PackageReviewRepository } from './package_review/repository.ts'; import type { AuditEventRecord, @@ -135,7 +133,7 @@ async function getCanvasDynamicRegistrationUrlSafe( appId, lms: 'canvas', }); - return buildCanvasDynamicRegistrationUrl(appId, state.state, appOrigin); + return buildDynamicRegistrationUrl(appId, 'canvas', state.state, appOrigin); } catch { return null; } @@ -155,7 +153,7 @@ async function getMoodleDynamicRegistrationUrlSafe( appId, lms: 'moodle', }); - return buildMoodleDynamicRegistrationUrl(appId, state.state, appOrigin); + return buildDynamicRegistrationUrl(appId, 'moodle', state.state, appOrigin); } catch { return null; } @@ -175,7 +173,7 @@ async function getSakaiDynamicRegistrationUrlSafe( appId, lms: 'sakai', }); - return buildSakaiDynamicRegistrationUrl(appId, state.state, appOrigin); + return buildDynamicRegistrationUrl(appId, 'sakai', state.state, appOrigin); } catch { return null; } diff --git a/src/app_launch_routes.ts b/src/app_launch_routes.ts index 40530ed..c816ea6 100644 --- a/src/app_launch_routes.ts +++ b/src/app_launch_routes.ts @@ -1,17 +1,37 @@ -import type { Hono } from '@hono/hono'; -import { createRuntimeSession, validateLaunchRequest } from './lti/launch.ts'; +import type { Context, Hono, Next } from '@hono/hono'; +import { + type CustomDeepLinkingLaunchContext, + customLaunchRouteHandler, + type CustomResourceLinkLaunchContext, +} from '@longsightgroup/lti-tool/hono'; +import type { LtiAuthorizedLaunch } from '@longsightgroup/lti-tool'; +import { createRuntimeSession } from './lti/runtime_session.ts'; import { formatLmsLabel } from './lti/platform_binding.ts'; import { buildResolvedLtiProfileDetail, resolveLtiProfileForDeployment, } from './lti/profile_resolution.ts'; -import { handleLoginInitiation, recordRejectedLaunchAudit } from './app_lti_support.ts'; -import { normalizeOptionalString, requireTrimmedString } from './app_request_support.ts'; +import { handleLoginInitiation } from './app_lti_support.ts'; import { buildRuntimeSessionUrl, requireConfiguredRuntimeOrigin } from './runtime_origin.ts'; import { errorMessage, statusForError } from './app_status_support.ts'; import type { AppServices } from './app_services.ts'; import { readEnv } from './platform/env.ts'; -import { buildRequestAuditEnvelope } from './request_audit.ts'; +import type { PackageReviewRepository } from './package_review/repository.ts'; +import { createDeepLinkingSession } from './lti/deep_linking_session.ts'; +import { resolveRequestAppOrigin } from './app_origin.ts'; +import { + authorizeLanternLaunch, + type LanternLaunchAuthorization, + mapDeepLinkingLaunch, + mapResourceLinkLaunch, +} from './lti/launch_mapping.ts'; +import { + readLaunchIdToken, + readVerifiedDeepLinkingAcceptLineItemClaim, +} from './lti/deep_linking_launch_settings.ts'; +import { adaptLtiToolHonoHandler } from './lti/hono_handler_adapter.ts'; +import { recordLaunchRejectedAudit } from './lti/launch_audit.ts'; +import { isLtiBoundaryDenialError } from './lti/lti_boundary_denial.ts'; export function registerLaunchRoutes(app: Hono, services: AppServices): void { app.get('/lti/login', async (context) => { @@ -22,78 +42,186 @@ export function registerLaunchRoutes(app: Hono, services: AppServices): void { return await handleLoginInitiation(context, services); }); - app.post('/lti/launch', async (context) => { + const handleLaunch = async (context: Context, next: Next) => { + const appOrigin = resolveRequestAppOrigin(context, services.env); + const ltiContext = await services.getLtiToolContext(appOrigin); const repository = services.getRepository(); - const formData = await context.req.formData(); - const state = normalizeOptionalString(formData.get('state')); - const idToken = normalizeOptionalString(formData.get('id_token')); - const request = buildRequestAuditEnvelope({ - context, - formData, + const verificationAuditWrites: Array> = []; + const launchIdToken = await readLaunchIdToken(context.req.raw.clone()); + const handler = customLaunchRouteHandler({ + ltiTool: ltiContext.tool, + logger: ltiContext.logger, + authorizeLaunch: (launch) => authorizeLanternLaunch(repository, launch), + renderResourceLink: (launchContext) => + renderLanternResourceLinkLaunch({ + services, + repository, + routeContext: context, + launchContext, + }), + renderDeepLinkingRequest: (launchContext) => + renderLanternDeepLinkingLaunch({ + repository, + launchContext, + launchIdToken, + }), + onVerificationFailure: async ({ hono, error }) => { + await recordLaunchRejectedAudit({ + repository, + context, + error, + }); + + return hono.text(error.message, 400); + }, + onVerificationEvent: ({ event }) => { + if (event.type !== 'jwks_kid_miss_refetch') { + return; + } + + verificationAuditWrites.push( + repository.recordAuditEvent({ + eventType: 'interop.path_used', + actorType: 'platform', + actorId: null, + deploymentRecordId: null, + packageVersionId: null, + attemptId: null, + lineItemBindingId: null, + status: 'succeeded', + summary: 'Lantern refetched platform JWKS during LTI launch verification.', + detail: { + scope: 'launch', + path: 'jwks_refetch', + issuer: event.issuer, + clientId: event.clientId, + deploymentId: event.deploymentId, + jwksUrl: event.jwksUrl, + }, + occurredAt: new Date().toISOString(), + }), + ); + }, + onError: ({ hono, error }) => hono.text(errorMessage(error), statusForError(error)), }); - try { - const launch = await validateLaunchRequest({ - repository, - state: requireTrimmedString(state, 'Launch state is required.'), - idToken: requireTrimmedString(idToken, 'Launch id_token is required.'), - loadJwks: services.loadCanvasJwks, - }); - const runtimeOrigin = requireConfiguredRuntimeOrigin( - readEnv('APP_RUNTIME_ORIGIN', services.env), - ); - const runtimeSession = await createRuntimeSession({ - repository, - launch, - }); - const deployment = await repository.getDeploymentBySlug(launch.internalDeploymentSlug); - const ltiProfile = deployment === null ? null : await resolveLtiProfileForDeployment({ - repository, - deployment, - }); - await repository.recordAuditEvent({ - eventType: 'launch.accepted', - actorType: 'platform', - actorId: launch.userId, - deploymentRecordId: launch.internalDeploymentId, - packageVersionId: launch.packageVersionId, - attemptId: runtimeSession.attemptId, - lineItemBindingId: null, - status: 'accepted', - summary: `Accepted the governed ${formatLmsLabel(launch.lms)} launch.`, - detail: { - lms: launch.lms, - internalDeploymentSlug: launch.internalDeploymentSlug, - issuer: launch.issuer, - clientId: launch.clientId, - deploymentId: launch.deploymentId, - userId: launch.userId, - userDisplayName: launch.userDisplayName, - userEmail: launch.userEmail, - userLogin: launch.userLogin, - resourceLinkId: launch.resourceLinkId, - contextId: launch.contextId, - ...(ltiProfile === null ? {} : buildResolvedLtiProfileDetail(ltiProfile)), - }, - occurredAt: new Date().toISOString(), - }); + const lanternHandler = adaptLtiToolHonoHandler(handler); + const response = await lanternHandler(context, next); + await Promise.all(verificationAuditWrites); - return context.redirect( - buildRuntimeSessionUrl({ - runtimeOrigin, - sessionId: runtimeSession.sessionId, - token: runtimeSession.sessionToken, - }), - 303, - ); - } catch (error) { - await recordRejectedLaunchAudit({ - repository, - state, + return response; + }; + + app.post('/lti/launch', handleLaunch); + app.post('/lti/deep-linking', handleLaunch); +} + +async function renderLanternResourceLinkLaunch(input: { + services: AppServices; + repository: PackageReviewRepository; + routeContext: Context; + launchContext: CustomResourceLinkLaunchContext>; +}): Promise { + const deployment = input.launchContext.launch.authorization.deployment; + const ltiProfile = await resolveLtiProfileForDeployment({ + repository: input.repository, + deployment, + }); + let launch: Awaited>; + + try { + launch = await mapResourceLinkLaunch({ + repository: input.repository, + verifiedLaunch: input.launchContext.launch, + session: input.launchContext.session, + message: input.launchContext.message, + }); + } catch (error) { + if (isLtiBoundaryDenialError(error)) { + await recordLaunchRejectedAudit({ + repository: input.repository, + context: input.routeContext, error, - request, + deployment, + ltiProfile, }); - return context.text(errorMessage(error), statusForError(error)); } + + throw error; + } + const runtimeOrigin = requireConfiguredRuntimeOrigin( + readEnv('APP_RUNTIME_ORIGIN', input.services.env), + ); + const runtimeSession = await createRuntimeSession({ + repository: input.repository, + launch, + ltiToolSessionId: input.launchContext.session.id, }); + await input.repository.recordAuditEvent({ + eventType: 'launch.accepted', + actorType: 'platform', + actorId: launch.userId, + deploymentRecordId: launch.internalDeploymentId, + packageVersionId: launch.packageVersionId, + attemptId: runtimeSession.attemptId, + lineItemBindingId: null, + status: 'accepted', + summary: `Accepted the governed ${formatLmsLabel(launch.lms)} launch.`, + detail: { + lms: launch.lms, + internalDeploymentSlug: launch.internalDeploymentSlug, + issuer: launch.issuer, + clientId: launch.clientId, + deploymentId: launch.deploymentId, + userId: launch.userId, + userDisplayName: launch.userDisplayName, + userEmail: launch.userEmail, + userLogin: launch.userLogin, + resourceLinkId: launch.resourceLinkId, + contextId: launch.contextId, + ...buildResolvedLtiProfileDetail(ltiProfile), + }, + occurredAt: new Date().toISOString(), + }); + + return input.launchContext.hono.redirect( + buildRuntimeSessionUrl({ + runtimeOrigin, + sessionId: runtimeSession.sessionId, + token: runtimeSession.sessionToken, + }), + 303, + ); +} + +async function renderLanternDeepLinkingLaunch(input: { + repository: PackageReviewRepository; + launchContext: CustomDeepLinkingLaunchContext>; + launchIdToken: string | null; +}): Promise { + const acceptLineItemClaim = readVerifiedDeepLinkingAcceptLineItemClaim(input.launchIdToken); + + if (!acceptLineItemClaim.success) { + throw new Error(acceptLineItemClaim.message); + } + + const request = mapDeepLinkingLaunch({ + verifiedLaunch: input.launchContext.launch, + session: input.launchContext.session, + message: input.launchContext.message, + acceptLineItem: acceptLineItemClaim.value, + }); + const session = await createDeepLinkingSession({ + repository: input.repository, + request, + }); + + return input.launchContext.hono.redirect( + `/lti/deep-linking/sessions/${session.sessionId}?token=${ + encodeURIComponent( + session.sessionToken, + ) + }`, + 303, + ); } diff --git a/src/app_lti_config_login_test.ts b/src/app_lti_config_login_test.ts index 76860e1..b4dc3d2 100644 --- a/src/app_lti_config_login_test.ts +++ b/src/app_lti_config_login_test.ts @@ -1,10 +1,19 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; +import type { Hono } from '@hono/hono'; +import { decodeJwt } from 'jose'; import { createApp } from './app.ts'; +import { + createLanternLtiToolContext, + createMemoryLtiStorage, + type LanternLtiToolContext, + syncLtiToolDeploymentBinding, +} from './lti/tool_provider.ts'; import { CANVAS_LTI_SCOPES } from './lti/types.ts'; import { buildDeploymentRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; +import type { InMemoryRepository } from './test_helpers/package_review_in_memory_shared.ts'; import { buildCanvasLoginRequest, buildDeploymentBinding, @@ -12,10 +21,57 @@ import { buildSakaiDeploymentBinding, buildSakaiLoginRequest, getTestToolPrivateJwkEnvValue, + TEST_LTI_STATE_SECRET, TEST_NOW, } from './test_helpers/lti.ts'; import { restoreEnv } from './app_test_support.ts'; +interface LtiToolStateClaims { + readonly iss: string; + readonly clientId: string; + readonly targetLinkUri: string; +} + +Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); +Deno.env.set('LTI_STATE_SECRET', TEST_LTI_STATE_SECRET); + +async function createLoginTestApp(repository: InMemoryRepository): Promise { + const storage = createMemoryLtiStorage(); + const contexts = new Map>(); + + for (const deployment of await repository.listDeploymentsByApp('chapter-4-asteroids')) { + if (deployment.binding === null) { + continue; + } + + await syncLtiToolDeploymentBinding({ + storage, + appTitle: deployment.label, + deploymentSlug: deployment.slug, + binding: deployment.binding, + }); + } + + return createApp({ + getRepository: () => repository, + getLtiToolContext(appOrigin) { + const cacheKey = appOrigin ?? ''; + let context = contexts.get(cacheKey); + + if (context === undefined) { + context = createLanternLtiToolContext({ + env: Deno.env, + storage, + ...(appOrigin === undefined ? {} : { appOrigin }), + }); + contexts.set(cacheKey, context); + } + + return context; + }, + }); +} + Deno.test('GET /lti/canvas/config.json publishes the pilot Canvas config document', async () => { const previousOrigin = Deno.env.get('APP_ORIGIN'); const previousJwk = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); @@ -92,9 +148,7 @@ Deno.test('GET /lti/login persists login state and redirects to the Canvas autho deployments: [buildDeploymentRecord({ binding: buildDeploymentBinding() })], }); const loginRequest = buildCanvasLoginRequest(); - const response = await createApp({ - getRepository: () => repository, - }).request( + const response = await (await createLoginTestApp(repository)).request( `http://localhost/lti/login?iss=${ encodeURIComponent( loginRequest.iss, @@ -133,14 +187,14 @@ Deno.test('GET /lti/login persists login state and redirects to the Canvas autho throw new Error('Expected saved login state in the Canvas redirect.'); } - const saved = await repository.getLoginStateByState(state); + const saved = readLtiToolStateClaims(state); assertEquals( redirected.origin + redirected.pathname, 'https://sso.canvaslms.com/api/lti/authorize_redirect', ); - assertEquals(saved?.clientId, loginRequest.clientId); - assertEquals(saved?.deploymentId, loginRequest.deploymentId); + assertEquals(saved.clientId, loginRequest.clientId); + assertEquals(redirected.searchParams.get('lti_deployment_id'), loginRequest.deploymentId); }); Deno.test('GET /lti/login returns a top-level launch escape page for iframe-embedded initiation', async () => { @@ -148,9 +202,7 @@ Deno.test('GET /lti/login returns a top-level launch escape page for iframe-embe deployments: [buildDeploymentRecord({ binding: buildDeploymentBinding() })], }); const loginRequest = buildCanvasLoginRequest(); - const response = await createApp({ - getRepository: () => repository, - }).request( + const response = await (await createLoginTestApp(repository)).request( `http://localhost/lti/login?iss=${ encodeURIComponent( loginRequest.iss, @@ -193,9 +245,7 @@ Deno.test('GET /lti/login uses LTI platform storage when the LMS advertises lti_ deployments: [buildDeploymentRecord({ binding: buildDeploymentBinding() })], }); const loginRequest = buildCanvasLoginRequest(); - const response = await createApp({ - getRepository: () => repository, - }).request( + const response = await (await createLoginTestApp(repository)).request( `http://localhost/lti/login?iss=${ encodeURIComponent( loginRequest.iss, @@ -260,9 +310,7 @@ Deno.test('GET /lti/login accepts Sakai-style login initiation and redirects to clientId: '7dbe6a13-f948-498c-87d7-768947ac5c56', deploymentId: '1', }); - const response = await createApp({ - getRepository: () => repository, - }).request( + const response = await (await createLoginTestApp(repository)).request( `http://localhost/lti/login?iss=${ encodeURIComponent( loginRequest.iss, @@ -297,16 +345,15 @@ Deno.test('GET /lti/login accepts Sakai-style login initiation and redirects to throw new Error('Expected saved login state in the Sakai redirect.'); } - const saved = await repository.getLoginStateByState(state); + const saved = readLtiToolStateClaims(state); assertEquals( redirected.origin + redirected.pathname, 'https://sakai.example/imsoidc/lti13/oidc_auth', ); - assertEquals(saved?.lms, 'sakai'); - assertEquals(saved?.canvasEnvironment, null); - assertEquals(saved?.clientId, loginRequest.clientId ?? null); - assertEquals(saved?.deploymentId, loginRequest.deploymentId); + assertEquals(saved.iss, loginRequest.iss); + assertEquals(saved.clientId, loginRequest.clientId ?? null); + assertEquals(redirected.searchParams.get('lti_deployment_id'), loginRequest.deploymentId); }); Deno.test('GET /lti/login accepts Moodle-style login initiation and redirects to the saved authentication endpoint', async () => { @@ -321,9 +368,7 @@ Deno.test('GET /lti/login accepts Moodle-style login initiation and redirects to }), ], }); - const response = await createApp({ - getRepository: () => repository, - }).request( + const response = await (await createLoginTestApp(repository)).request( 'http://localhost/lti/login?iss=https%3A%2F%2Fmoodle.example&login_hint=opaque-login-hint&target_link_uri=http%3A%2F%2Flocalhost%3A8417%2Flti%2Flaunch&client_id=moodle-client-123&deployment_id=moodle-deployment-123', ); @@ -342,13 +387,12 @@ Deno.test('GET /lti/login accepts Moodle-style login initiation and redirects to throw new Error('Expected saved login state in the Moodle redirect.'); } - const saved = await repository.getLoginStateByState(state); + const saved = readLtiToolStateClaims(state); assertEquals(redirected.origin + redirected.pathname, 'https://moodle.example/mod/lti/auth.php'); - assertEquals(saved?.lms, 'moodle'); - assertEquals(saved?.canvasEnvironment, null); - assertEquals(saved?.clientId, 'moodle-client-123'); - assertEquals(saved?.deploymentId, 'moodle-deployment-123'); + assertEquals(saved.iss, 'https://moodle.example'); + assertEquals(saved.clientId, 'moodle-client-123'); + assertEquals(redirected.searchParams.get('lti_deployment_id'), 'moodle-deployment-123'); }); Deno.test('GET /lti/login tolerates one extra percent-encoding layer on opaque LMS hints', async () => { @@ -363,9 +407,7 @@ Deno.test('GET /lti/login tolerates one extra percent-encoding layer on opaque L }), ], }); - const response = await createApp({ - getRepository: () => repository, - }).request( + const response = await (await createLoginTestApp(repository)).request( 'http://localhost/lti/login?iss=https%3A%2F%2Fmoodle.example&login_hint=opaque%252Flogin%253Fhint&target_link_uri=http%3A%2F%2Flocalhost%3A8417%2Flti%2Flaunch&client_id=moodle-client-123&deployment_id=moodle-deployment-123<i_message_hint=context%2523value', ); @@ -384,12 +426,10 @@ Deno.test('GET /lti/login tolerates one extra percent-encoding layer on opaque L throw new Error('Expected saved login state in the Moodle redirect.'); } - const saved = await repository.getLoginStateByState(state); const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); assertEquals(redirected.searchParams.get('login_hint'), 'opaque/login?hint'); - assertEquals(saved?.loginHint, 'opaque/login?hint'); - assertEquals(saved?.ltiMessageHint, 'context#value'); + assertEquals(redirected.searchParams.get('lti_message_hint'), 'context#value'); assertEquals( interopEvents.some((event) => event.detail.path === 'opaque_login_hint_decode'), true, @@ -421,9 +461,7 @@ Deno.test('GET /lti/login applies deployment override compatibility behavior onl updatedAt: TEST_NOW, }, }); - const response = await createApp({ - getRepository: () => repository, - }).request( + const response = await (await createLoginTestApp(repository)).request( 'http://localhost/lti/login?iss=https%3A%2F%2Fmoodle.example&login_hint=opaque%252Flogin%253Fhint&client_id=moodle-client-123&deployment_id=moodle-deployment-123<i_message_hint=context%2523value', ); @@ -442,13 +480,12 @@ Deno.test('GET /lti/login applies deployment override compatibility behavior onl throw new Error('Expected saved login state in the Moodle redirect.'); } - const saved = await repository.getLoginStateByState(state); + const saved = readLtiToolStateClaims(state); const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); assertEquals(redirected.searchParams.get('login_hint'), 'opaque/login?hint'); - assertEquals(saved?.loginHint, 'opaque/login?hint'); - assertEquals(saved?.ltiMessageHint, 'context#value'); - assertEquals(saved?.targetLinkUri, 'http://localhost/lti/launch'); + assertEquals(redirected.searchParams.get('lti_message_hint'), 'context#value'); + assertEquals(saved.targetLinkUri, 'http://localhost/lti/launch'); assertEquals( interopEvents.some( (event) => @@ -497,9 +534,7 @@ Deno.test('GET /lti/login rejects opaque hint compatibility decoding when the re updatedAt: TEST_NOW, }, }); - const response = await createApp({ - getRepository: () => repository, - }).request( + const response = await (await createLoginTestApp(repository)).request( 'http://localhost/lti/login?iss=https%3A%2F%2Fmoodle.example&login_hint=opaque%252Flogin%253Fhint&target_link_uri=http%3A%2F%2Flocalhost%3A8417%2Flti%2Flaunch&client_id=moodle-client-123&deployment_id=moodle-deployment-123<i_message_hint=context%2523value', ); const body = await response.text(); @@ -526,9 +561,7 @@ Deno.test("GET /lti/login falls back to Lantern's singular launch route when a n }), ], }); - const response = await createApp({ - getRepository: () => repository, - }).request( + const response = await (await createLoginTestApp(repository)).request( 'http://localhost/lti/login?iss=https%3A%2F%2Fmoodle.example&login_hint=opaque-login-hint&client_id=moodle-client-123&deployment_id=moodle-deployment-123', ); @@ -547,10 +580,10 @@ Deno.test("GET /lti/login falls back to Lantern's singular launch route when a n throw new Error('Expected saved login state in the Moodle redirect.'); } - const saved = await repository.getLoginStateByState(state); + const saved = readLtiToolStateClaims(state); assertEquals(redirected.searchParams.get('redirect_uri'), 'http://localhost:8417/lti/launch'); - assertEquals(saved?.targetLinkUri, 'http://localhost:8417/lti/launch'); + assertEquals(saved.targetLinkUri, 'http://localhost:8417/lti/launch'); } finally { restoreEnv('APP_ORIGIN', previousOrigin); } @@ -576,9 +609,7 @@ Deno.test('GET /lti/login rejects an omitted non-Canvas target_link_uri when the updatedAt: TEST_NOW, }, }); - const response = await createApp({ - getRepository: () => repository, - }).request( + const response = await (await createLoginTestApp(repository)).request( 'http://localhost/lti/login?iss=https%3A%2F%2Fmoodle.example&login_hint=opaque-login-hint&client_id=moodle-client-123&deployment_id=moodle-deployment-123', ); const body = await response.text(); @@ -608,9 +639,7 @@ Deno.test('GET /lti/login uses the forwarded public origin when Lantern fills a }), ], }); - const response = await createApp({ - getRepository: () => repository, - }).request( + const response = await (await createLoginTestApp(repository)).request( 'http://worker.internal/lti/login?iss=https%3A%2F%2Fmoodle.example&login_hint=opaque-login-hint&client_id=moodle-client-123&deployment_id=moodle-deployment-123', { headers: { @@ -635,10 +664,10 @@ Deno.test('GET /lti/login uses the forwarded public origin when Lantern fills a throw new Error('Expected saved login state in the Moodle redirect.'); } - const saved = await repository.getLoginStateByState(state); + const saved = readLtiToolStateClaims(state); assertEquals(redirected.searchParams.get('redirect_uri'), 'https://lantern.example/lti/launch'); - assertEquals(saved?.targetLinkUri, 'https://lantern.example/lti/launch'); + assertEquals(saved.targetLinkUri, 'https://lantern.example/lti/launch'); } finally { restoreEnv('APP_ORIGIN', previousOrigin); } @@ -648,9 +677,7 @@ Deno.test('GET /lti/login still rejects an omitted Canvas target_link_uri when m const repository = createInMemoryPackageReviewRepository({ deployments: [buildDeploymentRecord({ binding: buildDeploymentBinding() })], }); - const response = await createApp({ - getRepository: () => repository, - }).request( + const response = await (await createLoginTestApp(repository)).request( 'http://localhost/lti/login?iss=https%3A%2F%2Fcanvas.instructure.com&login_hint=opaque-login-hint&client_id=10000000000001&deployment_id=deployment-123', ); const body = await response.text(); @@ -671,9 +698,7 @@ Deno.test('GET /lti/login recovers when client_id is omitted but issuer and depl }), ], }); - const response = await createApp({ - getRepository: () => repository, - }).request( + const response = await (await createLoginTestApp(repository)).request( 'http://localhost/lti/login?iss=https%3A%2F%2Fmoodle.example&login_hint=opaque-login-hint&target_link_uri=http%3A%2F%2Flocalhost%3A8417%2Flti%2Flaunch&deployment_id=moodle-deployment-123', ); @@ -692,13 +717,34 @@ Deno.test('GET /lti/login recovers when client_id is omitted but issuer and depl throw new Error('Expected saved login state in the Moodle redirect.'); } - const saved = await repository.getLoginStateByState(state); + const saved = readLtiToolStateClaims(state); assertEquals(redirected.searchParams.get('client_id'), 'moodle-client-123'); - assertEquals(saved?.clientId, 'moodle-client-123'); - assertEquals(saved?.deploymentId, 'moodle-deployment-123'); + assertEquals(saved.clientId, 'moodle-client-123'); + assertEquals(redirected.searchParams.get('lti_deployment_id'), 'moodle-deployment-123'); }); +function readLtiToolStateClaims(state: string): LtiToolStateClaims { + const claims = decodeJwt(state); + const iss = readStringClaim(claims.iss, 'iss'); + const clientId = readStringClaim(claims.client_id, 'client_id'); + const targetLinkUri = readStringClaim(claims.target_link_uri, 'target_link_uri'); + + return { + iss, + clientId, + targetLinkUri, + }; +} + +function readStringClaim(value: unknown, name: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`Expected lti-tool state claim ${name}.`); + } + + return value; +} + Deno.test('GET /lti/login rejects ambiguous platform identity before redirecting', async () => { const repository = createInMemoryPackageReviewRepository({ deployments: [ diff --git a/src/app_lti_launch_authoring_test.ts b/src/app_lti_launch_authoring_test.ts index fbcdd32..94136bc 100644 --- a/src/app_lti_launch_authoring_test.ts +++ b/src/app_lti_launch_authoring_test.ts @@ -1,23 +1,26 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; import { createApp } from './app.ts'; -import { - LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - LTI_RESOURCE_LINK_REQUEST_MESSAGE_TYPE, -} from './lti/types.ts'; +import { LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE } from './lti/types.ts'; import { buildDeploymentRecord, buildPackageVersionRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; import { - buildLoginStateRecord, buildSakaiDeploymentBinding, + createSyncedTestLtiStorage, getTestCanvasJwks, signCanvasIdToken, + signLtiToolState, } from './test_helpers/lti.ts'; -import { withFetchStub } from './app_test_support.ts'; +import { withFetchStub, withRuntimeOriginEnv } from './app_test_support.ts'; -Deno.test('POST /lti/launch rejects Sakai authoring-mode requests and never creates a runtime session', async () => { +Deno.test('POST /lti/launch accepts Sakai Deep Linking launches and creates a picker session', async () => { + const binding = buildSakaiDeploymentBinding({ + issuer: 'https://sakai.example', + clientId: 'sakai-client-123', + deploymentId: 'sakai-deployment-123', + }); const repository = createInMemoryPackageReviewRepository({ packageVersions: [ buildPackageVersionRecord({ @@ -34,28 +37,21 @@ Deno.test('POST /lti/launch rejects Sakai authoring-mode requests and never crea label: 'Chapter 4 Asteroids Sakai Deployment', enabledPackageVersionId: 5, enabledPackageVersion: '0.1.0', - binding: buildSakaiDeploymentBinding({ - issuer: 'https://sakai.example', - clientId: 'sakai-client-123', - deploymentId: 'sakai-deployment-123', - }), - }), - ], - loginStates: [ - buildLoginStateRecord({ - lms: 'sakai', - issuer: 'https://sakai.example', - clientId: 'sakai-client-123', - deploymentId: 'sakai-deployment-123', - state: 'state-sakai-authoring', - nonce: 'nonce-sakai-authoring', - expiresAt: '2030-03-26T02:45:00Z', + binding, }), ], }); + const ltiStorage = await createSyncedTestLtiStorage({ binding }); const formData = new FormData(); - formData.set('state', 'state-sakai-authoring'); + formData.set( + 'state', + await signLtiToolState({ + nonce: 'nonce-sakai-authoring', + issuer: binding.issuer, + clientId: binding.clientId, + }), + ); formData.set( 'id_token', await signCanvasIdToken({ @@ -73,45 +69,44 @@ Deno.test('POST /lti/launch rejects Sakai authoring-mode requests and never crea }), ); - await withFetchStub( - () => - Promise.resolve( - new Response(JSON.stringify(getTestCanvasJwks()), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ), - async () => { - const response = await createApp({ - getRepository: () => repository, - }).request('http://localhost/lti/launch', { - method: 'POST', - body: formData, - }); - const body = await response.text(); - const savedState = await repository.getLoginStateByState('state-sakai-authoring'); - const runtimeSession = await repository.getLatestRuntimeSessionByDeploymentId(17); - const auditEvents = await repository.listAuditEventsByEventType('launch.rejected'); + await withRuntimeOriginEnv(async () => { + await withFetchStub( + () => + Promise.resolve( + new Response(JSON.stringify(getTestCanvasJwks()), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + async () => { + const response = await createApp({ + getRepository: () => repository, + getLtiToolStorage: () => ltiStorage, + }).request('http://localhost/lti/launch', { + method: 'POST', + body: formData, + }); + const location = response.headers.get('location'); + const runtimeSession = await repository.getLatestRuntimeSessionByDeploymentId(17); - assertEquals(response.status, 409); - assertStringIncludes( - body, - 'Launch rejected because /lti/launch only accepts LtiResourceLinkRequest for the governed runtime baseline.', - ); - assertEquals(savedState?.usedAt, null); - assertEquals(runtimeSession, null); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.detail.lms, 'sakai'); - assertEquals(auditEvents[0]?.detail.code, 'unsupported_message_type'); - assertEquals( - auditEvents[0]?.detail.message, - 'Launch rejected because /lti/launch only accepts LtiResourceLinkRequest for the governed runtime baseline.', - ); - assertEquals(auditEvents[0]?.detail.messageType, LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE); - assertEquals( - auditEvents[0]?.detail.supportedMessageType, - LTI_RESOURCE_LINK_REQUEST_MESSAGE_TYPE, - ); - }, - ); + assertEquals(response.status, 303); + if (location === null) { + throw new Error('Expected Deep Linking picker redirect.'); + } + assertStringIncludes(location, '/lti/deep-linking/sessions/'); + assertEquals(runtimeSession, null); + + const sessionId = location.match(/\/lti\/deep-linking\/sessions\/([^?]+)/)?.[1]; + + if (sessionId === undefined) { + throw new Error('Expected Deep Linking session id.'); + } + + const session = await repository.getDeepLinkingSessionById(sessionId); + + assertEquals(session?.deploymentRecordId, 17); + assertEquals(session?.deepLinkReturnUrl, 'https://sakai.example/portal/site/course'); + }, + ); + }); }); diff --git a/src/app_lti_launch_edge_test.ts b/src/app_lti_launch_edge_test.ts index 2441304..8a28182 100644 --- a/src/app_lti_launch_edge_test.ts +++ b/src/app_lti_launch_edge_test.ts @@ -1,8 +1,8 @@ -import { assertEquals, assertObjectMatch, assertStringIncludes } from '@std/assert'; +import { assertEquals, assertStringIncludes } from '@std/assert'; import { createApp } from './app.ts'; import { withRuntimeOriginEnv } from './app_test_support.ts'; +import { isLaunchRejectionError } from './lti/launch_rejection.ts'; import { rejectPackageNotApproved } from './lti/launch_support_matrix.ts'; -import { expectLaunchRejection } from './lti/launch_test_support.ts'; import { buildDeploymentRecord, buildPackageVersionRecord, @@ -11,9 +11,10 @@ import { } from './test_helpers/package_review.ts'; import { buildDeploymentBinding, - buildLoginStateRecord, + createSyncedTestLtiStorage, getTestCanvasJwks, signCanvasIdToken, + signLtiToolState, } from './test_helpers/lti.ts'; import { withFetchStub } from './app_test_support.ts'; @@ -24,7 +25,11 @@ Deno.test('launch rejection helpers classify governed approval denials as policy packageVersion: '0.1.0', }); } catch (error) { - const rejection = expectLaunchRejection(error); + if (!isLaunchRejectionError(error)) { + throw error; + } + + const rejection = error.rejection; assertEquals(rejection.category, 'policyDenied'); assertEquals(rejection.code, 'package_not_approved'); @@ -36,6 +41,77 @@ Deno.test('launch rejection helpers classify governed approval denials as policy throw new Error('Expected launch rejection.'); }); +Deno.test('POST /lti/launch records governed rejection audit with deployment and profile context', async () => { + await withRuntimeOriginEnv(async () => { + const repository = createInMemoryPackageReviewRepository({ + packageVersions: [ + buildPackageVersionRecord({ + id: 5, + approvalStatus: 'pending', + reviewedAt: null, + }), + ], + deployments: [ + buildDeploymentRecord({ + id: 3, + slug: 'chapter-4-asteroids-pilot', + label: 'Chapter 4 Asteroids Pilot Deployment', + enabledPackageVersionId: 5, + enabledPackageVersion: '0.1.0', + binding: buildDeploymentBinding(), + ltiProfileOverride: 'certification', + }), + ], + }); + const ltiStorage = await createSyncedTestLtiStorage(); + const formData = new FormData(); + + formData.set('state', await signLtiToolState({ nonce: 'nonce-unapproved-launch' })); + formData.set( + 'id_token', + await signCanvasIdToken({ + nonce: 'nonce-unapproved-launch', + audience: '10000000000001', + issuedAt: '2026-03-24T00:45:00Z', + expirationTime: '2h', + }), + ); + + await withFetchStub( + () => + Promise.resolve( + new Response(JSON.stringify(getTestCanvasJwks()), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + async () => { + const response = await createApp({ + getRepository: () => repository, + getLtiToolStorage: () => ltiStorage, + }).request('http://localhost/lti/launch', { + method: 'POST', + body: formData, + }); + const auditEvents = await repository.listAuditEventsByEventType('launch.rejected'); + + assertEquals(response.status, 409); + assertEquals(auditEvents.length, 1); + assertEquals(auditEvents[0]?.deploymentRecordId, 3); + assertEquals(auditEvents[0]?.packageVersionId, 5); + assertEquals(auditEvents[0]?.detail.code, 'package_not_approved'); + assertEquals(auditEvents[0]?.detail.category, 'policyDenied'); + assertEquals(auditEvents[0]?.detail.internalDeploymentSlug, 'chapter-4-asteroids-pilot'); + assertEquals(auditEvents[0]?.detail.issuer, 'https://canvas.instructure.com'); + assertEquals(auditEvents[0]?.detail.clientId, '10000000000001'); + assertEquals(auditEvents[0]?.detail.deploymentId, 'deployment-123'); + assertEquals(auditEvents[0]?.detail.ltiProfileId, 'certification'); + assertEquals(auditEvents[0]?.detail.ltiProfileSource, 'deploymentOverride'); + }, + ); + }); +}); + Deno.test('POST /lti/launch keeps reviewed assignment launches on the reviewed version and content after the deployment pin changes', async () => { await withRuntimeOriginEnv(async () => { const repository = createInMemoryPackageReviewRepository({ @@ -101,17 +177,11 @@ Deno.test('POST /lti/launch keeps reviewed assignment launches on the reviewed v contentTitle: 'Bonus Activity', }), ], - loginStates: [ - buildLoginStateRecord({ - state: 'state-reviewed-launch', - nonce: 'nonce-reviewed-launch', - expiresAt: '2030-03-26T02:45:00Z', - }), - ], }); + const ltiStorage = await createSyncedTestLtiStorage(); const formData = new FormData(); - formData.set('state', 'state-reviewed-launch'); + formData.set('state', await signLtiToolState({ nonce: 'nonce-reviewed-launch' })); formData.set( 'id_token', await signCanvasIdToken({ @@ -135,6 +205,7 @@ Deno.test('POST /lti/launch keeps reviewed assignment launches on the reviewed v async () => { const response = await createApp({ getRepository: () => repository, + getLtiToolStorage: () => ltiStorage, }).request('https://lantern.example/lti/launch', { method: 'POST', body: formData, @@ -194,17 +265,11 @@ Deno.test('POST /lti/launch rejects bad signed launches before any runtime hando binding: buildDeploymentBinding(), }), ], - loginStates: [ - buildLoginStateRecord({ - state: 'state-invalid-launch', - nonce: 'nonce-invalid-launch', - expiresAt: '2030-03-26T02:45:00Z', - }), - ], }); + const ltiStorage = await createSyncedTestLtiStorage(); const formData = new FormData(); - formData.set('state', 'state-invalid-launch'); + formData.set('state', await signLtiToolState({ nonce: 'nonce-invalid-launch' })); formData.set( 'id_token', await signCanvasIdToken({ @@ -214,49 +279,47 @@ Deno.test('POST /lti/launch rejects bad signed launches before any runtime hando }), ); - await withFetchStub( - () => - Promise.resolve( - new Response(JSON.stringify({ keys: [] }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ), - async () => { - const response = await createApp({ - getRepository: () => repository, - }).request('http://localhost/lti/launch', { - method: 'POST', - headers: { - 'x-real-ip': '203.0.113.44', - }, - body: formData, - }); - const body = await response.text(); + await withRuntimeOriginEnv(async () => { + await withFetchStub( + () => + Promise.resolve( + new Response(JSON.stringify({ keys: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + async () => { + const response = await createApp({ + getRepository: () => repository, + getLtiToolStorage: () => ltiStorage, + }).request('http://localhost/lti/launch', { + method: 'POST', + headers: { + 'x-real-ip': '203.0.113.44', + }, + body: formData, + }); + const body = await response.text(); - assertEquals(response.status, 409); - assertStringIncludes(body, 'Launch id_token signature or issuer validation failed.'); + assertEquals(response.status, 400); + assertStringIncludes(body, 'JWT verification failed'); - const auditEvents = await repository.listAuditEventsByEventType('launch.rejected'); + const auditEvents = await repository.listAuditEventsByEventType('launch.rejected'); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.deploymentRecordId, 3); - assertEquals(auditEvents[0]?.packageVersionId, 5); - assertEquals(auditEvents[0]?.detail.category, 'specInvalid'); - assertEquals(auditEvents[0]?.detail.code, 'signature_validation_failed'); - assertObjectMatch(auditEvents[0]?.detail.request ?? {}, { - method: 'POST', - path: '/lti/launch', - formKeys: ['id_token', 'state'], - queryKeys: [], - clientIpMasked: '203.0.113.x', - }); - assertEquals(JSON.stringify(auditEvents[0]?.detail ?? {}).includes('secret-id-token'), false); - }, - ); + assertEquals(auditEvents.length, 1); + assertEquals(auditEvents[0]?.deploymentRecordId, null); + assertEquals(auditEvents[0]?.packageVersionId, null); + assertEquals(auditEvents[0]?.detail.code, 'jwt_verification_failed'); + assertEquals( + JSON.stringify(auditEvents[0]?.detail ?? {}).includes('secret-id-token'), + false, + ); + }, + ); + }); }); -Deno.test('POST /lti/launch records interop evidence only when governed compatibility tolerates target_link_uri drift', async () => { +Deno.test('POST /lti/launch rejects target_link_uri drift at the lti-tool protocol boundary', async () => { await withRuntimeOriginEnv(async () => { const repository = createInMemoryPackageReviewRepository({ packageVersions: [ @@ -278,22 +341,21 @@ Deno.test('POST /lti/launch records interop evidence only when governed compatib ltiProfileOverride: 'governedCompatibility', }), ], - loginStates: [ - buildLoginStateRecord({ - state: 'state-target-drift-launch', - nonce: 'nonce-target-drift-launch', - expiresAt: '2030-03-26T02:45:00Z', - targetLinkUri: 'https://lantern.example/lti/launch/', - }), - ], lanternLtiProfileSettings: { defaultLtiProfile: 'certification', updatedAt: '2026-03-23T18:05:00Z', }, }); + const ltiStorage = await createSyncedTestLtiStorage(); const formData = new FormData(); - formData.set('state', 'state-target-drift-launch'); + formData.set( + 'state', + await signLtiToolState({ + nonce: 'nonce-target-drift-launch', + targetLinkUri: 'https://lantern.example/lti/launch/', + }), + ); formData.set( 'id_token', await signCanvasIdToken({ @@ -316,22 +378,15 @@ Deno.test('POST /lti/launch records interop evidence only when governed compatib async () => { const response = await createApp({ getRepository: () => repository, + getLtiToolStorage: () => ltiStorage, }).request('https://lantern.example/lti/launch', { method: 'POST', body: formData, }); const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - assertEquals(response.status, 303); - assertEquals( - interopEvents.some( - (event) => - event.detail.path === 'target_link_uri_drift' && - event.detail.ltiProfileId === 'governedCompatibility' && - event.detail.ltiProfileSource === 'deploymentOverride', - ), - true, - ); + assertEquals(response.status, 400); + assertEquals(interopEvents.length, 0); }, ); }); diff --git a/src/app_lti_launch_multilms_test.ts b/src/app_lti_launch_multilms_test.ts index 999065d..4038d2a 100644 --- a/src/app_lti_launch_multilms_test.ts +++ b/src/app_lti_launch_multilms_test.ts @@ -6,15 +6,21 @@ import { createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; import { - buildLoginStateRecord, buildMoodleDeploymentBinding, buildSakaiDeploymentBinding, + createSyncedTestLtiStorage, getTestCanvasJwks, signCanvasIdToken, + signLtiToolState, } from './test_helpers/lti.ts'; import { withFetchStub, withRuntimeOriginEnv } from './app_test_support.ts'; Deno.test('POST /lti/launch accepts a signed Moodle resource-link launch and records shared audit evidence', async () => { + const binding = buildMoodleDeploymentBinding({ + issuer: 'https://moodle.example', + clientId: 'moodle-client-123', + deploymentId: 'moodle-deployment-123', + }); const repository = createInMemoryPackageReviewRepository({ packageVersions: [ buildPackageVersionRecord({ @@ -31,28 +37,21 @@ Deno.test('POST /lti/launch accepts a signed Moodle resource-link launch and rec label: 'Chapter 4 Asteroids Moodle Deployment', enabledPackageVersionId: 5, enabledPackageVersion: '0.1.0', - binding: buildMoodleDeploymentBinding({ - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - }), - }), - ], - loginStates: [ - buildLoginStateRecord({ - lms: 'moodle', - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - state: 'state-moodle-launch', - nonce: 'nonce-moodle-launch', - expiresAt: '2030-03-26T02:45:00Z', + binding, }), ], }); + const ltiStorage = await createSyncedTestLtiStorage({ binding }); const formData = new FormData(); - formData.set('state', 'state-moodle-launch'); + formData.set( + 'state', + await signLtiToolState({ + nonce: 'nonce-moodle-launch', + issuer: binding.issuer, + clientId: binding.clientId, + }), + ); formData.set( 'id_token', await signCanvasIdToken({ @@ -84,6 +83,7 @@ Deno.test('POST /lti/launch accepts a signed Moodle resource-link launch and rec async () => { const response = await createApp({ getRepository: () => repository, + getLtiToolStorage: () => ltiStorage, }).request('http://localhost/lti/launch', { method: 'POST', body: formData, @@ -120,6 +120,11 @@ Deno.test('POST /lti/launch accepts a signed Moodle resource-link launch and rec }); Deno.test('POST /lti/launch accepts a signed Sakai resource-link launch and records shared audit evidence', async () => { + const binding = buildSakaiDeploymentBinding({ + issuer: 'https://sakai.example', + clientId: 'sakai-client-123', + deploymentId: 'sakai-deployment-123', + }); const repository = createInMemoryPackageReviewRepository({ packageVersions: [ buildPackageVersionRecord({ @@ -136,28 +141,21 @@ Deno.test('POST /lti/launch accepts a signed Sakai resource-link launch and reco label: 'Chapter 4 Asteroids Sakai Deployment', enabledPackageVersionId: 5, enabledPackageVersion: '0.1.0', - binding: buildSakaiDeploymentBinding({ - issuer: 'https://sakai.example', - clientId: 'sakai-client-123', - deploymentId: 'sakai-deployment-123', - }), - }), - ], - loginStates: [ - buildLoginStateRecord({ - lms: 'sakai', - issuer: 'https://sakai.example', - clientId: 'sakai-client-123', - deploymentId: 'sakai-deployment-123', - state: 'state-sakai-launch', - nonce: 'nonce-sakai-launch', - expiresAt: '2030-03-26T02:45:00Z', + binding, }), ], }); + const ltiStorage = await createSyncedTestLtiStorage({ binding }); const formData = new FormData(); - formData.set('state', 'state-sakai-launch'); + formData.set( + 'state', + await signLtiToolState({ + nonce: 'nonce-sakai-launch', + issuer: binding.issuer, + clientId: binding.clientId, + }), + ); formData.set( 'id_token', await signCanvasIdToken({ @@ -189,6 +187,7 @@ Deno.test('POST /lti/launch accepts a signed Sakai resource-link launch and reco async () => { const response = await createApp({ getRepository: () => repository, + getLtiToolStorage: () => ltiStorage, }).request('http://localhost/lti/launch', { method: 'POST', body: formData, diff --git a/src/app_lti_launch_test.ts b/src/app_lti_launch_test.ts index f79df54..7d2c705 100644 --- a/src/app_lti_launch_test.ts +++ b/src/app_lti_launch_test.ts @@ -12,12 +12,14 @@ import { } from './test_helpers/package_review.ts'; import { buildDeploymentBinding, - buildLoginStateRecord, + createSyncedTestLtiStorage, getTestCanvasJwks, getTestToolPrivateJwkEnvValue, signCanvasIdToken, + signLtiToolState, + TEST_LTI_STATE_SECRET, } from './test_helpers/lti.ts'; -import { EXAMPLE_SNAPSHOT_ROOT, withFetchStub } from './app_test_support.ts'; +import { EXAMPLE_SNAPSHOT_ROOT, withFetchStub, withRuntimeOriginEnv } from './app_test_support.ts'; Deno.test('POST /lti/launch validates the signed launch and redirects to a runtime-session handoff with a signed runtime bootstrap', async () => { const repository = createInMemoryPackageReviewRepository({ @@ -47,14 +49,9 @@ Deno.test('POST /lti/launch validates the signed launch and redirects to a runti ltiProfileOverride: 'certification', }), ], - loginStates: [ - buildLoginStateRecord({ - state: 'state-launch-123', - nonce: 'nonce-launch-123', - expiresAt: '2030-03-26T02:45:00Z', - }), - ], }); + const ltiStorage = await createSyncedTestLtiStorage(); + const state = await signLtiToolState({ nonce: 'nonce-launch-123' }); const idToken = await signCanvasIdToken({ nonce: 'nonce-launch-123', audience: '10000000000001', @@ -68,10 +65,11 @@ Deno.test('POST /lti/launch validates the signed launch and redirects to a runti const env = createObjectEnvReader({ APP_ORIGIN: 'https://lantern.example', APP_RUNTIME_ORIGIN: 'https://runtime.lantern.example', + LTI_STATE_SECRET: TEST_LTI_STATE_SECRET, LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), }); - formData.set('state', 'state-launch-123'); + formData.set('state', state); formData.set('id_token', idToken); await withFetchStub( @@ -86,6 +84,7 @@ Deno.test('POST /lti/launch validates the signed launch and redirects to a runti const app = createApp({ env, getRepository: () => repository, + getLtiToolStorage: () => ltiStorage, }); const response = await app.request('https://lantern.example/lti/launch', { method: 'POST', @@ -130,12 +129,12 @@ Deno.test('POST /lti/launch validates the signed launch and redirects to a runti assertEquals(attempt?.attemptId, saved.attemptId); assertEquals(attempt?.userDisplayName, 'Ada Lovelace'); assertEquals(attempt?.userEmail, 'ada@example.com'); - assertEquals(attempt?.userLogin, 'adal'); + assertEquals(attempt?.userLogin, null); assertEquals(auditEvents.length, 1); assertEquals(auditEvents[0]?.attemptId, saved.attemptId); assertEquals(auditEvents[0]?.detail.userDisplayName, 'Ada Lovelace'); assertEquals(auditEvents[0]?.detail.userEmail, 'ada@example.com'); - assertEquals(auditEvents[0]?.detail.userLogin, 'adal'); + assertEquals(auditEvents[0]?.detail.userLogin, null); assertEquals(auditEvents[0]?.detail.ltiProfileId, 'certification'); assertEquals(auditEvents[0]?.detail.ltiProfileSource, 'deploymentOverride'); assertEquals( @@ -153,7 +152,7 @@ Deno.test('POST /lti/launch validates the signed launch and redirects to a runti ); }); -Deno.test('POST /lti/launch rejects certification-profile launches when validation would need a JWKS refetch', async () => { +Deno.test('POST /lti/launch records lti-tool JWKS refetch verification evidence', async () => { const repository = createInMemoryPackageReviewRepository({ packageVersions: [ buildPackageVersionRecord({ @@ -174,14 +173,9 @@ Deno.test('POST /lti/launch rejects certification-profile launches when validati ltiProfileOverride: 'certification', }), ], - loginStates: [ - buildLoginStateRecord({ - state: 'state-launch-cert-jwks', - nonce: 'nonce-launch-cert-jwks', - expiresAt: '2030-03-26T02:45:00Z', - }), - ], }); + const ltiStorage = await createSyncedTestLtiStorage(); + const state = await signLtiToolState({ nonce: 'nonce-launch-cert-jwks' }); const idToken = await signCanvasIdToken({ nonce: 'nonce-launch-cert-jwks', audience: '10000000000001', @@ -191,36 +185,39 @@ Deno.test('POST /lti/launch rejects certification-profile launches when validati const formData = new FormData(); let jwksRequests = 0; - formData.set('state', 'state-launch-cert-jwks'); + formData.set('state', state); formData.set('id_token', idToken); - await withFetchStub( - () => { - jwksRequests += 1; + await withRuntimeOriginEnv(async () => { + await withFetchStub( + () => { + jwksRequests += 1; - return Promise.resolve( - new Response(JSON.stringify(jwksRequests === 1 ? { keys: [] } : getTestCanvasJwks()), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ); - }, - async () => { - const response = await createApp({ - getRepository: () => repository, - }).request('http://localhost/lti/launch', { - method: 'POST', - body: formData, - }); - const body = await response.text(); - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); + return Promise.resolve( + new Response(JSON.stringify(jwksRequests === 1 ? { keys: [] } : getTestCanvasJwks()), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + }, + async () => { + const response = await createApp({ + getRepository: () => repository, + getLtiToolStorage: () => ltiStorage, + }).request('http://localhost/lti/launch', { + method: 'POST', + body: formData, + }); + const body = await response.text(); + const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - assertEquals(response.status, 409); - assertStringIncludes(body, 'Launch id_token signature or issuer validation failed.'); - assertEquals(jwksRequests, 1); - assertEquals(interopEvents.length, 0); - }, - ); + assertEquals(response.status, 303, body); + assertEquals(jwksRequests, 2); + assertEquals(interopEvents.length, 1); + assertEquals(interopEvents[0]?.detail.path, 'jwks_refetch'); + }, + ); + }); }); function extractBootstrapFromHtml(html: string): BootstrapPayload { @@ -259,7 +256,11 @@ async function assertBootstrapSignature(bootstrap: BootstrapPayload): Promise { - const loginState = input.state === null - ? null - : await input.repository.getLoginStateByState(input.state).catch(() => null); - const deployment = loginState === null ? null : await input.repository - .getDeploymentByBinding({ - lms: loginState.lms, - issuer: loginState.issuer, - clientId: loginState.clientId, - deploymentId: loginState.deploymentId, - }) - .catch(() => null); - const ltiProfile = deployment === null ? null : await resolveLtiProfileForDeployment({ - repository: input.repository, - deployment, + issuer: string; + clientId: string | null; + deploymentId: string; +}): Promise<{ + deployment: DeploymentRecord; + completedPendingCanvasBinding: boolean; +}> { + let deployment = await input.repository.getDeploymentByPlatformIdentity({ + issuer: input.issuer, + clientId: input.clientId, + deploymentId: input.deploymentId, }); - const rejection = readLaunchRejection(input.error); - await input.repository.recordAuditEvent({ - eventType: 'launch.rejected', - actorType: 'platform', - actorId: null, - deploymentRecordId: deployment?.id ?? null, - packageVersionId: deployment?.enabledPackageVersionId ?? null, - attemptId: null, - lineItemBindingId: null, - status: 'failed', - summary: 'Rejected the governed LTI launch before runtime handoff.', - detail: { - lms: loginState?.lms ?? null, - category: rejection?.category ?? null, - code: rejection?.code ?? 'launch_validation_failed', - message: rejection?.message ?? errorMessage(input.error), - request: input.request, - ...rejection?.detail, - issuer: loginState?.issuer ?? null, - clientId: loginState?.clientId ?? null, - deploymentId: loginState?.deploymentId ?? null, - targetLinkUri: loginState?.targetLinkUri ?? null, - internalDeploymentSlug: deployment?.slug ?? null, - appId: deployment?.appId ?? null, - ...(ltiProfile === null ? {} : buildResolvedLtiProfileDetail(ltiProfile)), - }, - occurredAt: new Date().toISOString(), - }); -} + let completedPendingCanvasBinding = false; + + if (deployment === null && input.clientId !== null) { + deployment = await input.repository.completePendingCanvasBinding({ + issuer: input.issuer, + clientId: input.clientId, + deploymentId: input.deploymentId, + }); + completedPendingCanvasBinding = deployment !== null; + } -function readLaunchRejection(input: unknown): { - category: string; - code: string; - message: string; - detail: Record; -} | null { - if (!isLaunchRejectionError(input)) { - return null; + if (deployment === null) { + throw new Error( + `Deployment ${ + input.clientId ?? '(missing client_id)' + } / ${input.deploymentId} was not found for issuer ${input.issuer}.`, + ); } return { - category: input.category, - code: input.code, - message: input.message, - detail: input.detail, + deployment, + completedPendingCanvasBinding, }; } diff --git a/src/app_origin.ts b/src/app_origin.ts new file mode 100644 index 0000000..3622895 --- /dev/null +++ b/src/app_origin.ts @@ -0,0 +1,13 @@ +import type { Context } from '@hono/hono'; +import { type EnvReader, readEnv } from './platform/env.ts'; +import { resolveConfiguredPublicOrigin } from './public_origin.ts'; + +export function resolveRequestAppOrigin(context: Context, env: EnvReader): string { + return resolveConfiguredPublicOrigin({ + requestUrl: context.req.url, + forwardedHeader: context.req.header('forwarded') ?? null, + xForwardedHost: context.req.header('x-forwarded-host') ?? null, + xForwardedProto: context.req.header('x-forwarded-proto') ?? null, + configuredOrigin: readEnv('APP_ORIGIN', env), + }); +} diff --git a/src/app_request_support.ts b/src/app_request_support.ts index 5908a54..59a4ba4 100644 --- a/src/app_request_support.ts +++ b/src/app_request_support.ts @@ -1,6 +1,6 @@ import type { Context } from '@hono/hono'; import { type LtiProfileId, requireLtiProfileId } from './lti/profile.ts'; -import type { LoginRequest, LoginRequestCompatibility } from './lti/login.ts'; +import type { LoginRequest, LoginRequestCompatibility } from './lti/login_request.ts'; export { parseBrokerVerificationRunForm } from './app_request_broker_verification.ts'; export interface ReadLoginRequestResult { diff --git a/src/app_runtime_finalize_test.ts b/src/app_runtime_finalize_test.ts index d794280..3b7d45f 100644 --- a/src/app_runtime_finalize_test.ts +++ b/src/app_runtime_finalize_test.ts @@ -19,6 +19,7 @@ import { buildRuntimeSessionRecord, getTestToolPrivateJwkEnvValue, } from './test_helpers/lti.ts'; +import { createTestLtiToolContext } from './test_helpers/lti_tool_context.ts'; const WEB_CHECKUP_SNAPSHOT_ROOT = 'examples/apps/web-checkup'; @@ -64,9 +65,20 @@ Deno.test('POST /runtime/sessions/:id/finalize finalizes the durable attempt and }, }), ], - runtimeSessions: [buildRuntimeSessionRecord({ expiresAt: '2099-03-26T02:45:00Z' })], + runtimeSessions: [ + buildRuntimeSessionRecord({ + ltiToolSessionId: 'lti-tool-session-123', + expiresAt: '2099-03-26T02:45:00Z', + }), + ], + }); + const ltiToolContext = createTestLtiToolContext({ + sessionId: 'lti-tool-session-123', + }); + const app = createApp({ + getRepository: () => repository, + getLtiToolContext: () => Promise.resolve(ltiToolContext.context), }); - const app = createApp({ getRepository: () => repository }); await withFetchStub( (input) => { @@ -255,6 +267,7 @@ Deno.test('POST /runtime/sessions/:id/finalize accepts browser grader results wh ], runtimeSessions: [ buildRuntimeSessionRecord({ + ltiToolSessionId: 'lti-tool-session-123', appId: 'web-checkup', deploymentSlug: 'web-checkup-pilot', capabilities: [ @@ -271,7 +284,13 @@ Deno.test('POST /runtime/sessions/:id/finalize accepts browser grader results wh }), ], }); - const app = createApp({ getRepository: () => repository }); + const ltiToolContext = createTestLtiToolContext({ + sessionId: 'lti-tool-session-123', + }); + const app = createApp({ + getRepository: () => repository, + getLtiToolContext: () => Promise.resolve(ltiToolContext.context), + }); await withFetchStub( (input) => { @@ -517,7 +536,7 @@ Deno.test('POST /runtime/sessions/:id/score-proposal accepts typed app proposals }); }); -Deno.test('POST /runtime/sessions/:id/finalize records a failed grade publish when Canvas token exchange fails', async () => { +Deno.test('POST /runtime/sessions/:id/finalize records a failed grade publish when lti-tool score publish fails', async () => { const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); @@ -549,7 +568,17 @@ Deno.test('POST /runtime/sessions/:id/finalize records a failed grade publish wh }, }), ], - runtimeSessions: [buildRuntimeSessionRecord({ expiresAt: '2099-03-26T02:45:00Z' })], + runtimeSessions: [ + buildRuntimeSessionRecord({ + ltiToolSessionId: 'lti-tool-session-123', + expiresAt: '2099-03-26T02:45:00Z', + }), + ], + }); + const ltiToolContext = createTestLtiToolContext({ + sessionId: 'lti-tool-session-123', + submitScoreSuccess: false, + submitScoreMessage: 'simulated score failure', }); await withFetchStub( @@ -561,6 +590,7 @@ Deno.test('POST /runtime/sessions/:id/finalize records a failed grade publish wh async () => { const response = await createApp({ getRepository: () => repository, + getLtiToolContext: () => Promise.resolve(ltiToolContext.context), }).request( 'https://runtime.lantern.example/runtime/sessions/runtime-session-123/finalize', { @@ -587,7 +617,7 @@ Deno.test('POST /runtime/sessions/:id/finalize records a failed grade publish wh assertEquals(attempt?.status, 'completed'); assertEquals(attemptAuditEvents.length, 1); assertEquals(failedGradeAuditEvents.length, 1); - assertEquals(failedGradeAuditEvents[0]?.detail.code, 'token_request_failed'); + assertEquals(failedGradeAuditEvents[0]?.detail.code, 'score_publish_failed'); assertObjectMatch(failedGradeAuditEvents[0]?.detail.request ?? {}, { method: 'POST', path: '/runtime/sessions/runtime-session-123/finalize', diff --git a/src/app_runtime_routes.ts b/src/app_runtime_routes.ts index c54498b..0a0e0fd 100644 --- a/src/app_runtime_routes.ts +++ b/src/app_runtime_routes.ts @@ -14,20 +14,20 @@ import type { AppServices } from './app_services.ts'; import { recordRuntimeCapabilityAllowed, recordRuntimeRouteFailure, - recordRuntimeScoreProposalAccepted, - recordRuntimeSessionExited, recordRuntimeSessionStarted, requireRuntimeSession, resolveRuntimeSessionForAudit, } from './app_runtime_support.ts'; import { - acceptAttemptEvent, - finalizeRuntimeAttempt, readAttemptLocalState, submitEvidenceArtifact, - submitScoreProposal, writeAttemptLocalState, } from './runtime/gateway.ts'; +import { + acceptRuntimeAttemptEvent, + acceptRuntimeScoreProposal, + finalizeRuntimeSession, +} from './runtime/route_commands.ts'; import { failRuntimeOutcome, toRuntimeBrokerResult } from './runtime/gateway_errors.ts'; import type { RuntimeDeliveryDescriptor } from './runtime/delivery.ts'; import { @@ -173,37 +173,11 @@ export function registerRuntimeRoutes(app: Hono, services: AppServices): void { const payload = await context.req.json(); runtime.setRequestBody(payload); - const attemptEvent = await acceptAttemptEvent({ + await acceptRuntimeAttemptEvent({ repository: runtime.repository, session: runtime.session, payload, }); - await runtime.repository.recordAuditEvent({ - eventType: 'attempt.submitted', - actorType: 'system', - actorId: null, - deploymentRecordId: runtime.session.deploymentRecordId, - packageVersionId: runtime.session.packageVersionId, - attemptId: runtime.session.attemptId, - lineItemBindingId: null, - status: 'accepted', - summary: 'Accepted attempt submission through the runtime gateway.', - detail: { - sequence: attemptEvent.sequence, - eventType: attemptEvent.eventType, - }, - occurredAt: new Date().toISOString(), - }); - await recordRuntimeCapabilityAllowed({ - repository: runtime.repository, - session: runtime.session, - capability: 'submit_attempt_event', - route: 'attempt-events', - detail: { - eventType: attemptEvent.eventType, - sequence: attemptEvent.sequence, - }, - }); return context.json({ accepted: true }, 202); }, @@ -254,7 +228,7 @@ export function registerRuntimeRoutes(app: Hono, services: AppServices): void { const payload = await context.req.json(); runtime.setRequestBody(payload); - const result = await submitScoreProposal({ + const result = await acceptRuntimeScoreProposal({ repository: runtime.repository, session: runtime.session, payload, @@ -264,13 +238,6 @@ export function registerRuntimeRoutes(app: Hono, services: AppServices): void { return context.json(result, result.denial.category === 'policyDenied' ? 409 : 400); } - await recordRuntimeScoreProposalAccepted({ - repository: runtime.repository, - session: runtime.session, - scoreProposal: result.scoreProposal, - route: 'score-proposal', - }); - return context.json(result, 202); }, ); @@ -289,85 +256,19 @@ export function registerRuntimeRoutes(app: Hono, services: AppServices): void { const payload = await context.req.json(); runtime.setRequestBody(payload); - const result = await finalizeRuntimeAttempt({ + const result = await finalizeRuntimeSession({ + env: services.env, + getLtiToolContext: services.getLtiToolContext, + artifactStore: services.runtimeArtifactStore, repository: runtime.repository, session: runtime.session, payload, - env: services.env, - artifactStore: services.runtimeArtifactStore, + reviewedPackage, + delivery: runtime.delivery, + request: runtime.request, }); - if (result.finalizedNow) { - await runtime.repository.recordAuditEvent({ - eventType: 'attempt.finalized', - actorType: 'system', - actorId: null, - deploymentRecordId: runtime.session.deploymentRecordId, - packageVersionId: runtime.session.packageVersionId, - attemptId: runtime.session.attemptId, - lineItemBindingId: null, - status: 'accepted', - summary: 'Finalized the durable attempt inside the runtime gateway.', - detail: buildFinalizeAuditDetail(result), - occurredAt: new Date().toISOString(), - }); - await recordRuntimeSessionExited({ - repository: runtime.repository, - session: runtime.session, - reviewedPackage, - delivery: runtime.delivery, - completionState: result.attempt.completionState, - scoreGiven: result.score.scoreGiven, - scoreMaximum: result.score.scoreMaximum, - gradePublished: result.gradePublication?.status === 'published', - submissionMode: result.submissionMode, - evidenceArtifactCount: result.evidenceArtifacts.length, - evidenceArtifacts: result.evidenceArtifacts, - browserGraderResult: result.browserGraderResult, - route: 'finalize', - }); - } - - if (result.gradePublishedNow && result.gradePublication !== null) { - await runtime.repository.recordAuditEvent({ - eventType: 'grade_publish.succeeded', - actorType: 'system', - actorId: null, - deploymentRecordId: runtime.session.deploymentRecordId, - packageVersionId: runtime.session.packageVersionId, - attemptId: runtime.session.attemptId, - lineItemBindingId: result.lineItemBinding?.id ?? null, - status: 'succeeded', - summary: 'Published the final score to Canvas through AGS.', - detail: { - lineItemUrl: result.gradePublication.lineItemUrl, - scoreGiven: result.gradePublication.scoreGiven, - scoreMaximum: result.gradePublication.scoreMaximum, - }, - occurredAt: new Date().toISOString(), - }); - } - if (result.publishError !== null) { - await runtime.repository.recordAuditEvent({ - eventType: 'grade_publish.failed', - actorType: 'system', - actorId: null, - deploymentRecordId: runtime.session.deploymentRecordId, - packageVersionId: runtime.session.packageVersionId, - attemptId: runtime.session.attemptId, - lineItemBindingId: result.lineItemBinding?.id ?? null, - status: 'failed', - summary: 'Canvas AGS score publish failed.', - detail: { - code: result.publishError.code, - message: result.publishError.message, - request: runtime.request, - ...result.publishError.detail, - }, - occurredAt: new Date().toISOString(), - }); - return context.text( result.publishError.message, statusForFinalizePublishError(result.publishError.code), @@ -577,22 +478,6 @@ function authorizeRuntimeBearerToken(context: Context, session: RuntimeSessionRe }); } -function buildFinalizeAuditDetail( - result: Awaited>, -): Record { - return { - completionState: result.attempt.completionState, - scoreGiven: result.score.scoreGiven, - scoreMaximum: result.score.scoreMaximum, - submissionMode: result.submissionMode, - evidenceArtifactCount: result.evidenceArtifacts.length, - evidenceArtifacts: result.evidenceArtifacts, - ...(result.browserGraderResult === null - ? {} - : { browserGraderResult: result.browserGraderResult }), - }; -} - function requireRuntimeOriginBoundary(context: Context, services: AppServices): string { try { return requireRuntimeRequestOrigin({ diff --git a/src/app_services.ts b/src/app_services.ts index e3f82cf..8911c2e 100644 --- a/src/app_services.ts +++ b/src/app_services.ts @@ -42,6 +42,9 @@ import type { EvidenceArtifactStore } from './runtime/evidence_artifact_store.ts import { getDefaultEvidenceArtifactStore } from './runtime/evidence_artifact_store_fs.ts'; import type { ManifestReviewData } from './package_review/manifest.ts'; import type { AuthoringDraftRecord, PackageVersionRecord } from './package_review/types.ts'; +import { createMemoryLtiStorage, type LanternLtiToolContext } from './lti/tool_provider.ts'; +import { createCachedLtiToolContextFactory } from './lti/context_factory.ts'; +import type { LTIStorage } from '@longsightgroup/lti-tool'; export interface AppServices { env: EnvReader; @@ -65,6 +68,8 @@ export interface AppServices { }) => Promise; getRepository: () => PackageReviewRepository; getOpsRepository: () => OpsRepository; + getLtiToolStorage: () => LTIStorage; + getLtiToolContext: (appOrigin?: string) => Promise; loadCanvasJwks: (url: string) => Promise; readReferencePackageReviewData: (appId: string) => Promise; importPackageFromSource: ( @@ -87,9 +92,6 @@ export interface AppServices { ) => Promise; } -let defaultRepository: PackageReviewRepository | null = null; -let defaultOpsRepository: OpsRepository | null = null; - const LOCAL_REPOSITORY_MESSAGE = 'Lantern persistence is Cloudflare D1-only. Run Lantern through the Cloudflare Worker entrypoint with a DB binding.'; @@ -98,6 +100,8 @@ export function resolveServices(services: Partial): AppServices { const getRepository = services.getRepository ?? getDefaultRepository; const snapshotStore = services.packageSnapshotStore ?? getDefaultPackageSnapshotStore(); const runtimeArtifactStore = services.runtimeArtifactStore ?? getDefaultRuntimeArtifactStore(); + const ltiStorage = services.getLtiToolStorage?.() ?? createMemoryLtiStorage(); + const getLtiToolContext = createCachedLtiToolContextFactory({ env, storage: ltiStorage }); const importPackageFromSource = services.importPackageFromSource ?? ((source: PackageSource, options = {}) => importPackage({ @@ -144,6 +148,8 @@ export function resolveServices(services: Partial): AppServices { return isOpsRepository(repository) ? repository : getDefaultOpsRepository(); }), + getLtiToolStorage: services.getLtiToolStorage ?? (() => ltiStorage), + getLtiToolContext: services.getLtiToolContext ?? getLtiToolContext, loadCanvasJwks: services.loadCanvasJwks ?? defaultLoadCanvasJwks, importPackageFromSource, loadPackageSnapshotFromSource, @@ -163,21 +169,11 @@ function resolveReferencePackageSource(appId: string) { } function getDefaultRepository(): PackageReviewRepository { - if (defaultRepository === null) { - defaultRepository = createUnavailableLocalProxy( - LOCAL_REPOSITORY_MESSAGE, - ); - } - - return defaultRepository; + throw new Error(LOCAL_REPOSITORY_MESSAGE); } function getDefaultOpsRepository(): OpsRepository { - if (defaultOpsRepository === null) { - defaultOpsRepository = createUnavailableLocalProxy(LOCAL_REPOSITORY_MESSAGE); - } - - return defaultOpsRepository; + throw new Error(LOCAL_REPOSITORY_MESSAGE); } async function defaultLoadCanvasJwks(url: string): Promise { @@ -201,20 +197,3 @@ function isOpsRepository( typeof (repository as Partial).getRetryableGradePublicationLookup === 'function' ); } - -function createUnavailableLocalProxy(message: string): T { - return new Proxy( - {}, - { - get(_target, property) { - if (property === 'then') { - return null; - } - - return () => { - throw new Error(message); - }; - }, - }, - ) as T; -} diff --git a/src/app_status_support.ts b/src/app_status_support.ts index 4912a09..172d047 100644 --- a/src/app_status_support.ts +++ b/src/app_status_support.ts @@ -1,10 +1,13 @@ -import { isLaunchRejectionError, isLtiBoundaryDenialError } from './lti/launch_rejection.ts'; import { isRuntimeBrokerDenialError, isRuntimeOutcomeError } from './runtime/gateway_errors.ts'; export function deepLinkingReturnErrorMessage(error: unknown): string { const message = errorMessage(error); - if (message.includes('APP_ORIGIN') || message.includes('LTI_TOOL_PRIVATE_JWK')) { + if ( + message.includes('APP_ORIGIN') || + message.includes('LTI_TOOL_PRIVATE_JWK') || + message.includes('LTI_STATE_SECRET') + ) { return 'Lantern could not prepare the signed LMS return. Contact an operator and try again.'; } @@ -12,10 +15,6 @@ export function deepLinkingReturnErrorMessage(error: unknown): string { } export function statusForError(error: unknown): 409 | 500 { - if (isLaunchRejectionError(error)) { - return 409; - } - if (!(error instanceof Error)) { return 500; } @@ -37,6 +36,7 @@ export function statusForError(error: unknown): 409 | 500 { error.message.includes('Choose ') || error.message.includes('belongs to another deployment') || error.message.includes('must send client_id') || + error.message.includes('duplicate LMS bindings') || error.message.includes('Choose one supported Canvas environment') || error.message.includes('Choose one supported LMS deployment') || error.message.includes('active LTI profile') || @@ -58,14 +58,6 @@ export function statusForError(error: unknown): 409 | 500 { return 500; } -export function statusForDeepLinkingError(error: unknown): 400 | 409 | 500 { - if (isLtiBoundaryDenialError(error)) { - return error.category === 'policyDenied' ? 409 : 400; - } - - return 500; -} - export function statusForDeepLinkingSessionError(error: unknown): 404 | 409 | 500 { if (!(error instanceof Error)) { return 500; diff --git a/src/app_test_support.ts b/src/app_test_support.ts index e1b57e7..96376e2 100644 --- a/src/app_test_support.ts +++ b/src/app_test_support.ts @@ -1,6 +1,10 @@ import { createLocalJWKSet, jwtVerify } from 'jose'; import { getPublicJwkSet } from './lti/tool_key.ts'; -import { buildDeploymentBinding, getTestToolPrivateJwkEnvValue } from './test_helpers/lti.ts'; +import { + buildDeploymentBinding, + getTestToolPrivateJwkEnvValue, + TEST_LTI_STATE_SECRET, +} from './test_helpers/lti.ts'; export const REFERENCE_APP_SNAPSHOT_ROOTS = { 'chapter-4-asteroids': 'examples/apps/chapter-4-asteroids', @@ -28,15 +32,18 @@ export function restoreEnv(name: string, value: string | undefined): void { export async function withCanvasReturnEnv(run: () => Promise): Promise { const previousAppOrigin = Deno.env.get('APP_ORIGIN'); const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); + const previousStateSecret = Deno.env.get('LTI_STATE_SECRET'); Deno.env.set('APP_ORIGIN', 'https://lantern.example'); Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); + Deno.env.set('LTI_STATE_SECRET', TEST_LTI_STATE_SECRET); try { await run(); } finally { restoreEnv('APP_ORIGIN', previousAppOrigin); restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); + restoreEnv('LTI_STATE_SECRET', previousStateSecret); } } @@ -49,15 +56,21 @@ export async function withRuntimeOriginEnv( ): Promise { const previousAppOrigin = Deno.env.get('APP_ORIGIN'); const previousRuntimeOrigin = Deno.env.get('APP_RUNTIME_ORIGIN'); + const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); + const previousStateSecret = Deno.env.get('LTI_STATE_SECRET'); Deno.env.set('APP_ORIGIN', options.appOrigin ?? 'https://lantern.example'); Deno.env.set('APP_RUNTIME_ORIGIN', options.runtimeOrigin ?? 'https://runtime.lantern.example'); + Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); + Deno.env.set('LTI_STATE_SECRET', TEST_LTI_STATE_SECRET); try { return await run(); } finally { restoreEnv('APP_ORIGIN', previousAppOrigin); restoreEnv('APP_RUNTIME_ORIGIN', previousRuntimeOrigin); + restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); + restoreEnv('LTI_STATE_SECRET', previousStateSecret); } } @@ -76,7 +89,11 @@ export async function verifyDeepLinkingResponseJwt(jwt: string) { const keySet = createLocalJWKSet( await getPublicJwkSet({ get(name: string) { - return name === 'LTI_TOOL_PRIVATE_JWK' ? getTestToolPrivateJwkEnvValue() : undefined; + return name === 'LTI_TOOL_PRIVATE_JWK' + ? getTestToolPrivateJwkEnvValue() + : name === 'LTI_STATE_SECRET' + ? TEST_LTI_STATE_SECRET + : undefined; }, }), ); diff --git a/src/app_worker_services.ts b/src/app_worker_services.ts index 0f79448..e2b3271 100644 --- a/src/app_worker_services.ts +++ b/src/app_worker_services.ts @@ -56,6 +56,8 @@ import { type EvidenceArtifactStore, } from './runtime/evidence_artifact_store.ts'; import type { AuthoringDraftRecord, PackageVersionRecord } from './package_review/types.ts'; +import { createD1LtiStorage } from './lti/tool_provider.ts'; +import { createCachedLtiToolContextFactory } from './lti/context_factory.ts'; const WORKER_REPOSITORY_MESSAGE = 'Cloudflare Workers persistence requires a D1 binding named DB.'; const WORKER_RUNTIME_ARTIFACT_MESSAGE = @@ -93,6 +95,8 @@ export interface WorkerBindings extends Record { export function resolveWorkerServices(bindings: WorkerBindings, env: EnvReader): AppServices { const repositories = resolveWorkerRepositories(bindings); const snapshotStore = resolveWorkerSnapshotStore(bindings); + const ltiStorage = resolveWorkerLtiStorage(bindings); + const getLtiToolContext = createCachedLtiToolContextFactory({ env, storage: ltiStorage }); const importPackageFromSource = (source: PackageSource, options = {}) => importPackage({ ...options, @@ -125,6 +129,8 @@ export function resolveWorkerServices(bindings: WorkerBindings, env: EnvReader): materializeDraftPreviewPackageVersion: createUnsupportedDraftPreviewMaterializer(), getRepository: () => repositories.repository, getOpsRepository: () => repositories.opsRepository, + getLtiToolStorage: () => ltiStorage, + getLtiToolContext, loadCanvasJwks: defaultLoadCanvasJwks, importPackageFromSource, loadPackageSnapshotFromSource, @@ -137,6 +143,14 @@ export function resolveWorkerServices(bindings: WorkerBindings, env: EnvReader): }; } +function resolveWorkerLtiStorage(bindings: WorkerBindings) { + if (isD1Database(bindings.DB)) { + return createD1LtiStorage(bindings.DB); + } + + throw new Error(WORKER_REPOSITORY_MESSAGE); +} + function resolveWorkerAppWriterAgentSessions(bindings: WorkerBindings) { if (isAppWriterAgentNamespace(bindings.APP_WRITER_AGENT)) { return createCloudflareAppWriterAgentSessionCoordinator(bindings.APP_WRITER_AGENT); @@ -219,10 +233,7 @@ function resolveWorkerRepositories(bindings: WorkerBindings): { }; } - return { - repository: createUnsupportedWorkerProxy(WORKER_REPOSITORY_MESSAGE), - opsRepository: createUnsupportedWorkerProxy(WORKER_REPOSITORY_MESSAGE), - }; + throw new Error(WORKER_REPOSITORY_MESSAGE); } function resolveWorkerSnapshotStore(bindings: WorkerBindings): PackageSnapshotStore { @@ -278,23 +289,6 @@ function createUnsupportedDraftPreviewMaterializer() { }; } -function createUnsupportedWorkerProxy(message: string): T { - return new Proxy( - {}, - { - get(_target, property) { - if (property === Symbol.toStringTag) { - return 'UnsupportedWorkerService'; - } - - return () => { - throw new Error(message); - }; - }, - }, - ) as T; -} - function createUnsupportedSnapshotStore(message: string): PackageSnapshotStore { return { readBytes(): Promise { diff --git a/src/app_worker_services_test.ts b/src/app_worker_services_test.ts index 0ad588e..19a20e7 100644 --- a/src/app_worker_services_test.ts +++ b/src/app_worker_services_test.ts @@ -1,4 +1,4 @@ -import { assertEquals, assertRejects } from '@std/assert'; +import { assertEquals, assertRejects, assertThrows } from '@std/assert'; import { resolveWorkerServices } from './app_worker_services.ts'; import { selectAppWriterContext } from './app_writer/context.ts'; import type { AppGenerationPlan } from './app_writer/types.ts'; @@ -112,7 +112,7 @@ Deno.test('worker services import and reload the demo package from PACKAGE_ARTIF const env = createObjectEnvReader({ LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), }); - const services = resolveWorkerServices({ PACKAGE_ARTIFACTS: bucket }, env); + const services = resolveWorkerServices(withRequiredDb({ PACKAGE_ARTIFACTS: bucket }), env); assertEquals(await services.loadReferencePackageSnapshot('chapter-4-asteroids'), null); @@ -165,7 +165,7 @@ Deno.test('worker services import and reload arbitrary reviewed package sources' const env = createObjectEnvReader({ LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), }); - const services = resolveWorkerServices({ PACKAGE_ARTIFACTS: bucket }, env); + const services = resolveWorkerServices(withRequiredDb({ PACKAGE_ARTIFACTS: bucket }), env); const source = createMemoryPackageSource( Object.entries(QUICK_STUDY_SOURCE_FILES).map(([relativePath, bytes]) => ({ relativePath, @@ -221,7 +221,7 @@ Deno.test('worker services expose Dynamic Worker runtime delivery only when LOAD }); const missingLoaderServices = resolveWorkerServices( - { PACKAGE_ARTIFACTS: createInMemoryArtifactBucket({}) }, + withRequiredDb({ PACKAGE_ARTIFACTS: createInMemoryArtifactBucket({}) }), env, ); @@ -237,10 +237,10 @@ Deno.test('worker services expose Dynamic Worker runtime delivery only when LOAD ); const loaderServices = resolveWorkerServices( - { + withRequiredDb({ PACKAGE_ARTIFACTS: createInMemoryArtifactBucket({}), LOADER: createStubDynamicWorkerLoader(), - }, + }), env, ); @@ -275,7 +275,7 @@ Deno.test('worker services schedule app generation through APP_GENERATION_WORKFL LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), }); const services = resolveWorkerServices( - { + withRequiredDb({ APP_GENERATION_WORKFLOW: { create(input: unknown) { workflowCreateInputs.push(input); @@ -283,7 +283,7 @@ Deno.test('worker services schedule app generation through APP_GENERATION_WORKFL return Promise.resolve({ id: 'workflow-1' }); }, }, - }, + }), env, ); @@ -311,7 +311,7 @@ Deno.test('worker services route app writer Agent sessions through APP_WRITER_AG LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), }); const services = resolveWorkerServices( - { + withRequiredDb({ APP_WRITER_AGENT: { idFromName(name: string) { return `agent:${name}`; @@ -328,7 +328,7 @@ Deno.test('worker services route app writer Agent sessions through APP_WRITER_AG }; }, }, - }, + }), env, ); @@ -348,7 +348,7 @@ Deno.test('worker services route app writer workspace harness through APP_WRITER LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), }); const services = resolveWorkerServices( - { + withRequiredDb({ APP_WRITER_AGENT: { idFromName(name: string) { return `agent:${name}`; @@ -383,7 +383,7 @@ Deno.test('worker services route app writer workspace harness through APP_WRITER }; }, }, - }, + }), env, ); const contextSelection = selectAppWriterContext({ @@ -433,7 +433,7 @@ Deno.test('worker services use a platform source compiler binding when configure LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), }); const services = resolveWorkerServices( - { + withRequiredDb({ APP_WRITER_SOURCE_COMPILER: { fetch(request: Request) { compileRequests.push(request.clone()); @@ -452,7 +452,7 @@ Deno.test('worker services use a platform source compiler binding when configure ); }, }, - }, + }), env, ); @@ -483,7 +483,7 @@ Deno.test('worker services use a platform app previewer binding when configured' LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), }); const services = resolveWorkerServices( - { + withRequiredDb({ APP_WRITER_PREVIEWER: { fetch(request: Request) { previewRequests.push(request.clone()); @@ -511,7 +511,7 @@ Deno.test('worker services use a platform app previewer binding when configured' ); }, }, - }, + }), env, ); @@ -538,7 +538,7 @@ Deno.test('worker services fail clearly when app generation Workflow is not boun const env = createObjectEnvReader({ LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), }); - const services = resolveWorkerServices({}, env); + const services = resolveWorkerServices(withRequiredDb({}), env); assertEquals(services.appPackageSourceCompiler.supportsTypeScriptAuthoring, true); await assertRejects( @@ -572,6 +572,18 @@ Deno.test('worker services fail clearly when app generation Workflow is not boun ); }); +Deno.test('worker services fail during composition when DB is not bound', () => { + const env = createObjectEnvReader({ + LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), + }); + + assertThrows( + () => resolveWorkerServices({}, env), + Error, + 'Cloudflare Workers persistence requires a D1 binding named DB.', + ); +}); + function buildAppGenerationPlan(): AppGenerationPlan { return { appId: 'phonics-match', @@ -684,6 +696,15 @@ function createStubD1Database(results: Array>): D1Databa }; } +function withRequiredDb>( + bindings: T, +): T & { DB: D1Database } { + return { + ...bindings, + DB: createStubD1Database([]), + }; +} + async function readBucketText(bucket: RuntimeArtifactBucket, key: string): Promise { const object = await bucket.get(key); diff --git a/src/app_writer/background_execution.ts b/src/app_writer/background_execution.ts new file mode 100644 index 0000000..6b9623b --- /dev/null +++ b/src/app_writer/background_execution.ts @@ -0,0 +1,75 @@ +import type { RunAppPackageGenerationResult } from './service.ts'; +import type { AppGenerationRunRecord } from './types.ts'; +import type { PackageReviewRepository } from '../package_review/repository.ts'; + +export async function recordAppWriterWorkflowQueuedEvent(input: { + repository: Pick; + run: AppGenerationRunRecord; + workflowInstanceId: string | null; + summary: string; +}): Promise { + await input.repository.recordAuditEvent({ + eventType: 'app_generation.generating', + actorType: 'user', + actorId: input.run.ownerId, + deploymentRecordId: null, + packageVersionId: input.run.packageVersionId, + attemptId: null, + lineItemBindingId: null, + status: 'accepted', + summary: input.summary, + detail: { + generationId: input.run.generationId, + generationStatus: input.run.status, + requestedAppId: input.run.requestedAppId, + generatedAppId: input.run.generatedAppId, + selectedStarterId: input.run.selectedStarterId, + repairAttemptCount: input.run.repairAttemptCount, + findingCount: input.run.validationFindings.length, + workflowInstanceId: input.workflowInstanceId, + backgroundRunner: 'workflow', + }, + occurredAt: input.run.updatedAt, + }); +} + +export function scheduleAppWriterContinuation( + context: unknown, + continuation: Promise, + generationId: string, + label: 'generation' | 'revision', +): void { + const handled = continuation + .then(() => {}) + .catch((error) => { + console.error(`App writer ${label} ${generationId} failed in background.`, error); + }); + const executionContext = readExecutionContext(context); + + if (executionContext !== null) { + executionContext.waitUntil(handled); + return; + } + + void handled; +} + +function readExecutionContext( + context: unknown, +): { waitUntil(promise: Promise): void } | null { + try { + const candidate = (context as { executionCtx?: unknown }).executionCtx; + + if ( + candidate !== null && + typeof candidate === 'object' && + typeof (candidate as { waitUntil?: unknown }).waitUntil === 'function' + ) { + return candidate as { waitUntil(promise: Promise): void }; + } + } catch { + return null; + } + + return null; +} diff --git a/src/app_writer/html_inspection.ts b/src/app_writer/html_inspection.ts new file mode 100644 index 0000000..924d0ba --- /dev/null +++ b/src/app_writer/html_inspection.ts @@ -0,0 +1,13 @@ +export function hasStylesheetLink(html: string, href: string): boolean { + const hrefPattern = href.startsWith('./') + ? `(?:\\./)?${escapeRegExp(href.slice(2))}` + : escapeRegExp(href); + return new RegExp( + `]*\\brel=["']stylesheet["'])(?=[^>]*\\bhref=["']${hrefPattern}["'])[^>]*>`, + 'i', + ).test(html); +} + +function escapeRegExp(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/app_writer/html_inspection_test.ts b/src/app_writer/html_inspection_test.ts new file mode 100644 index 0000000..09cbf16 --- /dev/null +++ b/src/app_writer/html_inspection_test.ts @@ -0,0 +1,20 @@ +import { assertEquals } from '@std/assert'; +import { hasStylesheetLink } from './html_inspection.ts'; + +Deno.test('hasStylesheetLink accepts href and rel attributes in either order', () => { + assertEquals( + hasStylesheetLink('', './app.css'), + true, + ); + assertEquals( + hasStylesheetLink('', './app.css'), + true, + ); +}); + +Deno.test('hasStylesheetLink accepts links without an explicit dot slash', () => { + assertEquals( + hasStylesheetLink('', './app.css'), + true, + ); +}); diff --git a/src/app_writer/repository_d1.ts b/src/app_writer/repository_d1.ts index b073ba4..a45ff7b 100644 --- a/src/app_writer/repository_d1.ts +++ b/src/app_writer/repository_d1.ts @@ -1,31 +1,17 @@ import type { D1Database } from '../db/d1.ts'; import { queryD1First, runD1 } from '../db/d1.ts'; -import { readAppWriterSelectedContext } from './context.ts'; import type { PackageReviewRepository } from '../package_review/repository.ts'; +import { type AppGenerationRunRecord, type AppGenerationWorkspaceRecord } from './types.ts'; import { - APP_GENERATION_STATUSES, - APP_WRITER_STARTER_IDS, - type AppGenerationModelRequestMetadata, - type AppGenerationPlanStep, - type AppGenerationRunRecord, - type AppGenerationValidationFinding, - type AppGenerationWorkspaceRecord, - type AppWriterWorkspaceFile, -} from './types.ts'; -import { - expectNullableNumber, - expectNullableString, - expectNumber, - expectRecord, - expectString, - expectStringArray, - parseAppGenerationPlan, - parseGenerationPlanSteps, - parseModelRequestMetadata, - parseNormalizedRequest, - parseValidationFindings, - parseWorkspaceFiles, -} from './binding_result.ts'; + D1_APP_GENERATION_RUN_SELECT, + D1_APP_GENERATION_WORKSPACE_SELECT, + type D1AppGenerationRunRow, + type D1AppGenerationWorkspaceRow, + mapD1AppGenerationRunFields, + mapD1AppGenerationWorkspaceFields, + serializeAppGenerationRun, + serializeAppGenerationWorkspace, +} from './repository_d1_mapping.ts'; export function createD1AppGenerationRepositoryMethods( db: D1Database, @@ -151,72 +137,6 @@ export function createD1AppGenerationRepositoryMethods( }; } -const D1_APP_GENERATION_RUN_SELECT = ` - SELECT - generation_id AS generationId, - owner_id AS ownerId, - status, - requested_app_id AS requestedAppId, - generated_app_id AS generatedAppId, - generated_version AS generatedVersion, - package_version_id AS packageVersionId, - prompt_text AS promptText, - normalized_request_json AS normalizedRequestJson, - app_plan_json AS appPlanJson, - selected_starter_id AS selectedStarterId, - selected_context_json AS selectedContextJson, - model_request_metadata_json AS modelRequestMetadataJson, - generation_notes_json AS generationNotesJson, - validation_findings_json AS validationFindingsJson, - repair_attempt_count AS repairAttemptCount, - created_at AS createdAt, - updated_at AS updatedAt - FROM app_generation_runs -`; - -interface D1AppGenerationRunRow extends Record { - generationId: unknown; - ownerId: unknown; - status: unknown; - requestedAppId: unknown; - generatedAppId: unknown; - generatedVersion: unknown; - packageVersionId: unknown; - promptText: unknown; - normalizedRequestJson: unknown; - appPlanJson: unknown; - selectedStarterId: unknown; - selectedContextJson: unknown; - modelRequestMetadataJson: unknown; - generationNotesJson: unknown; - validationFindingsJson: unknown; - repairAttemptCount: unknown; - createdAt: unknown; - updatedAt: unknown; -} - -const D1_APP_GENERATION_WORKSPACE_SELECT = ` - SELECT - generation_id AS generationId, - selected_starter_id AS selectedStarterId, - files_json AS filesJson, - generation_plan_json AS generationPlanJson, - validation_findings_json AS validationFindingsJson, - repair_attempt_count AS repairAttemptCount, - updated_at AS updatedAt - FROM app_generation_workspaces -`; - -interface D1AppGenerationWorkspaceRow extends Record { - generationId: unknown; - selectedStarterId: unknown; - filesJson: unknown; - generationPlanJson: unknown; - validationFindingsJson: unknown; - repairAttemptCount: unknown; - updatedAt: unknown; -} - async function queryAppGenerationRunRow( db: D1Database, generationId: string, @@ -265,184 +185,6 @@ async function requireAppGenerationWorkspace( return mapD1AppGenerationWorkspaceFields(row); } -function serializeAppGenerationRun(record: AppGenerationRunRecord): unknown[] { - return [ - record.generationId, - record.ownerId, - record.status, - record.requestedAppId, - record.generatedAppId, - record.generatedVersion, - record.packageVersionId, - record.promptText, - record.normalizedRequest, - record.appPlan, - record.selectedStarterId, - record.selectedContext, - record.modelRequestMetadata, - record.generationNotes, - record.validationFindings, - record.repairAttemptCount, - record.createdAt, - record.updatedAt, - ]; -} - -function mapD1AppGenerationRunFields(row: D1AppGenerationRunRow): AppGenerationRunRecord { - return { - generationId: expectString(row.generationId, 'generationId'), - ownerId: expectString(row.ownerId, 'ownerId'), - status: expectStringLiteral(row.status, 'status', APP_GENERATION_STATUSES), - requestedAppId: expectNullableString(row.requestedAppId, 'requestedAppId'), - generatedAppId: expectNullableString(row.generatedAppId, 'generatedAppId'), - generatedVersion: expectNullableString(row.generatedVersion, 'generatedVersion'), - packageVersionId: expectNullableNumber(row.packageVersionId, 'packageVersionId'), - promptText: expectString(row.promptText, 'promptText'), - normalizedRequest: parseNullableJsonField( - row.normalizedRequestJson, - 'normalizedRequestJson', - (value) => parseNormalizedRequest(value, 'normalizedRequest'), - ), - appPlan: parseNullableJsonField( - row.appPlanJson, - 'appPlanJson', - (value) => parseAppGenerationPlan(value, 'appPlan'), - ), - selectedStarterId: expectNullableStringLiteral( - row.selectedStarterId, - 'selectedStarterId', - APP_WRITER_STARTER_IDS, - ), - selectedContext: readAppWriterSelectedContext( - parseJsonObjectField(row.selectedContextJson, 'selectedContextJson'), - expectNullableStringLiteral( - row.selectedStarterId, - 'selectedStarterId', - APP_WRITER_STARTER_IDS, - ), - ), - modelRequestMetadata: parseModelRequestMetadataField( - row.modelRequestMetadataJson, - 'modelRequestMetadataJson', - ), - generationNotes: parseStringArrayField(row.generationNotesJson, 'generationNotesJson'), - validationFindings: parseValidationFindingsField( - row.validationFindingsJson, - 'validationFindingsJson', - ), - repairAttemptCount: expectNumber(row.repairAttemptCount, 'repairAttemptCount'), - createdAt: expectString(row.createdAt, 'createdAt'), - updatedAt: expectString(row.updatedAt, 'updatedAt'), - }; -} - -function serializeAppGenerationWorkspace(record: AppGenerationWorkspaceRecord): unknown[] { - return [ - record.generationId, - record.selectedStarterId, - record.files, - record.generationPlan, - record.validationFindings, - record.repairAttemptCount, - record.updatedAt, - ]; -} - -function mapD1AppGenerationWorkspaceFields( - row: D1AppGenerationWorkspaceRow, -): AppGenerationWorkspaceRecord { - return { - generationId: expectString(row.generationId, 'generationId'), - selectedStarterId: expectStringLiteral( - row.selectedStarterId, - 'selectedStarterId', - APP_WRITER_STARTER_IDS, - ), - files: parseWorkspaceFilesField(row.filesJson, 'filesJson'), - generationPlan: parseGenerationPlanField(row.generationPlanJson, 'generationPlanJson'), - validationFindings: parseValidationFindingsField( - row.validationFindingsJson, - 'validationFindingsJson', - ), - repairAttemptCount: expectNumber(row.repairAttemptCount, 'repairAttemptCount'), - updatedAt: expectString(row.updatedAt, 'updatedAt'), - }; -} - -function parseWorkspaceFilesField(value: unknown, fieldName: string): AppWriterWorkspaceFile[] { - return parseWorkspaceFiles(parseJsonField(value, fieldName), fieldName); -} - -function parseGenerationPlanField(value: unknown, fieldName: string): AppGenerationPlanStep[] { - return parseGenerationPlanSteps(parseJsonField(value, fieldName), fieldName); -} - -function parseValidationFindingsField( - value: unknown, - fieldName: string, -): AppGenerationValidationFinding[] { - return parseValidationFindings(parseJsonField(value, fieldName), fieldName); -} - -function parseModelRequestMetadataField( - value: unknown, - fieldName: string, -): AppGenerationModelRequestMetadata[] { - return parseModelRequestMetadata(parseJsonField(value, fieldName), fieldName); -} - -function parseNullableJsonField( - value: unknown, - fieldName: string, - parse: (value: unknown) => T, -): T | null { - if (value === null) { - return null; - } - - return parse(parseJsonField(value, fieldName)); -} - -function parseJsonObjectField(value: unknown, fieldName: string): Record { - return expectRecord(parseJsonField(value, fieldName), fieldName); -} - -function parseStringArrayField(value: unknown, fieldName: string): string[] { - return expectStringArray(parseJsonField(value, fieldName), fieldName); -} - -function parseJsonField(value: unknown, fieldName: string): unknown { - if (typeof value !== 'string') { - throw new TypeError(`Expected D1 ${fieldName} to be JSON text.`); - } - - return JSON.parse(value); -} - -function expectStringLiteral( - value: unknown, - fieldName: string, - allowed: readonly T[], -): T { - if (typeof value !== 'string' || !allowed.includes(value as T)) { - throw new Error(`Unexpected ${fieldName} value.`); - } - - return value as T; -} - -function expectNullableStringLiteral( - value: unknown, - fieldName: string, - allowed: readonly T[], -): T | null { - if (value === null) { - return null; - } - - return expectStringLiteral(value, fieldName, allowed); -} - function isD1UniqueViolation(error: unknown): boolean { return error instanceof Error && error.message.includes('UNIQUE constraint failed'); } diff --git a/src/app_writer/repository_d1_mapping.ts b/src/app_writer/repository_d1_mapping.ts new file mode 100644 index 0000000..1371afd --- /dev/null +++ b/src/app_writer/repository_d1_mapping.ts @@ -0,0 +1,241 @@ +import { + expectD1NullableNumber, + expectD1NullableString, + expectD1NullableStringLiteral, + expectD1Number, + expectD1String, + expectD1StringLiteral, + parseD1JsonField, + parseD1NullableJsonField, +} from '../db/d1_row.ts'; +import { + expectRecord, + expectStringArray, + parseAppGenerationPlan, + parseGenerationPlanSteps, + parseModelRequestMetadata, + parseNormalizedRequest, + parseValidationFindings, + parseWorkspaceFiles, +} from './binding_result.ts'; +import { readAppWriterSelectedContext } from './context.ts'; +import { + APP_GENERATION_STATUSES, + APP_WRITER_STARTER_IDS, + type AppGenerationRunRecord, + type AppGenerationWorkspaceRecord, +} from './types.ts'; + +export const D1_APP_GENERATION_RUN_SELECT = ` + SELECT + generation_id AS generationId, + owner_id AS ownerId, + status, + requested_app_id AS requestedAppId, + generated_app_id AS generatedAppId, + generated_version AS generatedVersion, + package_version_id AS packageVersionId, + prompt_text AS promptText, + normalized_request_json AS normalizedRequestJson, + app_plan_json AS appPlanJson, + selected_starter_id AS selectedStarterId, + selected_context_json AS selectedContextJson, + model_request_metadata_json AS modelRequestMetadataJson, + generation_notes_json AS generationNotesJson, + validation_findings_json AS validationFindingsJson, + repair_attempt_count AS repairAttemptCount, + created_at AS createdAt, + updated_at AS updatedAt + FROM app_generation_runs +`; + +export interface D1AppGenerationRunRow extends Record { + generationId: unknown; + ownerId: unknown; + status: unknown; + requestedAppId: unknown; + generatedAppId: unknown; + generatedVersion: unknown; + packageVersionId: unknown; + promptText: unknown; + normalizedRequestJson: unknown; + appPlanJson: unknown; + selectedStarterId: unknown; + selectedContextJson: unknown; + modelRequestMetadataJson: unknown; + generationNotesJson: unknown; + validationFindingsJson: unknown; + repairAttemptCount: unknown; + createdAt: unknown; + updatedAt: unknown; +} + +export const D1_APP_GENERATION_WORKSPACE_SELECT = ` + SELECT + generation_id AS generationId, + selected_starter_id AS selectedStarterId, + files_json AS filesJson, + generation_plan_json AS generationPlanJson, + validation_findings_json AS validationFindingsJson, + repair_attempt_count AS repairAttemptCount, + updated_at AS updatedAt + FROM app_generation_workspaces +`; + +export interface D1AppGenerationWorkspaceRow extends Record { + generationId: unknown; + selectedStarterId: unknown; + filesJson: unknown; + generationPlanJson: unknown; + validationFindingsJson: unknown; + repairAttemptCount: unknown; + updatedAt: unknown; +} + +export function serializeAppGenerationRun(record: AppGenerationRunRecord): unknown[] { + return [ + record.generationId, + record.ownerId, + record.status, + record.requestedAppId, + record.generatedAppId, + record.generatedVersion, + record.packageVersionId, + record.promptText, + record.normalizedRequest, + record.appPlan, + record.selectedStarterId, + record.selectedContext, + record.modelRequestMetadata, + record.generationNotes, + record.validationFindings, + record.repairAttemptCount, + record.createdAt, + record.updatedAt, + ]; +} + +export function mapD1AppGenerationRunFields( + row: D1AppGenerationRunRow, +): AppGenerationRunRecord { + const selectedStarterId = expectD1NullableStringLiteral( + row.selectedStarterId, + 'app_generation_runs.selectedStarterId', + APP_WRITER_STARTER_IDS, + ); + + return { + generationId: expectD1String(row.generationId, 'app_generation_runs.generationId'), + ownerId: expectD1String(row.ownerId, 'app_generation_runs.ownerId'), + status: expectD1StringLiteral( + row.status, + 'app_generation_runs.status', + APP_GENERATION_STATUSES, + ), + requestedAppId: expectD1NullableString( + row.requestedAppId, + 'app_generation_runs.requestedAppId', + ), + generatedAppId: expectD1NullableString( + row.generatedAppId, + 'app_generation_runs.generatedAppId', + ), + generatedVersion: expectD1NullableString( + row.generatedVersion, + 'app_generation_runs.generatedVersion', + ), + packageVersionId: expectD1NullableNumber( + row.packageVersionId, + 'app_generation_runs.packageVersionId', + ), + promptText: expectD1String(row.promptText, 'app_generation_runs.promptText'), + normalizedRequest: parseD1NullableJsonField( + row.normalizedRequestJson, + 'app_generation_runs.normalizedRequestJson', + parseNormalizedRequest, + ), + appPlan: parseD1NullableJsonField( + row.appPlanJson, + 'app_generation_runs.appPlanJson', + parseAppGenerationPlan, + ), + selectedStarterId, + selectedContext: readAppWriterSelectedContext( + parseD1JsonField( + row.selectedContextJson, + 'app_generation_runs.selectedContextJson', + expectRecord, + ), + selectedStarterId, + ), + modelRequestMetadata: parseD1JsonField( + row.modelRequestMetadataJson, + 'app_generation_runs.modelRequestMetadataJson', + parseModelRequestMetadata, + ), + generationNotes: parseD1JsonField( + row.generationNotesJson, + 'app_generation_runs.generationNotesJson', + expectStringArray, + ), + validationFindings: parseD1JsonField( + row.validationFindingsJson, + 'app_generation_runs.validationFindingsJson', + parseValidationFindings, + ), + repairAttemptCount: expectD1Number( + row.repairAttemptCount, + 'app_generation_runs.repairAttemptCount', + ), + createdAt: expectD1String(row.createdAt, 'app_generation_runs.createdAt'), + updatedAt: expectD1String(row.updatedAt, 'app_generation_runs.updatedAt'), + }; +} + +export function serializeAppGenerationWorkspace(record: AppGenerationWorkspaceRecord): unknown[] { + return [ + record.generationId, + record.selectedStarterId, + record.files, + record.generationPlan, + record.validationFindings, + record.repairAttemptCount, + record.updatedAt, + ]; +} + +export function mapD1AppGenerationWorkspaceFields( + row: D1AppGenerationWorkspaceRow, +): AppGenerationWorkspaceRecord { + return { + generationId: expectD1String( + row.generationId, + 'app_generation_workspaces.generationId', + ), + selectedStarterId: expectD1StringLiteral( + row.selectedStarterId, + 'app_generation_workspaces.selectedStarterId', + APP_WRITER_STARTER_IDS, + ), + files: parseD1JsonField( + row.filesJson, + 'app_generation_workspaces.filesJson', + parseWorkspaceFiles, + ), + generationPlan: parseD1JsonField( + row.generationPlanJson, + 'app_generation_workspaces.generationPlanJson', + parseGenerationPlanSteps, + ), + validationFindings: parseD1JsonField( + row.validationFindingsJson, + 'app_generation_workspaces.validationFindingsJson', + parseValidationFindings, + ), + repairAttemptCount: expectD1Number( + row.repairAttemptCount, + 'app_generation_workspaces.repairAttemptCount', + ), + updatedAt: expectD1String(row.updatedAt, 'app_generation_workspaces.updatedAt'), + }; +} diff --git a/src/app_writer/repository_d1_test.ts b/src/app_writer/repository_d1_test.ts index 2c5389f..0e6dcde 100644 --- a/src/app_writer/repository_d1_test.ts +++ b/src/app_writer/repository_d1_test.ts @@ -44,6 +44,30 @@ Deno.test('D1 app generation repository creates and refetches generation runs', assertEquals(db.statements[0]?.parameters[10], 'simple-activity'); }); +Deno.test('D1 app generation repository rejects malformed persisted run JSON', async () => { + const record = buildAppGenerationRunRecord({ + normalizedRequest: buildNormalizedRequest(), + }); + const db = createPlannedD1Database({ + firstResults: [ + { + ...toD1AppGenerationRunRow(record), + normalizedRequestJson: JSON.stringify({ + ...record.normalizedRequest, + safeToGenerate: 'yes', + }), + }, + ], + }); + const repository = createD1AppGenerationRepositoryMethods(db); + + await assertRejects( + () => repository.getAppGenerationRunById(record.generationId), + TypeError, + 'app_generation_runs.normalizedRequestJson.safeToGenerate must be boolean.', + ); +}); + Deno.test('D1 app generation repository updates generation runs', async () => { const record = buildAppGenerationRunRecord({ status: 'planning', @@ -128,6 +152,25 @@ Deno.test('D1 app generation repository saves and refetches generation workspace assertEquals(typeof db.statements[0]?.parameters[3], 'string'); }); +Deno.test('D1 app generation repository rejects malformed persisted workspace JSON', async () => { + const record = buildAppGenerationWorkspaceRecord(); + const db = createPlannedD1Database({ + firstResults: [ + { + ...toD1AppGenerationWorkspaceRow(record), + filesJson: JSON.stringify([{ path: 'manifest.json', contents: 42 }]), + }, + ], + }); + const repository = createD1AppGenerationRepositoryMethods(db); + + await assertRejects( + () => repository.getAppGenerationWorkspaceByGenerationId(record.generationId), + TypeError, + 'filesJson[0].contents must be text.', + ); +}); + Deno.test('D1 app generation repository reports duplicate generation ids clearly', async () => { const db = createPlannedD1Database({ runError: new Error('UNIQUE constraint failed: app_generation_runs.generation_id'), diff --git a/src/app_writer/service_core.ts b/src/app_writer/service_core.ts index 43850a8..671cbfd 100644 --- a/src/app_writer/service_core.ts +++ b/src/app_writer/service_core.ts @@ -3,121 +3,47 @@ import { selectAppWriterContext, selectAppWriterRevisionContext, } from './context.ts'; -import type { - AppGenerationPlanningResult, - AppGenerationRunRecord, - AppGenerationWorkspaceRecord, - AppPackageGenerationInput, - AppPackageGenerationResult, - AppPackagePreviewer, - AppPackageSourceCompiler, - AppWriterAuthoringMode, -} from './types.ts'; +import type { AppGenerationRunRecord } from './types.ts'; import { APP_WRITER_DEFAULT_MAX_REPAIR_ATTEMPTS } from './recipe.ts'; -import type { AppWriterWorkspaceRunner } from './workspace_runner.ts'; -import { - buildGenerationFromPersistedWorkspace, - generateInitialPackageFiles, - planInitialPackage, -} from './service_stages.ts'; import { AppPackageGenerationFailedError } from './service_failures.ts'; import { failGenerationRun } from './service_failure_runs.ts'; import { finishGeneratedPackage } from './service_finish.ts'; import { recordGenerationActivity } from './service_audit.ts'; +import { buildInitialGenerationRun, formatRevisionPromptText } from './service_run_records.ts'; import { buildContextSelectionFromRun, buildGeneratorInputFromRun, - buildRevisionSourceFilesIfNeeded, selectAuthoringModeForGeneration, } from './service_revision.ts'; +import { + generateInitialPackage, + initializeAppPackageGenerationRun, +} from './service_workflow_steps.ts'; import { APP_GENERATION_AUDIT_EVENT_TYPES } from './service_constants.ts'; -import type { ImportedPackageVersion } from '../package_review/intake.ts'; -import type { PackageSource } from '../package_review/package_source.ts'; -import type { PackageReviewRepository } from '../package_review/repository.ts'; -import type { PackageSnapshotStore } from '../package_review/snapshot_store.ts'; import type { PackageVersionRecord } from '../package_review/types.ts'; - -export interface RunAppPackageGenerationInput { - repository: Pick< - PackageReviewRepository, - | 'createAppGenerationRun' - | 'updateAppGenerationRun' - | 'registerPackageVersion' - | 'recordAuditEvent' - | 'saveAppGenerationWorkspace' - | 'getAppGenerationWorkspaceByGenerationId' - | 'getAppGenerationRunById' - >; - workspaceRunner: AppWriterWorkspaceRunner; - packageSnapshotStore?: PackageSnapshotStore; - previewer?: AppPackagePreviewer; - sourceCompiler?: AppPackageSourceCompiler; - savePackage?: { - importPackageFromSource: ( - source: PackageSource, - options?: { storageRoot?: string }, - ) => Promise; - storageRoot?: string; - }; - generationId: string; - ownerId: string; - promptText: string; - requestedAppId?: string | null; - authoringMode?: AppWriterAuthoringMode; - maxRepairAttempts?: number; - now?: () => string; -} - -export interface RunAppPackageGenerationResult { - run: AppGenerationRunRecord; - generation: AppPackageGenerationResult; - packageVersion: PackageVersionRecord | null; -} - -export interface PlannedAppPackageGenerationRun { - run: AppGenerationRunRecord; - generatorInput: AppPackageGenerationInput; - contextSelection: AppWriterContextSelection; - initializedWorkspace: AppGenerationWorkspaceRecord; - planning: AppGenerationPlanningResult; -} - -export interface InitializedAppPackageGenerationRun { - run: AppGenerationRunRecord; - generatorInput: AppPackageGenerationInput; - contextSelection: AppWriterContextSelection; - initializedWorkspace: AppGenerationWorkspaceRecord; -} - -export interface GeneratedAppPackageFilesRun { - run: AppGenerationRunRecord; - generatorInput: AppPackageGenerationInput; - contextSelection: AppWriterContextSelection; - initializedWorkspace: AppGenerationWorkspaceRecord; - generation: AppPackageGenerationResult; -} - -export interface StartedAppPackageGenerationRun { - run: AppGenerationRunRecord; - continueGeneration: () => Promise; -} - -export interface ContinueAppPackageGenerationRunInput { - repository: - & RunAppPackageGenerationInput['repository'] - & Pick - & Partial>; - workspaceRunner: AppWriterWorkspaceRunner; - packageSnapshotStore?: PackageSnapshotStore; - previewer?: AppPackagePreviewer; - sourceCompiler?: AppPackageSourceCompiler; - savePackage?: RunAppPackageGenerationInput['savePackage']; - generationId: string; - maxRepairAttempts?: number; - now?: () => string; -} +import type { + ContinueAppPackageGenerationRunInput, + RunAppPackageGenerationInput, + RunAppPackageGenerationResult, + StartedAppPackageGenerationRun, +} from './service_run_types.ts'; export { APP_GENERATION_AUDIT_EVENT_TYPES }; +export type { + ContinueAppPackageGenerationRunInput, + GeneratedAppPackageFilesRun, + InitializedAppPackageGenerationRun, + PlannedAppPackageGenerationRun, + RunAppPackageGenerationInput, + RunAppPackageGenerationResult, + StartedAppPackageGenerationRun, +} from './service_run_types.ts'; +export { + finishGeneratedAppPackageRun, + generateAppPackageFilesForPlannedRun, + initializeAppPackageGenerationRun, + planAppPackageGenerationRun, +} from './service_workflow_steps.ts'; export async function startAppPackageGenerationRun( input: RunAppPackageGenerationInput, @@ -231,64 +157,6 @@ export async function startAppPackageRevisionRun( }; } -export async function initializeAppPackageGenerationRun( - input: ContinueAppPackageGenerationRunInput, -): Promise { - const existingRun = await input.repository.getAppGenerationRunById(input.generationId); - - if (existingRun === null) { - throw new Error(`App generation run ${input.generationId} was not found.`); - } - - const now = input.now ?? (() => new Date().toISOString()); - const contextSelection = buildContextSelectionFromRun(existingRun); - const generatorInput = buildGeneratorInputFromRun(existingRun, contextSelection); - const existingWorkspace = await input.repository.getAppGenerationWorkspaceByGenerationId( - existingRun.generationId, - ); - - if (existingWorkspace !== null && existingRun.status !== 'started') { - return { - run: existingRun, - generatorInput, - contextSelection, - initializedWorkspace: existingWorkspace, - }; - } - - const run = await input.repository.updateAppGenerationRun({ - ...existingRun, - status: 'initializing', - updatedAt: now(), - }); - await recordGenerationActivity({ - repository: input.repository, - run, - eventType: 'app_generation.initializing', - status: 'accepted', - summary: 'Prepared Lantern app writer recipe, starter workspace, and validation contract.', - }); - - const initializedWorkspace = await input.workspaceRunner.initialize({ - generationId: run.generationId, - contextSelection, - initializedAt: run.updatedAt, - ...(await buildRevisionSourceFilesIfNeeded({ - repository: input.repository, - packageSnapshotStore: input.packageSnapshotStore, - contextSelection, - })), - }); - const savedWorkspace = await input.repository.saveAppGenerationWorkspace(initializedWorkspace); - - return { - run, - generatorInput, - contextSelection, - initializedWorkspace: savedWorkspace, - }; -} - export async function runAppPackageGeneration( input: RunAppPackageGenerationInput, ): Promise { @@ -343,163 +211,6 @@ export async function continueAppPackageGenerationRun( }); } -export async function planAppPackageGenerationRun( - input: ContinueAppPackageGenerationRunInput, -): Promise { - const now = input.now ?? (() => new Date().toISOString()); - const initialized = await initializeAppPackageGenerationRun({ - ...input, - now, - }); - - try { - const planned = await planInitialPackage({ - repository: input.repository, - workspaceRunner: input.workspaceRunner, - run: initialized.run, - generatorInput: initialized.generatorInput, - contextSelection: initialized.contextSelection, - initializedWorkspace: initialized.initializedWorkspace, - now, - }); - - return { - ...planned, - generatorInput: initialized.generatorInput, - contextSelection: initialized.contextSelection, - initializedWorkspace: initialized.initializedWorkspace, - }; - } catch (error) { - if (error instanceof AppPackageGenerationFailedError) { - throw error; - } - - throw await failGenerationRun({ - repository: input.repository, - run: initialized.run, - error, - now, - }); - } -} - -export async function generateAppPackageFilesForPlannedRun(input: { - repository: Pick< - PackageReviewRepository, - | 'updateAppGenerationRun' - | 'recordAuditEvent' - | 'saveAppGenerationWorkspace' - | 'getAppGenerationWorkspaceByGenerationId' - | 'getAppGenerationRunById' - >; - workspaceRunner: AppWriterWorkspaceRunner; - planned: PlannedAppPackageGenerationRun; - now?: () => string; -}): Promise { - const now = input.now ?? (() => new Date().toISOString()); - - try { - const generated = await generateInitialPackageFiles({ - repository: input.repository, - workspaceRunner: input.workspaceRunner, - run: input.planned.run, - generatorInput: input.planned.generatorInput, - planning: input.planned.planning, - initializedWorkspace: input.planned.initializedWorkspace, - now, - }); - - return { - ...generated, - generatorInput: input.planned.generatorInput, - contextSelection: input.planned.contextSelection, - initializedWorkspace: input.planned.initializedWorkspace, - }; - } catch (error) { - if (error instanceof AppPackageGenerationFailedError) { - throw error; - } - - throw await failGenerationRun({ - repository: input.repository, - run: input.planned.run, - error, - now, - }); - } -} - -export async function finishGeneratedAppPackageRun(input: { - repository: - & RunAppPackageGenerationInput['repository'] - & Pick; - workspaceRunner: AppWriterWorkspaceRunner; - packageSnapshotStore?: PackageSnapshotStore; - previewer?: AppPackagePreviewer; - sourceCompiler?: AppPackageSourceCompiler; - savePackage?: RunAppPackageGenerationInput['savePackage']; - generated: GeneratedAppPackageFilesRun; - maxRepairAttempts?: number; - now?: () => string; -}): Promise { - const now = input.now ?? (() => new Date().toISOString()); - const run = (await input.repository.getAppGenerationRunById(input.generated.run.generationId)) ?? - input.generated.run; - const workspace = await input.repository.getAppGenerationWorkspaceByGenerationId( - run.generationId, - ); - const generation = buildGenerationFromPersistedWorkspace({ - baseGeneration: input.generated.generation, - run, - workspace, - }); - const repairAttempt = Math.max( - input.generated.run.repairAttemptCount, - run.repairAttemptCount, - workspace?.repairAttemptCount ?? 0, - ); - - try { - return await finishGeneratedPackage({ - input: { - repository: input.repository, - workspaceRunner: input.workspaceRunner, - ...(input.packageSnapshotStore === undefined - ? {} - : { packageSnapshotStore: input.packageSnapshotStore }), - generationId: input.generated.generatorInput.generationId, - ownerId: input.generated.generatorInput.ownerId, - promptText: input.generated.generatorInput.promptText, - requestedAppId: input.generated.generatorInput.requestedAppId, - ...(input.previewer === undefined ? {} : { previewer: input.previewer }), - ...(input.sourceCompiler === undefined ? {} : { sourceCompiler: input.sourceCompiler }), - ...(input.savePackage === undefined ? {} : { savePackage: input.savePackage }), - ...(input.maxRepairAttempts === undefined - ? {} - : { maxRepairAttempts: input.maxRepairAttempts }), - now, - }, - now, - generatorInput: input.generated.generatorInput, - contextSelection: input.generated.contextSelection, - run, - generation, - repairAttempt, - }); - } catch (error) { - if (error instanceof AppPackageGenerationFailedError) { - throw error; - } - - throw await failGenerationRun({ - repository: input.repository, - run, - error, - now, - }); - } -} - async function continueStartedAppPackageGeneration(continuation: { input: RunAppPackageGenerationInput; now: () => string; @@ -573,82 +284,3 @@ async function continueStartedAppPackageGeneration(continuation: { }); } } - -async function generateInitialPackage(input: { - input: RunAppPackageGenerationInput; - run: AppGenerationRunRecord; - generatorInput: AppPackageGenerationInput; - contextSelection: AppWriterContextSelection; - initializedWorkspace: AppGenerationWorkspaceRecord; - now: () => string; -}): Promise<{ run: AppGenerationRunRecord; generation: AppPackageGenerationResult }> { - const planned = await planInitialPackage({ - repository: input.input.repository, - workspaceRunner: input.input.workspaceRunner, - run: input.run, - generatorInput: input.generatorInput, - contextSelection: input.contextSelection, - initializedWorkspace: input.initializedWorkspace, - now: input.now, - }); - const generated = await generateInitialPackageFiles({ - repository: input.input.repository, - workspaceRunner: input.input.workspaceRunner, - run: planned.run, - generatorInput: input.generatorInput, - planning: planned.planning, - initializedWorkspace: input.initializedWorkspace, - now: input.now, - }); - - return { - run: generated.run, - generation: generated.generation, - }; -} - -function buildInitialGenerationRun(input: { - generationId: string; - ownerId: string; - promptText: string; - requestedAppId: string | null; - contextSelection: AppWriterContextSelection; - createdAt: string; -}): AppGenerationRunRecord { - return { - generationId: input.generationId, - ownerId: input.ownerId, - status: 'started', - requestedAppId: input.requestedAppId, - generatedAppId: null, - generatedVersion: null, - packageVersionId: null, - promptText: input.promptText, - normalizedRequest: null, - appPlan: null, - selectedStarterId: input.contextSelection.starterId, - selectedContext: input.contextSelection.selectedContext, - modelRequestMetadata: [], - generationNotes: [], - validationFindings: [], - repairAttemptCount: 0, - createdAt: input.createdAt, - updatedAt: input.createdAt, - }; -} - -function formatRevisionPromptText(input: { - promptText: string; - sourcePackageVersion: PackageVersionRecord; - targetVersion: string; -}): string { - return [ - `Revision request for ${input.sourcePackageVersion.appId}@${input.sourcePackageVersion.version}.`, - `Target version: ${input.targetVersion}.`, - `Preserve manifest app_id: ${input.sourcePackageVersion.appId}.`, - 'Start from the existing package snapshot and make only the requested refinement.', - '', - 'Instructor refinement prompt:', - input.promptText, - ].join('\n'); -} diff --git a/src/app_writer/service_finish.ts b/src/app_writer/service_finish.ts index 56e892b..59a2703 100644 --- a/src/app_writer/service_finish.ts +++ b/src/app_writer/service_finish.ts @@ -29,7 +29,7 @@ import { targetVersionForContext, validateRevisionPackageIdentity } from './serv import type { RunAppPackageGenerationInput, RunAppPackageGenerationResult, -} from './service_core.ts'; +} from './service_run_types.ts'; interface FinishState { run: AppGenerationRunRecord; diff --git a/src/app_writer/service_preview.ts b/src/app_writer/service_preview.ts index decddbc..3a23266 100644 --- a/src/app_writer/service_preview.ts +++ b/src/app_writer/service_preview.ts @@ -6,7 +6,7 @@ import type { AppPackagePreviewResult, } from './types.ts'; import { recordGenerationActivity } from './service_audit.ts'; -import type { RunAppPackageGenerationInput } from './service_core.ts'; +import type { RunAppPackageGenerationInput } from './service_run_types.ts'; import { updateGenerationPlanStepInWorkspace } from './service_workspace_snapshot.ts'; import { selectPackageWorkspaceFiles } from './workspace_files.ts'; diff --git a/src/app_writer/service_revision.ts b/src/app_writer/service_revision.ts index 96d061c..32518ab 100644 --- a/src/app_writer/service_revision.ts +++ b/src/app_writer/service_revision.ts @@ -9,11 +9,7 @@ import type { AppWriterWorkspaceFile, } from './types.ts'; import { selectPackageWorkspaceFiles } from './workspace_files.ts'; -import type { PackageReviewRepository } from '../package_review/repository.ts'; -import type { PackageSnapshotStore } from '../package_review/snapshot_store.ts'; -import type { PackageVersionRecord } from '../package_review/types.ts'; -import { LANTERN_APP_CSS } from '../styles/lantern_app_css.ts'; -import { PICO_CSS } from '../styles/pico_css.ts'; +export { buildRevisionSourceFilesIfNeeded } from './service_revision_source.ts'; export function buildContextSelectionFromRun( run: AppGenerationRunRecord, @@ -87,7 +83,7 @@ export function validateRevisionPackageIdentity(input: { return []; } - manifest = parsed as Record; + manifest = Object.fromEntries(Object.entries(parsed)); } catch { return []; } @@ -128,223 +124,6 @@ export function validateRevisionPackageIdentity(input: { return findings; } -export async function buildRevisionSourceFilesIfNeeded(input: { - repository: Partial>; - packageSnapshotStore: PackageSnapshotStore | undefined; - contextSelection: AppWriterContextSelection; -}): Promise<{ revisionSourceFiles?: AppWriterWorkspaceFile[] }> { - const revision = readAppWriterRevisionContext(input.contextSelection.selectedContext); - - if (revision === null) { - return {}; - } - - if (input.packageSnapshotStore === undefined) { - throw new Error( - 'App Writer revision initialization requires package snapshot storage so Lantern can load the previous reviewed package.', - ); - } - - const getPackageVersionById = input.repository.getPackageVersionById; - - if (getPackageVersionById === undefined) { - throw new Error( - 'App Writer revision initialization requires package version lookup so Lantern can load the previous package.', - ); - } - - const packageVersion = await getPackageVersionById(revision.sourcePackageVersionId); - - if (packageVersion === null) { - throw new Error( - `Revision source package version ${revision.sourcePackageVersionId} was not found.`, - ); - } - - if ( - packageVersion.appId !== revision.sourceAppId || - packageVersion.version !== revision.sourceVersion - ) { - throw new Error( - `Revision source package ${packageVersion.appId}@${packageVersion.version} did not match the recorded source ${revision.sourceAppId}@${revision.sourceVersion}.`, - ); - } - - const files = await loadRevisionSourcePackageFiles({ - packageSnapshotStore: input.packageSnapshotStore, - packageVersion, - targetVersion: revision.targetVersion, - }); - - return { revisionSourceFiles: files }; -} - -async function loadRevisionSourcePackageFiles(input: { - packageSnapshotStore: PackageSnapshotStore; - packageVersion: PackageVersionRecord; - targetVersion: string; -}): Promise { - const snapshotRoot = input.packageVersion.artifact.snapshotRoot; - const paths = await input.packageSnapshotStore.listFiles(snapshotRoot); - const files: AppWriterWorkspaceFile[] = []; - const seenPaths = new Set(); - - for (const path of paths) { - const bytes = await input.packageSnapshotStore.readBytes(snapshotRoot, path); - const contents = decodeRevisionSourceFile({ - path, - bytes, - }); - seenPaths.add(path); - - files.push({ - path, - role: 'package', - contents: rewriteRevisionSourceFile({ - path, - contents, - appId: input.packageVersion.appId, - targetVersion: input.targetVersion, - }), - }); - } - - if (!files.some((file) => file.path === 'manifest.json')) { - throw new Error( - `Revision source package ${input.packageVersion.appId}@${input.packageVersion.version} is missing manifest.json.`, - ); - } - - assertRevisionSourceFileExists({ - paths: seenPaths, - packageVersion: input.packageVersion, - path: 'dist/pico.min.css', - }); - assertRevisionSourceFileExists({ - paths: seenPaths, - packageVersion: input.packageVersion, - path: 'dist/lantern-app.css', - }); - assertRevisionSourceFileExists({ - paths: seenPaths, - packageVersion: input.packageVersion, - path: 'dist/app.css', - }); - - return files.sort((left, right) => left.path.localeCompare(right.path)); -} - -function assertRevisionSourceFileExists(input: { - paths: ReadonlySet; - packageVersion: PackageVersionRecord; - path: string; -}): void { - if (input.paths.has(input.path)) { - return; - } - - throw new Error( - `Revision source package ${input.packageVersion.appId}@${input.packageVersion.version} is missing required reviewed file ${input.path}.`, - ); -} - -function rewriteRevisionSourceFile(input: { - path: string; - contents: string; - appId: string; - targetVersion: string; -}): string { - switch (input.path) { - case 'manifest.json': - return rewriteRevisionManifest({ - contents: input.contents, - appId: input.appId, - targetVersion: input.targetVersion, - }); - case 'dist/index.html': - return ensureReviewedStylesheetLinks(input.contents); - case 'dist/pico.min.css': - return PICO_CSS; - case 'dist/lantern-app.css': - return LANTERN_APP_CSS; - default: - return input.contents; - } -} - -function ensureReviewedStylesheetLinks(html: string): string { - const requiredLinks = [ - { - href: './pico.min.css', - html: '', - }, - { - href: './lantern-app.css', - html: '', - }, - { href: './app.css', html: '' }, - ]; - const missingLinks = requiredLinks.filter((link) => !hasStylesheetLink(html, link.href)); - - if (missingLinks.length === 0) { - return html; - } - - const insertion = missingLinks.map((link) => link.html).join(''); - const rewritten = html.replace(/]*)>/i, `${insertion}`); - - if (rewritten === html) { - throw new Error('Revision source dist/index.html must contain a element.'); - } - - return rewritten; -} - -function hasStylesheetLink(html: string, href: string): boolean { - const escapedHref = href.replace('.', '\\.'); - const optionalDotSlash = escapedHref.startsWith('\\./') - ? `(?:\\./)?${escapedHref.slice(3)}` - : escapedHref; - return new RegExp( - `]*\\brel=["']stylesheet["'][^>]*\\bhref=["']${optionalDotSlash}["']`, - 'i', - ).test(html); -} - -function decodeRevisionSourceFile(input: { path: string; bytes: Uint8Array }): string { - try { - return new TextDecoder('utf-8', { fatal: true }).decode(input.bytes); - } catch { - throw new Error( - `Revision source package file ${input.path} is not UTF-8 text. App Writer revisions require text package files.`, - ); - } -} - -function rewriteRevisionManifest(input: { - contents: string; - appId: string; - targetVersion: string; -}): string { - const parsed = JSON.parse(input.contents) as unknown; - - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('Revision source manifest.json must be a JSON object.'); - } - - return `${ - JSON.stringify( - { - ...(parsed as Record), - app_id: input.appId, - version: input.targetVersion, - }, - null, - 2, - ) - }\n`; -} - export function selectAuthoringModeForGeneration( sourceCompiler: AppPackageSourceCompiler | undefined, ): AppWriterAuthoringMode { diff --git a/src/app_writer/service_revision_source.ts b/src/app_writer/service_revision_source.ts new file mode 100644 index 0000000..826a814 --- /dev/null +++ b/src/app_writer/service_revision_source.ts @@ -0,0 +1,214 @@ +import { type AppWriterContextSelection, readAppWriterRevisionContext } from './context.ts'; +import { hasStylesheetLink } from './html_inspection.ts'; +import type { AppWriterWorkspaceFile } from './types.ts'; +import type { PackageReviewRepository } from '../package_review/repository.ts'; +import type { PackageSnapshotStore } from '../package_review/snapshot_store.ts'; +import type { PackageVersionRecord } from '../package_review/types.ts'; +import { LANTERN_APP_CSS } from '../styles/lantern_app_css.ts'; +import { PICO_CSS } from '../styles/pico_css.ts'; + +export async function buildRevisionSourceFilesIfNeeded(input: { + repository: Partial>; + packageSnapshotStore: PackageSnapshotStore | undefined; + contextSelection: AppWriterContextSelection; +}): Promise<{ revisionSourceFiles?: AppWriterWorkspaceFile[] }> { + const revision = readAppWriterRevisionContext(input.contextSelection.selectedContext); + + if (revision === null) { + return {}; + } + + if (input.packageSnapshotStore === undefined) { + throw new Error( + 'App Writer revision initialization requires package snapshot storage so Lantern can load the previous reviewed package.', + ); + } + + const getPackageVersionById = input.repository.getPackageVersionById; + + if (getPackageVersionById === undefined) { + throw new Error( + 'App Writer revision initialization requires package version lookup so Lantern can load the previous package.', + ); + } + + const packageVersion = await getPackageVersionById(revision.sourcePackageVersionId); + + if (packageVersion === null) { + throw new Error( + `Revision source package version ${revision.sourcePackageVersionId} was not found.`, + ); + } + + if ( + packageVersion.appId !== revision.sourceAppId || + packageVersion.version !== revision.sourceVersion + ) { + throw new Error( + `Revision source package ${packageVersion.appId}@${packageVersion.version} did not match the recorded source ${revision.sourceAppId}@${revision.sourceVersion}.`, + ); + } + + const files = await loadRevisionSourcePackageFiles({ + packageSnapshotStore: input.packageSnapshotStore, + packageVersion, + targetVersion: revision.targetVersion, + }); + + return { revisionSourceFiles: files }; +} + +async function loadRevisionSourcePackageFiles(input: { + packageSnapshotStore: PackageSnapshotStore; + packageVersion: PackageVersionRecord; + targetVersion: string; +}): Promise { + const snapshotRoot = input.packageVersion.artifact.snapshotRoot; + const paths = await input.packageSnapshotStore.listFiles(snapshotRoot); + const files: AppWriterWorkspaceFile[] = []; + const seenPaths = new Set(); + + for (const path of paths) { + const bytes = await input.packageSnapshotStore.readBytes(snapshotRoot, path); + const contents = decodeRevisionSourceFile({ + path, + bytes, + }); + seenPaths.add(path); + + files.push({ + path, + role: 'package', + contents: rewriteRevisionSourceFile({ + path, + contents, + appId: input.packageVersion.appId, + targetVersion: input.targetVersion, + }), + }); + } + + if (!files.some((file) => file.path === 'manifest.json')) { + throw new Error( + `Revision source package ${input.packageVersion.appId}@${input.packageVersion.version} is missing manifest.json.`, + ); + } + + assertRevisionSourceFileExists({ + paths: seenPaths, + packageVersion: input.packageVersion, + path: 'dist/pico.min.css', + }); + assertRevisionSourceFileExists({ + paths: seenPaths, + packageVersion: input.packageVersion, + path: 'dist/lantern-app.css', + }); + assertRevisionSourceFileExists({ + paths: seenPaths, + packageVersion: input.packageVersion, + path: 'dist/app.css', + }); + + return files.sort((left, right) => left.path.localeCompare(right.path)); +} + +function assertRevisionSourceFileExists(input: { + paths: ReadonlySet; + packageVersion: PackageVersionRecord; + path: string; +}): void { + if (input.paths.has(input.path)) { + return; + } + + throw new Error( + `Revision source package ${input.packageVersion.appId}@${input.packageVersion.version} is missing required reviewed file ${input.path}.`, + ); +} + +function rewriteRevisionSourceFile(input: { + path: string; + contents: string; + appId: string; + targetVersion: string; +}): string { + switch (input.path) { + case 'manifest.json': + return rewriteRevisionManifest({ + contents: input.contents, + appId: input.appId, + targetVersion: input.targetVersion, + }); + case 'dist/index.html': + return ensureReviewedStylesheetLinks(input.contents); + case 'dist/pico.min.css': + return PICO_CSS; + case 'dist/lantern-app.css': + return LANTERN_APP_CSS; + default: + return input.contents; + } +} + +function ensureReviewedStylesheetLinks(html: string): string { + const requiredLinks = [ + { + href: './pico.min.css', + html: '', + }, + { + href: './lantern-app.css', + html: '', + }, + { href: './app.css', html: '' }, + ]; + const missingLinks = requiredLinks.filter((link) => !hasStylesheetLink(html, link.href)); + + if (missingLinks.length === 0) { + return html; + } + + const insertion = missingLinks.map((link) => link.html).join(''); + const rewritten = html.replace(/]*)>/i, `${insertion}`); + + if (rewritten === html) { + throw new Error('Revision source dist/index.html must contain a element.'); + } + + return rewritten; +} + +function decodeRevisionSourceFile(input: { path: string; bytes: Uint8Array }): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(input.bytes); + } catch { + throw new Error( + `Revision source package file ${input.path} is not UTF-8 text. App Writer revisions require text package files.`, + ); + } +} + +function rewriteRevisionManifest(input: { + contents: string; + appId: string; + targetVersion: string; +}): string { + const parsed: unknown = JSON.parse(input.contents); + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Revision source manifest.json must be a JSON object.'); + } + + return `${ + JSON.stringify( + { + ...Object.fromEntries(Object.entries(parsed)), + app_id: input.appId, + version: input.targetVersion, + }, + null, + 2, + ) + }\n`; +} diff --git a/src/app_writer/service_run_records.ts b/src/app_writer/service_run_records.ts new file mode 100644 index 0000000..7dcaed0 --- /dev/null +++ b/src/app_writer/service_run_records.ts @@ -0,0 +1,49 @@ +import type { AppWriterContextSelection } from './context.ts'; +import type { AppGenerationRunRecord } from './types.ts'; +import type { PackageVersionRecord } from '../package_review/types.ts'; + +export function buildInitialGenerationRun(input: { + generationId: string; + ownerId: string; + promptText: string; + requestedAppId: string | null; + contextSelection: AppWriterContextSelection; + createdAt: string; +}): AppGenerationRunRecord { + return { + generationId: input.generationId, + ownerId: input.ownerId, + status: 'started', + requestedAppId: input.requestedAppId, + generatedAppId: null, + generatedVersion: null, + packageVersionId: null, + promptText: input.promptText, + normalizedRequest: null, + appPlan: null, + selectedStarterId: input.contextSelection.starterId, + selectedContext: input.contextSelection.selectedContext, + modelRequestMetadata: [], + generationNotes: [], + validationFindings: [], + repairAttemptCount: 0, + createdAt: input.createdAt, + updatedAt: input.createdAt, + }; +} + +export function formatRevisionPromptText(input: { + promptText: string; + sourcePackageVersion: PackageVersionRecord; + targetVersion: string; +}): string { + return [ + `Revision request for ${input.sourcePackageVersion.appId}@${input.sourcePackageVersion.version}.`, + `Target version: ${input.targetVersion}.`, + `Preserve manifest app_id: ${input.sourcePackageVersion.appId}.`, + 'Start from the existing package snapshot and make only the requested refinement.', + '', + 'Instructor refinement prompt:', + input.promptText, + ].join('\n'); +} diff --git a/src/app_writer/service_run_types.ts b/src/app_writer/service_run_types.ts new file mode 100644 index 0000000..4d3d324 --- /dev/null +++ b/src/app_writer/service_run_types.ts @@ -0,0 +1,97 @@ +import type { AppWriterContextSelection } from './context.ts'; +import type { + AppGenerationPlanningResult, + AppGenerationRunRecord, + AppGenerationWorkspaceRecord, + AppPackageGenerationInput, + AppPackageGenerationResult, + AppPackagePreviewer, + AppPackageSourceCompiler, + AppWriterAuthoringMode, +} from './types.ts'; +import type { AppWriterWorkspaceRunner } from './workspace_runner.ts'; +import type { ImportedPackageVersion } from '../package_review/intake.ts'; +import type { PackageSource } from '../package_review/package_source.ts'; +import type { PackageReviewRepository } from '../package_review/repository.ts'; +import type { PackageSnapshotStore } from '../package_review/snapshot_store.ts'; +import type { PackageVersionRecord } from '../package_review/types.ts'; + +export interface RunAppPackageGenerationInput { + repository: Pick< + PackageReviewRepository, + | 'createAppGenerationRun' + | 'updateAppGenerationRun' + | 'registerPackageVersion' + | 'recordAuditEvent' + | 'saveAppGenerationWorkspace' + | 'getAppGenerationWorkspaceByGenerationId' + | 'getAppGenerationRunById' + >; + workspaceRunner: AppWriterWorkspaceRunner; + packageSnapshotStore?: PackageSnapshotStore; + previewer?: AppPackagePreviewer; + sourceCompiler?: AppPackageSourceCompiler; + savePackage?: { + importPackageFromSource: ( + source: PackageSource, + options?: { storageRoot?: string }, + ) => Promise; + storageRoot?: string; + }; + generationId: string; + ownerId: string; + promptText: string; + requestedAppId?: string | null; + authoringMode?: AppWriterAuthoringMode; + maxRepairAttempts?: number; + now?: () => string; +} + +export interface RunAppPackageGenerationResult { + run: AppGenerationRunRecord; + generation: AppPackageGenerationResult; + packageVersion: PackageVersionRecord | null; +} + +export interface PlannedAppPackageGenerationRun { + run: AppGenerationRunRecord; + generatorInput: AppPackageGenerationInput; + contextSelection: AppWriterContextSelection; + initializedWorkspace: AppGenerationWorkspaceRecord; + planning: AppGenerationPlanningResult; +} + +export interface InitializedAppPackageGenerationRun { + run: AppGenerationRunRecord; + generatorInput: AppPackageGenerationInput; + contextSelection: AppWriterContextSelection; + initializedWorkspace: AppGenerationWorkspaceRecord; +} + +export interface GeneratedAppPackageFilesRun { + run: AppGenerationRunRecord; + generatorInput: AppPackageGenerationInput; + contextSelection: AppWriterContextSelection; + initializedWorkspace: AppGenerationWorkspaceRecord; + generation: AppPackageGenerationResult; +} + +export interface StartedAppPackageGenerationRun { + run: AppGenerationRunRecord; + continueGeneration: () => Promise; +} + +export interface ContinueAppPackageGenerationRunInput { + repository: + & RunAppPackageGenerationInput['repository'] + & Pick + & Partial>; + workspaceRunner: AppWriterWorkspaceRunner; + packageSnapshotStore?: PackageSnapshotStore; + previewer?: AppPackagePreviewer; + sourceCompiler?: AppPackageSourceCompiler; + savePackage?: RunAppPackageGenerationInput['savePackage']; + generationId: string; + maxRepairAttempts?: number; + now?: () => string; +} diff --git a/src/app_writer/service_save.ts b/src/app_writer/service_save.ts index 372f622..7f7189e 100644 --- a/src/app_writer/service_save.ts +++ b/src/app_writer/service_save.ts @@ -7,7 +7,7 @@ import { recordGenerationActivity } from './service_audit.ts'; import type { RunAppPackageGenerationInput, RunAppPackageGenerationResult, -} from './service_core.ts'; +} from './service_run_types.ts'; import { updateGenerationPlanStepInWorkspace } from './service_workspace_snapshot.ts'; import type { ImportedPackageVersion } from '../package_review/intake.ts'; import { createMemoryPackageSource } from '../package_review/package_source.ts'; diff --git a/src/app_writer/service_typescript.ts b/src/app_writer/service_typescript.ts index e893556..5e294c9 100644 --- a/src/app_writer/service_typescript.ts +++ b/src/app_writer/service_typescript.ts @@ -1,6 +1,6 @@ import type { AppPackageGenerationResult, AppWriterWorkspaceFile } from './types.ts'; import { buildTypeScriptCompilerMissingFinding } from './service_failures.ts'; -import type { RunAppPackageGenerationInput } from './service_core.ts'; +import type { RunAppPackageGenerationInput } from './service_run_types.ts'; import { mergeWorkspaceFiles, selectNonPackageWorkspaceFiles, diff --git a/src/app_writer/service_workflow_steps.ts b/src/app_writer/service_workflow_steps.ts new file mode 100644 index 0000000..265b151 --- /dev/null +++ b/src/app_writer/service_workflow_steps.ts @@ -0,0 +1,282 @@ +import type { AppWriterContextSelection } from './context.ts'; +import type { + AppGenerationRunRecord, + AppGenerationWorkspaceRecord, + AppPackageGenerationInput, + AppPackageGenerationResult, + AppPackagePreviewer, + AppPackageSourceCompiler, +} from './types.ts'; +import type { AppWriterWorkspaceRunner } from './workspace_runner.ts'; +import { + buildGenerationFromPersistedWorkspace, + generateInitialPackageFiles, + planInitialPackage, +} from './service_stages.ts'; +import { AppPackageGenerationFailedError } from './service_failures.ts'; +import { failGenerationRun } from './service_failure_runs.ts'; +import { finishGeneratedPackage } from './service_finish.ts'; +import { recordGenerationActivity } from './service_audit.ts'; +import { + buildContextSelectionFromRun, + buildGeneratorInputFromRun, + buildRevisionSourceFilesIfNeeded, +} from './service_revision.ts'; +import type { + ContinueAppPackageGenerationRunInput, + GeneratedAppPackageFilesRun, + InitializedAppPackageGenerationRun, + PlannedAppPackageGenerationRun, + RunAppPackageGenerationInput, + RunAppPackageGenerationResult, +} from './service_run_types.ts'; +import type { PackageReviewRepository } from '../package_review/repository.ts'; +import type { PackageSnapshotStore } from '../package_review/snapshot_store.ts'; + +export async function initializeAppPackageGenerationRun( + input: ContinueAppPackageGenerationRunInput, +): Promise { + const existingRun = await input.repository.getAppGenerationRunById(input.generationId); + + if (existingRun === null) { + throw new Error(`App generation run ${input.generationId} was not found.`); + } + + const now = input.now ?? (() => new Date().toISOString()); + const contextSelection = buildContextSelectionFromRun(existingRun); + const generatorInput = buildGeneratorInputFromRun(existingRun, contextSelection); + const existingWorkspace = await input.repository.getAppGenerationWorkspaceByGenerationId( + existingRun.generationId, + ); + + if (existingWorkspace !== null && existingRun.status !== 'started') { + return { + run: existingRun, + generatorInput, + contextSelection, + initializedWorkspace: existingWorkspace, + }; + } + + const run = await input.repository.updateAppGenerationRun({ + ...existingRun, + status: 'initializing', + updatedAt: now(), + }); + await recordGenerationActivity({ + repository: input.repository, + run, + eventType: 'app_generation.initializing', + status: 'accepted', + summary: 'Prepared Lantern app writer recipe, starter workspace, and validation contract.', + }); + + const initializedWorkspace = await input.workspaceRunner.initialize({ + generationId: run.generationId, + contextSelection, + initializedAt: run.updatedAt, + ...(await buildRevisionSourceFilesIfNeeded({ + repository: input.repository, + packageSnapshotStore: input.packageSnapshotStore, + contextSelection, + })), + }); + const savedWorkspace = await input.repository.saveAppGenerationWorkspace(initializedWorkspace); + + return { + run, + generatorInput, + contextSelection, + initializedWorkspace: savedWorkspace, + }; +} + +export async function planAppPackageGenerationRun( + input: ContinueAppPackageGenerationRunInput, +): Promise { + const now = input.now ?? (() => new Date().toISOString()); + const initialized = await initializeAppPackageGenerationRun({ + ...input, + now, + }); + + try { + const planned = await planInitialPackage({ + repository: input.repository, + workspaceRunner: input.workspaceRunner, + run: initialized.run, + generatorInput: initialized.generatorInput, + contextSelection: initialized.contextSelection, + initializedWorkspace: initialized.initializedWorkspace, + now, + }); + + return { + ...planned, + generatorInput: initialized.generatorInput, + contextSelection: initialized.contextSelection, + initializedWorkspace: initialized.initializedWorkspace, + }; + } catch (error) { + if (error instanceof AppPackageGenerationFailedError) { + throw error; + } + + throw await failGenerationRun({ + repository: input.repository, + run: initialized.run, + error, + now, + }); + } +} + +export async function generateAppPackageFilesForPlannedRun(input: { + repository: Pick< + PackageReviewRepository, + | 'updateAppGenerationRun' + | 'recordAuditEvent' + | 'saveAppGenerationWorkspace' + | 'getAppGenerationWorkspaceByGenerationId' + | 'getAppGenerationRunById' + >; + workspaceRunner: AppWriterWorkspaceRunner; + planned: PlannedAppPackageGenerationRun; + now?: () => string; +}): Promise { + const now = input.now ?? (() => new Date().toISOString()); + + try { + const generated = await generateInitialPackageFiles({ + repository: input.repository, + workspaceRunner: input.workspaceRunner, + run: input.planned.run, + generatorInput: input.planned.generatorInput, + planning: input.planned.planning, + initializedWorkspace: input.planned.initializedWorkspace, + now, + }); + + return { + ...generated, + generatorInput: input.planned.generatorInput, + contextSelection: input.planned.contextSelection, + initializedWorkspace: input.planned.initializedWorkspace, + }; + } catch (error) { + if (error instanceof AppPackageGenerationFailedError) { + throw error; + } + + throw await failGenerationRun({ + repository: input.repository, + run: input.planned.run, + error, + now, + }); + } +} + +export async function finishGeneratedAppPackageRun(input: { + repository: + & RunAppPackageGenerationInput['repository'] + & Pick; + workspaceRunner: AppWriterWorkspaceRunner; + packageSnapshotStore?: PackageSnapshotStore; + previewer?: AppPackagePreviewer; + sourceCompiler?: AppPackageSourceCompiler; + savePackage?: RunAppPackageGenerationInput['savePackage']; + generated: GeneratedAppPackageFilesRun; + maxRepairAttempts?: number; + now?: () => string; +}): Promise { + const now = input.now ?? (() => new Date().toISOString()); + const run = (await input.repository.getAppGenerationRunById(input.generated.run.generationId)) ?? + input.generated.run; + const workspace = await input.repository.getAppGenerationWorkspaceByGenerationId( + run.generationId, + ); + const generation = buildGenerationFromPersistedWorkspace({ + baseGeneration: input.generated.generation, + run, + workspace, + }); + const repairAttempt = Math.max( + input.generated.run.repairAttemptCount, + run.repairAttemptCount, + workspace?.repairAttemptCount ?? 0, + ); + + try { + return await finishGeneratedPackage({ + input: { + repository: input.repository, + workspaceRunner: input.workspaceRunner, + ...(input.packageSnapshotStore === undefined + ? {} + : { packageSnapshotStore: input.packageSnapshotStore }), + generationId: input.generated.generatorInput.generationId, + ownerId: input.generated.generatorInput.ownerId, + promptText: input.generated.generatorInput.promptText, + requestedAppId: input.generated.generatorInput.requestedAppId, + ...(input.previewer === undefined ? {} : { previewer: input.previewer }), + ...(input.sourceCompiler === undefined ? {} : { sourceCompiler: input.sourceCompiler }), + ...(input.savePackage === undefined ? {} : { savePackage: input.savePackage }), + ...(input.maxRepairAttempts === undefined + ? {} + : { maxRepairAttempts: input.maxRepairAttempts }), + now, + }, + now, + generatorInput: input.generated.generatorInput, + contextSelection: input.generated.contextSelection, + run, + generation, + repairAttempt, + }); + } catch (error) { + if (error instanceof AppPackageGenerationFailedError) { + throw error; + } + + throw await failGenerationRun({ + repository: input.repository, + run, + error, + now, + }); + } +} + +export async function generateInitialPackage(input: { + input: RunAppPackageGenerationInput; + run: AppGenerationRunRecord; + generatorInput: AppPackageGenerationInput; + contextSelection: AppWriterContextSelection; + initializedWorkspace: AppGenerationWorkspaceRecord; + now: () => string; +}): Promise<{ run: AppGenerationRunRecord; generation: AppPackageGenerationResult }> { + const planned = await planInitialPackage({ + repository: input.input.repository, + workspaceRunner: input.input.workspaceRunner, + run: input.run, + generatorInput: input.generatorInput, + contextSelection: input.contextSelection, + initializedWorkspace: input.initializedWorkspace, + now: input.now, + }); + const generated = await generateInitialPackageFiles({ + repository: input.input.repository, + workspaceRunner: input.input.workspaceRunner, + run: planned.run, + generatorInput: input.generatorInput, + planning: planned.planning, + initializedWorkspace: input.initializedWorkspace, + now: input.now, + }); + + return { + run: generated.run, + generation: generated.generation, + }; +} diff --git a/src/app_writer/typescript_source_compiler.ts b/src/app_writer/typescript_source_compiler.ts index ee39070..4cc6523 100644 --- a/src/app_writer/typescript_source_compiler.ts +++ b/src/app_writer/typescript_source_compiler.ts @@ -10,7 +10,7 @@ const SOURCE_APP_PATH = 'source/app.ts'; const SOURCE_CONTENT_MODEL_PATH = 'source/content_model.ts'; const GENERATED_SDK_DECLARATION_PATH = 'source/lantern-sdk.d.ts'; const DIST_APP_PATH = 'dist/app.js'; -const TYPESCRIPT_LIBRARY_BASE_URL = 'https://esm.sh/typescript@5.9.3/lib/'; +const TYPESCRIPT_LIBRARY_BASE_URL = `https://esm.sh/typescript@${ts.version}/lib/`; const TYPESCRIPT_ROOT_LIBRARY_FILES = [ 'lib.es2022.d.ts', 'lib.dom.d.ts', @@ -378,7 +378,7 @@ function compilerOptions(): ts.CompilerOptions { noUncheckedIndexedAccess: true, exactOptionalPropertyTypes: true, target: ts.ScriptTarget.ES2022, - module: ts.ModuleKind.None, + module: ts.ModuleKind.ES2022, lib: ['lib.es2022.d.ts', 'lib.dom.d.ts'], skipLibCheck: true, noEmitOnError: true, diff --git a/src/app_writer/validation.ts b/src/app_writer/validation.ts index 4e00d55..f86e9dc 100644 --- a/src/app_writer/validation.ts +++ b/src/app_writer/validation.ts @@ -1,44 +1,20 @@ -import type { Capability } from '../../sdk/app-sdk.ts'; import { preflightLocalAppPackageSource } from '../authoring/local_app.ts'; import { createMemoryPackageSource } from '../package_review/package_source.ts'; import type { - AppGenerationPlan, AppGenerationValidationFinding, AppWriterStarterId, AppWriterWorkspaceFile, } from './types.ts'; import { selectPackageWorkspaceFiles } from './workspace_files.ts'; -import { LANTERN_APP_CSS } from '../styles/lantern_app_css.ts'; -import { PICO_CSS } from '../styles/pico_css.ts'; - -const REQUIRED_BASELINE_FILES = [ - 'manifest.json', - 'dist/index.html', - 'dist/pico.min.css', - 'dist/lantern-app.css', - 'dist/app.css', - 'dist/app.js', - 'content/activity.json', - 'preview/fixtures.json', - 'preview/tests.json', -] as const; - -const PINNED_STYLE_FILES: Readonly> = { - 'dist/pico.min.css': PICO_CSS, - 'dist/lantern-app.css': LANTERN_APP_CSS, -}; - -const GATEWAY_METHOD_CAPABILITIES: Readonly> = { - getLaunchContext: 'read_launch_context', - getActivityContent: 'read_activity_content', - readLocalState: 'read_local_state', - writeLocalState: 'write_local_state', - emitAttemptEvent: 'submit_attempt_event', - submitEvidenceArtifact: 'submit_evidence_artifact', - submitScoreProposal: 'finalize_attempt', - runBrowserGrader: 'finalize_attempt', - finalizeAttempt: 'finalize_attempt', -}; +import { + normalizeWorkspaceFiles, + validateAllowedPaths, + validateDesignContract, + validatePinnedStyleFiles, + validateRequiredFiles, +} from './validation_files.ts'; +export { validateGeneratedAppPackagePlanAlignment } from './validation_plan_alignment.ts'; +import { validateSdkCapabilities, validateStaticPolicy } from './validation_policy.ts'; export async function validateGeneratedAppPackage(input: { selectedStarterId: AppWriterStarterId; @@ -85,578 +61,3 @@ export async function validateGeneratedAppPackage(input: { return findings; } - -function validatePinnedStyleFiles( - files: ReadonlyMap, -): AppGenerationValidationFinding[] { - const findings: AppGenerationValidationFinding[] = []; - - for (const [path, expectedContents] of Object.entries(PINNED_STYLE_FILES)) { - const file = files.get(path); - - if (file === undefined || file.contents === expectedContents) { - continue; - } - - findings.push({ - code: 'pinned_style_file_modified', - severity: 'error', - message: `${path} is a Lantern-owned reviewed stylesheet and must not be modified.`, - file: path, - field: null, - fix: - `Restore ${path} to the pinned Lantern-provided stylesheet and put app-specific styles in dist/app.css.`, - detail: {}, - }); - } - - return findings; -} - -function validateDesignContract( - files: ReadonlyMap, -): AppGenerationValidationFinding[] { - const html = files.get('dist/index.html')?.contents; - - if (html === undefined) { - return []; - } - - const findings: AppGenerationValidationFinding[] = []; - - for (const href of ['./pico.min.css', './lantern-app.css', './app.css']) { - if (hasStylesheetLink(html, href)) { - continue; - } - - findings.push({ - code: 'design_stylesheet_missing', - severity: 'error', - message: `Generated app shell must load ${href}.`, - file: 'dist/index.html', - field: null, - fix: `Add inside dist/index.html .`, - detail: { - href, - }, - }); - } - - if (!hasLanternAppRoot(html)) { - findings.push({ - code: 'design_app_shell_missing', - severity: 'error', - message: 'Generated app shell must keep the Lantern app root.', - file: 'dist/index.html', - field: null, - fix: - 'Use
as the reviewed app root.', - detail: {}, - }); - } - - return findings; -} - -export function validateGeneratedAppPackagePlanAlignment(input: { - appPlan: AppGenerationPlan; - files: readonly AppWriterWorkspaceFile[]; -}): AppGenerationValidationFinding[] { - const manifestFile = selectPackageWorkspaceFiles(input.files).find( - (file) => file.path === 'manifest.json', - ); - - if (manifestFile === undefined) { - return []; - } - - let manifest: { - app_id?: unknown; - title?: unknown; - capabilities?: unknown; - grading?: { - mode?: unknown; - max_score?: unknown; - }; - }; - - try { - manifest = JSON.parse(manifestFile.contents) as typeof manifest; - } catch { - return []; - } - - const findings: AppGenerationValidationFinding[] = []; - - if (manifest.app_id !== input.appPlan.appId) { - findings.push({ - code: 'manifest_plan_app_id_mismatch', - severity: 'error', - message: `Generated manifest app_id must match planned app id ${input.appPlan.appId}.`, - file: 'manifest.json', - field: '/app_id', - fix: `Set manifest app_id to ${input.appPlan.appId}.`, - detail: { - expected: input.appPlan.appId, - actual: typeof manifest.app_id === 'string' ? manifest.app_id : null, - }, - }); - } - - if (manifest.title !== input.appPlan.title) { - findings.push({ - code: 'manifest_plan_title_mismatch', - severity: 'error', - message: `Generated manifest title must match planned title ${input.appPlan.title}.`, - file: 'manifest.json', - field: '/title', - fix: `Set manifest title to ${input.appPlan.title}.`, - detail: { - expected: input.appPlan.title, - actual: typeof manifest.title === 'string' ? manifest.title : null, - }, - }); - } - - if (manifest.grading?.mode !== input.appPlan.grading.mode) { - findings.push({ - code: 'manifest_plan_grading_mode_mismatch', - severity: 'error', - message: - `Generated manifest grading mode must match planned mode ${input.appPlan.grading.mode}.`, - file: 'manifest.json', - field: '/grading/mode', - fix: `Set manifest grading.mode to ${input.appPlan.grading.mode}.`, - detail: { - expected: input.appPlan.grading.mode, - actual: typeof manifest.grading?.mode === 'string' ? manifest.grading.mode : null, - }, - }); - } - - if (manifest.grading?.max_score !== input.appPlan.grading.maxScore) { - findings.push({ - code: 'manifest_plan_max_score_mismatch', - severity: 'error', - message: - `Generated manifest max score must match planned max score ${input.appPlan.grading.maxScore}.`, - file: 'manifest.json', - field: '/grading/max_score', - fix: `Set manifest grading.max_score to ${input.appPlan.grading.maxScore}.`, - detail: { - expected: input.appPlan.grading.maxScore, - actual: typeof manifest.grading?.max_score === 'number' ? manifest.grading.max_score : null, - }, - }); - } - - findings.push(...validateManifestCapabilitiesMatchPlan(manifest.capabilities, input.appPlan)); - - return findings; -} - -function normalizeWorkspaceFiles( - files: readonly AppWriterWorkspaceFile[], - findings: AppGenerationValidationFinding[], -): Map { - const normalizedFiles = new Map(); - - for (const file of files) { - const normalizedPath = normalizeGeneratedPath(file.path); - - if (normalizedPath === null) { - findings.push({ - code: 'file_path_invalid', - severity: 'error', - message: `Generated file path ${file.path} must stay inside the virtual workspace.`, - file: file.path, - field: null, - fix: - 'Use a relative package path without leading slash, backslash, or parent-directory segments.', - detail: {}, - }); - continue; - } - - if (normalizedFiles.has(normalizedPath)) { - findings.push({ - code: 'file_path_duplicate', - severity: 'error', - message: `Generated file path ${normalizedPath} appears more than once.`, - file: normalizedPath, - field: null, - fix: 'Return each package file at most once.', - detail: {}, - }); - continue; - } - - normalizedFiles.set(normalizedPath, { - path: normalizedPath, - contents: file.contents, - }); - } - - return normalizedFiles; -} - -function validateManifestCapabilitiesMatchPlan( - manifestCapabilities: unknown, - appPlan: AppGenerationPlan, -): AppGenerationValidationFinding[] { - if (!Array.isArray(manifestCapabilities)) { - return []; - } - - const plannedCapabilities = new Set(appPlan.capabilities); - const declaredCapabilities = new Set( - manifestCapabilities.filter( - (capability): capability is Capability => typeof capability === 'string', - ), - ); - const findings: AppGenerationValidationFinding[] = []; - - for (const capability of plannedCapabilities) { - if (declaredCapabilities.has(capability)) { - continue; - } - - findings.push({ - code: 'manifest_plan_capability_missing', - severity: 'error', - message: `Generated manifest must include planned capability ${capability}.`, - file: 'manifest.json', - field: '/capabilities', - fix: `Add ${capability} to manifest capabilities.`, - detail: { - capability, - }, - }); - } - - for (const capability of declaredCapabilities) { - if (plannedCapabilities.has(capability)) { - continue; - } - - findings.push({ - code: 'manifest_plan_capability_extra', - severity: 'error', - message: `Generated manifest declares unplanned capability ${capability}.`, - file: 'manifest.json', - field: '/capabilities', - fix: - `Remove ${capability} from manifest capabilities or repair the app plan before writing files.`, - detail: { - capability, - }, - }); - } - - return findings; -} - -function validateRequiredFiles( - files: ReadonlyMap, -): AppGenerationValidationFinding[] { - return REQUIRED_BASELINE_FILES.flatMap((path) => { - if (files.has(path)) { - return []; - } - - return [ - { - code: 'required_file_missing', - severity: 'error' as const, - message: `Generated package must include ${path}.`, - file: path, - field: null, - fix: `Add ${path} to the generated package files.`, - detail: {}, - }, - ]; - }); -} - -function validateAllowedPaths( - starterId: AppWriterStarterId, - files: ReadonlyMap, -): AppGenerationValidationFinding[] { - const findings: AppGenerationValidationFinding[] = []; - - for (const path of files.keys()) { - if (isAllowedPath(starterId, path)) { - continue; - } - - findings.push({ - code: 'file_path_not_allowed', - severity: 'error', - message: `Generated file ${path} is outside the ${starterId} file allowlist.`, - file: path, - field: null, - fix: 'Return only files in the Lantern starter allowlist.', - detail: { - starterId, - }, - }); - } - - return findings; -} - -function validateStaticPolicy( - files: ReadonlyMap, -): AppGenerationValidationFinding[] { - const findings: AppGenerationValidationFinding[] = []; - - for (const file of files.values()) { - if (!isStaticSourceFile(file.path)) { - continue; - } - - if (isPinnedStylePath(file.path)) { - continue; - } - - findings.push(...findPolicyMatches(file, STATIC_POLICY_RULES)); - } - - return findings; -} - -function validateSdkCapabilities( - files: ReadonlyMap, -): AppGenerationValidationFinding[] { - const manifestFile = files.get('manifest.json'); - const appFile = files.get('dist/app.js'); - - if (!manifestFile || !appFile) { - return []; - } - - let manifest: { capabilities?: unknown }; - - try { - manifest = JSON.parse(manifestFile.contents) as { capabilities?: unknown }; - } catch { - return []; - } - - if (!Array.isArray(manifest.capabilities)) { - return []; - } - - const declaredCapabilities = new Set( - manifest.capabilities.filter( - (capability): capability is Capability => typeof capability === 'string', - ), - ); - const findings: AppGenerationValidationFinding[] = []; - - for (const [method, capability] of Object.entries(GATEWAY_METHOD_CAPABILITIES)) { - if (!usesGatewayMethod(appFile.contents, method) || declaredCapabilities.has(capability)) { - continue; - } - - findings.push({ - code: 'sdk_capability_missing', - severity: 'error', - message: - `Generated app calls GatewayApp.${method}() but manifest capabilities do not include ${capability}.`, - file: 'dist/app.js', - field: '/capabilities', - fix: `Add ${capability} to manifest capabilities or remove the GatewayApp.${method}() call.`, - detail: { - method, - capability, - }, - }); - } - - return findings; -} - -function normalizeGeneratedPath(path: string): string | null { - const trimmed = path.trim(); - - if ( - trimmed === '' || - trimmed.startsWith('/') || - trimmed.includes('\\') || - trimmed.split('/').includes('..') - ) { - return null; - } - - return trimmed.replaceAll(/\/+/g, '/'); -} - -function isAllowedPath(starterId: AppWriterStarterId, path: string): boolean { - if (REQUIRED_BASELINE_FILES.includes(path as (typeof REQUIRED_BASELINE_FILES)[number])) { - return true; - } - - if (/^dist\/[a-zA-Z0-9._-]+\.(?:html|js|css)$/.test(path)) { - return true; - } - - if (path === 'scoring/rubric.json') { - return true; - } - - if (starterId === 'browser-autograder') { - return ( - /^grading\/specs\/[a-zA-Z0-9._-]+\.js$/.test(path) || path === 'evidence/example-output.json' - ); - } - - return false; -} - -function isStaticSourceFile(path: string): boolean { - return ( - path.endsWith('.html') || - path.endsWith('.js') || - path.endsWith('.css') || - path.endsWith('.json') - ); -} - -function isPinnedStylePath(path: string): boolean { - return Object.hasOwn(PINNED_STYLE_FILES, path); -} - -function hasStylesheetLink(html: string, href: string): boolean { - const hrefPattern = href.startsWith('./') - ? `(?:\\./)?${escapeRegExp(href.slice(2))}` - : escapeRegExp(href); - return new RegExp( - `]*\\brel=["']stylesheet["'])(?=[^>]*\\bhref=["']${hrefPattern}["'])[^>]*>`, - 'i', - ).test(html); -} - -function hasLanternAppRoot(html: string): boolean { - return /]*\bclass=["'][^"']*\bln-app\b[^"']*["'])(?=[^>]*\bdata-test=["']app-root["'])[^>]*>/i - .test( - html, - ); -} - -function escapeRegExp(value: string): string { - return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function findPolicyMatches( - file: AppWriterWorkspaceFile, - rules: readonly StaticPolicyRule[], -): AppGenerationValidationFinding[] { - return rules.flatMap((rule) => { - if (!rule.pattern.test(file.contents)) { - return []; - } - - return [ - { - code: rule.code, - severity: 'error' as const, - message: rule.message, - file: file.path, - field: null, - fix: rule.fix, - detail: {}, - }, - ]; - }); -} - -function usesGatewayMethod(source: string, method: string): boolean { - return new RegExp(`(?:GatewayApp|gateway)\\s*\\.\\s*${method}\\s*\\(`).test(source); -} - -interface StaticPolicyRule { - code: string; - pattern: RegExp; - message: string; - fix: string; -} - -const STATIC_POLICY_RULES: readonly StaticPolicyRule[] = [ - { - code: 'external_network_forbidden', - pattern: /\bfetch\s*\(|https?:\/\//i, - message: 'Generated apps cannot use arbitrary outbound network access.', - fix: - 'Use Lantern GatewayApp methods and reviewed package files instead of external network calls.', - }, - { - code: 'external_script_forbidden', - pattern: /]*\bsrc=["']https?:\/\//i, - message: 'Generated apps cannot load external scripts.', - fix: 'Keep all runtime code inside reviewed package files.', - }, - { - code: 'imports_forbidden', - pattern: /(^|\n)\s*import\s|import\s*\(/, - message: 'Generated apps cannot import packages or remote modules.', - fix: 'Use plain browser JavaScript and the Lantern GatewayApp SDK.', - }, - { - code: 'module_exports_forbidden', - pattern: /(^|\n)\s*export\s+(?:default|class|const|function|let|var|\{)/, - message: 'Generated apps cannot use module exports.', - fix: 'Keep reviewed app code as plain browser JavaScript loaded by dist/index.html.', - }, - { - code: 'browser_storage_forbidden', - pattern: /\b(?:localStorage|sessionStorage)\b/, - message: 'Generated apps cannot use localStorage or sessionStorage.', - fix: - 'Use GatewayApp.readLocalState() and GatewayApp.writeLocalState() when local state is declared.', - }, - { - code: 'browser_persistent_storage_forbidden', - pattern: /\bindexedDB\b|\bdocument\s*\.\s*cookie\b/, - message: 'Generated apps cannot use browser persistent storage outside the Lantern gateway.', - fix: 'Use GatewayApp local state and attempt events instead of cookies or IndexedDB.', - }, - { - code: 'dynamic_code_execution_forbidden', - pattern: /\beval\s*\(|\bnew\s+Function\s*\(/, - message: 'Generated apps cannot use dynamic code execution.', - fix: 'Use plain reviewed browser code with no eval or Function constructor.', - }, - { - code: 'worker_entrypoint_forbidden', - pattern: - /(?:^|\n)\s*export\s+default\s*\{[\s\S]{0,400}\bfetch\s*\(|(?:^|\n)\s*addEventListener\s*\(\s*["']fetch["']|\b(?:WorkerEntrypoint|ExportedHandler|ExecutionContext|ScheduledController|MessageBatch)\b/, - message: 'Generated apps cannot define Cloudflare Worker entrypoints.', - fix: - 'Keep Worker, routing, scheduling, and queue code in Lantern platform code. Generated apps must use window.GatewayApp only.', - }, - { - code: 'durable_object_forbidden', - pattern: - /\b(?:DurableObject|DurableObjectNamespace|DurableObjectState|Durable Object|Durable Objects|ctx\.storage|state\.storage|env\s*\.\s*[A-Z][A-Z0-9_]*\b)/i, - message: 'Generated apps cannot reference Durable Objects or platform storage bindings.', - fix: - 'Use GatewayApp local state and attempt events. Lantern may implement those capabilities with Durable Objects or D1 behind the gateway.', - }, - { - code: 'platform_boundary_forbidden', - pattern: - /\b(?:canvas\.instructure|canvas lms|moodle|sakai|lti|access_token|line_item|lineItem|writeGrade|gradePassback|Cloudflare|Workers?|D1|R2|Worker Loader)\b/i, - message: - 'Generated apps cannot reference LMS, grade passback, or Cloudflare platform internals.', - fix: 'Keep LMS, grading, storage, and Cloudflare concerns behind Lantern.', - }, - { - code: 'standards_runtime_forbidden', - pattern: - /\b(?:SCORM|LMSInitialize|LMSSetValue|LMSGetValue|LMSCommit|LMSFinish|API_1484_11|TinCan|xAPI|LRS|Learning Record Store|cmi5)\b/i, - message: 'Generated apps cannot use SCORM, xAPI, cmi5, or LRS APIs directly.', - fix: - 'Use Lantern GatewayApp local state, attempt events, and finalizeAttempt(). Lantern may export standards-shaped records behind the gateway later.', - }, -]; diff --git a/src/app_writer/validation_files.ts b/src/app_writer/validation_files.ts new file mode 100644 index 0000000..7e992bf --- /dev/null +++ b/src/app_writer/validation_files.ts @@ -0,0 +1,248 @@ +import type { + AppGenerationValidationFinding, + AppWriterStarterId, + AppWriterWorkspaceFile, +} from './types.ts'; +import { hasStylesheetLink } from './html_inspection.ts'; +import { LANTERN_APP_CSS } from '../styles/lantern_app_css.ts'; +import { PICO_CSS } from '../styles/pico_css.ts'; + +const REQUIRED_BASELINE_FILES = [ + 'manifest.json', + 'dist/index.html', + 'dist/pico.min.css', + 'dist/lantern-app.css', + 'dist/app.css', + 'dist/app.js', + 'content/activity.json', + 'preview/fixtures.json', + 'preview/tests.json', +] as const; + +const PINNED_STYLE_FILES: Readonly> = { + 'dist/pico.min.css': PICO_CSS, + 'dist/lantern-app.css': LANTERN_APP_CSS, +}; + +export function normalizeWorkspaceFiles( + files: readonly AppWriterWorkspaceFile[], + findings: AppGenerationValidationFinding[], +): Map { + const normalizedFiles = new Map(); + + for (const file of files) { + const normalizedPath = normalizeGeneratedPath(file.path); + + if (normalizedPath === null) { + findings.push({ + code: 'file_path_invalid', + severity: 'error', + message: `Generated file path ${file.path} must stay inside the virtual workspace.`, + file: file.path, + field: null, + fix: + 'Use a relative package path without leading slash, backslash, or parent-directory segments.', + detail: {}, + }); + continue; + } + + if (normalizedFiles.has(normalizedPath)) { + findings.push({ + code: 'file_path_duplicate', + severity: 'error', + message: `Generated file path ${normalizedPath} appears more than once.`, + file: normalizedPath, + field: null, + fix: 'Return each package file at most once.', + detail: {}, + }); + continue; + } + + normalizedFiles.set(normalizedPath, { + path: normalizedPath, + contents: file.contents, + }); + } + + return normalizedFiles; +} + +export function validateRequiredFiles( + files: ReadonlyMap, +): AppGenerationValidationFinding[] { + return REQUIRED_BASELINE_FILES.flatMap((path) => { + if (files.has(path)) { + return []; + } + + return [ + { + code: 'required_file_missing', + severity: 'error' as const, + message: `Generated package must include ${path}.`, + file: path, + field: null, + fix: `Add ${path} to the generated package files.`, + detail: {}, + }, + ]; + }); +} + +export function validateAllowedPaths( + starterId: AppWriterStarterId, + files: ReadonlyMap, +): AppGenerationValidationFinding[] { + const findings: AppGenerationValidationFinding[] = []; + + for (const path of files.keys()) { + if (isAllowedPath(starterId, path)) { + continue; + } + + findings.push({ + code: 'file_path_not_allowed', + severity: 'error', + message: `Generated file ${path} is outside the ${starterId} file allowlist.`, + file: path, + field: null, + fix: 'Return only files in the Lantern starter allowlist.', + detail: { + starterId, + }, + }); + } + + return findings; +} + +export function validatePinnedStyleFiles( + files: ReadonlyMap, +): AppGenerationValidationFinding[] { + const findings: AppGenerationValidationFinding[] = []; + + for (const [path, expectedContents] of Object.entries(PINNED_STYLE_FILES)) { + const file = files.get(path); + + if (file === undefined || file.contents === expectedContents) { + continue; + } + + findings.push({ + code: 'pinned_style_file_modified', + severity: 'error', + message: `${path} is a Lantern-owned reviewed stylesheet and must not be modified.`, + file: path, + field: null, + fix: + `Restore ${path} to the pinned Lantern-provided stylesheet and put app-specific styles in dist/app.css.`, + detail: {}, + }); + } + + return findings; +} + +export function validateDesignContract( + files: ReadonlyMap, +): AppGenerationValidationFinding[] { + const html = files.get('dist/index.html')?.contents; + + if (html === undefined) { + return []; + } + + const findings: AppGenerationValidationFinding[] = []; + + for (const href of ['./pico.min.css', './lantern-app.css', './app.css']) { + if (hasStylesheetLink(html, href)) { + continue; + } + + findings.push({ + code: 'design_stylesheet_missing', + severity: 'error', + message: `Generated app shell must load ${href}.`, + file: 'dist/index.html', + field: null, + fix: `Add inside dist/index.html .`, + detail: { + href, + }, + }); + } + + if (!hasLanternAppRoot(html)) { + findings.push({ + code: 'design_app_shell_missing', + severity: 'error', + message: 'Generated app shell must keep the Lantern app root.', + file: 'dist/index.html', + field: null, + fix: + 'Use
as the reviewed app root.', + detail: {}, + }); + } + + return findings; +} + +export function isStaticSourceFile(path: string): boolean { + return ( + path.endsWith('.html') || + path.endsWith('.js') || + path.endsWith('.css') || + path.endsWith('.json') + ); +} + +export function isPinnedStylePath(path: string): boolean { + return Object.hasOwn(PINNED_STYLE_FILES, path); +} + +function normalizeGeneratedPath(path: string): string | null { + const trimmed = path.trim(); + + if ( + trimmed === '' || + trimmed.startsWith('/') || + trimmed.includes('\\') || + trimmed.split('/').includes('..') + ) { + return null; + } + + return trimmed.replaceAll(/\/+/g, '/'); +} + +function isAllowedPath(starterId: AppWriterStarterId, path: string): boolean { + if (REQUIRED_BASELINE_FILES.includes(path as (typeof REQUIRED_BASELINE_FILES)[number])) { + return true; + } + + if (/^dist\/[a-zA-Z0-9._-]+\.(?:html|js|css)$/.test(path)) { + return true; + } + + if (path === 'scoring/rubric.json') { + return true; + } + + if (starterId === 'browser-autograder') { + return ( + /^grading\/specs\/[a-zA-Z0-9._-]+\.js$/.test(path) || path === 'evidence/example-output.json' + ); + } + + return false; +} + +function hasLanternAppRoot(html: string): boolean { + return /]*\bclass=["'][^"']*\bln-app\b[^"']*["'])(?=[^>]*\bdata-test=["']app-root["'])[^>]*>/i + .test( + html, + ); +} diff --git a/src/app_writer/validation_plan_alignment.ts b/src/app_writer/validation_plan_alignment.ts new file mode 100644 index 0000000..d5a4c37 --- /dev/null +++ b/src/app_writer/validation_plan_alignment.ts @@ -0,0 +1,193 @@ +import type { Capability } from '../../sdk/app-sdk.ts'; +import type { + AppGenerationPlan, + AppGenerationValidationFinding, + AppWriterWorkspaceFile, +} from './types.ts'; +import { selectPackageWorkspaceFiles } from './workspace_files.ts'; + +export function validateGeneratedAppPackagePlanAlignment(input: { + appPlan: AppGenerationPlan; + files: readonly AppWriterWorkspaceFile[]; +}): AppGenerationValidationFinding[] { + const manifestFile = selectPackageWorkspaceFiles(input.files).find( + (file) => file.path === 'manifest.json', + ); + + if (manifestFile === undefined) { + return []; + } + + const manifest = parsePlanManifest(manifestFile.contents); + + if (manifest === null) { + return []; + } + + const findings: AppGenerationValidationFinding[] = []; + + if (manifest.app_id !== input.appPlan.appId) { + findings.push({ + code: 'manifest_plan_app_id_mismatch', + severity: 'error', + message: `Generated manifest app_id must match planned app id ${input.appPlan.appId}.`, + file: 'manifest.json', + field: '/app_id', + fix: `Set manifest app_id to ${input.appPlan.appId}.`, + detail: { + expected: input.appPlan.appId, + actual: typeof manifest.app_id === 'string' ? manifest.app_id : null, + }, + }); + } + + if (manifest.title !== input.appPlan.title) { + findings.push({ + code: 'manifest_plan_title_mismatch', + severity: 'error', + message: `Generated manifest title must match planned title ${input.appPlan.title}.`, + file: 'manifest.json', + field: '/title', + fix: `Set manifest title to ${input.appPlan.title}.`, + detail: { + expected: input.appPlan.title, + actual: typeof manifest.title === 'string' ? manifest.title : null, + }, + }); + } + + if (manifest.grading?.mode !== input.appPlan.grading.mode) { + findings.push({ + code: 'manifest_plan_grading_mode_mismatch', + severity: 'error', + message: + `Generated manifest grading mode must match planned mode ${input.appPlan.grading.mode}.`, + file: 'manifest.json', + field: '/grading/mode', + fix: `Set manifest grading.mode to ${input.appPlan.grading.mode}.`, + detail: { + expected: input.appPlan.grading.mode, + actual: typeof manifest.grading?.mode === 'string' ? manifest.grading.mode : null, + }, + }); + } + + if (manifest.grading?.max_score !== input.appPlan.grading.maxScore) { + findings.push({ + code: 'manifest_plan_max_score_mismatch', + severity: 'error', + message: + `Generated manifest max score must match planned max score ${input.appPlan.grading.maxScore}.`, + file: 'manifest.json', + field: '/grading/max_score', + fix: `Set manifest grading.max_score to ${input.appPlan.grading.maxScore}.`, + detail: { + expected: input.appPlan.grading.maxScore, + actual: typeof manifest.grading?.max_score === 'number' ? manifest.grading.max_score : null, + }, + }); + } + + findings.push(...validateManifestCapabilitiesMatchPlan(manifest.capabilities, input.appPlan)); + + return findings; +} + +interface PlanManifest { + app_id?: unknown; + title?: unknown; + capabilities?: unknown; + grading?: { + mode?: unknown; + max_score?: unknown; + }; +} + +function parsePlanManifest(contents: string): PlanManifest | null { + let parsed: unknown; + + try { + parsed = JSON.parse(contents); + } catch { + return null; + } + + if (!isRecord(parsed)) { + return null; + } + + const manifest: PlanManifest = { + app_id: parsed.app_id, + title: parsed.title, + capabilities: parsed.capabilities, + }; + + if (isRecord(parsed.grading)) { + manifest.grading = { + mode: parsed.grading.mode, + max_score: parsed.grading.max_score, + }; + } + + return manifest; +} + +function validateManifestCapabilitiesMatchPlan( + manifestCapabilities: unknown, + appPlan: AppGenerationPlan, +): AppGenerationValidationFinding[] { + if (!Array.isArray(manifestCapabilities)) { + return []; + } + + const plannedCapabilities = new Set(appPlan.capabilities); + const declaredCapabilities = new Set( + manifestCapabilities.filter( + (capability): capability is Capability => typeof capability === 'string', + ), + ); + const findings: AppGenerationValidationFinding[] = []; + + for (const capability of plannedCapabilities) { + if (declaredCapabilities.has(capability)) { + continue; + } + + findings.push({ + code: 'manifest_plan_capability_missing', + severity: 'error', + message: `Generated manifest must include planned capability ${capability}.`, + file: 'manifest.json', + field: '/capabilities', + fix: `Add ${capability} to manifest capabilities.`, + detail: { + capability, + }, + }); + } + + for (const capability of declaredCapabilities) { + if (plannedCapabilities.has(capability)) { + continue; + } + + findings.push({ + code: 'manifest_plan_capability_extra', + severity: 'error', + message: `Generated manifest declares unplanned capability ${capability}.`, + file: 'manifest.json', + field: '/capabilities', + fix: + `Remove ${capability} from manifest capabilities or repair the app plan before writing files.`, + detail: { + capability, + }, + }); + } + + return findings; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/app_writer/validation_policy.ts b/src/app_writer/validation_policy.ts new file mode 100644 index 0000000..5f67ab0 --- /dev/null +++ b/src/app_writer/validation_policy.ts @@ -0,0 +1,210 @@ +import type { Capability } from '../../sdk/app-sdk.ts'; +import type { AppGenerationValidationFinding, AppWriterWorkspaceFile } from './types.ts'; +import { isPinnedStylePath, isStaticSourceFile } from './validation_files.ts'; + +const GATEWAY_METHOD_CAPABILITIES: Readonly> = { + getLaunchContext: 'read_launch_context', + getActivityContent: 'read_activity_content', + readLocalState: 'read_local_state', + writeLocalState: 'write_local_state', + emitAttemptEvent: 'submit_attempt_event', + submitEvidenceArtifact: 'submit_evidence_artifact', + submitScoreProposal: 'finalize_attempt', + runBrowserGrader: 'finalize_attempt', + finalizeAttempt: 'finalize_attempt', +}; + +export function validateStaticPolicy( + files: ReadonlyMap, +): AppGenerationValidationFinding[] { + const findings: AppGenerationValidationFinding[] = []; + + for (const file of files.values()) { + if (!isStaticSourceFile(file.path)) { + continue; + } + + if (isPinnedStylePath(file.path)) { + continue; + } + + findings.push(...findPolicyMatches(file, STATIC_POLICY_RULES)); + } + + return findings; +} + +export function validateSdkCapabilities( + files: ReadonlyMap, +): AppGenerationValidationFinding[] { + const manifestFile = files.get('manifest.json'); + const appFile = files.get('dist/app.js'); + + if (!manifestFile || !appFile) { + return []; + } + + let manifest: Record; + + try { + const parsed: unknown = JSON.parse(manifestFile.contents); + + if (!isRecord(parsed)) { + return []; + } + + manifest = parsed; + } catch { + return []; + } + + if (!Array.isArray(manifest.capabilities)) { + return []; + } + + const declaredCapabilities = new Set( + manifest.capabilities.filter( + (capability): capability is Capability => typeof capability === 'string', + ), + ); + const findings: AppGenerationValidationFinding[] = []; + + for (const [method, capability] of Object.entries(GATEWAY_METHOD_CAPABILITIES)) { + if (!usesGatewayMethod(appFile.contents, method) || declaredCapabilities.has(capability)) { + continue; + } + + findings.push({ + code: 'sdk_capability_missing', + severity: 'error', + message: + `Generated app calls GatewayApp.${method}() but manifest capabilities do not include ${capability}.`, + file: 'dist/app.js', + field: '/capabilities', + fix: `Add ${capability} to manifest capabilities or remove the GatewayApp.${method}() call.`, + detail: { + method, + capability, + }, + }); + } + + return findings; +} + +function findPolicyMatches( + file: AppWriterWorkspaceFile, + rules: readonly StaticPolicyRule[], +): AppGenerationValidationFinding[] { + return rules.flatMap((rule) => { + if (!rule.pattern.test(file.contents)) { + return []; + } + + return [ + { + code: rule.code, + severity: 'error' as const, + message: rule.message, + file: file.path, + field: null, + fix: rule.fix, + detail: {}, + }, + ]; + }); +} + +function usesGatewayMethod(source: string, method: string): boolean { + return new RegExp(`(?:GatewayApp|gateway)\\s*\\.\\s*${method}\\s*\\(`).test(source); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +interface StaticPolicyRule { + code: string; + pattern: RegExp; + message: string; + fix: string; +} + +const STATIC_POLICY_RULES: readonly StaticPolicyRule[] = [ + { + code: 'external_network_forbidden', + pattern: /\bfetch\s*\(|https?:\/\//i, + message: 'Generated apps cannot use arbitrary outbound network access.', + fix: + 'Use Lantern GatewayApp methods and reviewed package files instead of external network calls.', + }, + { + code: 'external_script_forbidden', + pattern: /]*\bsrc=["']https?:\/\//i, + message: 'Generated apps cannot load external scripts.', + fix: 'Keep all runtime code inside reviewed package files.', + }, + { + code: 'imports_forbidden', + pattern: /(^|\n)\s*import\s|import\s*\(/, + message: 'Generated apps cannot import packages or remote modules.', + fix: 'Use plain browser JavaScript and the Lantern GatewayApp SDK.', + }, + { + code: 'module_exports_forbidden', + pattern: /(^|\n)\s*export\s+(?:default|class|const|function|let|var|\{)/, + message: 'Generated apps cannot use module exports.', + fix: 'Keep reviewed app code as plain browser JavaScript loaded by dist/index.html.', + }, + { + code: 'browser_storage_forbidden', + pattern: /\b(?:localStorage|sessionStorage)\b/, + message: 'Generated apps cannot use localStorage or sessionStorage.', + fix: + 'Use GatewayApp.readLocalState() and GatewayApp.writeLocalState() when local state is declared.', + }, + { + code: 'browser_persistent_storage_forbidden', + pattern: /\bindexedDB\b|\bdocument\s*\.\s*cookie\b/, + message: 'Generated apps cannot use browser persistent storage outside the Lantern gateway.', + fix: 'Use GatewayApp local state and attempt events instead of cookies or IndexedDB.', + }, + { + code: 'dynamic_code_execution_forbidden', + pattern: /\beval\s*\(|\bnew\s+Function\s*\(/, + message: 'Generated apps cannot use dynamic code execution.', + fix: 'Use plain reviewed browser code with no eval or Function constructor.', + }, + { + code: 'worker_entrypoint_forbidden', + pattern: + /(?:^|\n)\s*export\s+default\s*\{[\s\S]{0,400}\bfetch\s*\(|(?:^|\n)\s*addEventListener\s*\(\s*["']fetch["']|\b(?:WorkerEntrypoint|ExportedHandler|ExecutionContext|ScheduledController|MessageBatch)\b/, + message: 'Generated apps cannot define Cloudflare Worker entrypoints.', + fix: + 'Keep Worker, routing, scheduling, and queue code in Lantern platform code. Generated apps must use window.GatewayApp only.', + }, + { + code: 'durable_object_forbidden', + pattern: + /\b(?:DurableObject|DurableObjectNamespace|DurableObjectState|Durable Object|Durable Objects|ctx\.storage|state\.storage|env\s*\.\s*[A-Z][A-Z0-9_]*\b)/i, + message: 'Generated apps cannot reference Durable Objects or platform storage bindings.', + fix: + 'Use GatewayApp local state and attempt events. Lantern may implement those capabilities with Durable Objects or D1 behind the gateway.', + }, + { + code: 'platform_boundary_forbidden', + pattern: + /\b(?:canvas\.instructure|canvas lms|moodle|sakai|lti|access_token|line_item|lineItem|writeGrade|gradePassback|Cloudflare|Workers?|D1|R2|Worker Loader)\b/i, + message: + 'Generated apps cannot reference LMS, grade passback, or Cloudflare platform internals.', + fix: 'Keep LMS, grading, storage, and Cloudflare concerns behind Lantern.', + }, + { + code: 'standards_runtime_forbidden', + pattern: + /\b(?:SCORM|LMSInitialize|LMSSetValue|LMSGetValue|LMSCommit|LMSFinish|API_1484_11|TinCan|xAPI|LRS|Learning Record Store|cmi5)\b/i, + message: 'Generated apps cannot use SCORM, xAPI, cmi5, or LRS APIs directly.', + fix: + 'Use Lantern GatewayApp local state, attempt events, and finalizeAttempt(). Lantern may export standards-shaped records behind the gateway later.', + }, +]; diff --git a/src/db/d1.ts b/src/db/d1.ts index f90d4d2..ca91938 100644 --- a/src/db/d1.ts +++ b/src/db/d1.ts @@ -53,7 +53,7 @@ export function prepareD1( return statement.bind(...normalizedParameters); } -export async function queryD1Objects>( +export async function queryD1Objects( db: D1Database, sql: string, parameters: readonly unknown[] = [], @@ -64,7 +64,7 @@ export async function queryD1Objects>( return result.results ?? []; } -export async function queryD1First>( +export async function queryD1First( db: D1Database, sql: string, parameters: readonly unknown[] = [], @@ -80,6 +80,21 @@ export async function runD1( assertD1Success(await prepareD1(db, sql, parameters).run()); } +export async function runD1Batch = Record>( + db: D1Database, + statements: readonly { sql: string; parameters?: readonly unknown[] }[], +): Promise[]> { + const results = await db.batch( + statements.map((statement) => prepareD1(db, statement.sql, statement.parameters ?? [])), + ); + + for (const result of results) { + assertD1Success(result); + } + + return results; +} + export async function runD1Statements(db: D1Database, sql: string): Promise { const statements = splitSqlStatements(sql); diff --git a/src/db/d1_migrations/0001_initial_schema.sql b/src/db/d1_migrations/0001_initial_schema.sql index f2470b9..80a056b 100644 --- a/src/db/d1_migrations/0001_initial_schema.sql +++ b/src/db/d1_migrations/0001_initial_schema.sql @@ -67,28 +67,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS deployments_lti_binding_unique CREATE UNIQUE INDEX IF NOT EXISTS deployments_app_lms_slot_unique ON deployments (app_id, lms_type); -CREATE TABLE IF NOT EXISTS lti_login_states ( - state TEXT PRIMARY KEY, - lms_type TEXT NOT NULL DEFAULT 'canvas' CHECK (lms_type IN ('canvas', 'moodle', 'sakai')), - canvas_environment TEXT CHECK ( - (lms_type = 'canvas' AND canvas_environment IN ('production', 'beta', 'test')) - OR (lms_type IN ('moodle', 'sakai') AND canvas_environment IS NULL) - ), - issuer TEXT NOT NULL, - client_id TEXT NOT NULL, - deployment_id TEXT NOT NULL, - nonce TEXT NOT NULL, - login_hint TEXT NOT NULL, - target_link_uri TEXT NOT NULL, - lti_message_hint TEXT, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - used_at TEXT -); - -CREATE INDEX IF NOT EXISTS lti_login_states_expires_at_idx - ON lti_login_states (expires_at); - CREATE TABLE IF NOT EXISTS dynamic_registration_states ( state TEXT PRIMARY KEY, app_id TEXT NOT NULL, @@ -101,6 +79,62 @@ CREATE TABLE IF NOT EXISTS dynamic_registration_states ( CREATE INDEX IF NOT EXISTS dynamic_registration_states_expires_at_idx ON dynamic_registration_states (expires_at); +CREATE TABLE IF NOT EXISTS lti_clients ( + id TEXT PRIMARY KEY NOT NULL, + platform_name TEXT NOT NULL, + iss TEXT NOT NULL, + client_id TEXT NOT NULL, + auth_url TEXT NOT NULL, + token_url TEXT NOT NULL, + jwks_url TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS lti_clients_issuer_client_idx + ON lti_clients (client_id, iss); + +CREATE UNIQUE INDEX IF NOT EXISTS lti_clients_iss_client_id_uniq + ON lti_clients (iss, client_id); + +CREATE TABLE IF NOT EXISTS lti_deployments ( + id TEXT PRIMARY KEY NOT NULL, + client_id TEXT NOT NULL REFERENCES lti_clients (id) ON DELETE CASCADE, + deployment_id TEXT NOT NULL, + deployment_name TEXT, + deployment_description TEXT +); + +CREATE INDEX IF NOT EXISTS lti_deployments_deployment_id_idx + ON lti_deployments (deployment_id); + +CREATE UNIQUE INDEX IF NOT EXISTS lti_deployments_client_deployment_uniq + ON lti_deployments (client_id, deployment_id); + +CREATE TABLE IF NOT EXISTS lti_nonces ( + nonce TEXT PRIMARY KEY NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS lti_nonces_expires_at_idx + ON lti_nonces (expires_at); + +CREATE TABLE IF NOT EXISTS lti_registration_sessions ( + id TEXT PRIMARY KEY NOT NULL, + payload TEXT NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS lti_registration_sessions_expires_at_idx + ON lti_registration_sessions (expires_at); + +CREATE TABLE IF NOT EXISTS lti_sessions ( + id TEXT PRIMARY KEY NOT NULL, + payload TEXT NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS lti_sessions_expires_at_idx + ON lti_sessions (expires_at); + CREATE TABLE IF NOT EXISTS runtime_sessions ( session_id TEXT PRIMARY KEY, session_token TEXT NOT NULL UNIQUE, @@ -156,6 +190,10 @@ CREATE TABLE IF NOT EXISTS attempts ( status TEXT NOT NULL CHECK (status IN ('in_progress', 'completed', 'abandoned')), completion_state TEXT CHECK (completion_state IN ('completed', 'abandoned')), local_state TEXT CHECK (local_state IS NULL OR json_valid(local_state)), + next_attempt_event_sequence INTEGER NOT NULL DEFAULT 1 CHECK (next_attempt_event_sequence > 0), + next_evidence_artifact_sequence INTEGER NOT NULL DEFAULT 1 CHECK ( + next_evidence_artifact_sequence > 0 + ), started_at TEXT NOT NULL, finalized_at TEXT ); @@ -364,6 +402,7 @@ CREATE TABLE IF NOT EXISTS preview_sessions ( fake_attempt_id TEXT NOT NULL, fake_score_maximum REAL NOT NULL, fixture_data TEXT NOT NULL CHECK (json_valid(fixture_data)), + next_evidence_sequence INTEGER NOT NULL DEFAULT 1 CHECK (next_evidence_sequence > 0), created_at TEXT NOT NULL ); diff --git a/src/db/d1_migrations/0007_lti_tool_tables.sql b/src/db/d1_migrations/0007_lti_tool_tables.sql new file mode 100644 index 0000000..cbda347 --- /dev/null +++ b/src/db/d1_migrations/0007_lti_tool_tables.sql @@ -0,0 +1,55 @@ +CREATE TABLE IF NOT EXISTS lti_clients ( + id TEXT PRIMARY KEY NOT NULL, + platform_name TEXT NOT NULL, + iss TEXT NOT NULL, + client_id TEXT NOT NULL, + auth_url TEXT NOT NULL, + token_url TEXT NOT NULL, + jwks_url TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS lti_clients_issuer_client_idx + ON lti_clients (client_id, iss); + +CREATE UNIQUE INDEX IF NOT EXISTS lti_clients_iss_client_id_uniq + ON lti_clients (iss, client_id); + +CREATE TABLE IF NOT EXISTS lti_deployments ( + id TEXT PRIMARY KEY NOT NULL, + client_id TEXT NOT NULL REFERENCES lti_clients (id) ON DELETE CASCADE, + deployment_id TEXT NOT NULL, + deployment_name TEXT, + deployment_description TEXT +); + +CREATE INDEX IF NOT EXISTS lti_deployments_deployment_id_idx + ON lti_deployments (deployment_id); + +CREATE UNIQUE INDEX IF NOT EXISTS lti_deployments_client_deployment_uniq + ON lti_deployments (client_id, deployment_id); + +CREATE TABLE IF NOT EXISTS lti_nonces ( + nonce TEXT PRIMARY KEY NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS lti_nonces_expires_at_idx + ON lti_nonces (expires_at); + +CREATE TABLE IF NOT EXISTS lti_registration_sessions ( + id TEXT PRIMARY KEY NOT NULL, + payload TEXT NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS lti_registration_sessions_expires_at_idx + ON lti_registration_sessions (expires_at); + +CREATE TABLE IF NOT EXISTS lti_sessions ( + id TEXT PRIMARY KEY NOT NULL, + payload TEXT NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS lti_sessions_expires_at_idx + ON lti_sessions (expires_at); diff --git a/src/db/d1_migrations/0008_runtime_session_lti_tool_session.sql b/src/db/d1_migrations/0008_runtime_session_lti_tool_session.sql new file mode 100644 index 0000000..fc29495 --- /dev/null +++ b/src/db/d1_migrations/0008_runtime_session_lti_tool_session.sql @@ -0,0 +1 @@ +ALTER TABLE runtime_sessions ADD COLUMN lti_tool_session_id TEXT REFERENCES lti_sessions (id) ON DELETE SET NULL; diff --git a/src/db/d1_migrations/0009_deep_linking_session_lti_tool_session.sql b/src/db/d1_migrations/0009_deep_linking_session_lti_tool_session.sql new file mode 100644 index 0000000..068bac1 --- /dev/null +++ b/src/db/d1_migrations/0009_deep_linking_session_lti_tool_session.sql @@ -0,0 +1,2 @@ +ALTER TABLE deep_linking_sessions + ADD COLUMN lti_tool_session_id TEXT REFERENCES lti_sessions (id) ON DELETE SET NULL; diff --git a/src/db/d1_row.ts b/src/db/d1_row.ts new file mode 100644 index 0000000..40d4d66 --- /dev/null +++ b/src/db/d1_row.ts @@ -0,0 +1,99 @@ +export function parseD1JsonField( + value: unknown, + fieldName: string, + parse: (value: unknown, fieldName: string) => T, +): T { + if (typeof value !== 'string') { + throw new TypeError(`Expected D1 ${fieldName} to be JSON text.`); + } + + return parse(JSON.parse(value), fieldName); +} + +export function parseD1NullableJsonField( + value: unknown, + fieldName: string, + parse: (value: unknown, fieldName: string) => T, +): T | null { + if (value === null) { + return null; + } + + return parseD1JsonField(value, fieldName, parse); +} + +export function expectD1String(value: unknown, fieldName: string): string { + if (typeof value !== 'string') { + throw new TypeError(`Expected D1 ${fieldName} to be text.`); + } + + return value; +} + +export function expectD1NullableString(value: unknown, fieldName: string): string | null { + if (value === null) { + return null; + } + + return expectD1String(value, fieldName); +} + +export function expectD1Number(value: unknown, fieldName: string): number { + if (typeof value !== 'number') { + throw new TypeError(`Expected D1 ${fieldName} to be numeric.`); + } + + return value; +} + +export function expectD1NullableNumber(value: unknown, fieldName: string): number | null { + if (value === null) { + return null; + } + + return expectD1Number(value, fieldName); +} + +export function expectD1StringLiteral( + value: unknown, + fieldName: string, + allowed: readonly T[], +): T { + if (typeof value !== 'string' || !allowed.includes(value as T)) { + throw new Error(`Unexpected D1 ${fieldName} value.`); + } + + // SAFETY: The includes check above proves value is one of the allowed literals. + return value as T; +} + +export function expectD1NullableStringLiteral( + value: unknown, + fieldName: string, + allowed: readonly T[], +): T | null { + if (value === null) { + return null; + } + + return expectD1StringLiteral(value, fieldName, allowed); +} + +export function expectD1JsonRecord( + value: unknown, + fieldName: string, +): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(`Expected D1 ${fieldName} JSON to be an object.`); + } + + return Object.fromEntries(Object.entries(value)); +} + +export function expectD1StringArray(value: unknown, fieldName: string): string[] { + if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) { + throw new TypeError(`Expected D1 ${fieldName} JSON to be a text array.`); + } + + return value; +} diff --git a/src/db/d1_row_test.ts b/src/db/d1_row_test.ts new file mode 100644 index 0000000..d0094ce --- /dev/null +++ b/src/db/d1_row_test.ts @@ -0,0 +1,34 @@ +import { assertEquals, assertThrows } from '@std/assert'; +import { + expectD1JsonRecord, + expectD1StringArray, + parseD1JsonField, + parseD1NullableJsonField, +} from './d1_row.ts'; + +Deno.test('D1 row parser rejects non-JSON text fields clearly', () => { + assertThrows( + () => parseD1JsonField({ value: true }, 'package_versions.capabilities', expectD1StringArray), + TypeError, + 'Expected D1 package_versions.capabilities to be JSON text.', + ); +}); + +Deno.test('D1 row parser rejects malformed JSON column shapes', () => { + assertThrows( + () => parseD1JsonField('{"not":"an array"}', 'runtime_sessions.agsScope', expectD1StringArray), + TypeError, + 'Expected D1 runtime_sessions.agsScope JSON to be a text array.', + ); +}); + +Deno.test('D1 nullable JSON row parser preserves null and parses records', () => { + assertEquals( + parseD1NullableJsonField(null, 'audit_events.detail', expectD1JsonRecord), + null, + ); + assertEquals( + parseD1NullableJsonField('{"code":"ok"}', 'audit_events.detail', expectD1JsonRecord), + { code: 'ok' }, + ); +}); diff --git a/src/lti/ags_session.ts b/src/lti/ags_session.ts new file mode 100644 index 0000000..fafc4d3 --- /dev/null +++ b/src/lti/ags_session.ts @@ -0,0 +1,18 @@ +import type { LTISession } from '@longsightgroup/lti-tool'; + +export function withAgsLineItemTarget(session: LTISession, lineItemUrl: string): LTISession { + const lineItemsUrl = session.services?.ags?.lineitems; + + return { + ...session, + services: { + ...session.services, + ags: { + lineitem: lineItemUrl, + scopes: session.services?.ags?.scopes ?? [], + ...(lineItemsUrl === undefined ? {} : { lineitems: lineItemsUrl }), + }, + }, + isAssignmentAndGradesAvailable: true, + }; +} diff --git a/src/lti/canvas_dynamic_registration.ts b/src/lti/canvas_dynamic_registration.ts deleted file mode 100644 index 1e2a458..0000000 --- a/src/lti/canvas_dynamic_registration.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { - buildCanvasDeepLinkingUrl, - buildCanvasJwksUrl, - buildCanvasLaunchUrl, - requireAppOrigin, -} from './config.ts'; -import { assertCanvasDynamicRegistrationMetadata } from './dynamic_registration_support.ts'; -import { resolveCanvasPlatform } from './canvas_platform.ts'; -import { type FetchLike, performFetch } from './fetch_like.ts'; -import { - CANVAS_LTI_SCOPES, - LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - LTI_RESOURCE_LINK_REQUEST_MESSAGE_TYPE, -} from './types.ts'; - -const LTI_TOOL_CONFIGURATION_CLAIM = 'https://purl.imsglobal.org/spec/lti-tool-configuration'; -const CANVAS_PRIVACY_LEVEL_EXTENSION = 'https://canvas.instructure.com/lti/privacy_level'; -const CANVAS_TOOL_ID_EXTENSION = 'https://canvas.instructure.com/lti/tool_id'; - -interface CanvasOpenIdProviderConfiguration { - issuer: string; - authorizationEndpoint: string; - registrationEndpoint: string; -} - -export function buildCanvasDynamicRegistrationUrl( - appId: string, - state: string | null = null, - appOrigin = requireAppOrigin(), -): string { - const url = new URL( - `${appOrigin}/admin/packages/${encodeURIComponent(appId)}/deployment/register/canvas`, - ); - - if (state !== null) { - url.searchParams.set('state', state); - } - - return url.toString(); -} - -export async function completeCanvasDynamicRegistration(input: { - appTitle: string; - openidConfigurationUrl: string; - registrationToken: string; - appOrigin?: string; - fetch?: FetchLike; -}): Promise<{ - canvasEnvironment: 'production' | 'beta' | 'test'; - issuer: string; - clientId: string; -}> { - const appOrigin = input.appOrigin ?? requireAppOrigin(); - const providerConfiguration = await loadCanvasOpenIdProviderConfiguration({ - openidConfigurationUrl: input.openidConfigurationUrl, - registrationToken: input.registrationToken, - ...(input.fetch === undefined ? {} : { fetch: input.fetch }), - }); - const registrationResponse = await submitCanvasRegistration({ - appOrigin, - appTitle: input.appTitle, - registrationEndpoint: providerConfiguration.registrationEndpoint, - registrationToken: input.registrationToken, - ...(input.fetch === undefined ? {} : { fetch: input.fetch }), - }); - const platform = resolveCanvasPlatform(providerConfiguration.issuer); - - return { - canvasEnvironment: platform.environment, - issuer: providerConfiguration.issuer, - clientId: requireString(registrationResponse, 'client_id'), - }; -} - -async function loadCanvasOpenIdProviderConfiguration(input: { - openidConfigurationUrl: string; - registrationToken: string; - fetch?: FetchLike; -}): Promise { - const response = await performFetch( - input.openidConfigurationUrl, - { - headers: { - authorization: `Bearer ${input.registrationToken}`, - }, - }, - input.fetch, - ); - - if (!response.ok) { - throw new Error(`Canvas openid_configuration fetch failed with status ${response.status}.`); - } - - const record = requireObject( - await response.json(), - 'Canvas openid_configuration must be a JSON object.', - ); - const providerConfiguration = { - issuer: requireString(record, 'issuer'), - authorizationEndpoint: requireString(record, 'authorization_endpoint'), - registrationEndpoint: requireString(record, 'registration_endpoint'), - }; - - assertCanvasDynamicRegistrationMetadata({ - openidConfigurationUrl: input.openidConfigurationUrl, - ...providerConfiguration, - }); - - return providerConfiguration; -} - -async function submitCanvasRegistration(input: { - appOrigin: string; - appTitle: string; - registrationEndpoint: string; - registrationToken: string; - fetch?: FetchLike; -}): Promise> { - const response = await performFetch( - input.registrationEndpoint, - { - method: 'POST', - headers: { - authorization: `Bearer ${input.registrationToken}`, - 'content-type': 'application/json', - }, - body: JSON.stringify(buildCanvasRegistrationRequest(input.appOrigin, input.appTitle)), - }, - input.fetch, - ); - - if (!response.ok) { - throw new Error(`Canvas registration failed with status ${response.status}.`); - } - - return requireObject( - await response.json(), - 'Canvas registration response must be a JSON object.', - ); -} - -function buildCanvasRegistrationRequest(appOrigin: string, appTitle: string) { - const origin = new URL(appOrigin); - const launchUrl = buildCanvasLaunchUrl(appOrigin); - const deepLinkingUrl = buildCanvasDeepLinkingUrl(appOrigin); - const resourceSelectionUrl = `${deepLinkingUrl}?placement=resource_selection`; - - return { - application_type: 'web', - response_types: ['id_token'], - grant_types: ['implicit', 'client_credentials'], - initiate_login_uri: `${appOrigin}/lti/login`, - redirect_uris: [launchUrl, deepLinkingUrl, resourceSelectionUrl], - client_name: `${appTitle} via Lantern`, - jwks_uri: buildCanvasJwksUrl(appOrigin), - scope: CANVAS_LTI_SCOPES.join(' '), - token_endpoint_auth_method: 'private_key_jwt', - [LTI_TOOL_CONFIGURATION_CLAIM]: { - domain: origin.host, - description: `Launch the approved ${appTitle} activity through Lantern's governed runtime.`, - target_link_uri: launchUrl, - claims: ['iss', 'sub', 'name', 'given_name', 'family_name', 'email', 'preferred_username'], - messages: [ - { - type: LTI_RESOURCE_LINK_REQUEST_MESSAGE_TYPE, - target_link_uri: launchUrl, - label: 'Lantern Demo', - placements: ['course_navigation'], - }, - { - type: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - target_link_uri: deepLinkingUrl, - label: 'Select Lantern activity', - placements: ['assignment_selection'], - }, - { - type: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - target_link_uri: resourceSelectionUrl, - label: 'Select Lantern activity', - placements: ['resource_selection'], - }, - ], - [CANVAS_PRIVACY_LEVEL_EXTENSION]: 'public', - [CANVAS_TOOL_ID_EXTENSION]: 'lantern-demo-broker', - }, - }; -} - -function requireObject(value: unknown, message: string): Record { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new Error(message); - } - - return value as Record; -} - -function requireString(record: Record, key: string): string { - const value = record[key]; - - if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`Canvas registration data is missing "${key}".`); - } - - return value; -} diff --git a/src/lti/config.ts b/src/lti/config.ts index cf12c31..102ea68 100644 --- a/src/lti/config.ts +++ b/src/lti/config.ts @@ -59,8 +59,8 @@ export type CanvasConfigPlacement = text: string; }; -export function requireAppOrigin(): string { - const appOrigin = readEnv(APP_ORIGIN_ENV)?.trim(); +export function requireAppOrigin(env?: EnvReader): string { + const appOrigin = readEnv(APP_ORIGIN_ENV, env)?.trim(); if (!appOrigin) { throw new Error( diff --git a/src/lti/config_test.ts b/src/lti/config_test.ts index 1b0b0a9..c280675 100644 --- a/src/lti/config_test.ts +++ b/src/lti/config_test.ts @@ -1,6 +1,6 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; import { CANVAS_LTI_SCOPES } from './types.ts'; -import { getTestToolPrivateJwkEnvValue } from '../test_helpers/lti.ts'; +import { getTestToolPrivateJwkEnvValue, TEST_LTI_STATE_SECRET } from '../test_helpers/lti.ts'; const TEST_CONFIG_ENV = { get(name: string): string | undefined { @@ -8,6 +8,8 @@ const TEST_CONFIG_ENV = { ? 'http://localhost:8417' : name === 'LTI_TOOL_PRIVATE_JWK' ? getTestToolPrivateJwkEnvValue() + : name === 'LTI_STATE_SECRET' + ? TEST_LTI_STATE_SECRET : undefined; }, }; @@ -15,9 +17,11 @@ const TEST_CONFIG_ENV = { Deno.test('GET /lti/canvas/config.json publishes one supported Canvas config document', async () => { const previousOrigin = Deno.env.get('APP_ORIGIN'); const previousJwk = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); + const previousStateSecret = Deno.env.get('LTI_STATE_SECRET'); Deno.env.set('APP_ORIGIN', 'http://localhost:8417'); Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); + Deno.env.set('LTI_STATE_SECRET', TEST_LTI_STATE_SECRET); try { const { createApp } = await import('../app.ts'); @@ -41,13 +45,16 @@ Deno.test('GET /lti/canvas/config.json publishes one supported Canvas config doc } finally { restoreEnv('APP_ORIGIN', previousOrigin); restoreEnv('LTI_TOOL_PRIVATE_JWK', previousJwk); + restoreEnv('LTI_STATE_SECRET', previousStateSecret); } }); Deno.test('GET /lti/jwks.json exposes only the public Lantern tool JWK', async () => { const previousJwk = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); + const previousStateSecret = Deno.env.get('LTI_STATE_SECRET'); Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); + Deno.env.set('LTI_STATE_SECRET', TEST_LTI_STATE_SECRET); try { const { createApp } = await import('../app.ts'); @@ -61,6 +68,7 @@ Deno.test('GET /lti/jwks.json exposes only the public Lantern tool JWK', async ( assertEquals(body.includes('"d"'), false); } finally { restoreEnv('LTI_TOOL_PRIVATE_JWK', previousJwk); + restoreEnv('LTI_STATE_SECRET', previousStateSecret); } }); diff --git a/src/lti/context_factory.ts b/src/lti/context_factory.ts new file mode 100644 index 0000000..f2e602e --- /dev/null +++ b/src/lti/context_factory.ts @@ -0,0 +1,26 @@ +import type { LTIStorage } from '@longsightgroup/lti-tool'; +import type { EnvReader } from '../platform/env.ts'; +import { createLanternLtiToolContext, type LanternLtiToolContext } from './tool_provider.ts'; + +export function createCachedLtiToolContextFactory(input: { + env: EnvReader; + storage: LTIStorage; +}): (appOrigin?: string) => Promise { + const contexts = new Map>(); + + return (appOrigin) => { + const cacheKey = appOrigin ?? ''; + let context = contexts.get(cacheKey); + + if (context === undefined) { + context = createLanternLtiToolContext({ + env: input.env, + storage: input.storage, + ...(appOrigin === undefined ? {} : { appOrigin }), + }); + contexts.set(cacheKey, context); + } + + return context; + }; +} diff --git a/src/lti/deep_linking.ts b/src/lti/deep_linking.ts index 7b9e776..9553fad 100644 --- a/src/lti/deep_linking.ts +++ b/src/lti/deep_linking.ts @@ -1,4 +1,3 @@ -export { validateDeepLinkingRequest } from './deep_linking_request.ts'; export { authorizeDeepLinkingSession, createDeepLinkingSession, diff --git a/src/lti/deep_linking_content_item.ts b/src/lti/deep_linking_content_item.ts new file mode 100644 index 0000000..890164f --- /dev/null +++ b/src/lti/deep_linking_content_item.ts @@ -0,0 +1,103 @@ +import { createLtiResourceLinkContentItem } from '@longsightgroup/lti-tool'; +import { buildCanvasLaunchUrl } from './config.ts'; +import { + buildLtiActivityResourceId, + type DeepLinkingPresentationDocumentTarget, + type DeepLinkingResponseContentItem, + type DeepLinkingSessionRecord, + LANTERN_PLACEMENT_CUSTOM_KEY, +} from './types.ts'; +import type { PackageVersionRecord, ReviewedPlacementRecord } from '../package_review/types.ts'; + +const FINAL_GRADE_TAG = 'final-grade'; + +export function buildDeepLinkingResourceLinkContentItem(input: { + session: DeepLinkingSessionRecord; + placement: ReviewedPlacementRecord; + packageVersion: PackageVersionRecord; + appOrigin?: string; +}): DeepLinkingResponseContentItem { + const title = buildPlacementTitle(input.placement); + const baseContentItem = { + title, + text: 'Launches one reviewed Lantern activity through the governed broker.', + url: buildCanvasLaunchUrl(input.appOrigin), + custom: { + [LANTERN_PLACEMENT_CUSTOM_KEY]: input.placement.placementId, + }, + ...buildOptionalLineItem({ + session: input.session, + placement: input.placement, + packageVersion: input.packageVersion, + title, + }), + }; + const validated = createLtiResourceLinkContentItem(baseContentItem); + const presentationDocumentTarget = resolvePresentationDocumentTarget( + input.session.acceptPresentationDocumentTargets, + ); + + return { + type: validated.type, + ...baseContentItem, + ...(presentationDocumentTarget === null ? {} : { + presentation: { + documentTarget: presentationDocumentTarget, + }, + }), + }; +} + +function buildPlacementTitle(placement: ReviewedPlacementRecord): string { + return placement.contentTitle ?? `${placement.packageTitle} ${placement.packageVersion}`; +} + +function buildOptionalLineItem(input: { + session: DeepLinkingSessionRecord; + placement: ReviewedPlacementRecord; + packageVersion: PackageVersionRecord; + title: string; +}): { + lineItem?: { + label: string; + scoreMaximum: number; + resourceId: string; + tag: string; + }; +} { + if ( + !input.session.acceptLineItem || input.session.placement !== 'assignment_selection' || + input.packageVersion.grading.maxScore === null + ) { + return {}; + } + + return { + lineItem: { + label: input.title, + scoreMaximum: input.packageVersion.grading.maxScore, + resourceId: buildReviewedPlacementLineItemResourceId(input.placement), + tag: FINAL_GRADE_TAG, + }, + }; +} + +function resolvePresentationDocumentTarget( + targets: DeepLinkingSessionRecord['acceptPresentationDocumentTargets'], +): DeepLinkingPresentationDocumentTarget | null { + if (targets.length === 0) { + return null; + } + + return targets.includes('iframe') ? 'iframe' : (targets[0] ?? null); +} + +export function buildReviewedPlacementLineItemResourceId( + placement: ReviewedPlacementRecord, +): string { + return buildLtiActivityResourceId({ + appId: placement.appId, + packageVersion: placement.packageVersion, + activityId: placement.activityId, + }); +} diff --git a/src/lti/deep_linking_launch_settings.ts b/src/lti/deep_linking_launch_settings.ts new file mode 100644 index 0000000..c6bb68b --- /dev/null +++ b/src/lti/deep_linking_launch_settings.ts @@ -0,0 +1,72 @@ +import { decodeJwt } from 'jose'; + +const CLAIM_DEEP_LINKING_SETTINGS = + 'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'; + +export type DeepLinkingAcceptLineItemClaim = + | { + success: true; + value: boolean; + } + | { + success: false; + message: string; + }; + +export async function readLaunchIdToken(request: Request): Promise { + let formData: FormData; + + try { + formData = await request.formData(); + } catch { + return null; + } + + const idToken = formData.get('id_token'); + + return typeof idToken === 'string' && idToken.trim() !== '' ? idToken : null; +} + +export function readVerifiedDeepLinkingAcceptLineItemClaim( + idToken: string | null, +): DeepLinkingAcceptLineItemClaim { + if (idToken === null) { + return { success: true, value: false }; + } + + try { + return readDeepLinkingAcceptLineItemFromPayload(decodeJwt(idToken)); + } catch { + return { + success: false, + message: 'Verified Deep Linking id_token could not be decoded.', + }; + } +} + +function readDeepLinkingAcceptLineItemFromPayload( + payload: Record, +): DeepLinkingAcceptLineItemClaim { + const settings = payload[CLAIM_DEEP_LINKING_SETTINGS]; + + if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) { + return { success: true, value: false }; + } + + // SAFETY: The object/null/array guard above narrows the JWT claim to a record-like shape; + // this only reads one optional extension field not exposed by lti-tool@0.1.3. + const acceptLineItem = (settings as Record).accept_lineitem; + + if (acceptLineItem === undefined) { + return { success: true, value: false }; + } + + if (typeof acceptLineItem !== 'boolean') { + return { + success: false, + message: 'Deep Linking accept_lineitem must be a boolean when provided.', + }; + } + + return { success: true, value: acceptLineItem }; +} diff --git a/src/lti/deep_linking_request.ts b/src/lti/deep_linking_request.ts deleted file mode 100644 index 07df98a..0000000 --- a/src/lti/deep_linking_request.ts +++ /dev/null @@ -1,711 +0,0 @@ -import type { JSONWebKeySet } from 'jose'; -import type { PackageReviewRepository } from '../package_review/repository.ts'; -import { recordInteropPathUsed } from '../interop_audit.ts'; -import { - buildRejectionDetailRecord, - type LtiBoundaryDenial, - LtiBoundaryDenialError, -} from './launch_rejection.ts'; -import { - optionalRecordClaim, - optionalStringClaim, - optionalTypedStringClaim, - readStringArray, - requireOptionalBooleanClaim, - requireRecordClaim, - requireStringClaim, - requireTrimmedValue, - resolveUserRole, - validateLtiAudience, -} from './claim_support.ts'; -import { verifyIdTokenWithJwksRetry } from './id_token_verification.ts'; -import { - assertLanternTargetLinkKind, - resolveLanternDeepLinkingPlacement, - targetLinkUrisMatch, - targetLinkUriUsesLanternDriftTolerance, -} from './target_link_uri.ts'; -import { getLtiProfileDefinition } from './profile.ts'; -import { resolveLtiProfileForDeployment } from './profile_resolution.ts'; -import { formatLmsLabel, resolveBindingJwksUrl } from './platform_binding.ts'; -import { loadJwks } from './token_support.ts'; -import type { - DeepLinkingAcceptType, - DeepLinkingPresentationDocumentTarget, - DeepLinkingSettings, - LtiPlacement, - ValidatedDeepLinkingRequest, -} from './types.ts'; -import { LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE } from './types.ts'; - -const CLAIM_MESSAGE_TYPE = 'https://purl.imsglobal.org/spec/lti/claim/message_type'; -const CLAIM_VERSION = 'https://purl.imsglobal.org/spec/lti/claim/version'; -const CLAIM_DEPLOYMENT_ID = 'https://purl.imsglobal.org/spec/lti/claim/deployment_id'; -const CLAIM_TARGET_LINK_URI = 'https://purl.imsglobal.org/spec/lti/claim/target_link_uri'; -const CLAIM_CONTEXT = 'https://purl.imsglobal.org/spec/lti/claim/context'; -const CLAIM_ROLES = 'https://purl.imsglobal.org/spec/lti/claim/roles'; -const CLAIM_DEEP_LINKING_SETTINGS = - 'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'; - -export type DeepLinkingRejectionCode = - | 'audience_mismatch' - | 'deployment_binding_missing' - | 'invalid_value' - | 'login_state_expired' - | 'login_state_missing' - | 'login_state_used' - | 'missing_required_value' - | 'nonce_bridge_not_allowed' - | 'request_mismatch' - | 'signature_validation_failed' - | 'target_link_uri_drift_not_allowed' - | 'unsupported_deep_linking_accept_type' - | 'unsupported_deep_linking_presentation_target' - | 'unsupported_lti_version' - | 'unsupported_message_type' - | 'unsupported_target_link_uri'; - -export interface DeepLinkingRejection extends LtiBoundaryDenial { - code: DeepLinkingRejectionCode; -} - -export class DeepLinkingRequestRejectionError extends LtiBoundaryDenialError { - readonly rejection: DeepLinkingRejection; - - constructor(rejection: DeepLinkingRejection) { - super(rejection); - this.name = 'DeepLinkingRequestRejectionError'; - this.rejection = rejection; - } - - override get code(): DeepLinkingRejectionCode { - return this.rejection.code; - } -} - -export function isDeepLinkingRequestRejectionError( - error: unknown, -): error is DeepLinkingRequestRejectionError { - return error instanceof DeepLinkingRequestRejectionError; -} - -export async function validateDeepLinkingRequest(input: { - repository: PackageReviewRepository; - state: string; - idToken: string; - now?: () => Date; - loadJwks?: (url: string) => Promise; -}): Promise { - const now = input.now ?? (() => new Date()); - const loadDeepLinkingJwks = input.loadJwks ?? loadJwks; - const state = requireDeepLinkingTrimmedValue({ - value: input.state, - field: 'state', - message: 'Deep Linking state is required.', - }); - const idToken = requireDeepLinkingTrimmedValue({ - value: input.idToken, - field: 'id_token', - message: 'Deep Linking id_token is required.', - }); - const loginState = await input.repository.getLoginStateByState(state); - - if (!loginState) { - rejectDeepLinkingSpecInvalid({ - code: 'login_state_missing', - message: `Login state ${state} was not found.`, - detail: { state }, - }); - } - - if (loginState.usedAt !== null) { - rejectDeepLinkingSpecInvalid({ - code: 'login_state_used', - message: `Login state ${state} has already been used.`, - detail: { state }, - }); - } - - if (Date.parse(loginState.expiresAt) <= now().getTime()) { - rejectDeepLinkingSpecInvalid({ - code: 'login_state_expired', - message: `Login state ${state} has expired.`, - detail: { state }, - }); - } - - const deployment = await input.repository.getDeploymentByBinding({ - lms: loginState.lms, - issuer: loginState.issuer, - clientId: loginState.clientId, - deploymentId: loginState.deploymentId, - }); - - if (!deployment?.binding) { - rejectDeepLinkingSpecInvalid({ - code: 'deployment_binding_missing', - message: `${ - formatLmsLabel( - loginState.lms, - ) - } deployment ${loginState.clientId} / ${loginState.deploymentId} was not found for issuer ${loginState.issuer}.`, - detail: { - issuer: loginState.issuer, - clientId: loginState.clientId, - deploymentId: loginState.deploymentId, - }, - }); - } - const ltiProfile = await resolveLtiProfileForDeployment({ - repository: input.repository, - deployment, - }); - const behavior = getLtiProfileDefinition(ltiProfile.id).behavior; - - let payload: Awaited>; - - try { - payload = await verifyIdTokenWithJwksRetry({ - idToken, - jwksUrl: resolveBindingJwksUrl(deployment.binding), - issuer: loginState.issuer, - audience: loginState.clientId, - now, - loadJwks: loadDeepLinkingJwks, - allowRetry: behavior.retryJwksRefetchOnce, - onRetry: async () => { - await recordInteropPathUsed({ - repository: input.repository, - scope: 'deep_linking', - path: 'jwks_refetch', - actorType: 'platform', - deploymentRecordId: deployment.id, - packageVersionId: deployment.enabledPackageVersionId, - summary: 'Lantern refetched platform JWKS during Deep Linking validation.', - detail: { - deploymentSlug: deployment.slug, - issuer: loginState.issuer, - clientId: loginState.clientId, - deploymentId: loginState.deploymentId, - }, - ltiProfile, - }); - }, - }); - } catch { - rejectDeepLinkingSpecInvalid({ - code: 'signature_validation_failed', - message: 'Deep Linking id_token signature or issuer validation failed.', - detail: {}, - }); - } - - validateDeepLinkingAudience({ - aud: payload.aud, - azp: payload.azp, - clientId: loginState.clientId, - subject: 'Deep Linking', - }); - - const deploymentId = requireDeepLinkingStringClaim({ - value: payload[CLAIM_DEPLOYMENT_ID], - claim: 'deployment_id', - message: 'Deep Linking deployment_id is required.', - }); - const targetLinkUri = requireDeepLinkingStringClaim({ - value: payload[CLAIM_TARGET_LINK_URI], - claim: 'target_link_uri', - message: 'Deep Linking target_link_uri is required.', - }); - const placement = requireDeepLinkingRouteTarget(targetLinkUri); - const nonce = resolveDeepLinkingNonce({ - payload, - allowJtiBridge: behavior.allowDeepLinkingJtiNonceBridge, - }); - const messageType = requireDeepLinkingStringClaim({ - value: payload[CLAIM_MESSAGE_TYPE], - claim: 'message_type', - message: 'Deep Linking message_type is required.', - }); - const version = requireDeepLinkingStringClaim({ - value: payload[CLAIM_VERSION], - claim: 'version', - message: 'Deep Linking LTI version is required.', - }); - - if (deploymentId !== loginState.deploymentId) { - rejectDeepLinkingRequestMismatch({ - field: 'deployment_id', - target: 'saved login state', - message: 'Deep Linking deployment_id did not match the saved login state.', - }); - } - - if ( - !targetLinkUrisMatch({ - expected: loginState.targetLinkUri, - actual: targetLinkUri, - allowLanternDrift: behavior.tolerateTargetLinkUriDrift, - }) - ) { - if ( - targetLinkUriUsesLanternDriftTolerance({ - expected: loginState.targetLinkUri, - actual: targetLinkUri, - }) - ) { - rejectDeepLinkingPolicyDenied({ - code: 'target_link_uri_drift_not_allowed', - message: 'Deep Linking target_link_uri drift is not allowed for the active LTI profile.', - detail: { - expectedTargetLinkUri: loginState.targetLinkUri, - actualTargetLinkUri: targetLinkUri, - }, - }); - } - - rejectDeepLinkingRequestMismatch({ - field: 'target_link_uri', - target: 'saved login state', - message: 'Deep Linking target_link_uri did not match the saved login state.', - }); - } - if ( - behavior.tolerateTargetLinkUriDrift && - targetLinkUriUsesLanternDriftTolerance({ - expected: loginState.targetLinkUri, - actual: targetLinkUri, - }) - ) { - await recordInteropPathUsed({ - repository: input.repository, - scope: 'deep_linking', - path: 'target_link_uri_drift', - actorType: 'platform', - deploymentRecordId: deployment.id, - packageVersionId: deployment.enabledPackageVersionId, - summary: 'Lantern tolerated bounded target_link_uri drift during Deep Linking validation.', - detail: { - deploymentSlug: deployment.slug, - issuer: loginState.issuer, - clientId: loginState.clientId, - deploymentId: loginState.deploymentId, - expectedTargetLinkUri: loginState.targetLinkUri, - actualTargetLinkUri: targetLinkUri, - }, - ltiProfile, - }); - } - if (nonce.value !== loginState.nonce) { - rejectDeepLinkingRequestMismatch({ - field: 'nonce', - target: 'saved login state', - message: 'Deep Linking nonce did not match the saved login state.', - }); - } - - if (messageType !== LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE) { - rejectDeepLinkingSpecInvalid({ - code: 'unsupported_message_type', - message: `Unsupported LTI message type ${messageType}.`, - detail: { - messageType, - supportedMessageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - }, - }); - } - - if (version !== '1.3.0') { - rejectDeepLinkingSpecInvalid({ - code: 'unsupported_lti_version', - message: `Unsupported LTI version ${version}.`, - detail: { - version, - supportedVersion: '1.3.0', - }, - }); - } - - if (deploymentId !== deployment.binding.deploymentId) { - rejectDeepLinkingRequestMismatch({ - field: 'deployment_id', - target: 'saved deployment binding', - message: 'Deep Linking deployment_id did not match the saved deployment binding.', - }); - } - - if (nonce.source === 'jti') { - await recordInteropPathUsed({ - repository: input.repository, - scope: 'deep_linking', - path: 'jti_nonce_bridge', - actorType: 'platform', - deploymentRecordId: deployment.id, - packageVersionId: deployment.enabledPackageVersionId, - summary: 'Lantern accepted jti as the Deep Linking nonce value.', - detail: { - deploymentSlug: deployment.slug, - issuer: loginState.issuer, - clientId: loginState.clientId, - deploymentId: loginState.deploymentId, - }, - ltiProfile, - }); - } - - const settings = parseDeepLinkingSettingsClaim(payload[CLAIM_DEEP_LINKING_SETTINGS]); - const context = optionalDeepLinkingRecordClaim({ - value: payload[CLAIM_CONTEXT], - claim: 'context', - message: 'Deep Linking context claim must be an object when provided.', - }); - const consumedState = await input.repository.consumeLoginState({ - state, - usedAt: now().toISOString(), - }); - - return { - lms: deployment.binding.lms, - internalDeploymentId: deployment.id, - internalDeploymentSlug: deployment.slug, - appId: deployment.appId, - userId: optionalStringClaim(payload.sub), - userRole: resolveUserRole(payload[CLAIM_ROLES]), - contextId: optionalStringClaim(context?.id), - contextTitle: optionalStringClaim(context?.title), - targetLinkUri, - deepLinkReturnUrl: settings.deepLinkReturnUrl, - data: settings.data, - placement, - settings: { - acceptTypes: settings.acceptTypes, - acceptMultiple: settings.acceptMultiple, - acceptPresentationDocumentTargets: settings.acceptPresentationDocumentTargets, - acceptLineItem: settings.acceptLineItem, - }, - issuedAt: now().toISOString(), - canvasEnvironment: consumedState.canvasEnvironment, - issuer: consumedState.issuer, - clientId: consumedState.clientId, - deploymentId: consumedState.deploymentId, - }; -} - -function resolveDeepLinkingNonce(input: { - payload: Record; - allowJtiBridge: boolean; -}): { - value: string; - source: 'nonce' | 'jti'; -} { - const nonce = optionalStringClaim(input.payload.nonce); - - if (nonce !== null) { - return { - value: nonce, - source: 'nonce', - }; - } - - const jti = optionalStringClaim(input.payload.jti); - - if (jti !== null) { - if (!input.allowJtiBridge) { - rejectDeepLinkingPolicyDenied({ - code: 'nonce_bridge_not_allowed', - message: 'Deep Linking nonce must use the nonce claim for the active LTI profile.', - detail: { - expectedNonceSource: 'nonce', - actualNonceSource: 'jti', - }, - }); - } - - return { - value: jti, - source: 'jti', - }; - } - - rejectDeepLinkingSpecInvalid({ - code: 'missing_required_value', - message: 'Deep Linking nonce is required.', - detail: { claim: 'nonce' }, - }); -} - -function requireDeepLinkingRouteTarget(targetLinkUri: string): LtiPlacement { - try { - assertLanternTargetLinkKind({ - targetLinkUri, - kind: 'deep_linking', - message: `Unsupported Deep Linking target_link_uri ${targetLinkUri}.`, - }); - } catch { - rejectDeepLinkingSpecInvalid({ - code: 'unsupported_target_link_uri', - message: `Unsupported Deep Linking target_link_uri ${targetLinkUri}.`, - detail: { targetLinkUri }, - }); - } - - const placement = resolveLanternDeepLinkingPlacement(targetLinkUri); - - if (placement === null) { - rejectDeepLinkingSpecInvalid({ - code: 'unsupported_target_link_uri', - message: `Unsupported Deep Linking placement in target_link_uri ${targetLinkUri}.`, - detail: { targetLinkUri }, - }); - } - - return placement; -} - -function parseDeepLinkingSettingsClaim(value: unknown): DeepLinkingSettings & { - deepLinkReturnUrl: string; - data: string | null; -} { - const settings = requireDeepLinkingRecordClaim({ - value, - claim: 'deep_linking_settings', - message: 'Deep Linking settings claim is required.', - }); - const acceptTypes = parseAcceptTypes(settings.accept_types); - - return { - acceptTypes, - acceptMultiple: requireDeepLinkingOptionalBooleanClaim({ - value: settings.accept_multiple, - claim: 'accept_multiple', - message: 'Deep Linking accept_multiple must be a boolean when provided.', - fallback: false, - }), - acceptPresentationDocumentTargets: parsePresentationDocumentTargets( - settings.accept_presentation_document_targets, - ), - acceptLineItem: requireDeepLinkingOptionalBooleanClaim({ - value: settings.accept_lineitem, - claim: 'accept_lineitem', - message: 'Deep Linking accept_lineitem must be a boolean when provided.', - fallback: false, - }), - deepLinkReturnUrl: requireDeepLinkingStringClaim({ - value: settings.deep_link_return_url, - claim: 'deep_link_return_url', - message: 'Deep Linking return URL is required.', - }), - data: optionalDeepLinkingTypedStringClaim({ - value: settings.data, - claim: 'data', - message: 'Deep Linking data must be a string when provided.', - }), - }; -} - -function parseAcceptTypes(value: unknown): DeepLinkingAcceptType[] { - const items = readStringArray(value); - - if (items.length === 0) { - rejectDeepLinkingSpecInvalid({ - code: 'missing_required_value', - message: 'Deep Linking accept_types must include ltiResourceLink.', - detail: { claim: 'accept_types' }, - }); - } - - const unsupported = items.filter((item) => item !== 'ltiResourceLink'); - - if (unsupported.length > 0) { - rejectDeepLinkingSpecInvalid({ - code: 'unsupported_deep_linking_accept_type', - message: `Unsupported Deep Linking accept_types: ${unsupported.join(', ')}.`, - detail: { acceptTypes: unsupported.join(', ') }, - }); - } - - return ['ltiResourceLink']; -} - -function parsePresentationDocumentTargets(value: unknown): DeepLinkingPresentationDocumentTarget[] { - const items = readStringArray(value); - - if (items.length === 0) { - return []; - } - - const supported = new Set(['embed', 'iframe', 'window']); - - for (const item of items) { - if (!supported.has(item as DeepLinkingPresentationDocumentTarget)) { - rejectDeepLinkingSpecInvalid({ - code: 'unsupported_deep_linking_presentation_target', - message: `Unsupported Deep Linking presentation target ${item}.`, - detail: { presentationTarget: item }, - }); - } - } - - return Array.from(new Set(items)) as DeepLinkingPresentationDocumentTarget[]; -} - -function requireDeepLinkingTrimmedValue(input: { - value: string; - field: string; - message: string; -}): string { - try { - return requireTrimmedValue(input.value, input.message); - } catch { - rejectDeepLinkingSpecInvalid({ - code: 'missing_required_value', - message: input.message, - detail: { field: input.field }, - }); - } -} - -function requireDeepLinkingStringClaim(input: { - value: unknown; - claim: string; - message: string; -}): string { - try { - return requireStringClaim(input.value, input.message); - } catch { - rejectDeepLinkingSpecInvalid({ - code: 'missing_required_value', - message: input.message, - detail: { claim: input.claim }, - }); - } -} - -function requireDeepLinkingRecordClaim(input: { - value: unknown; - claim: string; - message: string; -}): Record { - try { - return requireRecordClaim(input.value, input.message); - } catch { - rejectDeepLinkingSpecInvalid({ - code: 'missing_required_value', - message: input.message, - detail: { claim: input.claim }, - }); - } -} - -function optionalDeepLinkingRecordClaim(input: { - value: unknown; - claim: string; - message: string; -}): Record | null { - try { - return optionalRecordClaim(input.value, input.message); - } catch { - rejectDeepLinkingSpecInvalid({ - code: 'invalid_value', - message: input.message, - detail: { claim: input.claim }, - }); - } -} - -function requireDeepLinkingOptionalBooleanClaim(input: { - value: unknown; - claim: string; - message: string; - fallback: boolean; -}): boolean { - try { - return requireOptionalBooleanClaim(input.value, input.message, input.fallback); - } catch { - rejectDeepLinkingSpecInvalid({ - code: 'invalid_value', - message: input.message, - detail: { claim: input.claim }, - }); - } -} - -function optionalDeepLinkingTypedStringClaim(input: { - value: unknown; - claim: string; - message: string; -}): string | null { - try { - return optionalTypedStringClaim(input.value, input.message); - } catch { - rejectDeepLinkingSpecInvalid({ - code: 'invalid_value', - message: input.message, - detail: { claim: input.claim }, - }); - } -} - -function validateDeepLinkingAudience(input: { - aud: string | string[] | undefined; - azp: unknown; - clientId: string; - subject: string; -}): void { - try { - validateLtiAudience(input); - } catch (error) { - rejectDeepLinkingSpecInvalid({ - code: 'audience_mismatch', - message: error instanceof Error - ? error.message - : `${input.subject} audience validation failed.`, - detail: { clientId: input.clientId }, - }); - } -} - -function rejectDeepLinkingRequestMismatch(input: { - field: string; - target: string; - message: string; -}): never { - rejectDeepLinkingSpecInvalid({ - code: 'request_mismatch', - message: input.message, - detail: { - field: input.field, - target: input.target, - }, - }); -} - -function rejectDeepLinkingSpecInvalid(input: { - code: DeepLinkingRejectionCode; - message: string; - detail: Record; -}): never { - rejectDeepLinking({ - category: 'specInvalid', - code: input.code, - message: input.message, - detail: buildRejectionDetailRecord(input.detail), - }); -} - -function rejectDeepLinkingPolicyDenied(input: { - code: DeepLinkingRejectionCode; - message: string; - detail: Record; -}): never { - rejectDeepLinking({ - category: 'policyDenied', - code: input.code, - message: input.message, - detail: buildRejectionDetailRecord(input.detail), - }); -} - -function rejectDeepLinking(rejection: DeepLinkingRejection): never { - throw new DeepLinkingRequestRejectionError(rejection); -} diff --git a/src/lti/deep_linking_request_test.ts b/src/lti/deep_linking_request_test.ts deleted file mode 100644 index 06b18e6..0000000 --- a/src/lti/deep_linking_request_test.ts +++ /dev/null @@ -1,760 +0,0 @@ -import { assertEquals, assertExists } from '@std/assert'; -import { validateDeepLinkingRequest } from './deep_linking.ts'; -import { assertRejectsDeepLinking, decodeJwtPayload } from './deep_linking_test_helpers.ts'; -import { isLtiBoundaryDenialError } from './launch_rejection.ts'; -import { - buildDeploymentBinding, - buildLoginStateRecord, - buildMoodleDeploymentBinding, - buildSakaiDeploymentBinding, - getTestCanvasJwks, - signCanvasIdToken, -} from '../test_helpers/lti.ts'; -import { - buildDeploymentRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from '../test_helpers/package_review.ts'; -import { LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE } from './types.ts'; - -Deno.test('deep linking launch helpers encode assignment-selection claims without requiring a subject claim', async () => { - const token = await signCanvasIdToken({ - nonce: 'nonce-deep-linking', - subject: null, - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', - deepLinkData: 'dl-state-123', - deepLinkAcceptTypes: ['ltiResourceLink'], - deepLinkAcceptPresentationDocumentTargets: ['iframe'], - deepLinkAcceptLineItem: false, - }); - const payload = decodeJwtPayload(token); - const settings = payload[ - 'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings' - ] as Record; - - assertEquals('sub' in payload, false); - assertExists(settings); - assertEquals(settings.accept_types, ['ltiResourceLink']); - assertEquals(settings.accept_multiple, false); - assertEquals(settings.accept_presentation_document_targets, ['iframe']); - assertEquals(settings.deep_link_return_url, 'https://canvas.example/courses/42/deep_link_return'); -}); - -Deno.test('deep linking validator accepts a supported assignment-selection launch and preserves authoring settings', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - installScope: 'assignment', - approvalStatus: 'approved', - reviewedAt: '2026-03-24T16:15:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - loginStates: [ - buildLoginStateRecord({ - state: 'state-deep-linking', - nonce: 'nonce-deep-linking', - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - createdAt: '2026-03-24T16:10:00Z', - expiresAt: '2026-03-24T16:20:00Z', - }), - ], - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-deep-linking', - subject: null, - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', - deepLinkData: 'dl-state-123', - roles: ['http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'], - }); - const request = await validateDeepLinkingRequest({ - repository, - state: 'state-deep-linking', - idToken, - now: () => new Date('2026-03-24T16:15:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }); - - assertEquals(request?.placement, 'assignment_selection'); - assertEquals(request?.userId, null); - assertEquals(request?.userRole, 'instructor'); - assertEquals(request?.settings.acceptTypes, ['ltiResourceLink']); - assertEquals(request?.deepLinkReturnUrl, 'https://canvas.example/courses/42/deep_link_return'); - const savedState = await repository.getLoginStateByState('state-deep-linking'); - - assertEquals(savedState?.usedAt !== null, true); -}); - -Deno.test('deep linking validator rejects unsupported content-item types without consuming login state', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - id: 7, - appId: 'chapter-4-asteroids', - binding: buildDeploymentBinding(), - }), - ], - loginStates: [ - buildLoginStateRecord({ - state: 'state-deep-linking-unsupported', - nonce: 'nonce-deep-linking-unsupported', - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - createdAt: '2026-03-24T16:10:00Z', - expiresAt: '2026-03-24T16:20:00Z', - }), - ], - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-deep-linking-unsupported', - subject: null, - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', - deepLinkAcceptTypes: ['html'], - }); - - await assertRejectsDeepLinking( - () => - validateDeepLinkingRequest({ - repository, - state: 'state-deep-linking-unsupported', - idToken, - now: () => new Date('2026-03-24T16:15:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }), - 'Unsupported Deep Linking accept_types: html.', - ); - - const savedState = await repository.getLoginStateByState('state-deep-linking-unsupported'); - - assertEquals(savedState?.usedAt, null); -}); - -Deno.test('deep linking validator rejects unsupported content-item types with a typed spec-invalid denial', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - id: 7, - appId: 'chapter-4-asteroids', - binding: buildDeploymentBinding(), - }), - ], - loginStates: [ - buildLoginStateRecord({ - state: 'state-deep-linking-typed-denial', - nonce: 'nonce-deep-linking-typed-denial', - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - createdAt: '2026-03-24T16:10:00Z', - expiresAt: '2026-03-24T16:20:00Z', - }), - ], - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-deep-linking-typed-denial', - subject: null, - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', - deepLinkAcceptTypes: ['html'], - }); - - try { - await validateDeepLinkingRequest({ - repository, - state: 'state-deep-linking-typed-denial', - idToken, - now: () => new Date('2026-03-24T16:15:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }); - } catch (error) { - if (!isLtiBoundaryDenialError(error)) { - throw error; - } - - assertEquals(error.code, 'unsupported_deep_linking_accept_type'); - assertEquals(error.category, 'specInvalid'); - assertEquals(error.message, 'Unsupported Deep Linking accept_types: html.'); - assertEquals(error.detail.acceptTypes, 'html'); - return; - } - - throw new Error('Expected typed Deep Linking denial.'); -}); - -Deno.test('deep linking validator retries JWKS once before rejecting a valid authoring request during key rollover', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - installScope: 'assignment', - approvalStatus: 'approved', - reviewedAt: '2026-03-24T16:15:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - ltiProfileOverride: 'governedCompatibility', - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-24T16:00:00Z', - }, - loginStates: [ - buildLoginStateRecord({ - state: 'state-deep-linking-retry', - nonce: 'nonce-deep-linking-retry', - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - createdAt: '2026-03-24T16:10:00Z', - expiresAt: '2026-03-24T16:20:00Z', - }), - ], - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-deep-linking-retry', - subject: null, - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', - }); - let jwksRequests = 0; - - const request = await validateDeepLinkingRequest({ - repository, - state: 'state-deep-linking-retry', - idToken, - now: () => new Date('2026-03-24T16:15:00Z'), - loadJwks: () => { - jwksRequests += 1; - - return Promise.resolve(jwksRequests === 1 ? { keys: [] } : getTestCanvasJwks()); - }, - }); - - assertEquals(jwksRequests, 2); - assertEquals(request.placement, 'assignment_selection'); - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - assertEquals( - interopEvents.some((event) => event.detail.path === 'jwks_refetch'), - true, - ); -}); - -Deno.test('deep linking validator rejects a key-rollover request in certification mode without retrying JWKS', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - installScope: 'assignment', - approvalStatus: 'approved', - reviewedAt: '2026-03-24T16:15:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-24T16:00:00Z', - }, - loginStates: [ - buildLoginStateRecord({ - state: 'state-deep-linking-retry-certification', - nonce: 'nonce-deep-linking-retry-certification', - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - createdAt: '2026-03-24T16:10:00Z', - expiresAt: '2026-03-24T16:20:00Z', - }), - ], - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-deep-linking-retry-certification', - subject: null, - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', - }); - let jwksRequests = 0; - - try { - await validateDeepLinkingRequest({ - repository, - state: 'state-deep-linking-retry-certification', - idToken, - now: () => new Date('2026-03-24T16:15:00Z'), - loadJwks: () => { - jwksRequests += 1; - - return Promise.resolve(jwksRequests === 1 ? { keys: [] } : getTestCanvasJwks()); - }, - }); - } catch (error) { - if (!isLtiBoundaryDenialError(error)) { - throw error; - } - - assertEquals(jwksRequests, 1); - assertEquals(error.code, 'signature_validation_failed'); - assertEquals(error.category, 'specInvalid'); - return; - } - - throw new Error('Expected certification Deep Linking denial.'); -}); - -Deno.test('deep linking validator accepts jti as the nonce-equivalent and records the bridge path', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - installScope: 'assignment', - approvalStatus: 'approved', - reviewedAt: '2026-03-24T16:15:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - ltiProfileOverride: 'governedCompatibility', - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-24T16:00:00Z', - }, - loginStates: [ - buildLoginStateRecord({ - state: 'state-deep-linking-jti', - nonce: 'nonce-deep-linking-jti', - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - createdAt: '2026-03-24T16:10:00Z', - expiresAt: '2026-03-24T16:20:00Z', - }), - ], - }); - const idToken = await signCanvasIdToken({ - nonce: null, - jwtId: 'nonce-deep-linking-jti', - subject: null, - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', - }); - - const request = await validateDeepLinkingRequest({ - repository, - state: 'state-deep-linking-jti', - idToken, - now: () => new Date('2026-03-24T16:15:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }); - - assertEquals(request.placement, 'assignment_selection'); - - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - - assertEquals( - interopEvents.some((event) => event.detail.path === 'jti_nonce_bridge'), - true, - ); -}); - -Deno.test('deep linking validator rejects the jti nonce bridge in certification mode with a policy denial', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - installScope: 'assignment', - approvalStatus: 'approved', - reviewedAt: '2026-03-24T16:15:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-24T16:00:00Z', - }, - loginStates: [ - buildLoginStateRecord({ - state: 'state-deep-linking-jti-certification', - nonce: 'nonce-deep-linking-jti-certification', - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - createdAt: '2026-03-24T16:10:00Z', - expiresAt: '2026-03-24T16:20:00Z', - }), - ], - }); - const idToken = await signCanvasIdToken({ - nonce: null, - jwtId: 'nonce-deep-linking-jti-certification', - subject: null, - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', - }); - - try { - await validateDeepLinkingRequest({ - repository, - state: 'state-deep-linking-jti-certification', - idToken, - now: () => new Date('2026-03-24T16:15:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }); - } catch (error) { - if (!isLtiBoundaryDenialError(error)) { - throw error; - } - - assertEquals(error.code, 'nonce_bridge_not_allowed'); - assertEquals(error.category, 'policyDenied'); - return; - } - - throw new Error('Expected certification Deep Linking denial.'); -}); - -Deno.test('deep linking validator tolerates target_link_uri scheme, query, and trailing-slash drift when the callback host and route still match', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - installScope: 'assignment', - approvalStatus: 'approved', - reviewedAt: '2026-03-24T16:15:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - ltiProfileOverride: 'governedCompatibility', - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-24T16:00:00Z', - }, - loginStates: [ - buildLoginStateRecord({ - state: 'state-deep-linking-normalized', - nonce: 'nonce-deep-linking-normalized', - targetLinkUri: 'https://lantern.example/lti/deep-linking/', - createdAt: '2026-03-24T16:10:00Z', - expiresAt: '2026-03-24T16:20:00Z', - }), - ], - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-deep-linking-normalized', - subject: null, - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://lantern.example/lti/deep-linking?placement=assignment_selection', - deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', - }); - - const request = await validateDeepLinkingRequest({ - repository, - state: 'state-deep-linking-normalized', - idToken, - now: () => new Date('2026-03-24T16:15:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }); - - assertEquals( - request.targetLinkUri, - 'http://lantern.example/lti/deep-linking?placement=assignment_selection', - ); - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - - assertEquals( - interopEvents.some((event) => event.detail.path === 'target_link_uri_drift'), - true, - ); -}); - -Deno.test('deep linking validator rejects target_link_uri normalization drift in certification mode with a policy denial', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - installScope: 'assignment', - approvalStatus: 'approved', - reviewedAt: '2026-03-24T16:15:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-24T16:00:00Z', - }, - loginStates: [ - buildLoginStateRecord({ - state: 'state-deep-linking-normalized-certification', - nonce: 'nonce-deep-linking-normalized-certification', - targetLinkUri: 'https://lantern.example/lti/deep-linking/', - createdAt: '2026-03-24T16:10:00Z', - expiresAt: '2026-03-24T16:20:00Z', - }), - ], - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-deep-linking-normalized-certification', - subject: null, - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://lantern.example/lti/deep-linking?placement=assignment_selection', - deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', - }); - - try { - await validateDeepLinkingRequest({ - repository, - state: 'state-deep-linking-normalized-certification', - idToken, - now: () => new Date('2026-03-24T16:15:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }); - } catch (error) { - if (!isLtiBoundaryDenialError(error)) { - throw error; - } - - assertEquals(error.code, 'target_link_uri_drift_not_allowed'); - assertEquals(error.category, 'policyDenied'); - return; - } - - throw new Error('Expected certification Deep Linking denial.'); -}); - -Deno.test('deep linking validator preserves resource_selection placement when the target_link_uri names it explicitly', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - installScope: 'assignment', - approvalStatus: 'approved', - reviewedAt: '2026-03-24T16:15:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - loginStates: [ - buildLoginStateRecord({ - state: 'state-deep-linking-resource-selection', - nonce: 'nonce-deep-linking-resource-selection', - targetLinkUri: 'https://lantern.example/lti/deep-linking?placement=resource_selection', - createdAt: '2026-03-24T16:10:00Z', - expiresAt: '2026-03-24T16:20:00Z', - }), - ], - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-deep-linking-resource-selection', - subject: null, - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: - 'http://lantern.example/lti/deep-linking/?placement=resource_selection&source=canvas', - deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', - }); - - const request = await validateDeepLinkingRequest({ - repository, - state: 'state-deep-linking-resource-selection', - idToken, - now: () => new Date('2026-03-24T16:15:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }); - - assertEquals(request.placement, 'resource_selection'); - assertEquals( - request.targetLinkUri, - 'http://lantern.example/lti/deep-linking/?placement=resource_selection&source=canvas', - ); -}); - -Deno.test('deep linking validator rejects target_link_uri placement drift between the saved login state and the launch', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - id: 7, - appId: 'chapter-4-asteroids', - binding: buildDeploymentBinding(), - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-24T16:00:00Z', - }, - loginStates: [ - buildLoginStateRecord({ - state: 'state-deep-linking-placement-mismatch', - nonce: 'nonce-deep-linking-placement-mismatch', - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - createdAt: '2026-03-24T16:10:00Z', - expiresAt: '2026-03-24T16:20:00Z', - }), - ], - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-deep-linking-placement-mismatch', - subject: null, - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://localhost:8417/lti/deep-linking?placement=resource_selection', - deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', - }); - - try { - await validateDeepLinkingRequest({ - repository, - state: 'state-deep-linking-placement-mismatch', - idToken, - now: () => new Date('2026-03-24T16:15:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }); - } catch (error) { - if (!isLtiBoundaryDenialError(error)) { - throw error; - } - - assertEquals(error.code, 'request_mismatch'); - assertEquals(error.category, 'specInvalid'); - return; - } - - throw new Error('Expected certification Deep Linking denial.'); -}); - -Deno.test('deep linking validator accepts Moodle and Sakai launches when the saved deployment binding matches', async () => { - const cases = [ - { - lms: 'moodle' as const, - state: 'state-deep-linking-moodle', - nonce: 'nonce-deep-linking-moodle', - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - }, - { - lms: 'sakai' as const, - state: 'state-deep-linking-sakai', - nonce: 'nonce-deep-linking-sakai', - issuer: 'https://sakai.example', - clientId: 'sakai-client-123', - deploymentId: 'sakai-deployment-123', - }, - ]; - - for (const testCase of cases) { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - id: 7, - appId: 'chapter-4-asteroids', - binding: testCase.lms === 'moodle' - ? buildMoodleDeploymentBinding({ - issuer: testCase.issuer, - clientId: testCase.clientId, - deploymentId: testCase.deploymentId, - }) - : buildSakaiDeploymentBinding({ - issuer: testCase.issuer, - clientId: testCase.clientId, - deploymentId: testCase.deploymentId, - }), - }), - ], - loginStates: [ - buildLoginStateRecord({ - lms: testCase.lms, - issuer: testCase.issuer, - clientId: testCase.clientId, - deploymentId: testCase.deploymentId, - state: testCase.state, - nonce: testCase.nonce, - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - createdAt: '2026-03-24T16:10:00Z', - expiresAt: '2026-03-24T16:20:00Z', - }), - ], - }); - const idToken = await signCanvasIdToken({ - deploymentBinding: { - issuer: testCase.issuer, - clientId: testCase.clientId, - deploymentId: testCase.deploymentId, - }, - nonce: testCase.nonce, - audience: testCase.clientId, - subject: null, - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - deepLinkReturnUrl: `${testCase.issuer}/deep_link_return`, - roles: ['http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'], - }); - - const request = await validateDeepLinkingRequest({ - repository, - state: testCase.state, - idToken, - now: () => new Date('2026-03-24T16:15:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }); - - const savedState = await repository.getLoginStateByState(testCase.state); - - assertEquals(request.lms, testCase.lms); - assertEquals(request.canvasEnvironment, null); - assertEquals(request.issuer, testCase.issuer); - assertEquals(request.clientId, testCase.clientId); - assertEquals(request.deploymentId, testCase.deploymentId); - assertEquals(request.deepLinkReturnUrl, `${testCase.issuer}/deep_link_return`); - assertEquals(savedState?.usedAt !== null, true); - } -}); diff --git a/src/lti/deep_linking_response.ts b/src/lti/deep_linking_response.ts deleted file mode 100644 index f71b075..0000000 --- a/src/lti/deep_linking_response.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { SignJWT } from 'jose'; -import { buildCanvasLaunchUrl } from './config.ts'; -import { loadToolSigningKey } from './tool_key.ts'; -import type { EnvReader } from '../platform/env.ts'; -import { - buildLtiActivityResourceId, - type DeepLinkingPresentationDocumentTarget, - type DeepLinkingResponseContentItem, - type DeepLinkingResponseLineItem, - type DeepLinkingResponseSubmission, - type DeepLinkingSessionRecord, - LANTERN_PLACEMENT_CUSTOM_KEY, - LTI_DEEP_LINKING_RESPONSE_MESSAGE_TYPE, -} from './types.ts'; -import type { - DeploymentRecord, - PackageVersionRecord, - ReviewedPlacementRecord, -} from '../package_review/types.ts'; - -const CLAIM_MESSAGE_TYPE = 'https://purl.imsglobal.org/spec/lti/claim/message_type'; -const CLAIM_VERSION = 'https://purl.imsglobal.org/spec/lti/claim/version'; -const CLAIM_DEPLOYMENT_ID = 'https://purl.imsglobal.org/spec/lti/claim/deployment_id'; -const CLAIM_DATA = 'https://purl.imsglobal.org/spec/lti-dl/claim/data'; -const CLAIM_CONTENT_ITEMS = 'https://purl.imsglobal.org/spec/lti-dl/claim/content_items'; -const FINAL_GRADE_TAG = 'final-grade'; -const RESPONSE_JWT_TTL_SECONDS = 5 * 60; - -export async function buildDeepLinkingResponseSubmission(input: { - session: DeepLinkingSessionRecord; - deployment: DeploymentRecord; - placement: ReviewedPlacementRecord; - packageVersion: PackageVersionRecord; - env: EnvReader; - appOrigin?: string; - now?: () => Date; -}): Promise { - const now = input.now ?? (() => new Date()); - const binding = input.deployment.binding; - - if (binding === null) { - throw new Error(`Deployment ${input.deployment.slug} is missing its LTI binding.`); - } - - const toolKey = await loadToolSigningKey(input.env); - const issuedAt = Math.floor(now().getTime() / 1000); - const contentItems = [buildDeepLinkingResponseContentItem(input)]; - const jwtId = crypto.randomUUID(); - const claims = { - nonce: jwtId, - [CLAIM_DEPLOYMENT_ID]: binding.deploymentId, - [CLAIM_MESSAGE_TYPE]: LTI_DEEP_LINKING_RESPONSE_MESSAGE_TYPE, - [CLAIM_VERSION]: '1.3.0', - [CLAIM_CONTENT_ITEMS]: contentItems, - ...(input.session.data === null ? {} : { [CLAIM_DATA]: input.session.data }), - }; - const jwt = await new SignJWT(claims) - .setProtectedHeader({ - alg: toolKey.privateJwk.alg, - kid: toolKey.publicJwk.kid, - typ: 'JWT', - }) - .setIssuer(binding.clientId) - .setSubject(binding.clientId) - .setAudience(binding.issuer) - .setIssuedAt(issuedAt) - .setExpirationTime(issuedAt + RESPONSE_JWT_TTL_SECONDS) - .setJti(jwtId) - .sign(toolKey.privateKey); - - return { - returnUrl: input.session.deepLinkReturnUrl, - jwt, - formFields: { - JWT: jwt, - }, - }; -} - -export function buildReviewedPlacementLineItemResourceId( - placement: ReviewedPlacementRecord, -): string { - return buildLtiActivityResourceId({ - appId: placement.appId, - packageVersion: placement.packageVersion, - activityId: placement.activityId, - }); -} - -function buildDeepLinkingResponseContentItem(input: { - session: DeepLinkingSessionRecord; - placement: ReviewedPlacementRecord; - packageVersion: PackageVersionRecord; - appOrigin?: string; -}): DeepLinkingResponseContentItem { - const title = input.placement.contentTitle ?? - `${input.placement.packageTitle} ${input.placement.packageVersion}`; - const lineItem = - input.session.acceptLineItem && input.session.placement === 'assignment_selection' - ? buildOptionalLineItem({ - placement: input.placement, - packageVersion: input.packageVersion, - title, - }) - : null; - const presentationDocumentTarget = resolvePresentationDocumentTarget( - input.session.acceptPresentationDocumentTargets, - ); - - return { - type: 'ltiResourceLink', - title, - text: 'Launches one reviewed Lantern activity through the governed broker.', - url: buildCanvasLaunchUrl(input.appOrigin), - custom: { - [LANTERN_PLACEMENT_CUSTOM_KEY]: input.placement.placementId, - }, - ...(presentationDocumentTarget === null ? {} : { - presentation: { - documentTarget: presentationDocumentTarget, - }, - }), - ...(lineItem === null ? {} : { lineItem }), - }; -} - -function buildOptionalLineItem(input: { - placement: ReviewedPlacementRecord; - packageVersion: PackageVersionRecord; - title: string; -}): DeepLinkingResponseLineItem | null { - if (input.packageVersion.grading.maxScore === null) { - return null; - } - - return { - scoreMaximum: input.packageVersion.grading.maxScore, - label: input.title, - resourceId: buildReviewedPlacementLineItemResourceId(input.placement), - tag: FINAL_GRADE_TAG, - }; -} - -function resolvePresentationDocumentTarget( - targets: DeepLinkingSessionRecord['acceptPresentationDocumentTargets'], -): DeepLinkingPresentationDocumentTarget | null { - if (targets.length === 0) { - return null; - } - - return targets.includes('iframe') ? 'iframe' : (targets[0] ?? null); -} diff --git a/src/lti/deep_linking_response_test.ts b/src/lti/deep_linking_response_test.ts deleted file mode 100644 index 95f8358..0000000 --- a/src/lti/deep_linking_response_test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { assertEquals, assertExists } from '@std/assert'; -import { createLocalJWKSet, jwtVerify } from 'jose'; -import { - buildDeepLinkingResponseSubmission, - buildReviewedPlacementLineItemResourceId, -} from './deep_linking_response.ts'; -import { LANTERN_PLACEMENT_CUSTOM_KEY, LTI_DEEP_LINKING_RESPONSE_MESSAGE_TYPE } from './types.ts'; -import { - buildDeepLinkingSessionRecord, - buildDeploymentBinding, - getTestToolPrivateJwkEnvValue, -} from '../test_helpers/lti.ts'; -import { - buildDeploymentRecord, - buildPackageVersionRecord, - buildReviewedPlacementRecord, -} from '../test_helpers/package_review.ts'; -import { getPublicJwkSet } from './tool_key.ts'; - -Deno.test('deep linking response builder signs one LtiDeepLinkingResponse JWT and echoes the saved data claim', async () => { - const deploymentBinding = buildDeploymentBinding(); - const response = await buildDeepLinkingResponseSubmission({ - session: buildDeepLinkingSessionRecord({ - data: 'deep-linking-state-token', - }), - deployment: buildDeploymentRecord({ - binding: deploymentBinding, - }), - placement: buildReviewedPlacementRecord(), - packageVersion: buildPackageVersionRecord({ - installScope: 'assignment', - }), - appOrigin: 'https://lantern.example', - now: () => new Date('2026-03-24T18:30:00Z'), - env: buildResponseEnv(), - }); - const verified = await verifyResponseJwt( - response.jwt, - deploymentBinding, - new Date('2026-03-24T18:31:00Z'), - ); - - assertEquals(response.returnUrl.includes('deep_link_return'), true); - assertEquals(response.formFields.JWT, response.jwt); - assertEquals(verified.payload.iss, deploymentBinding.clientId); - assertEquals(verified.payload.sub, deploymentBinding.clientId); - assertEquals(verified.payload.aud, deploymentBinding.issuer); - assertEquals(typeof verified.payload.jti, 'string'); - assertEquals(verified.payload.nonce, verified.payload.jti); - assertEquals( - verified.payload['https://purl.imsglobal.org/spec/lti/claim/deployment_id'], - deploymentBinding.deploymentId, - ); - assertEquals( - verified.payload['https://purl.imsglobal.org/spec/lti/claim/message_type'], - LTI_DEEP_LINKING_RESPONSE_MESSAGE_TYPE, - ); - assertEquals(verified.payload['https://purl.imsglobal.org/spec/lti/claim/version'], '1.3.0'); - assertEquals( - verified.payload['https://purl.imsglobal.org/spec/lti-dl/claim/data'], - 'deep-linking-state-token', - ); -}); - -Deno.test("deep linking response builder returns one launch content item with Lantern's singular launch URL and placement key", async () => { - const placement = buildReviewedPlacementRecord({ - placementId: 'placement-456', - contentTitle: 'Bonus Activity', - }); - const response = await buildDeepLinkingResponseSubmission({ - session: buildDeepLinkingSessionRecord({ - data: null, - }), - deployment: buildDeploymentRecord({ - binding: buildDeploymentBinding(), - }), - placement, - packageVersion: buildPackageVersionRecord({ - installScope: 'assignment', - }), - appOrigin: 'https://lantern.example', - env: buildResponseEnv(), - }); - const verified = await verifyResponseJwt( - response.jwt, - buildDeploymentBinding(), - new Date('2026-03-24T18:31:00Z'), - ); - const contentItems = verified.payload[ - 'https://purl.imsglobal.org/spec/lti-dl/claim/content_items' - ] as Array>; - const contentItem = contentItems[0]; - - assertExists(contentItem); - assertEquals(contentItems.length, 1); - assertEquals(contentItem.type, 'ltiResourceLink'); - assertEquals(contentItem.title, 'Bonus Activity'); - assertEquals(contentItem.url, 'https://lantern.example/lti/launch'); - assertEquals((contentItem.presentation as Record).documentTarget, 'iframe'); - assertEquals( - (contentItem.custom as Record)[LANTERN_PLACEMENT_CUSTOM_KEY], - 'placement-456', - ); - assertEquals( - typeof (contentItem.custom as Record)[LANTERN_PLACEMENT_CUSTOM_KEY], - 'string', - ); -}); - -Deno.test('deep linking response builder derives optional line-item metadata from reviewed package facts', async () => { - const placement = buildReviewedPlacementRecord({ - placementId: 'placement-line-item', - packageVersion: '0.2.0', - contentTitle: 'Bonus Activity', - activityId: '/content/bonus.json', - contentPath: '/content/bonus.json', - }); - const packageVersion = buildPackageVersionRecord({ - version: '0.2.0', - installScope: 'assignment', - grading: { - mode: 'declarative', - rubricFile: '/scoring/rubric.json', - maxScore: 25, - }, - }); - const response = await buildDeepLinkingResponseSubmission({ - session: buildDeepLinkingSessionRecord({ - acceptLineItem: true, - }), - deployment: buildDeploymentRecord({ - binding: buildDeploymentBinding(), - }), - placement, - packageVersion, - appOrigin: 'https://lantern.example', - env: buildResponseEnv(), - }); - const verified = await verifyResponseJwt( - response.jwt, - buildDeploymentBinding(), - new Date('2026-03-24T18:31:00Z'), - ); - const contentItems = verified.payload[ - 'https://purl.imsglobal.org/spec/lti-dl/claim/content_items' - ] as Array>; - const lineItem = contentItems[0]?.lineItem as Record; - - assertExists(lineItem); - assertEquals(lineItem.label, 'Bonus Activity'); - assertEquals(lineItem.scoreMaximum, 25); - assertEquals(lineItem.tag, 'final-grade'); - assertEquals(lineItem.resourceId, buildReviewedPlacementLineItemResourceId(placement)); -}); - -Deno.test('deep linking response builder suppresses line-item metadata for resource_selection placements', async () => { - const response = await buildDeepLinkingResponseSubmission({ - session: buildDeepLinkingSessionRecord({ - placement: 'resource_selection', - acceptLineItem: true, - }), - deployment: buildDeploymentRecord({ - binding: buildDeploymentBinding(), - }), - placement: buildReviewedPlacementRecord(), - packageVersion: buildPackageVersionRecord({ - installScope: 'course', - grading: { - mode: 'declarative', - rubricFile: '/scoring/rubric.json', - maxScore: 25, - }, - }), - appOrigin: 'https://lantern.example', - env: buildResponseEnv(), - }); - const verified = await verifyResponseJwt( - response.jwt, - buildDeploymentBinding(), - new Date('2026-03-24T18:31:00Z'), - ); - const contentItems = verified.payload[ - 'https://purl.imsglobal.org/spec/lti-dl/claim/content_items' - ] as Array>; - - assertEquals('lineItem' in (contentItems[0] ?? {}), false); -}); - -Deno.test('deep linking response builder falls back to the first accepted presentation target when iframe is unavailable', async () => { - const response = await buildDeepLinkingResponseSubmission({ - session: buildDeepLinkingSessionRecord({ - acceptPresentationDocumentTargets: ['window', 'embed'], - }), - deployment: buildDeploymentRecord({ - binding: buildDeploymentBinding(), - }), - placement: buildReviewedPlacementRecord(), - packageVersion: buildPackageVersionRecord({ - installScope: 'assignment', - }), - appOrigin: 'https://lantern.example', - env: buildResponseEnv(), - }); - const verified = await verifyResponseJwt( - response.jwt, - buildDeploymentBinding(), - new Date('2026-03-24T18:31:00Z'), - ); - const contentItems = verified.payload[ - 'https://purl.imsglobal.org/spec/lti-dl/claim/content_items' - ] as Array>; - const firstContentItem = contentItems[0]; - - assertExists(firstContentItem); - - assertEquals((firstContentItem.presentation as Record).documentTarget, 'window'); -}); - -function buildResponseEnv(): { get(name: string): string | undefined } { - const privateJwk = getTestToolPrivateJwkEnvValue(); - - return { - get(name: string) { - return name === 'LTI_TOOL_PRIVATE_JWK' ? privateJwk : undefined; - }, - }; -} - -async function verifyResponseJwt( - jwt: string, - binding: ReturnType, - currentDate: Date, -) { - const keySet = createLocalJWKSet(await getPublicJwkSet(buildResponseEnv())); - - return await jwtVerify(jwt, keySet, { - issuer: binding.clientId, - audience: binding.issuer, - currentDate, - }); -} diff --git a/src/lti/deep_linking_response_tool.ts b/src/lti/deep_linking_response_tool.ts new file mode 100644 index 0000000..6886a00 --- /dev/null +++ b/src/lti/deep_linking_response_tool.ts @@ -0,0 +1,36 @@ +import { buildDeepLinkingResourceLinkContentItem } from './deep_linking_content_item.ts'; +import { requireLtiToolSession } from './lti_tool_session.ts'; +import type { DeepLinkingSessionRecord } from './types.ts'; +import type { LanternLtiToolContext } from './tool_provider.ts'; +import type { PackageVersionRecord, ReviewedPlacementRecord } from '../package_review/types.ts'; + +export async function createDeepLinkingReturnResponseWithLtiTool(input: { + ltiContext: LanternLtiToolContext; + session: DeepLinkingSessionRecord; + placement: ReviewedPlacementRecord; + packageVersion: PackageVersionRecord; + appOrigin?: string; +}): Promise { + const ltiSession = await requireLtiToolSession({ + storage: input.ltiContext.storage, + ltiToolSessionId: input.session.ltiToolSessionId, + missingMessage: 'Deep Linking return requires a verified lti-tool session.', + notFoundMessage: 'The verified LTI session for this Deep Linking return was not found.', + }); + + const result = await input.ltiContext.tool.createAdvantage(ltiSession) + .createDeepLinkingHtmlResponse([ + buildDeepLinkingResourceLinkContentItem({ + session: input.session, + placement: input.placement, + packageVersion: input.packageVersion, + ...(input.appOrigin === undefined ? {} : { appOrigin: input.appOrigin }), + }), + ]); + + if (!result.success) { + throw new Error(result.error.message); + } + + return result.data; +} diff --git a/src/lti/deep_linking_session.ts b/src/lti/deep_linking_session.ts index 74f6223..46b513b 100644 --- a/src/lti/deep_linking_session.ts +++ b/src/lti/deep_linking_session.ts @@ -27,6 +27,7 @@ export async function createDeepLinkingSession(input: { return await input.repository.createDeepLinkingSession({ sessionId: nextOpaqueToken(), sessionToken: nextOpaqueToken(), + ltiToolSessionId: input.request.ltiToolSessionId, deploymentRecordId: input.request.internalDeploymentId, deploymentSlug: input.request.internalDeploymentSlug, appId: input.request.appId, diff --git a/src/lti/deep_linking_test_helpers.ts b/src/lti/deep_linking_test_helpers.ts deleted file mode 100644 index c58b21e..0000000 --- a/src/lti/deep_linking_test_helpers.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { assertEquals } from '@std/assert'; - -export function decodeJwtPayload(token: string): Record { - const parts = token.split('.'); - const payload = parts[1]; - - if (!payload) { - throw new Error('JWT payload segment is required.'); - } - - const normalized = payload.replaceAll('-', '+').replaceAll('_', '/'); - const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); - - return JSON.parse(atob(padded)) as Record; -} - -export async function assertRejectsDeepLinking( - run: () => Promise, - message: string, -): Promise { - try { - await run(); - } catch (error) { - assertEquals(error instanceof Error, true); - assertEquals((error as Error).message, message); - return; - } - - throw new Error('Expected Deep Linking validation to reject.'); -} diff --git a/src/lti/dynamic_registration_tool.ts b/src/lti/dynamic_registration_tool.ts new file mode 100644 index 0000000..a81c0e2 --- /dev/null +++ b/src/lti/dynamic_registration_tool.ts @@ -0,0 +1,154 @@ +import type { + DynamicRegistrationForm, + LtiDynamicRegistrationCompletionResult, + RegistrationRequest, +} from '@longsightgroup/lti-tool'; +import { resolveCanvasPlatform } from './canvas_platform.ts'; +import { requireAppOrigin } from './config.ts'; +import type { DeploymentBinding } from './types.ts'; +import type { LanternLtiToolContext } from './tool_provider.ts'; + +export type DynamicRegistrationLms = 'canvas' | 'moodle' | 'sakai'; + +export interface LanternDynamicRegistrationAppState { + appId: string; + lms: DynamicRegistrationLms; +} + +export interface LanternDynamicRegistrationCompletion { + appState: LanternDynamicRegistrationAppState; + launchConfig: LtiDynamicRegistrationCompletionResult['launchConfig']; + canvasEnvironment: 'production' | 'beta' | 'test' | null; + binding: DeploymentBinding | null; + usedDefaultDeploymentId: boolean; +} + +const DEFAULT_DYNAMIC_REGISTRATION_SERVICES: DynamicRegistrationForm['services'] = [ + 'ags', + 'nrps', + 'deep_linking', +]; + +export function buildDynamicRegistrationUrl( + appId: string, + lms: DynamicRegistrationLms, + state: string | null = null, + appOrigin = requireAppOrigin(), +): string { + const url = new URL( + `${appOrigin}/admin/packages/${encodeURIComponent(appId)}/deployment/register/${lms}`, + ); + + if (state !== null) { + url.searchParams.set('state', state); + } + + return url.toString(); +} + +export async function completeLanternDynamicRegistrationWithLtiTool(input: { + ltiContext: LanternLtiToolContext; + appId: string; + lms: DynamicRegistrationLms; + requestPath: string; + openidConfigurationUrl: string; + registrationToken: string | null; +}): Promise { + const registrationRequest: RegistrationRequest = { + openid_configuration: input.openidConfigurationUrl, + ...(input.registrationToken === null ? {} : { registration_token: input.registrationToken }), + }; + const initiated = await input.ltiContext.dynamicRegistration.initiateDynamicRegistration( + registrationRequest, + input.requestPath, + { + appState: { + appId: input.appId, + lms: input.lms, + }, + }, + ); + + if (!initiated.success) { + throw new Error(initiated.error.message); + } + + const completed = await input.ltiContext.dynamicRegistration.completeDynamicRegistration({ + services: DEFAULT_DYNAMIC_REGISTRATION_SERVICES, + sessionToken: readRegistrationSessionToken(initiated.data), + }); + + if (!completed.success) { + throw new Error(completed.error.message); + } + + const appState = readLanternDynamicRegistrationAppState(completed.data.appState); + + if (appState.appId !== input.appId || appState.lms !== input.lms) { + throw new Error('lti-tool dynamic registration returned mismatched Lantern app state.'); + } + + const canvasEnvironment = input.lms === 'canvas' + ? resolveCanvasPlatform(completed.data.launchConfig.iss).environment + : null; + const binding = input.lms === 'canvas' + ? null + : buildDeploymentBinding(input.lms, completed.data.launchConfig); + + return { + appState, + launchConfig: completed.data.launchConfig, + canvasEnvironment, + binding, + usedDefaultDeploymentId: completed.data.launchConfig.deploymentId === 'default', + }; +} + +function buildDeploymentBinding( + lms: Exclude, + launchConfig: LtiDynamicRegistrationCompletionResult['launchConfig'], +): DeploymentBinding { + return { + lms, + issuer: launchConfig.iss, + clientId: launchConfig.clientId, + deploymentId: launchConfig.deploymentId, + authorizationEndpoint: launchConfig.authUrl, + accessTokenUrl: launchConfig.tokenUrl, + jwksUrl: launchConfig.jwksUrl, + }; +} + +function readRegistrationSessionToken(html: string): string { + const match = html.match(/\bname=["']sessionToken["'][^>]*\bvalue=["']([^"']+)["']/i) ?? + html.match(/\bvalue=["']([^"']+)["'][^>]*\bname=["']sessionToken["']/i); + + if (match?.[1] === undefined || match[1].trim() === '') { + throw new Error('lti-tool dynamic registration did not return a registration session token.'); + } + + return match[1]; +} + +function readLanternDynamicRegistrationAppState( + value: LtiDynamicRegistrationCompletionResult['appState'], +): LanternDynamicRegistrationAppState { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('lti-tool dynamic registration did not return Lantern app state.'); + } + + const record = value as Record; + + if (typeof record.appId !== 'string' || record.appId.trim() === '') { + throw new Error('lti-tool dynamic registration app state is missing appId.'); + } + + if (record.lms !== 'canvas' && record.lms !== 'moodle' && record.lms !== 'sakai') { + throw new Error('lti-tool dynamic registration app state is missing lms.'); + } + + return { + appId: record.appId, + lms: record.lms, + }; +} diff --git a/src/lti/dynamic_registration_validation_test.ts b/src/lti/dynamic_registration_validation_test.ts deleted file mode 100644 index 6ed03b5..0000000 --- a/src/lti/dynamic_registration_validation_test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { assertEquals, assertRejects } from '@std/assert'; -import { completeCanvasDynamicRegistration } from './canvas_dynamic_registration.ts'; -import { completeMoodleDynamicRegistration } from './moodle_dynamic_registration.ts'; -import { completeSakaiDynamicRegistration } from './sakai_dynamic_registration.ts'; - -Deno.test('Moodle dynamic registration falls back to deployment_id embedded in the openid_configuration URL', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - Deno.env.set('APP_ORIGIN', 'https://lantern.example'); - - try { - const registration = await completeMoodleDynamicRegistration({ - appTitle: 'Chapter 4 Asteroids', - openidConfigurationUrl: - 'https://moodle.example/mod/lti/openid-configuration.php?deployment_id=moodle-deployment-777', - registrationToken: null, - appOrigin: 'https://lantern.example', - fetch(input) { - const url = typeof input === 'string' ? input : input.toString(); - - if ( - url === - 'https://moodle.example/mod/lti/openid-configuration.php?deployment_id=moodle-deployment-777' - ) { - return Promise.resolve( - new Response( - JSON.stringify({ - issuer: 'https://moodle.example', - authorization_endpoint: 'https://moodle.example/mod/lti/auth.php', - token_endpoint: 'https://moodle.example/mod/lti/token.php', - jwks_uri: 'https://moodle.example/mod/lti/certs.php', - registration_endpoint: 'https://moodle.example/mod/lti/openid-registration.php', - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ), - ); - } - - if (url === 'https://moodle.example/mod/lti/openid-registration.php') { - return Promise.resolve( - new Response( - JSON.stringify({ - client_id: 'moodle-client-777', - 'https://purl.imsglobal.org/spec/lti-tool-configuration': {}, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ), - ); - } - - throw new Error(`Unexpected fetch ${url}`); - }, - }); - - assertEquals(registration.binding.deploymentId, 'moodle-deployment-777'); - assertEquals(registration.interoperability.usedDeploymentIdFallback, true); - assertEquals(registration.interoperability.deploymentIdSource, 'openid_query'); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - } -}); - -Deno.test('Moodle dynamic registration rejects metadata that drifts off the issuer host', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - Deno.env.set('APP_ORIGIN', 'https://lantern.example'); - - try { - await assertRejects( - () => - completeMoodleDynamicRegistration({ - appTitle: 'Chapter 4 Asteroids', - openidConfigurationUrl: 'https://moodle.example/mod/lti/openid-configuration.php', - registrationToken: null, - appOrigin: 'https://lantern.example', - fetch(input) { - const url = typeof input === 'string' ? input : input.toString(); - - if (url === 'https://moodle.example/mod/lti/openid-configuration.php') { - return Promise.resolve( - new Response( - JSON.stringify({ - issuer: 'https://moodle.example', - authorization_endpoint: 'https://auth.other.example/auth', - token_endpoint: 'https://moodle.example/mod/lti/token.php', - jwks_uri: 'https://moodle.example/mod/lti/certs.php', - registration_endpoint: 'https://moodle.example/mod/lti/openid-registration.php', - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ), - ); - } - - throw new Error(`Unexpected fetch ${url}`); - }, - }), - Error, - 'Moodle registration metadata must stay on one HTTPS host anchored at the issuer.', - ); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - } -}); - -Deno.test('Canvas dynamic registration rejects metadata that does not match the supported Canvas authorization endpoint', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - Deno.env.set('APP_ORIGIN', 'https://lantern.example'); - - try { - await assertRejects( - () => - completeCanvasDynamicRegistration({ - appTitle: 'Chapter 4 Asteroids', - openidConfigurationUrl: - 'https://canvas.instructure.com/api/lti/security/openid-configuration', - registrationToken: 'registration-123', - appOrigin: 'https://lantern.example', - fetch(input) { - const url = typeof input === 'string' ? input : input.toString(); - - if (url === 'https://canvas.instructure.com/api/lti/security/openid-configuration') { - return Promise.resolve( - new Response( - JSON.stringify({ - issuer: 'https://canvas.instructure.com', - authorization_endpoint: 'https://evil.example/authorize', - registration_endpoint: 'https://canvas.instructure.com/api/lti/registrations', - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ), - ); - } - - throw new Error(`Unexpected fetch ${url}`); - }, - }), - Error, - 'Canvas authorization endpoint did not match the supported Canvas platform metadata.', - ); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - } -}); - -Deno.test('Sakai dynamic registration keeps the Worker fetch call bound to globalThis', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - const previousFetch = globalThis.fetch; - Deno.env.set('APP_ORIGIN', 'https://lantern.example'); - - globalThis.fetch = function ( - this: typeof globalThis, - input: RequestInfo | URL, - init?: RequestInit, - ) { - if (this !== globalThis) { - throw new TypeError('Illegal invocation'); - } - - const url = typeof input === 'string' ? input : input.toString(); - - if (url === 'https://sakai.example/imsblis/lti13/well_known') { - return Promise.resolve( - new Response( - JSON.stringify({ - issuer: 'https://sakai.example', - authorization_endpoint: 'https://sakai.example/imsoidc/lti13/oidc_auth', - token_endpoint: 'https://sakai.example/imsblis/lti13/token/91', - jwks_uri: 'https://sakai.example/imsblis/lti13/keyset', - registration_endpoint: 'https://sakai.example/imsblis/lti13/registration_endpoint/91', - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ), - ); - } - - if (url === 'https://sakai.example/imsblis/lti13/registration_endpoint/91') { - assertEquals(init?.method, 'POST'); - - return Promise.resolve( - new Response( - JSON.stringify({ - client_id: 'sakai-client-123', - 'https://purl.imsglobal.org/spec/lti-tool-configuration': { - deployment_id: 'sakai-deployment-123', - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ), - ); - } - - throw new Error(`Unexpected fetch ${url}`); - } as typeof fetch; - - try { - const binding = await completeSakaiDynamicRegistration({ - appId: 'chapter-4-asteroids', - appTitle: 'Chapter 4 Asteroids', - openidConfigurationUrl: 'https://sakai.example/imsblis/lti13/well_known', - registrationToken: 'registration-123', - appOrigin: 'https://lantern.example', - }); - - assertEquals(binding, { - lms: 'sakai', - issuer: 'https://sakai.example', - clientId: 'sakai-client-123', - deploymentId: 'sakai-deployment-123', - authorizationEndpoint: 'https://sakai.example/imsoidc/lti13/oidc_auth', - accessTokenUrl: 'https://sakai.example/imsblis/lti13/token/91', - jwksUrl: 'https://sakai.example/imsblis/lti13/keyset', - }); - } finally { - globalThis.fetch = previousFetch; - restoreEnv('APP_ORIGIN', previousOrigin); - } -}); - -function restoreEnv(name: string, value: string | undefined): void { - if (value === undefined) { - Deno.env.delete(name); - return; - } - - Deno.env.set(name, value); -} diff --git a/src/lti/hono_handler_adapter.ts b/src/lti/hono_handler_adapter.ts new file mode 100644 index 0000000..c84de53 --- /dev/null +++ b/src/lti/hono_handler_adapter.ts @@ -0,0 +1,12 @@ +import type { Context, Next } from '@hono/hono'; + +type LanternHonoHandler = ( + context: Context, + next: Next, +) => Response | Promise; + +export function adaptLtiToolHonoHandler(handler: unknown): LanternHonoHandler { + // lti-tool publishes handlers against npm:hono while Lantern imports Hono from JSR. + // Keep the dependency-source bridge in one adapter instead of route code. + return handler as LanternHonoHandler; +} diff --git a/src/lti/id_token_verification.ts b/src/lti/id_token_verification.ts deleted file mode 100644 index 1fa2654..0000000 --- a/src/lti/id_token_verification.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { createLocalJWKSet, type JSONWebKeySet, jwtVerify } from 'jose'; - -export async function verifyIdTokenWithJwksRetry(input: { - idToken: string; - issuer: string; - audience: string; - jwksUrl: string; - now: () => Date; - loadJwks: (url: string) => Promise; - allowRetry?: boolean; - onRetry?: () => void | Promise; -}): Promise>['payload']> { - const initialJwks = await input.loadJwks(input.jwksUrl); - - try { - return await verifyIdToken(input, initialJwks); - } catch (error) { - if (input.allowRetry === false) { - throw error; - } - - await input.onRetry?.(); - const refreshedJwks = await input.loadJwks(input.jwksUrl); - - return await verifyIdToken(input, refreshedJwks); - } -} - -async function verifyIdToken( - input: { - idToken: string; - issuer: string; - audience: string; - now: () => Date; - }, - jwks: JSONWebKeySet, -): Promise>['payload']> { - const verified = await jwtVerify(input.idToken, createLocalJWKSet(jwks), { - issuer: input.issuer, - audience: input.audience, - currentDate: input.now(), - }); - - return verified.payload; -} diff --git a/src/lti/launch.ts b/src/lti/launch.ts deleted file mode 100644 index c20b51e..0000000 --- a/src/lti/launch.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { validateLaunchRequest } from './launch_validation.ts'; -export { createRuntimeSession } from './runtime_session.ts'; diff --git a/src/lti/launch_audit.ts b/src/lti/launch_audit.ts new file mode 100644 index 0000000..0bcd5d4 --- /dev/null +++ b/src/lti/launch_audit.ts @@ -0,0 +1,94 @@ +import type { Context } from '@hono/hono'; +import type { LtiLaunchVerificationError } from '@longsightgroup/lti-tool'; +import type { PackageReviewRepository } from '../package_review/repository.ts'; +import type { DeploymentRecord } from '../package_review/types.ts'; +import type { ResolvedLtiProfile } from './profile.ts'; +import { buildResolvedLtiProfileDetail } from './profile_resolution.ts'; +import { isLtiBoundaryDenialError } from './lti_boundary_denial.ts'; + +export async function recordLaunchRejectedAudit(input: { + repository: Pick; + context: Context; + error: unknown; + deployment?: DeploymentRecord | null; + ltiProfile?: ResolvedLtiProfile | null; + summary?: string; +}): Promise { + const detail = buildRejectedLaunchDetail(input); + + await input.repository.recordAuditEvent({ + eventType: 'launch.rejected', + actorType: 'platform', + actorId: null, + deploymentRecordId: input.deployment?.id ?? null, + packageVersionId: input.deployment?.enabledPackageVersionId ?? null, + attemptId: null, + lineItemBindingId: null, + status: 'failed', + summary: input.summary ?? 'Rejected the governed LTI launch before runtime handoff.', + detail, + occurredAt: new Date().toISOString(), + }); +} + +function buildRejectedLaunchDetail(input: { + context: Context; + error: unknown; + deployment?: DeploymentRecord | null; + ltiProfile?: ResolvedLtiProfile | null; +}): Record { + const base = { + requestPath: new URL(input.context.req.url).pathname, + forwardedHost: input.context.req.header('x-forwarded-host') ?? null, + forwardedProto: input.context.req.header('x-forwarded-proto') ?? null, + }; + + if (isLtiBoundaryDenialError(input.error)) { + return { + ...base, + category: input.error.category, + code: input.error.code, + message: input.error.message, + ...input.error.detail, + ...(input.deployment === undefined || input.deployment === null ? {} : { + internalDeploymentSlug: input.deployment.slug, + issuer: input.deployment.binding?.issuer ?? null, + clientId: input.deployment.binding?.clientId ?? null, + deploymentId: input.deployment.binding?.deploymentId ?? null, + }), + ...(input.ltiProfile === undefined || input.ltiProfile === null + ? {} + : buildResolvedLtiProfileDetail(input.ltiProfile)), + }; + } + + const verificationError = readVerificationError(input.error); + + if (verificationError !== null) { + return { + ...base, + category: 'specInvalid', + code: verificationError.code, + message: verificationError.message, + }; + } + + return { + ...base, + category: 'specInvalid', + code: 'unknown_launch_error', + message: input.error instanceof Error ? input.error.message : 'Unknown launch error.', + }; +} + +function readVerificationError(error: unknown): LtiLaunchVerificationError | null { + if ( + error instanceof Error && + 'code' in error && + typeof (error as { code: unknown }).code === 'string' + ) { + return error as LtiLaunchVerificationError; + } + + return null; +} diff --git a/src/lti/launch_mapping.ts b/src/lti/launch_mapping.ts new file mode 100644 index 0000000..a096dbe --- /dev/null +++ b/src/lti/launch_mapping.ts @@ -0,0 +1,207 @@ +import type { + LtiAuthorizedLaunch, + LTISession, + LtiVerifiedLaunch, + LtiVerifiedLaunchAuthorizationResult, + ResolvedLtiDeepLinkingLaunchMessage, + ResolvedLtiResourceLinkLaunchMessage, +} from '@longsightgroup/lti-tool'; +import { resolveLaunchTarget } from './launch_target_resolution.ts'; +import { resolveUserRole } from './claim_support.ts'; +import { createOpaqueToken } from './token_support.ts'; +import type { + DeepLinkingAcceptType, + DeepLinkingPresentationDocumentTarget, + LaunchServiceClaims, + LmsType, + ValidatedDeepLinkingRequest, + ValidatedLaunch, +} from './types.ts'; +import type { PackageReviewRepository } from '../package_review/repository.ts'; +import type { DeploymentRecord } from '../package_review/types.ts'; + +export interface LanternLaunchAuthorization { + readonly deployment: DeploymentRecord; +} + +export async function authorizeLanternLaunch( + repository: PackageReviewRepository, + launch: LtiVerifiedLaunch, +): Promise> { + const deployment = await repository.getDeploymentByPlatformIdentity({ + issuer: launch.issuer, + clientId: launch.clientId, + deploymentId: launch.deploymentId, + }); + + if (deployment === null || deployment.binding === null) { + return { + success: false, + code: 'lantern_deployment_missing', + message: + `Lantern has no governed deployment for ${launch.issuer} / ${launch.clientId} / ${launch.deploymentId}.`, + }; + } + + return { + success: true, + data: { + deployment, + }, + }; +} + +export async function mapResourceLinkLaunch(input: { + repository: PackageReviewRepository; + verifiedLaunch: LtiAuthorizedLaunch; + session: LTISession; + message: ResolvedLtiResourceLinkLaunchMessage; +}): Promise { + const deployment = input.verifiedLaunch.authorization.deployment; + const binding = deployment.binding; + + if (binding === null) { + throw new Error(`Deployment ${deployment.slug} does not have an LMS binding.`); + } + + const contextId = requireString(input.message.context?.id, 'Launch context.id is required.'); + const resolved = await resolveLaunchTarget({ + repository: input.repository, + deployment, + resourceLinkId: input.message.resourceLink.id, + contextId, + customClaim: input.message.customParameters, + now: () => new Date(), + }); + + return { + lms: binding.lms, + canvasEnvironment: binding.lms === 'canvas' ? binding.canvasEnvironment : null, + issuer: input.verifiedLaunch.issuer, + clientId: input.verifiedLaunch.clientId, + deploymentId: input.verifiedLaunch.deploymentId, + internalDeploymentId: deployment.id, + internalDeploymentSlug: deployment.slug, + appId: resolved.packageVersion.appId, + packageVersionId: resolved.packageVersion.id, + packageVersion: resolved.packageVersion.version, + contentPath: resolved.contentPath, + attemptId: createOpaqueToken(), + userId: input.session.user.id, + userDisplayName: input.session.user.name ?? null, + userEmail: input.session.user.email ?? null, + userLogin: readOptionalString(input.session.jwtPayload.preferred_username), + userRole: resolveUserRole(input.message.roles), + resourceLinkId: input.message.resourceLink.id, + resourceLinkTitle: input.message.resourceLink.title ?? null, + contextId, + contextTitle: input.message.context?.title ?? null, + targetLinkUri: input.verifiedLaunch.targetLinkUri, + returnUrl: readLaunchPresentationReturnUrl(input.session.jwtPayload), + activityId: resolved.activityId, + services: mapLaunchServiceClaims(input.session), + issuedAt: issuedAtIso(input.session), + }; +} + +export function mapDeepLinkingLaunch(input: { + verifiedLaunch: LtiAuthorizedLaunch; + session: LTISession; + message: ResolvedLtiDeepLinkingLaunchMessage; + acceptLineItem: boolean; +}): ValidatedDeepLinkingRequest { + const deployment = input.verifiedLaunch.authorization.deployment; + const binding = deployment.binding; + + if (binding === null) { + throw new Error(`Deployment ${deployment.slug} does not have an LMS binding.`); + } + + return { + ltiToolSessionId: input.session.id, + lms: binding.lms as LmsType, + canvasEnvironment: binding.lms === 'canvas' ? binding.canvasEnvironment : null, + issuer: input.verifiedLaunch.issuer, + clientId: input.verifiedLaunch.clientId, + deploymentId: input.verifiedLaunch.deploymentId, + internalDeploymentId: deployment.id, + internalDeploymentSlug: deployment.slug, + appId: deployment.appId, + userId: input.session.user.id, + userRole: resolveUserRole(input.message.roles), + contextId: input.message.context?.id ?? null, + contextTitle: input.message.context?.title ?? null, + targetLinkUri: input.verifiedLaunch.targetLinkUri, + deepLinkReturnUrl: input.message.deepLinkingSettings.returnUrl, + data: input.message.deepLinkingSettings.data ?? null, + placement: resolveDeepLinkingPlacement(input.verifiedLaunch.targetLinkUri), + settings: { + acceptTypes: input.message.deepLinkingSettings.acceptTypes + .filter((value): value is DeepLinkingAcceptType => value === 'ltiResourceLink'), + acceptMultiple: input.message.deepLinkingSettings.acceptMultiple, + acceptPresentationDocumentTargets: input.message.deepLinkingSettings + .acceptPresentationDocumentTargets.filter(( + value, + ): value is DeepLinkingPresentationDocumentTarget => + value === 'iframe' || value === 'window' || value === 'embed' + ), + acceptLineItem: input.acceptLineItem, + }, + issuedAt: issuedAtIso(input.session), + }; +} + +export function mapLaunchServiceClaims(session: LTISession): LaunchServiceClaims { + return { + ags: session.services?.ags === undefined ? null : { + scope: [...session.services.ags.scopes], + lineitemsUrl: session.services.ags.lineitems ?? null, + lineitemUrl: session.services.ags.lineitem ?? null, + }, + nrps: session.services?.nrps === undefined ? null : { + contextMembershipsUrl: session.services.nrps.membershipUrl, + serviceVersions: [...session.services.nrps.versions], + }, + }; +} + +function resolveDeepLinkingPlacement( + targetLinkUri: string, +): ValidatedDeepLinkingRequest['placement'] { + const url = new URL(targetLinkUri); + const placement = url.searchParams.get('placement'); + + if (placement === 'resource_selection') { + return 'resource_selection'; + } + + return 'assignment_selection'; +} + +function issuedAtIso(session: LTISession): string { + return typeof session.jwtPayload.iat === 'number' + ? new Date(session.jwtPayload.iat * 1000).toISOString() + : new Date().toISOString(); +} + +function readLaunchPresentationReturnUrl(payload: LTISession['jwtPayload']): string | null { + const claim = payload['https://purl.imsglobal.org/spec/lti/claim/launch_presentation']; + + if (typeof claim !== 'object' || claim === null || Array.isArray(claim)) { + return null; + } + + return readOptionalString((claim as Record).return_url); +} + +function readOptionalString(value: unknown): string | null { + return typeof value === 'string' && value.trim() !== '' ? value : null; +} + +function requireString(value: unknown, message: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(message); + } + + return value; +} diff --git a/src/lti/launch_placement_default_path_test.ts b/src/lti/launch_placement_default_path_test.ts deleted file mode 100644 index 3be973f..0000000 --- a/src/lti/launch_placement_default_path_test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { assertEquals } from '@std/assert'; -import { validateLaunchRequest } from './launch.ts'; -import { - buildDeploymentBinding, - buildLoginStateRecord, - getTestCanvasJwks, - signCanvasIdToken, -} from '../test_helpers/lti.ts'; -import { - buildDeploymentRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from '../test_helpers/package_review.ts'; - -Deno.test('validateLaunchRequest keeps the deployment-pin runtime path for launches without reviewed placement keys', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - version: '0.1.0', - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - buildPackageVersionRecord({ - id: 2, - version: '0.2.0', - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:10:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - loginStates: [buildLoginStateRecord()], - }); - const launch = await validateLaunchRequest({ - repository, - state: 'state-123', - idToken: await signCanvasIdToken({ - nonce: 'nonce-123', - audience: '10000000000001', - resourceLinkId: 'resource-link-legacy', - }), - now: () => new Date('2026-03-23T22:45:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }); - - assertEquals(launch.packageVersionId, 1); - assertEquals(launch.packageVersion, '0.1.0'); - assertEquals(launch.activityId, 'resource-link-legacy'); -}); diff --git a/src/lti/launch_placement_test.ts b/src/lti/launch_placement_test.ts deleted file mode 100644 index aaffae1..0000000 --- a/src/lti/launch_placement_test.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { assertEquals, assertRejects } from '@std/assert'; -import { validateLaunchRequest } from './launch.ts'; -import { expectLaunchRejection } from './launch_test_support.ts'; -import { - buildDeploymentBinding, - buildLoginStateRecord, - getTestCanvasJwks, - signCanvasIdToken, -} from '../test_helpers/lti.ts'; -import { - buildDeploymentRecord, - buildPackageVersionRecord, - buildReviewedPlacementRecord, - createInMemoryPackageReviewRepository, -} from '../test_helpers/package_review.ts'; - -Deno.test('validateLaunchRequest resolves reviewed placements from the launch custom claim and binds the first Canvas resource link', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - version: '0.1.0', - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - buildPackageVersionRecord({ - id: 2, - version: '0.2.0', - installScope: 'assignment', - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:10:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - reviewedPlacements: [ - buildReviewedPlacementRecord({ - placementId: 'placement-123', - deploymentRecordId: 7, - packageVersionId: 2, - packageVersion: '0.2.0', - activityId: '/content/bonus.json', - contentPath: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - ], - loginStates: [buildLoginStateRecord()], - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-123', - audience: '10000000000001', - resourceLinkId: 'resource-link-reviewed', - custom: { - lantern_placement_id: 'placement-123', - }, - }); - const launch = await validateLaunchRequest({ - repository, - state: 'state-123', - idToken, - now: () => new Date('2026-03-23T22:45:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }); - const placement = await repository.getReviewedPlacementById('placement-123'); - - assertEquals(launch.packageVersionId, 2); - assertEquals(launch.packageVersion, '0.2.0'); - assertEquals(launch.activityId, '/content/bonus.json'); - assertEquals((launch as unknown as Record).contentPath, '/content/bonus.json'); - assertEquals(placement?.resourceLinkId, 'resource-link-reviewed'); - assertEquals(placement?.boundAt, '2026-03-23T22:45:00.000Z'); -}); - -Deno.test('validateLaunchRequest rejects reviewed placement launches with a stable code when the saved placement belongs to another deployment', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - version: '0.1.0', - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - buildPackageVersionRecord({ - id: 2, - version: '0.2.0', - installScope: 'assignment', - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:10:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - slug: 'chapter-4-asteroids-pilot', - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - reviewedPlacements: [ - buildReviewedPlacementRecord({ - placementId: 'placement-123', - deploymentRecordId: 8, - deploymentSlug: 'chapter-4-asteroids-other', - packageVersionId: 2, - packageVersion: '0.2.0', - activityId: '/content/bonus.json', - contentPath: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - ], - loginStates: [buildLoginStateRecord()], - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-123', - audience: '10000000000001', - resourceLinkId: 'resource-link-reviewed', - custom: { - lantern_placement_id: 'placement-123', - }, - }); - const error = await assertRejects(() => - validateLaunchRequest({ - repository, - state: 'state-123', - idToken, - now: () => new Date('2026-03-23T22:45:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }) - ); - const rejection = expectLaunchRejection(error); - - assertEquals(rejection.code, 'reviewed_placement_deployment_mismatch'); - assertEquals(rejection.detail.placementId, 'placement-123'); - assertEquals(rejection.detail.deploymentSlug, 'chapter-4-asteroids-pilot'); -}); - -Deno.test('validateLaunchRequest rejects reviewed placement launches with a stable code when the saved placement context does not match', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - version: '0.1.0', - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - buildPackageVersionRecord({ - id: 2, - version: '0.2.0', - installScope: 'assignment', - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:10:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - reviewedPlacements: [ - buildReviewedPlacementRecord({ - placementId: 'placement-123', - deploymentRecordId: 7, - packageVersionId: 2, - packageVersion: '0.2.0', - activityId: '/content/bonus.json', - contentPath: '/content/bonus.json', - contentTitle: 'Bonus Activity', - contextId: 'course-99', - }), - ], - loginStates: [buildLoginStateRecord()], - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-123', - audience: '10000000000001', - custom: { - lantern_placement_id: 'placement-123', - }, - }); - const error = await assertRejects(() => - validateLaunchRequest({ - repository, - state: 'state-123', - idToken, - now: () => new Date('2026-03-23T22:45:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }) - ); - const rejection = expectLaunchRejection(error); - - assertEquals(rejection.code, 'reviewed_placement_context_mismatch'); - assertEquals(rejection.detail.placementId, 'placement-123'); - assertEquals(rejection.detail.contextId, 'course-42'); -}); - -Deno.test('validateLaunchRequest rejects reviewed placement launches with a stable code when the resource link conflicts with the saved placement binding', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - version: '0.1.0', - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - buildPackageVersionRecord({ - id: 2, - version: '0.2.0', - installScope: 'assignment', - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:10:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - reviewedPlacements: [ - buildReviewedPlacementRecord({ - placementId: 'placement-123', - deploymentRecordId: 7, - packageVersionId: 2, - packageVersion: '0.2.0', - activityId: '/content/bonus.json', - contentPath: '/content/bonus.json', - resourceLinkId: 'resource-link-reviewed', - boundAt: '2026-03-23T22:40:00Z', - }), - ], - loginStates: [buildLoginStateRecord()], - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-123', - audience: '10000000000001', - resourceLinkId: 'resource-link-other', - custom: { - lantern_placement_id: 'placement-123', - }, - }); - - const error = await assertRejects(() => - validateLaunchRequest({ - repository, - state: 'state-123', - idToken, - now: () => new Date('2026-03-23T22:45:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }) - ); - const rejection = expectLaunchRejection(error); - - assertEquals(rejection.code, 'reviewed_placement_resource_link_conflict'); - assertEquals(rejection.detail.placementId, 'placement-123'); - assertEquals(rejection.detail.resourceLinkId, 'resource-link-other'); - - const loginState = await repository.getLoginStateByState('state-123'); - - assertEquals(loginState?.usedAt, null); -}); diff --git a/src/lti/launch_runtime_session_test.ts b/src/lti/launch_runtime_session_test.ts index dfc1fe6..b9838fc 100644 --- a/src/lti/launch_runtime_session_test.ts +++ b/src/lti/launch_runtime_session_test.ts @@ -1,5 +1,5 @@ import { assertEquals } from '@std/assert'; -import { createRuntimeSession } from './launch.ts'; +import { createRuntimeSession } from './runtime_session.ts'; import { buildRuntimeSessionRecord, buildValidatedLaunch } from '../test_helpers/lti.ts'; import { buildPackageVersionRecord, diff --git a/src/lti/launch_service_claims.ts b/src/lti/launch_service_claims.ts deleted file mode 100644 index f3fbdad..0000000 --- a/src/lti/launch_service_claims.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { optionalRecordClaim, optionalStringClaim, requireStringClaim } from './claim_support.ts'; -import type { LaunchServiceClaims } from './types.ts'; - -const CLAIM_AGS_ENDPOINT = 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint'; -const CLAIM_NRPS_SERVICE = 'https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice'; - -export function parseLaunchServiceClaims(payload: Record): LaunchServiceClaims { - return { - ags: parseAgsServiceClaim( - optionalRecordClaim( - payload[CLAIM_AGS_ENDPOINT], - 'Launch AGS endpoint claim must be an object when provided.', - ), - ), - nrps: parseNrpsServiceClaim( - optionalRecordClaim( - payload[CLAIM_NRPS_SERVICE], - 'Launch namesroleservice claim must be an object when provided.', - ), - ), - }; -} - -function parseAgsServiceClaim(value: Record | null): LaunchServiceClaims['ags'] { - if (value === null) { - return null; - } - - return { - scope: Array.isArray(value.scope) - ? value.scope - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .filter((item) => item !== '') - : [], - lineitemsUrl: optionalStringClaim(value.lineitems), - lineitemUrl: optionalStringClaim(value.lineitem), - }; -} - -function parseNrpsServiceClaim(value: Record | null): LaunchServiceClaims['nrps'] { - if (value === null) { - return null; - } - - const contextMembershipsUrl = requireStringClaim( - value.context_memberships_url, - 'Launch namesroleservice.context_memberships_url is required.', - ); - - return { - contextMembershipsUrl, - serviceVersions: Array.isArray(value.service_versions) - ? value.service_versions - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .filter((item) => item !== '') - : [], - }; -} diff --git a/src/lti/launch_test_support.ts b/src/lti/launch_test_support.ts deleted file mode 100644 index b090639..0000000 --- a/src/lti/launch_test_support.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { isLaunchRejectionError, type LaunchRejection } from './launch_rejection.ts'; - -export function expectLaunchRejection(error: unknown): LaunchRejection { - if (!isLaunchRejectionError(error)) { - throw error; - } - - return (error as { rejection: LaunchRejection }).rejection; -} diff --git a/src/lti/launch_validation.ts b/src/lti/launch_validation.ts deleted file mode 100644 index ca57858..0000000 --- a/src/lti/launch_validation.ts +++ /dev/null @@ -1,295 +0,0 @@ -import type { JSONWebKeySet } from 'jose'; -import type { PackageReviewRepository } from '../package_review/repository.ts'; -import { - optionalRecordClaim, - optionalStringClaim, - requireRecordClaim, - requireStringClaim, - requireTrimmedValue, - resolveUserRole, - validateLtiAudience, -} from './claim_support.ts'; -import { parseLaunchServiceClaims } from './launch_service_claims.ts'; -import { recordInteropPathUsed } from '../interop_audit.ts'; -import { - assertSupportedLaunchMessageType, - assertSupportedLaunchVersion, - rejectDeploymentBindingMissing, - rejectDeploymentMismatch, - rejectLoginStateExpired, - rejectLoginStateMissing, - rejectLoginStateUsed, - rejectSignatureValidationFailed, - requireBaselineStringClaim, -} from './launch_support_matrix.ts'; -import { resolveLaunchTarget } from './launch_target_resolution.ts'; -import { getLtiProfileDefinition } from './profile.ts'; -import { resolveLtiProfileForDeployment } from './profile_resolution.ts'; -import { verifyIdTokenWithJwksRetry } from './id_token_verification.ts'; -import { formatLmsLabel, resolveBindingJwksUrl } from './platform_binding.ts'; -import { targetLinkUrisMatch, targetLinkUriUsesLanternDriftTolerance } from './target_link_uri.ts'; -import { createOpaqueToken, loadJwks } from './token_support.ts'; -import type { ValidatedLaunch } from './types.ts'; - -const CLAIM_MESSAGE_TYPE = 'https://purl.imsglobal.org/spec/lti/claim/message_type'; -const CLAIM_VERSION = 'https://purl.imsglobal.org/spec/lti/claim/version'; -const CLAIM_DEPLOYMENT_ID = 'https://purl.imsglobal.org/spec/lti/claim/deployment_id'; -const CLAIM_TARGET_LINK_URI = 'https://purl.imsglobal.org/spec/lti/claim/target_link_uri'; -const CLAIM_RESOURCE_LINK = 'https://purl.imsglobal.org/spec/lti/claim/resource_link'; -const CLAIM_CONTEXT = 'https://purl.imsglobal.org/spec/lti/claim/context'; -const CLAIM_CUSTOM = 'https://purl.imsglobal.org/spec/lti/claim/custom'; -const CLAIM_ROLES = 'https://purl.imsglobal.org/spec/lti/claim/roles'; -const CLAIM_LAUNCH_PRESENTATION = 'https://purl.imsglobal.org/spec/lti/claim/launch_presentation'; - -export async function validateLaunchRequest(input: { - repository: PackageReviewRepository; - state: string; - idToken: string; - now?: () => Date; - createOpaqueToken?: () => string; - loadJwks?: (url: string) => Promise; -}): Promise { - const now = input.now ?? (() => new Date()); - const nextOpaqueToken = input.createOpaqueToken ?? createOpaqueToken; - const loadLaunchJwks = input.loadJwks ?? loadJwks; - const state = requireTrimmedValue(input.state, 'Launch state is required.'); - const idToken = requireTrimmedValue(input.idToken, 'Launch id_token is required.'); - const loginState = await input.repository.getLoginStateByState(state); - - if (!loginState) { - rejectLoginStateMissing(state); - } - - if (loginState.usedAt !== null) { - rejectLoginStateUsed(state); - } - - if (Date.parse(loginState.expiresAt) <= now().getTime()) { - rejectLoginStateExpired(state); - } - - const deployment = await input.repository.getDeploymentByBinding({ - lms: loginState.lms, - issuer: loginState.issuer, - clientId: loginState.clientId, - deploymentId: loginState.deploymentId, - }); - - if (!deployment?.binding) { - rejectDeploymentBindingMissing({ - lmsLabel: formatLmsLabel(loginState.lms), - issuer: loginState.issuer, - clientId: loginState.clientId, - deploymentId: loginState.deploymentId, - }); - } - - const ltiProfile = await resolveLtiProfileForDeployment({ - repository: input.repository, - deployment, - }); - const behavior = getLtiProfileDefinition(ltiProfile.id).behavior; - - let payload: Awaited>; - - try { - payload = await verifyIdTokenWithJwksRetry({ - idToken, - jwksUrl: resolveBindingJwksUrl(deployment.binding), - issuer: loginState.issuer, - audience: loginState.clientId, - now, - loadJwks: loadLaunchJwks, - allowRetry: behavior.retryJwksRefetchOnce, - onRetry: async () => { - await recordInteropPathUsed({ - repository: input.repository, - scope: 'launch', - path: 'jwks_refetch', - actorType: 'platform', - deploymentRecordId: deployment.id, - packageVersionId: deployment.enabledPackageVersionId, - summary: 'Lantern refetched platform JWKS during launch validation.', - detail: { - deploymentSlug: deployment.slug, - issuer: loginState.issuer, - clientId: loginState.clientId, - deploymentId: loginState.deploymentId, - }, - ltiProfile, - }); - }, - }); - } catch { - rejectSignatureValidationFailed(); - } - - validateLtiAudience({ - aud: payload.aud, - azp: payload.azp, - clientId: loginState.clientId, - subject: 'Launch', - }); - - const deploymentId = requireStringClaim( - payload[CLAIM_DEPLOYMENT_ID], - 'Launch deployment_id is required.', - ); - const targetLinkUri = requireStringClaim( - payload[CLAIM_TARGET_LINK_URI], - 'Launch target_link_uri is required.', - ); - const nonce = requireStringClaim(payload.nonce, 'Launch nonce is required.'); - const messageType = requireStringClaim( - payload[CLAIM_MESSAGE_TYPE], - 'Launch message_type is required.', - ); - const version = requireStringClaim(payload[CLAIM_VERSION], 'Launch LTI version is required.'); - - if (deploymentId !== loginState.deploymentId) { - rejectDeploymentMismatch({ - field: 'deployment_id', - target: 'saved login state', - }); - } - - if ( - !targetLinkUrisMatch({ - expected: loginState.targetLinkUri, - actual: targetLinkUri, - allowLanternDrift: behavior.tolerateTargetLinkUriDrift, - }) - ) { - rejectDeploymentMismatch({ - field: 'target_link_uri', - target: 'saved login state', - }); - } - - if ( - behavior.tolerateTargetLinkUriDrift && - targetLinkUriUsesLanternDriftTolerance({ - expected: loginState.targetLinkUri, - actual: targetLinkUri, - }) - ) { - await recordInteropPathUsed({ - repository: input.repository, - scope: 'launch', - path: 'target_link_uri_drift', - actorType: 'platform', - deploymentRecordId: deployment.id, - packageVersionId: deployment.enabledPackageVersionId, - summary: 'Lantern tolerated bounded target_link_uri drift during launch validation.', - detail: { - deploymentSlug: deployment.slug, - issuer: loginState.issuer, - clientId: loginState.clientId, - deploymentId: loginState.deploymentId, - expectedTargetLinkUri: loginState.targetLinkUri, - actualTargetLinkUri: targetLinkUri, - }, - ltiProfile, - }); - } - - if (nonce !== loginState.nonce) { - rejectDeploymentMismatch({ - field: 'nonce', - target: 'saved login state', - }); - } - - assertSupportedLaunchMessageType(messageType); - assertSupportedLaunchVersion(version); - - if (deploymentId !== deployment.binding.deploymentId) { - rejectDeploymentMismatch({ - field: 'deployment_id', - target: `saved ${deployment.binding.lms} deployment binding`, - }); - } - - const resourceLink = requireRecordClaim( - payload[CLAIM_RESOURCE_LINK], - 'Launch resource_link claim is required.', - ); - const resourceLinkId = requireStringClaim( - resourceLink.id, - 'Launch resource_link.id is required.', - ); - const context = readClaimRecord(payload[CLAIM_CONTEXT]); - const contextId = requireBaselineStringClaim(context?.id, 'context.id'); - const resolvedLaunch = await resolveLaunchTarget({ - repository: input.repository, - deployment, - resourceLinkId, - contextId, - customClaim: payload[CLAIM_CUSTOM], - now, - }); - const consumedState = await input.repository.consumeLoginState({ - state, - usedAt: now().toISOString(), - }); - const launchPresentation = optionalRecordClaim( - payload[CLAIM_LAUNCH_PRESENTATION], - 'Launch launch_presentation claim must be an object when provided.', - ); - const userId = requireStringClaim(payload.sub, 'Launch subject is required.'); - const userDisplayName = resolveLaunchUserDisplayName(payload); - const userEmail = optionalStringClaim(payload.email); - const userLogin = optionalStringClaim(payload.preferred_username); - - return { - lms: deployment.binding.lms, - internalDeploymentId: deployment.id, - internalDeploymentSlug: deployment.slug, - appId: resolvedLaunch.packageVersion.appId, - packageVersionId: resolvedLaunch.packageVersion.id, - packageVersion: resolvedLaunch.packageVersion.version, - contentPath: resolvedLaunch.contentPath, - attemptId: `attempt-${nextOpaqueToken()}`, - userId, - userDisplayName, - userEmail, - userLogin, - userRole: resolveUserRole(payload[CLAIM_ROLES]), - resourceLinkId, - resourceLinkTitle: optionalStringClaim(resourceLink.title), - contextId, - contextTitle: optionalStringClaim(context?.title), - targetLinkUri, - returnUrl: optionalStringClaim(launchPresentation?.return_url), - activityId: resolvedLaunch.activityId, - services: parseLaunchServiceClaims(payload), - issuedAt: now().toISOString(), - canvasEnvironment: consumedState.canvasEnvironment, - issuer: consumedState.issuer, - clientId: consumedState.clientId, - deploymentId: consumedState.deploymentId, - }; -} - -function readClaimRecord(value: unknown): Record | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null; - } - - return value as Record; -} - -function resolveLaunchUserDisplayName(payload: Record): string | null { - const explicitName = optionalStringClaim(payload.name); - - if (explicitName !== null) { - return explicitName; - } - - const nameParts = [ - optionalStringClaim(payload.given_name), - optionalStringClaim(payload.family_name), - ].filter((value): value is string => value !== null); - - return nameParts.length === 0 ? null : nameParts.join(' '); -} diff --git a/src/lti/launch_validation_multilms_test.ts b/src/lti/launch_validation_multilms_test.ts deleted file mode 100644 index 7cd6b9a..0000000 --- a/src/lti/launch_validation_multilms_test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { assertEquals } from '@std/assert'; -import { validateLaunchRequest } from './launch.ts'; -import { - buildLoginStateRecord, - buildMoodleDeploymentBinding, - buildSakaiDeploymentBinding, - getTestCanvasJwks, - signCanvasIdToken, -} from '../test_helpers/lti.ts'; -import { - buildDeploymentRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from '../test_helpers/package_review.ts'; - -Deno.test('validateLaunchRequest accepts a signed Sakai launch with matching state and deployment binding', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildSakaiDeploymentBinding({ - issuer: 'https://sakai.example', - clientId: '7dbe6a13-f948-498c-87d7-768947ac5c56', - deploymentId: '1', - }), - }), - ], - loginStates: [ - buildLoginStateRecord({ - lms: 'sakai', - issuer: 'https://sakai.example', - clientId: '7dbe6a13-f948-498c-87d7-768947ac5c56', - deploymentId: '1', - }), - ], - }); - const idToken = await signCanvasIdToken({ - deploymentBinding: { - issuer: 'https://sakai.example', - clientId: '7dbe6a13-f948-498c-87d7-768947ac5c56', - deploymentId: '1', - }, - nonce: 'nonce-123', - audience: '7dbe6a13-f948-498c-87d7-768947ac5c56', - name: 'Sakai Instructor', - email: 'sakai@example.edu', - preferredUsername: 'sakai-instructor', - }); - const launch = await validateLaunchRequest({ - repository, - state: 'state-123', - idToken, - now: () => new Date('2026-03-23T22:45:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }); - - assertEquals(launch.lms, 'sakai'); - assertEquals(launch.canvasEnvironment, null); - assertEquals(launch.deploymentId, '1'); - assertEquals(launch.clientId, '7dbe6a13-f948-498c-87d7-768947ac5c56'); - assertEquals(launch.appId, 'chapter-4-asteroids'); - assertEquals(launch.userDisplayName, 'Sakai Instructor'); - assertEquals(launch.userEmail, 'sakai@example.edu'); - assertEquals(launch.userLogin, 'sakai-instructor'); -}); - -Deno.test('validateLaunchRequest accepts a signed Moodle launch with matching state and deployment binding', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildMoodleDeploymentBinding({ - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - }), - }), - ], - loginStates: [ - buildLoginStateRecord({ - lms: 'moodle', - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - }), - ], - }); - const idToken = await signCanvasIdToken({ - deploymentBinding: { - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - }, - nonce: 'nonce-123', - audience: 'moodle-client-123', - givenName: 'Moodle', - familyName: 'Teacher', - email: 'moodle@example.edu', - preferredUsername: 'mteacher', - }); - const launch = await validateLaunchRequest({ - repository, - state: 'state-123', - idToken, - now: () => new Date('2026-03-23T22:45:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }); - - assertEquals(launch.lms, 'moodle'); - assertEquals(launch.canvasEnvironment, null); - assertEquals(launch.deploymentId, 'moodle-deployment-123'); - assertEquals(launch.clientId, 'moodle-client-123'); - assertEquals(launch.resourceLinkId, 'resource-link-123'); - assertEquals(launch.contextId, 'course-42'); - assertEquals(launch.appId, 'chapter-4-asteroids'); - assertEquals(launch.userDisplayName, 'Moodle Teacher'); - assertEquals(launch.userEmail, 'moodle@example.edu'); - assertEquals(launch.userLogin, 'mteacher'); -}); diff --git a/src/lti/launch_validation_test.ts b/src/lti/launch_validation_test.ts deleted file mode 100644 index 61a7984..0000000 --- a/src/lti/launch_validation_test.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { assertEquals, assertRejects } from '@std/assert'; -import { validateLaunchRequest } from './launch.ts'; -import { expectLaunchRejection } from './launch_test_support.ts'; -import { - buildDeploymentBinding, - buildLoginStateRecord, - getTestCanvasJwks, - signCanvasIdToken, -} from '../test_helpers/lti.ts'; -import { - buildDeploymentRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from '../test_helpers/package_review.ts'; - -Deno.test('validateLaunchRequest accepts a signed launch with matching state, nonce, and deployment binding', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - loginStates: [buildLoginStateRecord()], - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-123', - audience: '10000000000001', - name: 'Ada Lovelace', - email: 'ada@example.com', - preferredUsername: 'adal', - }); - const launch = await validateLaunchRequest({ - repository, - state: 'state-123', - idToken, - now: () => new Date('2026-03-23T22:45:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }); - const loginState = await repository.getLoginStateByState('state-123'); - - assertEquals(launch.deploymentId, 'deployment-123'); - assertEquals(launch.clientId, '10000000000001'); - assertEquals(launch.appId, 'chapter-4-asteroids'); - assertEquals(launch.packageVersionId, 1); - assertEquals(launch.userDisplayName, 'Ada Lovelace'); - assertEquals(launch.userEmail, 'ada@example.com'); - assertEquals(launch.userLogin, 'adal'); - assertEquals(loginState?.usedAt !== null, true); -}); - -Deno.test('validateLaunchRequest rejects invalid signatures and mismatched target_link_uri', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - loginStates: [ - buildLoginStateRecord(), - buildLoginStateRecord({ - state: 'state-target', - nonce: 'nonce-target', - }), - ], - }); - const targetMismatchToken = await signCanvasIdToken({ - nonce: 'nonce-target', - targetLinkUri: 'http://localhost:8417/lti/runtime/chapter-4-asteroids', - }); - const invalidSignatureToken = await signCanvasIdToken({ - nonce: 'nonce-123', - }); - - await assertRejects( - () => - validateLaunchRequest({ - repository, - state: 'state-123', - idToken: invalidSignatureToken, - now: () => new Date('2026-03-23T22:45:00Z'), - loadJwks: () => Promise.resolve({ keys: [] }), - }), - Error, - 'Launch id_token signature or issuer validation failed.', - ); - await assertRejects( - () => - validateLaunchRequest({ - repository, - state: 'state-target', - idToken: targetMismatchToken, - now: () => new Date('2026-03-23T22:45:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }), - Error, - 'Launch target_link_uri did not match the saved login state.', - ); -}); - -Deno.test('validateLaunchRequest retries JWKS once before rejecting a valid launch during key rollover', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - ltiProfileOverride: 'governedCompatibility', - }), - ], - loginStates: [buildLoginStateRecord()], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-23T18:05:00Z', - }, - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-123', - audience: '10000000000001', - }); - let jwksRequests = 0; - - const launch = await validateLaunchRequest({ - repository, - state: 'state-123', - idToken, - now: () => new Date('2026-03-23T22:45:00Z'), - loadJwks: () => { - jwksRequests += 1; - - return Promise.resolve(jwksRequests === 1 ? { keys: [] } : getTestCanvasJwks()); - }, - }); - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - - assertEquals(jwksRequests, 2); - assertEquals(launch.clientId, '10000000000001'); - assertEquals( - interopEvents.some( - (event) => - event.detail.path === 'jwks_refetch' && - event.detail.ltiProfileId === 'governedCompatibility' && - event.detail.ltiProfileSource === 'deploymentOverride', - ), - true, - ); -}); - -Deno.test('validateLaunchRequest tolerates target_link_uri scheme, query, and trailing-slash drift when the callback host and route still match', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - ltiProfileOverride: 'governedCompatibility', - }), - ], - loginStates: [ - buildLoginStateRecord({ - state: 'state-normalized-target', - nonce: 'nonce-normalized-target', - targetLinkUri: 'https://lantern.example/lti/launch/', - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-23T18:05:00Z', - }, - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-normalized-target', - targetLinkUri: 'http://lantern.example/lti/launch?placement=course_navigation', - }); - - const launch = await validateLaunchRequest({ - repository, - state: 'state-normalized-target', - idToken, - now: () => new Date('2026-03-23T22:45:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }); - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - - assertEquals( - launch.targetLinkUri, - 'http://lantern.example/lti/launch?placement=course_navigation', - ); - assertEquals( - interopEvents.some( - (event) => - event.detail.path === 'target_link_uri_drift' && - event.detail.ltiProfileId === 'governedCompatibility' && - event.detail.ltiProfileSource === 'deploymentOverride', - ), - true, - ); -}); - -Deno.test('validateLaunchRequest rejects JWKS retry when the resolved profile is certification', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - loginStates: [buildLoginStateRecord()], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-23T18:05:00Z', - }, - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-123', - audience: '10000000000001', - }); - let jwksRequests = 0; - - await assertRejects( - () => - validateLaunchRequest({ - repository, - state: 'state-123', - idToken, - now: () => new Date('2026-03-23T22:45:00Z'), - loadJwks: () => { - jwksRequests += 1; - - return Promise.resolve(jwksRequests === 1 ? { keys: [] } : getTestCanvasJwks()); - }, - }), - Error, - 'Launch id_token signature or issuer validation failed.', - ); - - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - - assertEquals(jwksRequests, 1); - assertEquals(interopEvents.length, 0); -}); - -Deno.test('validateLaunchRequest rejects target_link_uri drift when the resolved profile is certification', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - loginStates: [ - buildLoginStateRecord({ - state: 'state-cert-target', - nonce: 'nonce-cert-target', - targetLinkUri: 'https://lantern.example/lti/launch/', - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-23T18:05:00Z', - }, - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-cert-target', - targetLinkUri: 'http://lantern.example/lti/launch?placement=course_navigation', - }); - - await assertRejects( - () => - validateLaunchRequest({ - repository, - state: 'state-cert-target', - idToken, - now: () => new Date('2026-03-23T22:45:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }), - Error, - 'Launch target_link_uri did not match the saved login state.', - ); -}); - -Deno.test('validateLaunchRequest rejects unsupported message types with a stable support-matrix code', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - loginStates: [ - buildLoginStateRecord({ - state: 'state-message', - nonce: 'nonce-message', - }), - ], - }); - const messageMismatchToken = await signCanvasIdToken({ - nonce: 'nonce-message', - messageType: 'LtiDeepLinkingRequest', - }); - const error = await assertRejects(() => - validateLaunchRequest({ - repository, - state: 'state-message', - idToken: messageMismatchToken, - now: () => new Date('2026-03-23T22:45:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }) - ); - const rejection = expectLaunchRejection(error); - - assertEquals(rejection.code, 'unsupported_message_type'); - assertEquals(rejection.detail.messageType, 'LtiDeepLinkingRequest'); - assertEquals(rejection.detail.supportedMessageType, 'LtiResourceLinkRequest'); - - const preservedState = await repository.getLoginStateByState('state-message'); - - assertEquals(preservedState?.usedAt, null); -}); - -Deno.test('validateLaunchRequest rejects missing context.id as an explicit governed baseline denial', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - loginStates: [buildLoginStateRecord()], - }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-123', - audience: '10000000000001', - contextId: '', - }); - const error = await assertRejects(() => - validateLaunchRequest({ - repository, - state: 'state-123', - idToken, - now: () => new Date('2026-03-23T22:45:00Z'), - loadJwks: () => Promise.resolve(getTestCanvasJwks()), - }) - ); - const rejection = expectLaunchRejection(error); - - assertEquals(rejection.code, 'missing_baseline_claim'); - assertEquals(rejection.detail.claim, 'context.id'); - assertEquals(rejection.detail.rule, 'governed_runtime_baseline'); -}); diff --git a/src/lti/login.ts b/src/lti/login.ts deleted file mode 100644 index 4cd23ff..0000000 --- a/src/lti/login.ts +++ /dev/null @@ -1,297 +0,0 @@ -import type { PackageReviewRepository } from '../package_review/repository.ts'; -import { getLtiProfileDefinition, type ResolvedLtiProfile } from './profile.ts'; -import { resolveLtiProfileForDeployment } from './profile_resolution.ts'; -import { buildLanternTargetLinkUri } from './target_link_uri.ts'; -import type { LoginStateRecord } from './types.ts'; -import { resolveAuthorizationEndpoint } from './platform_binding.ts'; - -const LOGIN_STATE_TTL_MS = 5 * 60 * 1000; - -export type LoginCompatibilityPath = - | 'opaque_login_hint_decode' - | 'opaque_lti_message_hint_decode' - | 'platform_default_launch_target'; - -export interface LoginRequest { - iss: string; - loginHint: string; - targetLinkUri: string | null; - clientId: string | null; - deploymentId: string; - ltiMessageHint: string | null; -} - -export interface LoginRequestCompatibility { - decodedLoginHint: string | null; - decodedLtiMessageHint: string | null; -} - -export interface LoginRedirectResult { - location: string; - loginState: LoginStateRecord; - deploymentRecordId: number; - deploymentSlug: string; - packageVersionId: number | null; - ltiProfile: ResolvedLtiProfile; - compatibilityPathsUsed: LoginCompatibilityPath[]; -} - -export async function createLoginRedirect(input: { - repository: PackageReviewRepository; - loginRequest: LoginRequest; - loginCompatibility?: LoginRequestCompatibility; - appOrigin?: string; - now?: () => Date; - createOpaqueToken?: () => string; -}): Promise { - const now = input.now ?? (() => new Date()); - const createOpaqueToken = input.createOpaqueToken ?? defaultOpaqueToken; - const loginRequest = normalizeLoginRequest(input.loginRequest); - const loginCompatibility = normalizeLoginRequestCompatibility(input.loginCompatibility); - let deployment; - - try { - deployment = await input.repository.getDeploymentByPlatformIdentity({ - issuer: loginRequest.iss, - clientId: loginRequest.clientId, - deploymentId: loginRequest.deploymentId, - }); - } catch (error) { - if ( - error instanceof Error && - error.message.includes('Multiple deployments matched issuer') && - !error.message.includes('must send client_id') - ) { - throw new Error( - 'Choose one supported LMS deployment. Resolve the duplicate LMS bindings before login can continue.', - ); - } - - throw error; - } - - if (!deployment && loginRequest.clientId !== null) { - deployment = await input.repository.completePendingCanvasBinding({ - issuer: loginRequest.iss, - clientId: loginRequest.clientId, - deploymentId: loginRequest.deploymentId, - }); - } - - if (!deployment?.binding) { - if (loginRequest.clientId === null) { - throw new Error( - `Deployment ${loginRequest.deploymentId} was not found for issuer ${loginRequest.iss}. Include client_id in the login request or save a matching LMS binding before login can continue.`, - ); - } - - throw new Error( - `Deployment ${loginRequest.clientId} / ${loginRequest.deploymentId} was not found for issuer ${loginRequest.iss}.`, - ); - } - - const binding = deployment.binding; - const ltiProfile = await resolveLtiProfileForDeployment({ - repository: input.repository, - deployment, - }); - const behavior = getLtiProfileDefinition(ltiProfile.id).behavior; - const compatibilityPathsUsed: LoginCompatibilityPath[] = []; - const loginHint = resolveOpaqueLoginCompatibility({ - field: 'login_hint', - rawValue: loginRequest.loginHint, - decodedValue: loginCompatibility.decodedLoginHint, - allowDecode: behavior.decodeOpaqueHints, - path: 'opaque_login_hint_decode', - compatibilityPathsUsed, - }); - const ltiMessageHint = resolveOptionalOpaqueLoginCompatibility({ - field: 'lti_message_hint', - rawValue: loginRequest.ltiMessageHint, - decodedValue: loginCompatibility.decodedLtiMessageHint, - allowDecode: behavior.decodeOpaqueHints, - path: 'opaque_lti_message_hint_decode', - compatibilityPathsUsed, - }); - const createdAt = now(); - const targetLinkUri = resolveLoginTargetLinkUri({ - targetLinkUri: loginRequest.targetLinkUri, - lms: binding.lms, - appOrigin: input.appOrigin, - allowPlatformDefaultLaunchTarget: behavior.allowPlatformDefaultLaunchTarget, - }); - - if (targetLinkUri.usedCompatibilityPath) { - compatibilityPathsUsed.push('platform_default_launch_target'); - } - - const loginState = await input.repository.createLoginState({ - lms: binding.lms, - state: createOpaqueToken(), - nonce: createOpaqueToken(), - canvasEnvironment: binding.lms === 'canvas' ? binding.canvasEnvironment : null, - issuer: binding.issuer, - clientId: binding.clientId, - deploymentId: binding.deploymentId, - loginHint, - targetLinkUri: targetLinkUri.value, - ltiMessageHint, - createdAt: createdAt.toISOString(), - expiresAt: new Date(createdAt.getTime() + LOGIN_STATE_TTL_MS).toISOString(), - usedAt: null, - }); - const location = new URL(resolveAuthorizationEndpoint(binding)); - - location.searchParams.set('client_id', loginState.clientId); - location.searchParams.set('login_hint', loginState.loginHint); - location.searchParams.set('nonce', loginState.nonce); - location.searchParams.set('redirect_uri', loginState.targetLinkUri); - location.searchParams.set('response_mode', 'form_post'); - location.searchParams.set('response_type', 'id_token'); - location.searchParams.set('scope', 'openid'); - location.searchParams.set('state', loginState.state); - - if (loginState.ltiMessageHint !== null) { - location.searchParams.set('lti_message_hint', loginState.ltiMessageHint); - } - - return { - location: location.toString(), - loginState, - deploymentRecordId: deployment.id, - deploymentSlug: deployment.slug, - packageVersionId: deployment.enabledPackageVersionId, - ltiProfile, - compatibilityPathsUsed, - }; -} - -function normalizeLoginRequest(request: LoginRequest): LoginRequest { - return { - iss: requireTrimmedValue(request.iss, 'LTI issuer is required.'), - loginHint: requireTrimmedValue(request.loginHint, 'LTI login_hint is required.'), - targetLinkUri: normalizeOptionalValue(request.targetLinkUri), - clientId: normalizeOptionalValue(request.clientId), - deploymentId: requireTrimmedValue(request.deploymentId, 'LTI deployment_id is required.'), - ltiMessageHint: normalizeOptionalValue(request.ltiMessageHint), - }; -} - -function normalizeLoginRequestCompatibility( - compatibility: LoginRequestCompatibility | undefined, -): LoginRequestCompatibility { - return { - decodedLoginHint: normalizeOptionalValue(compatibility?.decodedLoginHint ?? null), - decodedLtiMessageHint: normalizeOptionalValue(compatibility?.decodedLtiMessageHint ?? null), - }; -} - -function resolveOpaqueLoginCompatibility(input: { - field: 'login_hint' | 'lti_message_hint'; - rawValue: string; - decodedValue: string | null; - allowDecode: boolean; - path: LoginCompatibilityPath; - compatibilityPathsUsed: LoginCompatibilityPath[]; -}): string { - if (input.decodedValue === null || input.decodedValue === input.rawValue) { - return input.rawValue; - } - - if (!input.allowDecode) { - throw new Error( - `The active LTI profile does not allow opaque ${input.field} compatibility decoding.`, - ); - } - - input.compatibilityPathsUsed.push(input.path); - return input.decodedValue; -} - -function resolveOptionalOpaqueLoginCompatibility(input: { - field: 'lti_message_hint'; - rawValue: string | null; - decodedValue: string | null; - allowDecode: boolean; - path: LoginCompatibilityPath; - compatibilityPathsUsed: LoginCompatibilityPath[]; -}): string | null { - if (input.rawValue === null) { - return null; - } - - return resolveOpaqueLoginCompatibility({ - field: input.field, - rawValue: input.rawValue, - decodedValue: input.decodedValue, - allowDecode: input.allowDecode, - path: input.path, - compatibilityPathsUsed: input.compatibilityPathsUsed, - }); -} - -function resolveLoginTargetLinkUri(input: { - targetLinkUri: string | null; - lms: LoginStateRecord['lms']; - appOrigin: string | undefined; - allowPlatformDefaultLaunchTarget: boolean; -}): { - value: string; - usedCompatibilityPath: boolean; -} { - if (input.targetLinkUri !== null) { - return { - value: input.targetLinkUri, - usedCompatibilityPath: false, - }; - } - - if (input.lms === 'canvas') { - throw new Error( - 'LTI target_link_uri is required for Canvas login because Canvas launches use more than one Lantern callback route.', - ); - } - - if (!input.allowPlatformDefaultLaunchTarget) { - throw new Error('The active LTI profile requires target_link_uri for this login.'); - } - - return { - value: buildLanternTargetLinkUri('launch', input.appOrigin), - usedCompatibilityPath: true, - }; -} - -function normalizeOptionalValue(value: string | null): string | null { - if (value === null) { - return null; - } - - const trimmed = value.trim(); - - return trimmed === '' ? null : trimmed; -} - -function requireTrimmedValue(value: string | null, message: string): string { - if (value === null) { - throw new Error(message); - } - - const trimmed = value.trim(); - - if (trimmed === '') { - throw new Error(message); - } - - return trimmed; -} - -function defaultOpaqueToken(): string { - return encodeBase64Url(crypto.getRandomValues(new Uint8Array(18))); -} - -function encodeBase64Url(bytes: Uint8Array): string { - const chunk = Array.from(bytes, (byte) => String.fromCodePoint(byte)).join(''); - - return btoa(chunk).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); -} diff --git a/src/lti/login_policy.ts b/src/lti/login_policy.ts new file mode 100644 index 0000000..eef5314 --- /dev/null +++ b/src/lti/login_policy.ts @@ -0,0 +1,81 @@ +import { getLtiProfileDefinition, type ResolvedLtiProfile } from './profile.ts'; +import { buildLanternTargetLinkUri } from './target_link_uri.ts'; +import type { DeploymentRecord } from '../package_review/types.ts'; + +export interface LoginPolicyRequest { + readonly request: { + readonly loginHint: string; + readonly ltiMessageHint: string | null; + readonly targetLinkUri: string | null; + }; + readonly compatibility: { + readonly decodedLoginHint: string | null; + readonly decodedLtiMessageHint: string | null; + }; +} + +export function resolveLoginPolicy(input: { + appOrigin: string; + binding: NonNullable; + loginRequest: LoginPolicyRequest; + ltiProfile: ResolvedLtiProfile; +}): { + loginHint: string; + ltiMessageHint?: string; + targetLinkUri: string; + compatibilityPaths: string[]; +} { + const profileBehavior = getLtiProfileDefinition(input.ltiProfile.id).behavior; + const compatibilityPaths: string[] = []; + + if ( + (input.loginRequest.compatibility.decodedLoginHint !== null || + input.loginRequest.compatibility.decodedLtiMessageHint !== null) && + !profileBehavior.decodeOpaqueHints + ) { + throw new Error('The active LTI profile does not allow opaque login hint decoding.'); + } + + const loginHint = input.loginRequest.compatibility.decodedLoginHint ?? + input.loginRequest.request.loginHint; + const ltiMessageHint = input.loginRequest.request.ltiMessageHint === null + ? undefined + : input.loginRequest.compatibility.decodedLtiMessageHint ?? + input.loginRequest.request.ltiMessageHint; + + if (input.loginRequest.compatibility.decodedLoginHint !== null) { + compatibilityPaths.push('opaque_login_hint_decode'); + } + + if (input.loginRequest.compatibility.decodedLtiMessageHint !== null) { + compatibilityPaths.push('opaque_lti_message_hint_decode'); + } + + if (input.loginRequest.request.targetLinkUri !== null) { + return { + loginHint, + ...(ltiMessageHint === undefined ? {} : { ltiMessageHint }), + targetLinkUri: input.loginRequest.request.targetLinkUri, + compatibilityPaths, + }; + } + + if (input.binding.lms === 'canvas') { + throw new Error( + 'Canvas launches use more than one Lantern callback route; target_link_uri is required.', + ); + } + + if (!profileBehavior.allowPlatformDefaultLaunchTarget) { + throw new Error('The active LTI profile requires target_link_uri in login initiation.'); + } + + compatibilityPaths.push('platform_default_launch_target'); + + return { + loginHint, + ...(ltiMessageHint === undefined ? {} : { ltiMessageHint }), + targetLinkUri: buildLanternTargetLinkUri('launch', input.appOrigin), + compatibilityPaths, + }; +} diff --git a/src/lti/login_request.ts b/src/lti/login_request.ts new file mode 100644 index 0000000..8c23779 --- /dev/null +++ b/src/lti/login_request.ts @@ -0,0 +1,13 @@ +export interface LoginRequest { + iss: string; + loginHint: string; + targetLinkUri: string | null; + clientId: string | null; + deploymentId: string; + ltiMessageHint: string | null; +} + +export interface LoginRequestCompatibility { + decodedLoginHint: string | null; + decodedLtiMessageHint: string | null; +} diff --git a/src/lti/login_test.ts b/src/lti/login_test.ts deleted file mode 100644 index f5c49f5..0000000 --- a/src/lti/login_test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { assertEquals, assertRejects } from '@std/assert'; -import { createLoginRedirect } from './login.ts'; -import { buildCanvasLoginRequest, buildDeploymentBinding } from '../test_helpers/lti.ts'; -import { - buildDeploymentRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from '../test_helpers/package_review.ts'; - -Deno.test('createLoginRedirect persists one-time state and redirects to the Canvas authorization endpoint', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - binding: buildDeploymentBinding(), - }), - ], - }); - const tokens = ['state-login-123', 'nonce-login-123']; - const result = await createLoginRedirect({ - repository, - loginRequest: buildCanvasLoginRequest(), - now: () => new Date('2026-03-23T22:45:00Z'), - createOpaqueToken: () => { - const next = tokens.shift(); - - if (!next) { - throw new Error('Expected another deterministic login token.'); - } - - return next; - }, - }); - const location = new URL(result.location); - const saved = await repository.getLoginStateByState('state-login-123'); - - assertEquals( - location.origin + location.pathname, - 'https://sso.canvaslms.com/api/lti/authorize_redirect', - ); - assertEquals(location.searchParams.get('response_type'), 'id_token'); - assertEquals(location.searchParams.get('response_mode'), 'form_post'); - assertEquals(location.searchParams.get('scope'), 'openid'); - assertEquals(location.searchParams.get('state'), 'state-login-123'); - assertEquals(location.searchParams.get('nonce'), 'nonce-login-123'); - assertEquals(saved?.state, 'state-login-123'); - assertEquals(saved?.nonce, 'nonce-login-123'); -}); - -Deno.test('unknown deployment binding blocks login initiation before redirect', async () => { - const repository = createInMemoryPackageReviewRepository(); - - await assertRejects( - () => - createLoginRedirect({ - repository, - loginRequest: buildCanvasLoginRequest({ - clientId: 'missing-client', - deploymentId: 'missing-deployment', - }), - }), - Error, - 'Deployment missing-client / missing-deployment was not found', - ); -}); - -Deno.test('createLoginRedirect resolves a unique saved binding when client_id is omitted by the platform', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - binding: buildDeploymentBinding({ - clientId: 'canvas-client-unique', - deploymentId: 'deployment-unique', - }), - }), - ], - }); - const result = await createLoginRedirect({ - repository, - loginRequest: buildCanvasLoginRequest({ - clientId: null, - deploymentId: 'deployment-unique', - }), - now: () => new Date('2026-03-23T22:45:00Z'), - createOpaqueToken: () => crypto.randomUUID(), - }); - const redirected = new URL(result.location); - - assertEquals(result.loginState.clientId, 'canvas-client-unique'); - assertEquals(redirected.searchParams.get('client_id'), 'canvas-client-unique'); -}); - -Deno.test('createLoginRedirect waits for the resolved deployment profile before applying login compatibility behavior', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - binding: { - lms: 'moodle', - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - authorizationEndpoint: 'https://moodle.example/mod/lti/auth.php', - accessTokenUrl: 'https://moodle.example/mod/lti/token.php', - jwksUrl: 'https://moodle.example/mod/lti/certs.php', - }, - ltiProfileOverride: 'governedCompatibility', - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-23T22:45:00Z', - }, - }); - const result = await createLoginRedirect({ - repository, - loginRequest: { - iss: 'https://moodle.example', - loginHint: 'opaque%2Flogin%3Fhint', - targetLinkUri: null, - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - ltiMessageHint: 'context%23value', - }, - loginCompatibility: { - decodedLoginHint: 'opaque/login?hint', - decodedLtiMessageHint: 'context#value', - }, - appOrigin: 'http://localhost:8417', - now: () => new Date('2026-03-23T22:45:00Z'), - createOpaqueToken: () => crypto.randomUUID(), - }); - - assertEquals(result.loginState.loginHint, 'opaque/login?hint'); - assertEquals(result.loginState.ltiMessageHint, 'context#value'); - assertEquals(result.loginState.targetLinkUri, 'http://localhost:8417/lti/launch'); - assertEquals(result.compatibilityPathsUsed, [ - 'opaque_login_hint_decode', - 'opaque_lti_message_hint_decode', - 'platform_default_launch_target', - ]); - assertEquals(result.ltiProfile.id, 'governedCompatibility'); - assertEquals(result.ltiProfile.source, 'deploymentOverride'); -}); - -Deno.test('createLoginRedirect rejects opaque login compatibility paths when the resolved profile is certification', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - binding: { - lms: 'moodle', - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - authorizationEndpoint: 'https://moodle.example/mod/lti/auth.php', - accessTokenUrl: 'https://moodle.example/mod/lti/token.php', - jwksUrl: 'https://moodle.example/mod/lti/certs.php', - }, - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-23T22:45:00Z', - }, - }); - - await assertRejects( - () => - createLoginRedirect({ - repository, - loginRequest: { - iss: 'https://moodle.example', - loginHint: 'opaque%2Flogin%3Fhint', - targetLinkUri: 'http://localhost:8417/lti/launch', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - ltiMessageHint: null, - }, - loginCompatibility: { - decodedLoginHint: 'opaque/login?hint', - decodedLtiMessageHint: null, - }, - }), - Error, - 'active LTI profile does not allow opaque', - ); -}); - -Deno.test('login state records carry the expected target_link_uri and expiry window', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - binding: buildDeploymentBinding(), - }), - ], - }); - const result = await createLoginRedirect({ - repository, - loginRequest: buildCanvasLoginRequest(), - now: () => new Date('2026-03-23T22:45:00Z'), - createOpaqueToken: () => crypto.randomUUID(), - }); - - assertEquals(result.loginState.targetLinkUri, 'http://localhost:8417/lti/launch'); - assertEquals(result.loginState.expiresAt > result.loginState.createdAt, true); -}); - -Deno.test('login state records preserve the dedicated deep-linking target_link_uri', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - binding: buildDeploymentBinding(), - }), - ], - }); - const result = await createLoginRedirect({ - repository, - loginRequest: buildCanvasLoginRequest({ - targetLinkUri: 'http://localhost:8417/lti/deep-linking', - }), - now: () => new Date('2026-03-23T22:45:00Z'), - createOpaqueToken: () => crypto.randomUUID(), - }); - - assertEquals(result.loginState.targetLinkUri, 'http://localhost:8417/lti/deep-linking'); - assertEquals( - new URL(result.location).searchParams.get('redirect_uri'), - 'http://localhost:8417/lti/deep-linking', - ); -}); - -Deno.test('login state records preserve Deep Linking placement-specific target_link_uri values', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - binding: buildDeploymentBinding(), - }), - ], - }); - const result = await createLoginRedirect({ - repository, - loginRequest: buildCanvasLoginRequest({ - targetLinkUri: 'http://localhost:8417/lti/deep-linking?placement=resource_selection', - }), - now: () => new Date('2026-03-23T22:45:00Z'), - createOpaqueToken: () => crypto.randomUUID(), - }); - - assertEquals( - result.loginState.targetLinkUri, - 'http://localhost:8417/lti/deep-linking?placement=resource_selection', - ); - assertEquals( - new URL(result.location).searchParams.get('redirect_uri'), - 'http://localhost:8417/lti/deep-linking?placement=resource_selection', - ); -}); - -Deno.test('createLoginRedirect seals a pending Canvas registration on the first real launch', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - appId: 'chapter-4-asteroids', - version: '0.1.0', - title: 'Chapter 4 Asteroids', - description: null, - owner: { type: 'user', id: 'user-1' }, - entrypoint: 'dist/index.html', - roles: ['learner'], - installScope: 'assignment', - capabilities: [], - grading: { - mode: 'completion', - rubricFile: null, - maxScore: null, - }, - approvalStatus: 'approved', - reviewNotes: null, - accessibilityReview: null, - reviewedAt: '2026-03-23T22:45:00Z', - validationIssues: [], - manifestJson: {}, - artifact: { - snapshotRoot: 'var/packages/chapter-4-asteroids/0.1.0', - manifestPath: 'manifest.json', - entrypointPath: 'dist/index.html', - digest: 'digest', - }, - runtimeContractSignature: 'test-reviewed-runtime-contract-signature', - importedAt: '2026-03-23T22:45:00Z', - }), - ], - }); - await repository.saveCanvasRegistration({ - slug: 'chapter-4-asteroids-pilot', - label: 'Chapter 4 Asteroids Pilot Deployment', - appId: 'chapter-4-asteroids', - canvasEnvironment: 'production', - issuer: 'https://canvas.instructure.com', - clientId: 'canvas-client-777', - }); - - const result = await createLoginRedirect({ - repository, - loginRequest: buildCanvasLoginRequest({ - clientId: 'canvas-client-777', - deploymentId: 'deployment-777', - }), - now: () => new Date('2026-03-23T22:45:00Z'), - createOpaqueToken: () => crypto.randomUUID(), - }); - const deployment = await repository.getDeploymentBySlug('chapter-4-asteroids-pilot'); - - assertEquals(result.loginState.clientId, 'canvas-client-777'); - assertEquals(result.loginState.deploymentId, 'deployment-777'); - assertEquals(deployment?.binding?.lms, 'canvas'); - assertEquals(deployment?.binding?.clientId, 'canvas-client-777'); - assertEquals(deployment?.binding?.deploymentId, 'deployment-777'); -}); - -Deno.test('createLoginRedirect rejects an omitted client_id when issuer and deployment_id still match multiple bindings', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - id: 1, - slug: 'shared-canvas-a', - binding: buildDeploymentBinding({ - issuer: 'https://shared.example', - clientId: 'shared-client-a', - deploymentId: 'shared-deployment', - }), - }), - buildDeploymentRecord({ - id: 2, - slug: 'shared-canvas-b', - binding: buildDeploymentBinding({ - issuer: 'https://shared.example', - clientId: 'shared-client-b', - deploymentId: 'shared-deployment', - }), - }), - ], - }); - - await assertRejects( - () => - createLoginRedirect({ - repository, - loginRequest: buildCanvasLoginRequest({ - iss: 'https://shared.example', - clientId: null, - deploymentId: 'shared-deployment', - }), - }), - Error, - 'Platform must send client_id', - ); -}); diff --git a/src/lti/lti_tool_session.ts b/src/lti/lti_tool_session.ts new file mode 100644 index 0000000..a8bbc5a --- /dev/null +++ b/src/lti/lti_tool_session.ts @@ -0,0 +1,27 @@ +import type { LTISession, LTIStorage } from '@longsightgroup/lti-tool'; + +export async function requireLtiToolSession(input: { + storage?: LTIStorage; + getStorage?: () => LTIStorage | Promise; + ltiToolSessionId: string | null; + missingMessage: string; + notFoundMessage: string; +}): Promise { + if (input.ltiToolSessionId === null) { + throw new Error(input.missingMessage); + } + + const storage = input.storage ?? await input.getStorage?.(); + + if (storage === undefined) { + throw new Error('LTI storage is required to load a verified lti-tool session.'); + } + + const session = await storage.getSession(input.ltiToolSessionId); + + if (session === undefined) { + throw new Error(input.notFoundMessage); + } + + return session; +} diff --git a/src/lti/moodle_dynamic_registration.ts b/src/lti/moodle_dynamic_registration.ts deleted file mode 100644 index e128d68..0000000 --- a/src/lti/moodle_dynamic_registration.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { requireAppOrigin } from './config.ts'; -import { assertHostedDynamicRegistrationMetadata } from './dynamic_registration_support.ts'; -import { type FetchLike, performFetch } from './fetch_like.ts'; -import { type DeploymentBinding, LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE } from './types.ts'; - -const LTI_TOOL_CONFIGURATION_CLAIM = 'https://purl.imsglobal.org/spec/lti-tool-configuration'; -const MOODLE_SCOPE_LIST = [ - 'https://purl.imsglobal.org/spec/lti-ags/scope/score', - 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly', - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', - 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly', -] as const; - -interface MoodleOpenIdProviderConfiguration { - issuer: string; - authorizationEndpoint: string; - tokenEndpoint: string; - jwksUrl: string; - registrationEndpoint: string; -} - -export function buildMoodleDynamicRegistrationUrl( - appId: string, - state: string | null = null, - appOrigin = requireAppOrigin(), -): string { - const url = new URL( - `${appOrigin}/admin/packages/${encodeURIComponent(appId)}/deployment/register/moodle`, - ); - - if (state !== null) { - url.searchParams.set('state', state); - } - - return url.toString(); -} - -export async function completeMoodleDynamicRegistration(input: { - appTitle: string; - openidConfigurationUrl: string; - registrationToken: string | null; - appOrigin?: string; - fetch?: FetchLike; -}): Promise<{ - binding: Extract; - interoperability: { - usedDeploymentIdFallback: boolean; - deploymentIdSource: 'tool_configuration' | 'response' | 'openid_query' | null; - }; -}> { - const appOrigin = input.appOrigin ?? requireAppOrigin(); - const providerConfiguration = await loadMoodleOpenIdProviderConfiguration( - input.openidConfigurationUrl, - input.fetch, - ); - const registrationResponse = await submitMoodleRegistration({ - appOrigin, - appTitle: input.appTitle, - registrationEndpoint: providerConfiguration.registrationEndpoint, - registrationToken: input.registrationToken, - ...(input.fetch === undefined ? {} : { fetch: input.fetch }), - }); - - const deploymentId = requireDeploymentId(registrationResponse, input.openidConfigurationUrl); - - return { - binding: { - lms: 'moodle', - issuer: providerConfiguration.issuer, - clientId: requireString(registrationResponse, 'client_id'), - deploymentId: deploymentId.value, - authorizationEndpoint: providerConfiguration.authorizationEndpoint, - accessTokenUrl: providerConfiguration.tokenEndpoint, - jwksUrl: providerConfiguration.jwksUrl, - }, - interoperability: { - usedDeploymentIdFallback: deploymentId.source !== 'tool_configuration', - deploymentIdSource: deploymentId.source, - }, - }; -} - -async function loadMoodleOpenIdProviderConfiguration( - openidConfigurationUrl: string, - fetcher?: FetchLike, -): Promise { - const response = await performFetch(openidConfigurationUrl, undefined, fetcher); - - if (!response.ok) { - throw new Error(`Moodle openid_configuration fetch failed with status ${response.status}.`); - } - - const json = await response.json(); - const record = requireObject(json, 'Moodle openid_configuration must be a JSON object.'); - - const providerConfiguration = { - issuer: requireString(record, 'issuer'), - authorizationEndpoint: requireString(record, 'authorization_endpoint'), - tokenEndpoint: requireString(record, 'token_endpoint'), - jwksUrl: requireString(record, 'jwks_uri'), - registrationEndpoint: requireString(record, 'registration_endpoint'), - }; - - assertHostedDynamicRegistrationMetadata({ - platformLabel: 'Moodle', - openidConfigurationUrl, - ...providerConfiguration, - }); - - return providerConfiguration; -} - -async function submitMoodleRegistration(input: { - appOrigin: string; - appTitle: string; - registrationEndpoint: string; - registrationToken: string | null; - fetch?: FetchLike; -}): Promise> { - const headers = new Headers({ - 'content-type': 'application/json', - }); - - if (input.registrationToken !== null) { - headers.set('authorization', `Bearer ${input.registrationToken}`); - } - - const response = await performFetch( - input.registrationEndpoint, - { - method: 'POST', - headers, - body: JSON.stringify(buildMoodleRegistrationRequest(input.appOrigin, input.appTitle)), - }, - input.fetch, - ); - - if (!response.ok) { - throw new Error(`Moodle registration failed with status ${response.status}.`); - } - - return requireObject( - await response.json(), - 'Moodle registration response must be a JSON object.', - ); -} - -function buildMoodleRegistrationRequest(appOrigin: string, appTitle: string) { - const origin = new URL(appOrigin); - const launchUrl = `${appOrigin}/lti/launch`; - const deepLinkingUrl = `${appOrigin}/lti/deep-linking`; - - return { - application_type: 'web', - response_types: ['id_token'], - grant_types: ['implicit', 'client_credentials'], - initiate_login_uri: `${appOrigin}/lti/login`, - redirect_uris: [launchUrl, deepLinkingUrl], - client_name: `${appTitle} via Lantern`, - jwks_uri: `${appOrigin}/lti/jwks.json`, - scope: MOODLE_SCOPE_LIST.join(' '), - token_endpoint_auth_method: 'private_key_jwt', - [LTI_TOOL_CONFIGURATION_CLAIM]: { - domain: origin.host, - description: `Launch the approved ${appTitle} activity through Lantern's governed runtime.`, - target_link_uri: launchUrl, - claims: ['iss', 'sub', 'name', 'given_name', 'family_name', 'email', 'preferred_username'], - messages: [ - { - type: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - target_link_uri: deepLinkingUrl, - label: 'Select Lantern activity', - }, - ], - }, - }; -} - -function requireDeploymentId( - record: Record, - openidConfigurationUrl: string, -): { - value: string; - source: 'tool_configuration' | 'response' | 'openid_query' | null; -} { - const toolConfiguration = requireObject( - record[LTI_TOOL_CONFIGURATION_CLAIM], - 'Moodle registration response did not include tool configuration.', - ); - const openidConfiguration = new URL(openidConfigurationUrl); - const candidates = [ - { - source: 'tool_configuration' as const, - value: readOptionalString(toolConfiguration, 'deployment_id'), - }, - { - source: 'response' as const, - value: readOptionalString(record, 'deployment_id'), - }, - { - source: 'openid_query' as const, - value: openidConfiguration.searchParams.get('deployment_id') ?? - openidConfiguration.searchParams.get('deploymentId'), - }, - ]; - - for (const candidate of candidates) { - if (candidate.value !== null) { - return { - value: candidate.value, - source: candidate.source, - }; - } - } - - throw new Error('Moodle registration response did not include a deployment_id.'); -} - -function requireObject(value: unknown, message: string): Record { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new Error(message); - } - - return value as Record; -} - -function requireString(record: Record, key: string): string { - const value = readOptionalString(record, key); - - if (!value) { - throw new Error(`Moodle registration data is missing "${key}".`); - } - - return value; -} - -function readOptionalString(record: Record, key: string): string | null { - const value = record[key]; - return typeof value === 'string' && value.trim() !== '' ? value : null; -} diff --git a/src/lti/nrps_roster_verification.ts b/src/lti/nrps_roster_verification.ts new file mode 100644 index 0000000..3f15a59 --- /dev/null +++ b/src/lti/nrps_roster_verification.ts @@ -0,0 +1,34 @@ +import type { AppServices } from '../app_services.ts'; +import { requireLtiToolSession } from './lti_tool_session.ts'; +import type { LanternLtiToolContext } from './tool_provider.ts'; +import type { RuntimeSessionRecord } from './types.ts'; + +export async function readNrpsMemberCount(input: { + services: AppServices; + session: RuntimeSessionRecord; +}): Promise { + let ltiContext: LanternLtiToolContext | undefined; + const ltiSession = await requireLtiToolSession({ + getStorage: async () => { + ltiContext = await input.services.getLtiToolContext(); + return ltiContext.storage; + }, + ltiToolSessionId: input.session.ltiToolSessionId, + missingMessage: 'The verified LTI session for roster verification is required.', + notFoundMessage: 'The verified LTI session for roster verification was not found.', + }); + + if (ltiContext === undefined) { + throw new Error('The lti-tool context for roster verification was not loaded.'); + } + + const result = await ltiContext.tool.createAdvantage(ltiSession).getMembers({ + followPagination: true, + }); + + if (!result.success) { + throw new Error(result.error.message); + } + + return result.data.members.length; +} diff --git a/src/lti/platform_binding.ts b/src/lti/platform_binding.ts index f46785e..7728d45 100644 --- a/src/lti/platform_binding.ts +++ b/src/lti/platform_binding.ts @@ -1,5 +1,4 @@ import { resolveCanvasPlatform } from './canvas_platform.ts'; -import { resolveCanvasTokenEndpoint } from './service_support.ts'; import type { DeploymentBinding, LmsType } from './types.ts'; export function resolveAuthorizationEndpoint(binding: DeploymentBinding): string { @@ -44,3 +43,12 @@ export function formatLmsLabel(lms: LmsType): string { return 'Sakai'; } } + +function resolveCanvasTokenEndpoint(authorizationEndpoint: string): string { + const url = new URL(authorizationEndpoint); + url.pathname = '/login/oauth2/token'; + url.search = ''; + url.hash = ''; + + return url.toString(); +} diff --git a/src/lti/platform_binding_lookup_test.ts b/src/lti/platform_binding_lookup_test.ts deleted file mode 100644 index 397edf6..0000000 --- a/src/lti/platform_binding_lookup_test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { assertEquals, assertRejects } from '@std/assert'; -import { resolveCanvasIssuer } from './config.ts'; -import { createLoginRedirect } from './login.ts'; -import { - buildCanvasDeploymentBinding, - buildCanvasLoginRequest, - buildMoodleDeploymentBinding, - buildSakaiDeploymentBinding, - buildSakaiLoginRequest, -} from '../test_helpers/lti.ts'; -import { - buildDeploymentRecord, - createInMemoryPackageReviewRepository, -} from '../test_helpers/package_review.ts'; - -Deno.test('createLoginRedirect rejects an ambiguous platform tuple shared across LMS slots', async () => { - const canvasIssuer = resolveCanvasIssuer('production'); - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - id: 2, - slug: 'chapter-4-asteroids-moodle', - binding: buildMoodleDeploymentBinding({ - issuer: canvasIssuer, - clientId: 'shared-client-123', - deploymentId: 'shared-deployment-123', - }), - }), - buildDeploymentRecord({ - id: 1, - slug: 'chapter-4-asteroids-canvas', - binding: buildCanvasDeploymentBinding({ - canvasEnvironment: 'production', - issuer: canvasIssuer, - clientId: 'shared-client-123', - deploymentId: 'shared-deployment-123', - }), - }), - ], - }); - - await assertRejects( - () => - createLoginRedirect({ - repository, - loginRequest: buildCanvasLoginRequest({ - iss: canvasIssuer, - clientId: 'shared-client-123', - deploymentId: 'shared-deployment-123', - }), - }), - Error, - 'Choose one supported LMS deployment.', - ); -}); - -Deno.test('createLoginRedirect resolves a Sakai-only binding through shared platform identity lookup', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - id: 2, - slug: 'chapter-4-asteroids-sakai', - binding: buildSakaiDeploymentBinding({ - issuer: 'https://sakai.example', - clientId: 'sakai-client-123', - deploymentId: '1', - }), - }), - ], - }); - - const result = await createLoginRedirect({ - repository, - loginRequest: buildSakaiLoginRequest({ - iss: 'https://sakai.example', - clientId: 'sakai-client-123', - deploymentId: '1', - }), - }); - - assertEquals(result.loginState.lms, 'sakai'); - assertEquals(result.loginState.canvasEnvironment, null); - assertEquals( - new URL(result.location).origin + new URL(result.location).pathname, - 'https://sakai.example/imsoidc/lti13/oidc_auth', - ); -}); diff --git a/src/lti/runtime_session.ts b/src/lti/runtime_session.ts index d352f7b..af175e4 100644 --- a/src/lti/runtime_session.ts +++ b/src/lti/runtime_session.ts @@ -11,6 +11,7 @@ const RUNTIME_SESSION_TTL_MS = 10 * 60 * 1000; export async function createRuntimeSession(input: { repository: PackageReviewRepository; launch: ValidatedLaunch; + ltiToolSessionId?: string | null; now?: () => Date; createOpaqueToken?: () => string; }): Promise { @@ -59,6 +60,7 @@ export async function createRuntimeSession(input: { return await input.repository.createRuntimeSession({ sessionId: nextOpaqueToken(), sessionToken: nextOpaqueToken(), + ltiToolSessionId: input.ltiToolSessionId ?? null, attemptId: attempt.attemptId, deploymentRecordId: input.launch.internalDeploymentId, deploymentSlug: input.launch.internalDeploymentSlug, diff --git a/src/lti/sakai_dynamic_registration.ts b/src/lti/sakai_dynamic_registration.ts deleted file mode 100644 index 5a6217c..0000000 --- a/src/lti/sakai_dynamic_registration.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { requireAppOrigin } from './config.ts'; -import { assertHostedDynamicRegistrationMetadata } from './dynamic_registration_support.ts'; -import { type FetchLike, performFetch } from './fetch_like.ts'; -import { type DeploymentBinding, LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE } from './types.ts'; - -const SAKAI_TOOL_CONFIGURATION_CLAIM = 'https://purl.imsglobal.org/spec/lti-tool-configuration'; - -interface SakaiOpenIdProviderConfiguration { - issuer: string; - authorizationEndpoint: string; - tokenEndpoint: string; - jwksUrl: string; - registrationEndpoint: string; -} - -export function buildSakaiDynamicRegistrationUrl( - appId: string, - state: string | null = null, - appOrigin = requireAppOrigin(), -): string { - const url = new URL( - `${appOrigin}/admin/packages/${encodeURIComponent(appId)}/deployment/register/sakai`, - ); - - if (state !== null) { - url.searchParams.set('state', state); - } - - return url.toString(); -} - -export async function completeSakaiDynamicRegistration(input: { - appId: string; - appTitle: string; - openidConfigurationUrl: string; - registrationToken: string; - appOrigin?: string; - fetch?: FetchLike; -}): Promise> { - const appOrigin = input.appOrigin ?? requireAppOrigin(); - const providerConfiguration = await loadSakaiOpenIdProviderConfiguration( - input.openidConfigurationUrl, - input.fetch, - ); - const registrationResponse = await submitSakaiRegistration({ - appOrigin, - appTitle: input.appTitle, - registrationEndpoint: providerConfiguration.registrationEndpoint, - registrationToken: input.registrationToken, - ...(input.fetch === undefined ? {} : { fetch: input.fetch }), - }); - - return extractSakaiDeploymentBinding( - providerConfiguration, - registrationResponse, - input.openidConfigurationUrl, - ); -} - -async function loadSakaiOpenIdProviderConfiguration( - openidConfigurationUrl: string, - fetcher?: FetchLike, -): Promise { - const response = await performFetch(openidConfigurationUrl, undefined, fetcher); - - if (!response.ok) { - throw new Error(`Sakai openid_configuration fetch failed with status ${response.status}.`); - } - - const json = await response.json(); - const record = requireObject(json, 'Sakai openid_configuration must be a JSON object.'); - - const providerConfiguration = { - issuer: requireString(record, 'issuer'), - authorizationEndpoint: requireString(record, 'authorization_endpoint'), - tokenEndpoint: requireString(record, 'token_endpoint'), - jwksUrl: requireString(record, 'jwks_uri'), - registrationEndpoint: requireString(record, 'registration_endpoint'), - }; - - assertHostedDynamicRegistrationMetadata({ - platformLabel: 'Sakai', - openidConfigurationUrl, - ...providerConfiguration, - }); - - return providerConfiguration; -} - -async function submitSakaiRegistration(input: { - appOrigin: string; - appTitle: string; - registrationEndpoint: string; - registrationToken: string; - fetch?: FetchLike; -}): Promise> { - const response = await performFetch( - input.registrationEndpoint, - { - method: 'POST', - headers: { - authorization: `Bearer ${input.registrationToken}`, - 'content-type': 'application/json', - }, - body: JSON.stringify(buildSakaiRegistrationRequest(input.appOrigin, input.appTitle)), - }, - input.fetch, - ); - - if (!response.ok) { - throw new Error(`Sakai registration failed with status ${response.status}.`); - } - - return requireObject(await response.json(), 'Sakai registration response must be a JSON object.'); -} - -function buildSakaiRegistrationRequest(appOrigin: string, appTitle: string) { - const origin = new URL(appOrigin); - const launchUrl = `${appOrigin}/lti/launch`; - const deepLinkingUrl = `${appOrigin}/lti/deep-linking`; - - return { - application_type: 'web', - response_types: ['id_token'], - grant_types: ['implicit', 'client_credentials'], - initiate_login_uri: `${appOrigin}/lti/login`, - redirect_uris: [launchUrl, deepLinkingUrl], - client_name: `${appTitle} via Lantern`, - jwks_uri: `${appOrigin}/lti/jwks.json`, - token_endpoint_auth_method: 'private_key_jwt', - [SAKAI_TOOL_CONFIGURATION_CLAIM]: { - domain: origin.host, - description: `Launch the approved ${appTitle} activity through Lantern's governed runtime.`, - target_link_uri: launchUrl, - claims: ['iss', 'sub', 'name', 'given_name', 'family_name', 'email', 'preferred_username'], - messages: [ - { - type: 'LtiResourceLinkRequest', - target_link_uri: launchUrl, - label: `${appTitle} in Lantern`, - }, - { - type: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - target_link_uri: deepLinkingUrl, - label: 'Select Lantern activity', - }, - ], - }, - }; -} - -function extractSakaiDeploymentBinding( - providerConfiguration: SakaiOpenIdProviderConfiguration, - registrationResponse: Record, - openidConfigurationUrl: string, -): Extract { - const toolConfiguration = requireObject( - registrationResponse[SAKAI_TOOL_CONFIGURATION_CLAIM], - 'Sakai registration response did not include tool configuration.', - ); - const deploymentId = readOptionalString(toolConfiguration, 'deployment_id') ?? - new URL(openidConfigurationUrl).searchParams.get('deploymentId'); - - if (!deploymentId) { - throw new Error('Sakai registration response did not include a deployment_id.'); - } - - return { - lms: 'sakai', - issuer: providerConfiguration.issuer, - clientId: requireString(registrationResponse, 'client_id'), - deploymentId, - authorizationEndpoint: providerConfiguration.authorizationEndpoint, - accessTokenUrl: providerConfiguration.tokenEndpoint, - jwksUrl: providerConfiguration.jwksUrl, - }; -} - -function requireObject(value: unknown, message: string): Record { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new Error(message); - } - - return value as Record; -} - -function requireString(record: Record, key: string): string { - const value = readOptionalString(record, key); - - if (!value) { - throw new Error(`Sakai registration data is missing "${key}".`); - } - - return value; -} - -function readOptionalString(record: Record, key: string): string | null { - const value = record[key]; - return typeof value === 'string' && value.trim() !== '' ? value : null; -} diff --git a/src/lti/service_memberships.ts b/src/lti/service_memberships.ts deleted file mode 100644 index 82eb6b9..0000000 --- a/src/lti/service_memberships.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - createServiceHeaders, - fetchWithUnauthorizedRetry, - mapMembership, - parseNextLink, - readJsonResponse, - requireRecord, - requireTrimmedString, -} from './service_support.ts'; - -const NRPS_CONTENT_TYPE = 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json'; - -export interface NrpsMembership { - userId: string | null; - roles: string[]; - status: string | null; - name: string | null; - email: string | null; -} - -export async function readContextMemberships(input: { - accessToken: string; - retryUnauthorized?: () => Promise; - contextMembershipsUrl: string; -}): Promise { - const memberships: NrpsMembership[] = []; - let nextUrl: string | null = requireTrimmedString( - input.contextMembershipsUrl, - 'Canvas NRPS memberships URL is required.', - ); - - while (nextUrl !== null) { - const response = await fetchWithUnauthorizedRetry({ - accessToken: input.accessToken, - ...(input.retryUnauthorized === undefined - ? {} - : { retryUnauthorized: input.retryUnauthorized }), - request: (accessToken) => - fetch(nextUrl!, { - headers: createServiceHeaders({ - Authorization: `Bearer ${accessToken}`, - Accept: `${NRPS_CONTENT_TYPE}, application/json`, - }), - }), - }); - const payload = await readJsonResponse(response, 'Canvas NRPS memberships read failed.'); - const container = requireRecord(payload, 'Canvas NRPS memberships response must be an object.'); - const members = container.members; - - if (!Array.isArray(members)) { - throw new TypeError('Canvas NRPS memberships response must include members.'); - } - - memberships.push(...members.map((member, index) => mapMembership(member, index))); - nextUrl = parseNextLink(response.headers.get('link')); - } - - return memberships; -} diff --git a/src/lti/service_support.ts b/src/lti/service_support.ts deleted file mode 100644 index fdf839f..0000000 --- a/src/lti/service_support.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { NrpsMembership } from './service_memberships.ts'; - -export const LTI_SERVICE_USER_AGENT = 'Lantern-LTI-Service/1.0'; - -export interface ParsedLineItem { - lineItemUrl: string; - resourceLinkId: string | null; - resourceId: string | null; - tag: string | null; - label: string | null; - scoreMaximum: number; -} - -export function parseLineItemCollection(value: unknown): ParsedLineItem[] { - if (!Array.isArray(value)) { - throw new TypeError('LTI line item lookup must return an array.'); - } - - return value.map((item, index) => mapLineItem(item, index)); -} - -export function mapMembership(value: unknown, index: number): NrpsMembership { - const record = requireRecord(value, `Canvas NRPS member ${index} must be an object.`); - const userId = valueAsString(record.user_id); - const name = valueAsString(record.name); - const email = valueAsString(record.email); - const status = valueAsString(record.status); - const rolesValue = record.roles; - - return { - userId: userId === null ? null : userId.trim(), - roles: Array.isArray(rolesValue) - ? rolesValue.filter((role): role is string => typeof role === 'string' && role.trim() !== '') - : [], - status: status === null ? null : status.trim(), - name: name === null ? null : name.trim(), - email: email === null ? null : email.trim(), - }; -} - -export async function readJsonResponse(response: Response, message: string): Promise { - if (!response.ok) { - throw new Error(`${message} Status ${response.status}.`); - } - - return await response.json(); -} - -export async function readMaybeJson(response: Response): Promise | null> { - const text = await response.text(); - - if (text.trim() === '') { - return null; - } - - const parsed = JSON.parse(text) as unknown; - - return requireRecord(parsed, 'LTI service response body must be an object.'); -} - -export function parseNextLink(headerValue: string | null): string | null { - if (!headerValue) { - return null; - } - - for (const part of headerValue.split(',')) { - const trimmed = part.trim(); - - if (!trimmed.includes('rel="next"')) { - continue; - } - - const match = trimmed.match(/^<([^>]+)>/); - - if (match?.[1]) { - return match[1]; - } - } - - return null; -} - -export function resolveCanvasTokenEndpoint(authorizationEndpoint: string): string { - const url = new URL(authorizationEndpoint); - - url.pathname = '/login/oauth2/token'; - url.search = ''; - - return url.toString(); -} - -export function toLineItemsUrl(lineItemUrl: string): string { - const url = new URL(lineItemUrl); - const segments = url.pathname.split('/').filter(Boolean); - - if (segments.length < 2) { - throw new Error('Line item URL could not be resolved to a lineitems URL.'); - } - - segments.pop(); - url.pathname = `/${segments.join('/')}`; - url.search = ''; - - return url.toString(); -} - -export function toScoresUrl(lineItemUrl: string): string { - return `${lineItemUrl.replace(/\/$/, '')}/scores`; -} - -export function requireTrimmedString(value: string | null, message: string): string { - if (value === null || value.trim() === '') { - throw new Error(message); - } - - return value.trim(); -} - -export function uniqueTrimmedStrings(values: string[], message: string): string[] { - const uniqueValues = [...new Set(values.map((value) => value.trim()).filter(Boolean))]; - - if (uniqueValues.length === 0) { - throw new Error(message); - } - - return uniqueValues; -} - -export function createServiceHeaders(input: HeadersInit): Headers { - const headers = new Headers(input); - headers.set('user-agent', LTI_SERVICE_USER_AGENT); - return headers; -} - -export async function fetchWithUnauthorizedRetry(input: { - accessToken: string; - request: (accessToken: string) => Promise; - retryUnauthorized?: () => Promise; -}): Promise { - const response = await input.request(input.accessToken); - - if (response.status !== 401 || input.retryUnauthorized === undefined) { - return response; - } - - return await input.request(await input.retryUnauthorized()); -} - -function mapLineItem(value: unknown, index: number): ParsedLineItem { - const record = requireRecord(value, `LTI line item ${index} must be an object.`); - - return { - lineItemUrl: requireTrimmedString( - valueAsString(record.id), - `LTI line item ${index} must include id.`, - ), - resourceLinkId: optionalTrimmedString(record.resourceLinkId), - resourceId: optionalTrimmedString(record.resourceId), - tag: optionalTrimmedString(record.tag), - label: optionalTrimmedString(record.label), - scoreMaximum: requireNumber( - record.scoreMaximum, - `LTI line item ${index} must include scoreMaximum.`, - ), - }; -} - -export function requireRecord(value: unknown, message: string): Record { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new Error(message); - } - - return value as Record; -} - -function requireNumber(value: unknown, message: string): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new TypeError(message); - } - - return value; -} - -function valueAsString(value: unknown): string | null { - return typeof value === 'string' ? value : null; -} - -function optionalTrimmedString(value: unknown): string | null { - if (typeof value !== 'string') { - return null; - } - - const trimmed = value.trim(); - - return trimmed === '' ? null : trimmed; -} diff --git a/src/lti/services.ts b/src/lti/services.ts deleted file mode 100644 index 7d68dd3..0000000 --- a/src/lti/services.ts +++ /dev/null @@ -1,290 +0,0 @@ -import * as oauth from 'oauth4webapi'; -import type { EnvReader } from '../platform/env.ts'; -import { resolveCanvasPlatform } from './canvas_platform.ts'; -import { resolveServiceTokenEndpoint } from './platform_binding.ts'; -import { - createServiceHeaders, - fetchWithUnauthorizedRetry, - parseLineItemCollection, - readJsonResponse, - readMaybeJson, - requireTrimmedString, - resolveCanvasTokenEndpoint, - toLineItemsUrl, - toScoresUrl, - uniqueTrimmedStrings, -} from './service_support.ts'; -import { loadToolSigningKey } from './tool_key.ts'; -import type { DeploymentBinding } from './types.ts'; -import type { GradePublicationRecord } from '../package_review/types.ts'; - -const LINE_ITEM_CONTENT_TYPE = 'application/vnd.ims.lis.v2.lineitem+json'; -const LINE_ITEM_CONTAINER_CONTENT_TYPE = 'application/vnd.ims.lis.v2.lineitemcontainer+json'; -const SCORE_CONTENT_TYPE = 'application/vnd.ims.lis.v1.score+json'; - -export interface ServiceAccessToken { - accessToken: string; - expiresIn: number | null; - scope: string[]; -} - -export interface EnsureLineItemInput { - accessToken: string; - retryUnauthorized?: () => Promise; - lineitemsUrl: string | null; - lineitemUrl?: string | null; - resourceLinkId: string; - resourceId: string; - tag: string; - label: string; - scoreMaximum: number; -} - -export interface EnsureLineItemResult { - lineItemsUrl: string; - lineItemUrl: string; - resourceId: string; - tag: string; - label: string; - scoreMaximum: number; - created: boolean; -} - -export interface PublishFinalScoreInput { - accessToken: string; - retryUnauthorized?: () => Promise; - lineItemUrl: string; - platformUserId: string; - scoreGiven: number; - scoreMaximum: number; - activityProgress: GradePublicationRecord['activityProgress']; - gradingProgress: GradePublicationRecord['gradingProgress']; - timestamp?: string; -} - -export type PublishFinalScoreResult = { accepted: boolean; status: number }; - -export type { NrpsMembership } from './service_memberships.ts'; - -async function performServiceAccessTokenRequest(input: { - issuer: string; - clientId: string; - tokenEndpoint: string; - scopes: string[]; - env: EnvReader; - deploymentId?: string; -}): Promise { - const clientId = requireTrimmedString( - input.clientId, - 'LTI client ID is required for service auth.', - ); - const scopes = uniqueTrimmedStrings(input.scopes, 'At least one LTI service scope is required.'); - const signingKey = await loadToolSigningKey(input.env); - const as: oauth.AuthorizationServer = { - issuer: input.issuer, - token_endpoint: input.tokenEndpoint, - }; - const client: oauth.Client = { - client_id: clientId, - }; - const parameters = new URLSearchParams({ - scope: scopes.join(' '), - }); - - if (input.deploymentId !== undefined) { - parameters.set('deployment_id', input.deploymentId); - } - - const response = await oauth.clientCredentialsGrantRequest( - as, - client, - oauth.PrivateKeyJwt(signingKey.privateKey, { - [oauth.modifyAssertion]: (header) => { - header.kid = signingKey.publicJwk.kid; - }, - }), - parameters, - ); - const token = await oauth.processClientCredentialsResponse(as, client, response); - - if (token.token_type !== 'bearer') { - throw new Error( - `LTI service token endpoint returned unsupported token type ${token.token_type}.`, - ); - } - - return { - accessToken: token.access_token, - expiresIn: token.expires_in ?? null, - scope: token.scope ? token.scope.split(' ').filter(Boolean) : scopes, - }; -} - -export async function requestServiceAccessToken(input: { - binding: DeploymentBinding; - scopes: string[]; - env: EnvReader; -}): Promise { - return await performServiceAccessTokenRequest({ - issuer: input.binding.issuer, - clientId: input.binding.clientId, - tokenEndpoint: resolveServiceTokenEndpoint(input.binding), - scopes: input.scopes, - env: input.env, - ...(input.binding.deploymentId === undefined - ? {} - : { deploymentId: input.binding.deploymentId }), - }); -} - -export async function requestCanvasServiceAccessToken(input: { - issuer: string; - clientId: string; - scopes: string[]; - env: EnvReader; - deploymentId?: string; -}): Promise { - const platform = resolveCanvasPlatform(input.issuer); - - return await performServiceAccessTokenRequest({ - issuer: platform.issuer, - clientId: input.clientId, - tokenEndpoint: resolveCanvasTokenEndpoint(platform.authorizationEndpoint), - scopes: input.scopes, - env: input.env, - ...(input.deploymentId === undefined ? {} : { deploymentId: input.deploymentId }), - }); -} - -export async function ensureLineItem(input: EnsureLineItemInput): Promise { - if (input.lineitemUrl) { - return { - lineItemsUrl: input.lineitemsUrl ?? toLineItemsUrl(input.lineitemUrl), - lineItemUrl: requireTrimmedString(input.lineitemUrl, 'AGS line item URL is required.'), - resourceId: input.resourceId, - tag: input.tag, - label: input.label, - scoreMaximum: input.scoreMaximum, - created: false, - }; - } - - const lineItemsUrl = requireTrimmedString(input.lineitemsUrl, 'AGS lineitems URL is required.'); - const listResponse = await fetchWithUnauthorizedRetry({ - accessToken: input.accessToken, - ...(input.retryUnauthorized === undefined - ? {} - : { retryUnauthorized: input.retryUnauthorized }), - request: (accessToken) => - fetch(lineItemsUrl, { - headers: createServiceHeaders({ - Authorization: `Bearer ${accessToken}`, - Accept: - `${LINE_ITEM_CONTAINER_CONTENT_TYPE}, ${LINE_ITEM_CONTENT_TYPE}, application/json`, - }), - }), - }); - const listedItems = await readJsonResponse(listResponse, 'Line item lookup failed.'); - const existing = parseLineItemCollection(listedItems).find( - (candidate) => - candidate.resourceLinkId === input.resourceLinkId && - candidate.resourceId === input.resourceId && - candidate.tag === input.tag, - ); - - if (existing) { - return { - lineItemsUrl, - lineItemUrl: existing.lineItemUrl, - resourceId: existing.resourceId ?? input.resourceId, - tag: existing.tag ?? input.tag, - label: existing.label ?? input.label, - scoreMaximum: existing.scoreMaximum, - created: false, - }; - } - - const createResponse = await fetchWithUnauthorizedRetry({ - accessToken: input.accessToken, - ...(input.retryUnauthorized === undefined - ? {} - : { retryUnauthorized: input.retryUnauthorized }), - request: (accessToken) => - fetch(lineItemsUrl, { - method: 'POST', - headers: createServiceHeaders({ - Authorization: `Bearer ${accessToken}`, - Accept: `${LINE_ITEM_CONTENT_TYPE}, application/json`, - 'content-type': LINE_ITEM_CONTENT_TYPE, - }), - body: JSON.stringify({ - scoreMaximum: input.scoreMaximum, - label: input.label, - resourceId: input.resourceId, - resourceLinkId: input.resourceLinkId, - tag: input.tag, - }), - }), - }); - - if (!createResponse.ok) { - throw new Error(`Line item create failed with status ${createResponse.status}.`); - } - - const created = await readMaybeJson(createResponse); - const lineItemUrl = created && typeof created.id === 'string' ? created.id : requireTrimmedString( - createResponse.headers.get('location'), - 'Line item create response did not include an id or location.', - ); - - return { - lineItemsUrl, - lineItemUrl, - resourceId: input.resourceId, - tag: input.tag, - label: created && typeof created.label === 'string' ? created.label : input.label, - scoreMaximum: created && typeof created.scoreMaximum === 'number' - ? created.scoreMaximum - : input.scoreMaximum, - created: true, - }; -} - -export async function publishFinalScore( - input: PublishFinalScoreInput, -): Promise { - const response = await fetchWithUnauthorizedRetry({ - accessToken: input.accessToken, - ...(input.retryUnauthorized === undefined - ? {} - : { retryUnauthorized: input.retryUnauthorized }), - request: (accessToken) => - fetch(toScoresUrl(input.lineItemUrl), { - method: 'POST', - headers: createServiceHeaders({ - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - 'content-type': SCORE_CONTENT_TYPE, - }), - body: JSON.stringify({ - userId: input.platformUserId, - scoreGiven: input.scoreGiven, - scoreMaximum: input.scoreMaximum, - activityProgress: input.activityProgress, - gradingProgress: input.gradingProgress, - timestamp: input.timestamp ?? new Date().toISOString(), - }), - }), - }); - - if (!response.ok) { - throw new Error(`Canvas score publish failed with status ${response.status}.`); - } - - return { - accepted: true, - status: response.status, - }; -} - -export { readContextMemberships } from './service_memberships.ts'; diff --git a/src/lti/services_nrps_test.ts b/src/lti/services_nrps_test.ts deleted file mode 100644 index a2e0544..0000000 --- a/src/lti/services_nrps_test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { assertEquals } from '@std/assert'; -import { buildNrpsLaunchService } from '../test_helpers/lti.ts'; -import { withFetchStub } from '../test_helpers/fetch_stub.ts'; -import { readContextMemberships } from './services.ts'; - -Deno.test('LTI services client reads paginated NRPS memberships from the launch-scoped context_memberships_url', async () => { - const nrps = buildNrpsLaunchService(); - const calls: string[] = []; - - const members = await withFetchStub( - (input, init) => { - const url = String(input); - const headers = new Headers(init?.headers); - - calls.push(url); - assertEquals(headers.get('user-agent'), 'Lantern-LTI-Service/1.0'); - - if (url === nrps.contextMembershipsUrl) { - return new Response( - JSON.stringify({ - members: [ - { - user_id: 'canvas-user-123', - roles: ['Learner'], - name: 'Ada Lovelace', - email: 'ada@example.com', - status: 'Active', - }, - ], - }), - { - status: 200, - headers: { - 'content-type': 'application/json', - link: - '; rel="next"', - }, - }, - ); - } - - return new Response( - JSON.stringify({ - members: [ - { - user_id: 'canvas-user-456', - roles: ['Instructor'], - name: 'Grace Hopper', - email: 'grace@example.com', - status: 'Active', - }, - ], - }), - { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }, - ); - }, - async () => - await readContextMemberships({ - accessToken: 'canvas-access-token', - contextMembershipsUrl: nrps.contextMembershipsUrl, - }), - ); - - assertEquals(calls, [ - nrps.contextMembershipsUrl, - 'https://canvas.example/api/lti/courses/42/names_and_roles?page=2', - ]); - assertEquals(members.length, 2); - assertEquals(members[0]?.userId, 'canvas-user-123'); - assertEquals(members[1]?.roles, ['Instructor']); -}); - -Deno.test('LTI services client retries one NRPS page with a fresh token after a 401', async () => { - const nrps = buildNrpsLaunchService(); - const authorizations: string[] = []; - let refreshCount = 0; - - const members = await withFetchStub( - (input, init) => { - const url = String(input); - const headers = new Headers(init?.headers); - const authorization = headers.get('authorization'); - - authorizations.push(`${url} ${authorization}`); - - if (authorization === 'Bearer canvas-access-token') { - return new Response(null, { status: 401 }); - } - - return new Response( - JSON.stringify({ - members: [ - { - user_id: 'canvas-user-123', - roles: ['Learner'], - }, - ], - }), - { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }, - ); - }, - async () => - await readContextMemberships({ - accessToken: 'canvas-access-token', - retryUnauthorized: () => { - refreshCount += 1; - - return Promise.resolve('canvas-access-token-refreshed'); - }, - contextMembershipsUrl: nrps.contextMembershipsUrl, - }), - ); - - assertEquals(refreshCount, 1); - assertEquals(authorizations, [ - `${nrps.contextMembershipsUrl} Bearer canvas-access-token`, - `${nrps.contextMembershipsUrl} Bearer canvas-access-token-refreshed`, - ]); - assertEquals(members.length, 1); -}); diff --git a/src/lti/services_test.ts b/src/lti/services_test.ts deleted file mode 100644 index 7da0b40..0000000 --- a/src/lti/services_test.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { - buildAgsLaunchService, - buildDeploymentBinding, - buildMoodleDeploymentBinding, - buildSakaiDeploymentBinding, - getTestToolPrivateJwkEnvValue, -} from '../test_helpers/lti.ts'; -import { restoreEnv, withFetchStub } from '../test_helpers/fetch_stub.ts'; -import { ensureLineItem, publishFinalScore, requestServiceAccessToken } from './services.ts'; - -const TEST_SERVICE_ENV = { - get(name: string): string | undefined { - return name === 'LTI_TOOL_PRIVATE_JWK' ? getTestToolPrivateJwkEnvValue() : undefined; - }, -}; - -Deno.test('LTI services client reuses a launch lineitem or creates one from the lineitems container before score publish', async () => { - const ags = buildAgsLaunchService({}, 'lineitems'); - let requestCount = 0; - - const result = await withFetchStub( - (input, init) => { - requestCount += 1; - const url = String(input); - const headers = new Headers(init?.headers); - - assertEquals(url, ags.lineitemsUrl); - assertEquals(init?.method ?? 'GET', 'GET'); - assertEquals((init?.headers as HeadersInit | undefined) !== undefined, true); - assertEquals(headers.get('user-agent'), 'Lantern-LTI-Service/1.0'); - assertEquals(headers.get('authorization'), 'Bearer canvas-access-token'); - - return new Response( - JSON.stringify([ - { - id: 'https://canvas.example/api/lti/courses/42/line_items/9', - resourceLinkId: 'resource-link-123', - resourceId: 'chapter-4-asteroids:0.1.0', - tag: 'final-grade', - label: 'Chapter 4 Asteroids Final Grade', - scoreMaximum: 100, - }, - ]), - { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }, - ); - }, - async () => - await ensureLineItem({ - accessToken: 'canvas-access-token', - lineitemsUrl: ags.lineitemsUrl, - resourceLinkId: 'resource-link-123', - resourceId: 'chapter-4-asteroids:0.1.0', - tag: 'final-grade', - label: 'Chapter 4 Asteroids Final Grade', - scoreMaximum: 100, - }), - ); - - assertEquals(requestCount, 1); - assertEquals(result.lineItemUrl, 'https://canvas.example/api/lti/courses/42/line_items/9'); - assertEquals(result.created, false); -}); - -Deno.test('LTI services client requests RS256 client-credentials tokens from the saved deployment binding', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const bindings = [ - buildDeploymentBinding(), - buildMoodleDeploymentBinding(), - buildSakaiDeploymentBinding(), - ] as const; - const requests: Array<{ - url: string; - method: string; - body: string; - authorization: string | null; - }> = []; - - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - await withFetchStub( - (input, init) => { - const url = String(input); - const method = init?.method ?? 'GET'; - const body = typeof init?.body === 'string' - ? init.body - : init?.body instanceof URLSearchParams - ? init.body.toString() - : ''; - const headers = new Headers(init?.headers); - - requests.push({ - url, - method, - body, - authorization: headers.get('authorization'), - }); - - const matchingBinding = bindings.find((binding) => { - switch (binding.lms) { - case 'canvas': - return url === 'https://sso.canvaslms.com/login/oauth2/token'; - case 'moodle': - case 'sakai': - return url === binding.accessTokenUrl; - default: - return false; - } - }); - - if (matchingBinding !== undefined) { - assertEquals(method, 'POST'); - assertStringIncludes(body, 'grant_type=client_credentials'); - assertStringIncludes(body, `client_id=${matchingBinding.clientId}`); - assertStringIncludes(body, `deployment_id=${matchingBinding.deploymentId}`); - assertStringIncludes( - body, - 'scope=https%3A%2F%2Fpurl.imsglobal.org%2Fspec%2Flti-ags%2Fscope%2Fscore', - ); - assertStringIncludes(body, 'client_assertion_type='); - assertStringIncludes(body, 'client_assertion='); - - return new Response( - JSON.stringify({ - access_token: `${matchingBinding.lms}-access-token`, - token_type: 'bearer', - expires_in: 300, - scope: [ - 'https://purl.imsglobal.org/spec/lti-ags/scope/score', - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', - ].join(' '), - }), - { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }, - ); - } - - throw new Error(`Unexpected request to ${url}.`); - }, - async () => { - for (const binding of bindings) { - const token = await requestServiceAccessToken({ - binding, - scopes: [ - 'https://purl.imsglobal.org/spec/lti-ags/scope/score', - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', - ], - env: TEST_SERVICE_ENV, - }); - - assertEquals(token.accessToken, `${binding.lms}-access-token`); - assertEquals(token.expiresIn, 300); - } - }, - ); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } - - assertEquals(requests.length, 3); -}); - -Deno.test('LTI services client tolerates sparse line-item payloads and creates a fresh record instead of aborting grading flow', async () => { - const ags = buildAgsLaunchService({}, 'lineitems'); - const requests: string[] = []; - - const result = await withFetchStub( - (input, init) => { - const url = String(input); - const method = init?.method ?? 'GET'; - const headers = new Headers(init?.headers); - - requests.push(`${method} ${url}`); - - if (method === 'GET') { - assertEquals(headers.get('user-agent'), 'Lantern-LTI-Service/1.0'); - return new Response( - JSON.stringify([ - { - id: 'https://canvas.example/api/lti/courses/42/line_items/9', - scoreMaximum: 100, - }, - ]), - { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }, - ); - } - - assertEquals(method, 'POST'); - assertEquals(headers.get('user-agent'), 'Lantern-LTI-Service/1.0'); - - return new Response( - JSON.stringify({ - id: 'https://canvas.example/api/lti/courses/42/line_items/11', - label: 'Chapter 4 Asteroids Final Grade', - scoreMaximum: 100, - }), - { - status: 201, - headers: { - 'content-type': 'application/json', - }, - }, - ); - }, - async () => - await ensureLineItem({ - accessToken: 'canvas-access-token', - lineitemsUrl: ags.lineitemsUrl, - resourceLinkId: 'resource-link-123', - resourceId: 'chapter-4-asteroids:0.1.0', - tag: 'final-grade', - label: 'Chapter 4 Asteroids Final Grade', - scoreMaximum: 100, - }), - ); - - assertEquals(requests, [`GET ${ags.lineitemsUrl}`, `POST ${ags.lineitemsUrl}`]); - assertEquals(result.created, true); - assertEquals(result.lineItemUrl, 'https://canvas.example/api/lti/courses/42/line_items/11'); -}); - -Deno.test('LTI services client publishes final AGS scores with server-owned identity', async () => { - const requests: Array<{ - url: string; - method: string; - body: string; - authorization: string | null; - }> = []; - - await withFetchStub( - (input, init) => { - const url = String(input); - const method = init?.method ?? 'GET'; - const body = typeof init?.body === 'string' - ? init.body - : init?.body instanceof URLSearchParams - ? init.body.toString() - : ''; - const headers = new Headers(init?.headers); - - requests.push({ - url, - method, - body, - authorization: headers.get('authorization'), - }); - - assertEquals(url, 'https://canvas.example/api/lti/courses/42/line_items/9/scores'); - assertEquals(method, 'POST'); - assertEquals(headers.get('authorization'), 'Bearer canvas-access-token'); - assertEquals(headers.get('user-agent'), 'Lantern-LTI-Service/1.0'); - assertStringIncludes(body, '"userId":"canvas-user-123"'); - assertStringIncludes(body, '"scoreGiven":85'); - assertStringIncludes(body, '"scoreMaximum":100'); - - return new Response(null, { status: 202 }); - }, - async () => { - const publication = await publishFinalScore({ - accessToken: 'canvas-access-token', - lineItemUrl: 'https://canvas.example/api/lti/courses/42/line_items/9', - platformUserId: 'canvas-user-123', - scoreGiven: 85, - scoreMaximum: 100, - activityProgress: 'Completed', - gradingProgress: 'FullyGraded', - timestamp: '2026-03-24T03:00:00Z', - }); - - assertEquals(publication.accepted, true); - assertEquals(publication.status, 202); - }, - ); - - assertEquals(requests.length, 1); -}); - -Deno.test('LTI services client retries one Canvas score publish with a fresh token after a 401', async () => { - const tokens: string[] = []; - let refreshCount = 0; - - const publication = await withFetchStub( - (_input, init) => { - const headers = new Headers(init?.headers); - const authorization = headers.get('authorization'); - - tokens.push(authorization ?? ''); - assertEquals(headers.get('user-agent'), 'Lantern-LTI-Service/1.0'); - - if (authorization === 'Bearer canvas-access-token') { - return new Response(null, { status: 401 }); - } - - return new Response(null, { status: 202 }); - }, - async () => - await publishFinalScore({ - accessToken: 'canvas-access-token', - retryUnauthorized: () => { - refreshCount += 1; - - return Promise.resolve('canvas-access-token-refreshed'); - }, - lineItemUrl: 'https://canvas.example/api/lti/courses/42/line_items/9', - platformUserId: 'canvas-user-123', - scoreGiven: 85, - scoreMaximum: 100, - activityProgress: 'Completed', - gradingProgress: 'FullyGraded', - timestamp: '2026-03-24T03:00:00Z', - }), - ); - - assertEquals(refreshCount, 1); - assertEquals(tokens, ['Bearer canvas-access-token', 'Bearer canvas-access-token-refreshed']); - assertEquals(publication.status, 202); -}); diff --git a/src/lti/tool_key.ts b/src/lti/tool_key.ts index c52393c..0a89c48 100644 --- a/src/lti/tool_key.ts +++ b/src/lti/tool_key.ts @@ -27,7 +27,8 @@ export interface LoadedToolSigningKey { publicJwk: ToolPublicJwk; } -export async function loadToolSigningKey(env: EnvReader): Promise { +/** Loads the raw private JWK string used by Lantern and LTI Tool. */ +export function readToolPrivateJwkValue(env: EnvReader): string { const rawValue = env.get(TOOL_PRIVATE_JWK_ENV); if (!rawValue) { @@ -36,7 +37,11 @@ export async function loadToolSigningKey(env: EnvReader): Promise { + const privateJwk = await parseToolPrivateJwk(readToolPrivateJwkValue(env)); const importedKey = await importJWK(privateJwk, privateJwk.alg); if (!(importedKey instanceof CryptoKey) || importedKey.type !== 'private') { diff --git a/src/lti/tool_provider.ts b/src/lti/tool_provider.ts new file mode 100644 index 0000000..e9e0930 --- /dev/null +++ b/src/lti/tool_provider.ts @@ -0,0 +1,153 @@ +import { + createNoopLogger, + type DynamicRegistrationConfig, + importLtiToolKeyPairFromJwk, + LtiDynamicRegistration, + type LtiLogger, + type LTIStorage, + LTITool, + upsertLaunchRegistration, +} from '@longsightgroup/lti-tool'; +import { D1Storage } from '@longsightgroup/lti-tool/storage/d1'; +import { MemoryStorage } from '@longsightgroup/lti-tool/storage/memory'; +import type { AnyD1Database } from 'drizzle-orm/d1'; +import { type D1Database } from '../db/d1.ts'; +import { type EnvReader, readEnv } from '../platform/env.ts'; +import { requireAppOrigin } from './config.ts'; +import { + resolveAuthorizationEndpoint, + resolveBindingJwksUrl, + resolveServiceTokenEndpoint, +} from './platform_binding.ts'; +import { loadToolSigningKey } from './tool_key.ts'; +import type { DeploymentBinding } from './types.ts'; + +const LTI_STATE_SECRET_ENV = 'LTI_STATE_SECRET'; +const MIN_STATE_SECRET_BYTES = 32; + +export interface LanternLtiToolContext { + readonly storage: LTIStorage; + readonly tool: LTITool; + readonly dynamicRegistration: LtiDynamicRegistration; + readonly logger: LtiLogger; +} + +export interface LanternLtiToolContextInput { + readonly env: EnvReader; + readonly storage: LTIStorage; + readonly appOrigin?: string; + readonly logger?: LtiLogger; +} + +export function createD1LtiStorage(database: D1Database, logger?: LtiLogger): LTIStorage { + return new D1Storage({ + // SAFETY: Cloudflare's D1 binding satisfies the runtime query surface expected by drizzle-orm/d1. + database: database as unknown as AnyD1Database, + ...(logger === undefined ? {} : { logger }), + }); +} + +export function createMemoryLtiStorage(logger?: LtiLogger): LTIStorage { + return new MemoryStorage(logger === undefined ? {} : { logger }); +} + +export async function createLanternLtiToolContext( + input: LanternLtiToolContextInput, +): Promise { + const logger = input.logger ?? createNoopLogger(); + const toolSigningKey = await loadToolSigningKey(input.env); + const keyMaterial = await importLtiToolKeyPairFromJwk(toolSigningKey.privateJwk); + const appOrigin = input.appOrigin ?? requireAppOrigin(input.env); + const config = { + stateSecret: readLtiStateSecret(input.env), + keyPair: keyMaterial.keyPair, + storage: input.storage, + logger, + security: { + keyId: keyMaterial.keyId, + remoteJwks: { + timeoutDuration: 5_000, + cooldownDuration: 30_000, + cacheMaxAge: 300_000, + }, + }, + dynamicRegistration: buildDynamicRegistrationConfig(appOrigin), + }; + + return { + storage: input.storage, + tool: new LTITool(config), + dynamicRegistration: new LtiDynamicRegistration(config), + logger, + }; +} + +export async function syncLtiToolDeploymentBinding(input: { + readonly storage: LTIStorage; + readonly appTitle: string; + readonly deploymentSlug: string; + readonly binding: DeploymentBinding; +}): Promise { + await upsertLaunchRegistration(input.storage, { + iss: input.binding.issuer, + clientId: input.binding.clientId, + deploymentId: input.binding.deploymentId, + authUrl: resolveAuthorizationEndpoint(input.binding), + tokenUrl: resolveServiceTokenEndpoint(input.binding), + jwksUrl: resolveBindingJwksUrl(input.binding), + name: `${input.appTitle} via Lantern`, + deploymentName: input.deploymentSlug, + }); +} + +function readLtiStateSecret(env: EnvReader): Uint8Array { + const rawValue = readEnv(LTI_STATE_SECRET_ENV, env)?.trim(); + + if (!rawValue) { + throw new Error( + `Missing ${LTI_STATE_SECRET_ENV}. Lantern needs a stable secret of at least ${MIN_STATE_SECRET_BYTES} bytes for LTI OIDC state signing.`, + ); + } + + const bytes = new TextEncoder().encode(rawValue); + + if (bytes.byteLength < MIN_STATE_SECRET_BYTES) { + throw new Error( + `${LTI_STATE_SECRET_ENV} must be at least ${MIN_STATE_SECRET_BYTES} UTF-8 bytes.`, + ); + } + + return bytes; +} + +function buildDynamicRegistrationConfig(appOrigin: string): DynamicRegistrationConfig { + return { + url: appOrigin, + name: 'Lantern', + description: 'Launch approved Lantern learning activities through the governed runtime.', + loginUri: `${appOrigin}/lti/login`, + launchUri: `${appOrigin}/lti/launch`, + jwksUri: `${appOrigin}/lti/jwks.json`, + deepLinkingUri: `${appOrigin}/lti/deep-linking`, + redirectUris: [ + `${appOrigin}/lti/launch`, + `${appOrigin}/lti/deep-linking`, + `${appOrigin}/lti/deep-linking?placement=resource_selection`, + ], + platforms: { + canvas: { + privacyLevel: 'public', + toolId: 'lantern-demo-broker', + resourceLinkPlacements: ['course_navigation'], + deepLinkPlacements: ['assignment_selection', 'resource_selection'], + }, + moodle: { + deepLinkPlacements: ['assignment_selection'], + }, + sakai: { + resourceLinkPlacements: ['course_navigation'], + deepLinkPlacements: ['assignment_selection'], + }, + }, + }; +} diff --git a/src/lti/types.ts b/src/lti/types.ts index fae6c41..98d27e4 100644 --- a/src/lti/types.ts +++ b/src/lti/types.ts @@ -59,22 +59,6 @@ export interface CanvasPlatformConfig { jwksUrl: string; } -export interface LoginStateRecord { - lms: LmsType; - canvasEnvironment: CanvasEnvironment | null; - issuer: string; - clientId: string; - deploymentId: string; - state: string; - nonce: string; - loginHint: string; - targetLinkUri: string; - ltiMessageHint: string | null; - createdAt: string; - expiresAt: string; - usedAt: string | null; -} - export interface DynamicRegistrationStateRecord { state: string; appId: string; @@ -132,6 +116,7 @@ export interface ValidatedLaunch { export interface RuntimeSessionRecord { sessionId: string; sessionToken: string; + ltiToolSessionId: string | null; attemptId: string; deploymentRecordId: number; deploymentSlug: string; @@ -176,6 +161,7 @@ export type LtiPlacement = | typeof LTI_RESOURCE_SELECTION_PLACEMENT; export interface ValidatedDeepLinkingRequest { + ltiToolSessionId: string | null; lms: LmsType; canvasEnvironment: CanvasEnvironment | null; issuer: string; @@ -206,6 +192,7 @@ export interface DeepLinkingSessionSelection { export interface DeepLinkingSessionRecord { sessionId: string; sessionToken: string; + ltiToolSessionId: string | null; deploymentRecordId: number; deploymentSlug: string; appId: string; diff --git a/src/ops/repository_d1.ts b/src/ops/repository_d1.ts index 7e032ff..4859437 100644 --- a/src/ops/repository_d1.ts +++ b/src/ops/repository_d1.ts @@ -1,54 +1,20 @@ import type { D1Database } from '../db/d1.ts'; -import { queryD1First, queryD1Objects, runD1 } from '../db/d1.ts'; +import { queryD1Objects } from '../db/d1.ts'; import type { PackageReviewRepository } from '../package_review/repository.ts'; -import { mapAttemptEvidenceArtifactRow } from '../package_review/repository_mappers_attempts.ts'; -import type { AttemptEvidenceArtifactRow } from '../package_review/repository_row_types.ts'; -import type { AttemptEvidenceArtifactRecord } from '../package_review/types.ts'; -import { BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS } from './broker_verification_paths.ts'; +import { type D1InventoryQueryRow, mapD1InventoryRow } from './repository_d1_mapping.ts'; import { - assertBrokerVerificationRunInput, - mapActivitySnapshotRow, - mapBrokerVerificationStatusRows, - mapCertificationWorkflowStatusRow, - mapDiagnosticRows, - mapGradePublicationSnapshotRow, - mapInventoryRow, - mapLatestOfficialCertificationEvidenceRow, - mapRecentLaunchRows, - mapRuntimeEvidenceSnapshotRow, -} from './repository_mapping.ts'; -import { mapRetryLookupRow } from './repository_retry_mapping.ts'; -import type { - ActivitySnapshotRow, - CertificationWorkflowStatusRow, - DiagnosticRow, - GradePublicationSnapshotRow, - InternalBrokerVerificationRow, - InventoryQueryRow, - LatestOfficialCertificationEvidenceRow, - OfficialBrokerVerificationRow, - OpsRepository, - RecentLaunchRow, - RecordBrokerVerificationRunInput, - RetryLookupRow, -} from './repository_types.ts'; -import type { - ControlPlaneAnonymousEvidenceArtifact, - ControlPlaneDiagnosticItem, - ControlPlaneRuntimeEvidenceSnapshot, - DeploymentActivitySnapshot, - DeploymentGradePublicationSnapshot, - RetryableGradePublicationLookup, -} from './types.ts'; - -const DEPLOYMENT_SUPPORTED_PATH_SQL = ` - CASE deployments.lms_type - WHEN 'canvas' THEN '${BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS.canvas}' - WHEN 'moodle' THEN '${BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS.moodle}' - WHEN 'sakai' THEN '${BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS.sakai}' - ELSE NULL - END -`; + getLatestBrokerVerificationStatusForD1, + getLatestOfficialCertificationEvidenceForD1, + listCertificationWorkflowStatusesForD1, + recordBrokerVerificationRunForD1, +} from './repository_d1_broker.ts'; +import { + getControlPlaneDeploymentDetailForD1, + getRetryableGradePublicationLookupForD1, +} from './repository_d1_detail.ts'; +import { D1_INVENTORY_QUERY } from './repository_d1_queries.ts'; +import { mapInventoryRow } from './repository_mapping.ts'; +import type { OpsRepository } from './repository_types.ts'; export function createD1OpsRepository( db: D1Database, @@ -65,142 +31,15 @@ export function createD1OpsRepository( }, async getControlPlaneDeploymentDetail(deploymentRecordId) { - const inventory = await getInventoryRow(db, deploymentRecordId); - - if (inventory === null) { - return null; - } - - const latestLaunch = await getActivitySnapshot( - db, - D1_LATEST_LAUNCH_QUERY, - deploymentRecordId, - ); - const latestRuntimeSession = await getRuntimeEvidenceSnapshot( - db, - D1_LATEST_RUNTIME_SESSION_QUERY, - deploymentRecordId, - ); - const latestRuntimeOutcome = await getRuntimeEvidenceSnapshot( - db, - D1_LATEST_RUNTIME_OUTCOME_QUERY, - deploymentRecordId, - ); - const latestGradePublish = await getLatestGradePublication(db, deploymentRecordId); - const retryableGradePublication = latestGradePublish?.status === 'failed' - ? await getRetryableGradePublicationLookupForD1(db, latestGradePublish.attemptId) - : null; - const latestRuntimeAttemptId = latestRuntimeOutcome?.attemptId ?? - latestRuntimeSession?.attemptId ?? null; - - return { - inventory, - latestInstallEvidence: inventory.installEvidence, - latestLaunch, - latestRuntimeSession, - latestRuntimeOutcome, - latestAnonymousEvidence: deriveLatestAnonymousEvidence( - inventory.appId, - latestRuntimeAttemptId === null - ? [] - : await listAttemptEvidenceArtifacts(db, latestRuntimeAttemptId), - ), - recentLaunches: await listRecentAcceptedLaunches(db, deploymentRecordId), - latestCompatibilityPath: await getActivitySnapshot( - db, - D1_LATEST_COMPATIBILITY_PATH_QUERY, - deploymentRecordId, - ), - latestAgsSmoke: await getActivitySnapshot( - db, - D1_LATEST_AGS_SMOKE_QUERY, - deploymentRecordId, - ), - latestNrpsRead: await getActivitySnapshot(db, D1_LATEST_NRPS_QUERY, deploymentRecordId), - latestGradePublish, - pilotUsage: inventory.pilotUsage, - diagnostics: await listDiagnostics( - db, - deploymentRecordId, - retryableGradePublication?.attemptId ?? null, - ), - retryableGradePublication, - brokerVerification: inventory.brokerVerification, - }; + return await getControlPlaneDeploymentDetailForD1(db, deploymentRecordId); }, async listCertificationWorkflowStatuses() { - const workflowKeys = ['core', 'deepLinking', 'nrps', 'ags'] as const; - const rows: CertificationWorkflowStatusRow[] = []; - - for (const workflowKey of workflowKeys) { - const row = await queryD1First( - db, - ` - SELECT - ? AS workflowKey, - broker_verification_runs.deployment_record_id AS deploymentRecordId, - deployments.label AS deploymentLabel, - broker_verification_runs.status, - broker_verification_runs.summary, - broker_verification_runs.detail_url AS detailUrl, - broker_verification_runs.checked_at AS checkedAt - FROM broker_verification_runs - LEFT JOIN deployments - ON deployments.id = broker_verification_runs.deployment_record_id - WHERE broker_verification_runs.workflow_key = ? - AND broker_verification_runs.deployment_record_id IS NOT NULL - AND broker_verification_runs.source IN ('manual', 'ci') - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 - `, - [workflowKey, workflowKey], - ); - - rows.push( - row === null - ? { - workflowKey, - deploymentRecordId: null, - deploymentLabel: null, - status: null, - summary: null, - detailUrl: null, - checkedAt: null, - } - : mapD1CertificationWorkflowStatusRow(row), - ); - } - - return rows.map((row) => mapCertificationWorkflowStatusRow(row)); + return await listCertificationWorkflowStatusesForD1(db); }, async getLatestOfficialCertificationEvidence() { - const row = await queryD1First( - db, - ` - SELECT - workflow_key AS workflowKey, - status, - certification_state AS certificationState, - summary, - detail_url AS detailUrl, - checked_at AS checkedAt - FROM broker_verification_runs - WHERE source = '1edtech' - AND workflow_key IS NOT NULL - ORDER BY checked_at DESC, id DESC - LIMIT 1 - `, - ); - - return mapLatestOfficialCertificationEvidenceRow( - row === null ? null : mapD1LatestOfficialCertificationEvidenceRow(row), - ); - }, - - async getLatestBrokerVerification() { - return await this.getLatestBrokerVerificationStatus(); + return await getLatestOfficialCertificationEvidenceForD1(db); }, async getLatestBrokerVerificationStatus() { @@ -220,1223 +59,3 @@ export function createD1OpsRepository( }, }; } - -const D1_INVENTORY_QUERY = ` - SELECT - deployments.id AS deploymentId, - deployments.slug AS deploymentSlug, - deployments.label AS deploymentLabel, - deployments.app_id AS appId, - COALESCE( - enabled_package.title, - ( - SELECT package_versions.title - FROM package_versions - WHERE package_versions.app_id = deployments.app_id - ORDER BY package_versions.imported_at DESC, package_versions.id DESC - LIMIT 1 - ), - deployments.label - ) AS appTitle, - COALESCE( - enabled_package.owner_id, - ( - SELECT package_versions.owner_id - FROM package_versions - WHERE package_versions.app_id = deployments.app_id - ORDER BY package_versions.imported_at DESC, package_versions.id DESC - LIMIT 1 - ) - ) AS ownerId, - deployments.enabled_package_version_id AS enabledPackageVersionId, - enabled_package.version AS enabledPackageVersion, - enabled_package.approval_status AS approvalStatus, - enabled_package.reviewed_at AS reviewedAt, - deployments.lms_type AS bindingLmsType, - ( - SELECT audit_events.status - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'deployment.binding_saved' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS installEvidenceStatus, - ( - SELECT audit_events.summary - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'deployment.binding_saved' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS installEvidenceSummary, - ( - SELECT audit_events.detail - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'deployment.binding_saved' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS installEvidenceDetail, - ( - SELECT audit_events.occurred_at - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'deployment.binding_saved' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS installEvidenceOccurredAt, - ( - SELECT broker_verification_runs.scope - FROM broker_verification_runs - WHERE broker_verification_runs.deployment_record_id = deployments.id - AND broker_verification_runs.scope = ${DEPLOYMENT_SUPPORTED_PATH_SQL} - AND broker_verification_runs.source IN ('manual', 'ci') - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 - ) AS internalBrokerVerificationScope, - ( - SELECT broker_verification_runs.source - FROM broker_verification_runs - WHERE broker_verification_runs.deployment_record_id = deployments.id - AND broker_verification_runs.scope = ${DEPLOYMENT_SUPPORTED_PATH_SQL} - AND broker_verification_runs.source IN ('manual', 'ci') - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 - ) AS internalBrokerVerificationSource, - ( - SELECT broker_verification_runs.status - FROM broker_verification_runs - WHERE broker_verification_runs.deployment_record_id = deployments.id - AND broker_verification_runs.scope = ${DEPLOYMENT_SUPPORTED_PATH_SQL} - AND broker_verification_runs.source IN ('manual', 'ci') - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 - ) AS internalBrokerVerificationStatus, - ( - SELECT broker_verification_runs.summary - FROM broker_verification_runs - WHERE broker_verification_runs.deployment_record_id = deployments.id - AND broker_verification_runs.scope = ${DEPLOYMENT_SUPPORTED_PATH_SQL} - AND broker_verification_runs.source IN ('manual', 'ci') - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 - ) AS internalBrokerVerificationSummary, - ( - SELECT broker_verification_runs.detail_url - FROM broker_verification_runs - WHERE broker_verification_runs.deployment_record_id = deployments.id - AND broker_verification_runs.scope = ${DEPLOYMENT_SUPPORTED_PATH_SQL} - AND broker_verification_runs.source IN ('manual', 'ci') - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 - ) AS internalBrokerVerificationDetailUrl, - ( - SELECT broker_verification_runs.checked_at - FROM broker_verification_runs - WHERE broker_verification_runs.deployment_record_id = deployments.id - AND broker_verification_runs.scope = ${DEPLOYMENT_SUPPORTED_PATH_SQL} - AND broker_verification_runs.source IN ('manual', 'ci') - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 - ) AS internalBrokerVerificationCheckedAt, - ( - SELECT broker_verification_runs.scope - FROM broker_verification_runs - WHERE broker_verification_runs.scope = ${DEPLOYMENT_SUPPORTED_PATH_SQL} - AND broker_verification_runs.source = '1edtech' - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 - ) AS officialBrokerVerificationScope, - ( - SELECT broker_verification_runs.status - FROM broker_verification_runs - WHERE broker_verification_runs.scope = ${DEPLOYMENT_SUPPORTED_PATH_SQL} - AND broker_verification_runs.source = '1edtech' - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 - ) AS officialBrokerVerificationStatus, - ( - SELECT broker_verification_runs.certification_state - FROM broker_verification_runs - WHERE broker_verification_runs.scope = ${DEPLOYMENT_SUPPORTED_PATH_SQL} - AND broker_verification_runs.source = '1edtech' - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 - ) AS officialBrokerVerificationCertificationState, - ( - SELECT broker_verification_runs.detail_url - FROM broker_verification_runs - WHERE broker_verification_runs.scope = ${DEPLOYMENT_SUPPORTED_PATH_SQL} - AND broker_verification_runs.source = '1edtech' - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 - ) AS officialBrokerVerificationDetailUrl, - ( - SELECT broker_verification_runs.checked_at - FROM broker_verification_runs - WHERE broker_verification_runs.scope = ${DEPLOYMENT_SUPPORTED_PATH_SQL} - AND broker_verification_runs.source = '1edtech' - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 - ) AS officialBrokerVerificationCheckedAt, - deployments.canvas_environment AS bindingCanvasEnvironment, - deployments.issuer AS bindingIssuer, - deployments.client_id AS bindingClientId, - deployments.deployment_id AS bindingDeploymentId, - deployments.authorization_endpoint AS bindingAuthorizationEndpoint, - deployments.access_token_url AS bindingAccessTokenUrl, - deployments.jwks_url AS bindingJwksUrl, - deployments.updated_at AS updatedAt, - ( - SELECT audit_events.occurred_at - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type LIKE 'launch.%' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS lastLaunchAt, - ( - SELECT CASE - WHEN audit_events.event_type = 'launch.rejected' - OR audit_events.status = 'failed' - THEN 'failed' - ELSE 'succeeded' - END - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type LIKE 'launch.%' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS lastLaunchStatus, - ( - SELECT audit_events.occurred_at - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'deployment.nrps_verified' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS lastNrpsReadAt, - ( - SELECT CASE - WHEN audit_events.status = 'failed' THEN 'failed' - WHEN audit_events.status = 'succeeded' THEN 'succeeded' - ELSE 'pending' - END - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'deployment.nrps_verified' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS lastNrpsReadStatus, - ( - SELECT grade_publications.updated_at - FROM attempts - INNER JOIN grade_publications - ON grade_publications.attempt_id = attempts.attempt_id - WHERE attempts.deployment_record_id = deployments.id - ORDER BY grade_publications.updated_at DESC, grade_publications.id DESC - LIMIT 1 - ) AS lastGradePublishAt, - ( - SELECT grade_publications.status - FROM attempts - INNER JOIN grade_publications - ON grade_publications.attempt_id = attempts.attempt_id - WHERE attempts.deployment_record_id = deployments.id - ORDER BY grade_publications.updated_at DESC, grade_publications.id DESC - LIMIT 1 - ) AS lastGradePublishStatus, - ( - SELECT COUNT(*) - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'launch.accepted' - ) AS totalLaunches, - ( - SELECT COUNT(*) - FROM attempts - WHERE attempts.deployment_record_id = deployments.id - ) AS attemptsStarted, - ( - SELECT COUNT(*) - FROM attempts - WHERE attempts.deployment_record_id = deployments.id - AND attempts.completion_state = 'completed' - ) AS attemptsCompleted, - ( - SELECT COUNT(*) - FROM attempts - INNER JOIN grade_publications - ON grade_publications.attempt_id = attempts.attempt_id - WHERE attempts.deployment_record_id = deployments.id - AND grade_publications.status = 'published' - ) AS gradePublishesSucceeded, - ( - SELECT COUNT(*) - FROM attempts - INNER JOIN grade_publications - ON grade_publications.attempt_id = attempts.attempt_id - WHERE attempts.deployment_record_id = deployments.id - AND grade_publications.status = 'failed' - ) AS gradePublishesFailed, - ( - SELECT COUNT(DISTINCT attempts.user_id) - FROM attempts - WHERE attempts.deployment_record_id = deployments.id - ) AS recentActiveUsers, - COALESCE( - ( - SELECT MAX(audit_events.occurred_at) - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'launch.accepted' - ), - ( - SELECT audit_events.occurred_at - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type LIKE 'launch.%' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) - ) AS usageLastLaunchAt, - CURRENT_TIMESTAMP AS measuredAt - FROM deployments - LEFT JOIN package_versions AS enabled_package - ON enabled_package.id = deployments.enabled_package_version_id - WHERE deployments.lms_type <> 'preview' -`; - -const D1_ACTIVITY_SELECT = ` - SELECT - event_type AS eventType, - status, - summary, - attempt_id AS attemptId, - detail, - occurred_at AS occurredAt - FROM audit_events -`; - -const D1_LATEST_LAUNCH_QUERY = ` - ${D1_ACTIVITY_SELECT} - WHERE deployment_record_id = ? - AND event_type LIKE 'launch.%' - ORDER BY occurred_at DESC, id DESC - LIMIT 1 -`; - -const D1_LATEST_RUNTIME_SESSION_QUERY = ` - ${D1_ACTIVITY_SELECT} - WHERE deployment_record_id = ? - AND event_type = 'runtime.session.started' - ORDER BY occurred_at DESC, id DESC - LIMIT 1 -`; - -const D1_LATEST_RUNTIME_OUTCOME_QUERY = ` - ${D1_ACTIVITY_SELECT} - WHERE deployment_record_id = ? - AND event_type LIKE 'runtime.%' - AND event_type NOT IN ('runtime.session.started', 'runtime.capability.allowed') - ORDER BY occurred_at DESC, id DESC - LIMIT 1 -`; - -const D1_LATEST_COMPATIBILITY_PATH_QUERY = ` - ${D1_ACTIVITY_SELECT} - WHERE deployment_record_id = ? - AND event_type = 'interop.path_used' - AND COALESCE(json_extract(detail, '$.scope'), '') IN ( - 'login', - 'launch', - 'deep_linking', - 'service' - ) - ORDER BY occurred_at DESC, id DESC - LIMIT 1 -`; - -const D1_LATEST_NRPS_QUERY = ` - ${D1_ACTIVITY_SELECT} - WHERE deployment_record_id = ? - AND event_type = 'deployment.nrps_verified' - ORDER BY occurred_at DESC, id DESC - LIMIT 1 -`; - -const D1_LATEST_AGS_SMOKE_QUERY = ` - ${D1_ACTIVITY_SELECT} - WHERE deployment_record_id = ? - AND event_type = 'deployment.ags_smoke_verified' - ORDER BY occurred_at DESC, id DESC - LIMIT 1 -`; - -async function getInventoryRow(db: D1Database, deploymentRecordId: number) { - const row = await queryD1First( - db, - ` - ${D1_INVENTORY_QUERY} - AND deployments.id = ? - ORDER BY deployments.updated_at DESC, deployments.id DESC - `, - [deploymentRecordId], - ); - - return row === null ? null : mapInventoryRow(mapD1InventoryRow(row)); -} - -async function getActivitySnapshot( - db: D1Database, - sql: string, - deploymentRecordId: number, -): Promise { - const row = await queryD1First(db, sql, [deploymentRecordId]); - - return row === null ? null : mapActivitySnapshotRow(mapD1ActivitySnapshotRow(row)); -} - -async function getRuntimeEvidenceSnapshot( - db: D1Database, - sql: string, - deploymentRecordId: number, -): Promise { - const row = await queryD1First(db, sql, [deploymentRecordId]); - - return row === null ? null : mapRuntimeEvidenceSnapshotRow(mapD1ActivitySnapshotRow(row)); -} - -async function listRecentAcceptedLaunches(db: D1Database, deploymentRecordId: number) { - const rows = await queryD1Objects( - db, - ` - SELECT - audit_events.summary, - audit_events.actor_id AS actorId, - COALESCE( - attempts.user_id, - NULLIF(json_extract(audit_events.detail, '$.userId'), ''), - audit_events.actor_id - ) AS userId, - COALESCE( - attempts.user_display_name, - NULLIF(json_extract(audit_events.detail, '$.userDisplayName'), '') - ) AS userDisplayName, - COALESCE( - attempts.user_email, - NULLIF(json_extract(audit_events.detail, '$.userEmail'), '') - ) AS userEmail, - COALESCE( - attempts.user_login, - NULLIF(json_extract(audit_events.detail, '$.userLogin'), '') - ) AS userLogin, - audit_events.attempt_id AS attemptId, - audit_events.detail, - audit_events.occurred_at AS occurredAt - FROM audit_events - LEFT JOIN attempts - ON attempts.attempt_id = audit_events.attempt_id - WHERE audit_events.deployment_record_id = ? - AND audit_events.event_type = 'launch.accepted' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 10 - `, - [deploymentRecordId], - ); - - return mapRecentLaunchRows(rows.map(mapD1RecentLaunchRow)); -} - -async function getLatestGradePublication( - db: D1Database, - deploymentRecordId: number, -): Promise { - const row = await queryD1First( - db, - ` - SELECT - grade_publications.attempt_id AS attemptId, - grade_publications.status, - grade_publications.line_item_url AS lineItemUrl, - grade_publications.platform_user_id AS platformUserId, - grade_publications.score_given AS scoreGiven, - grade_publications.score_maximum AS scoreMaximum, - grade_publications.activity_progress AS activityProgress, - grade_publications.grading_progress AS gradingProgress, - grade_publications.published_at AS publishedAt, - grade_publications.updated_at AS updatedAt, - grade_publications.error_code AS errorCode, - grade_publications.error_detail AS errorDetail - FROM attempts - INNER JOIN grade_publications - ON grade_publications.attempt_id = attempts.attempt_id - WHERE attempts.deployment_record_id = ? - ORDER BY grade_publications.updated_at DESC, grade_publications.id DESC - LIMIT 1 - `, - [deploymentRecordId], - ); - - return row === null ? null : mapGradePublicationSnapshotRow(mapD1GradePublicationRow(row)); -} - -async function listDiagnostics( - db: D1Database, - deploymentRecordId: number, - retryableAttemptId: string | null, -): Promise { - const rows = await queryD1Objects( - db, - ` - SELECT - id, - event_type AS eventType, - actor_type AS actorType, - status, - deployment_record_id AS deploymentRecordId, - attempt_id AS attemptId, - summary, - detail, - occurred_at AS occurredAt - FROM audit_events - WHERE deployment_record_id = ? - AND status = 'failed' - AND ( - event_type LIKE 'launch.%' - OR event_type = 'deep_linking.request.rejected' - OR event_type = 'deployment.nrps_verified' - OR event_type LIKE 'grade_publish.%' - OR event_type LIKE 'broker_verification.%' - OR event_type LIKE 'runtime.%' - OR event_type LIKE 'reviewer.%' - ) - ORDER BY occurred_at DESC, id DESC - `, - [deploymentRecordId], - ); - - return mapDiagnosticRows(rows.map(mapD1DiagnosticRow), retryableAttemptId); -} - -async function getLatestBrokerVerificationStatusForD1(db: D1Database) { - const internalRow = await queryD1First( - db, - ` - SELECT - scope, - source, - status, - summary, - detail_url AS detailUrl, - checked_at AS checkedAt - FROM broker_verification_runs - WHERE deployment_record_id IS NOT NULL - AND source IN ('manual', 'ci') - ORDER BY checked_at DESC, id DESC - LIMIT 1 - `, - ); - - if (internalRow !== null) { - const internal = mapD1InternalBrokerVerificationRow(internalRow); - const officialRow = await queryD1First( - db, - ` - SELECT - scope, - status, - certification_state AS certificationState, - detail_url AS detailUrl, - checked_at AS checkedAt - FROM broker_verification_runs - WHERE scope = ? - AND source = '1edtech' - ORDER BY checked_at DESC, id DESC - LIMIT 1 - `, - [internal.scope], - ); - - return mapBrokerVerificationStatusRows( - internal, - officialRow === null ? null : mapD1OfficialBrokerVerificationRow(officialRow), - ); - } - - const officialRow = await queryD1First( - db, - ` - SELECT - scope, - status, - certification_state AS certificationState, - detail_url AS detailUrl, - checked_at AS checkedAt - FROM broker_verification_runs - WHERE source = '1edtech' - ORDER BY checked_at DESC, id DESC - LIMIT 1 - `, - ); - - return mapBrokerVerificationStatusRows( - null, - officialRow === null ? null : mapD1OfficialBrokerVerificationRow(officialRow), - ); -} - -async function recordBrokerVerificationRunForD1( - db: D1Database, - input: RecordBrokerVerificationRunInput, -): Promise { - assertBrokerVerificationRunInput(input); - await runD1( - db, - ` - INSERT INTO broker_verification_runs ( - deployment_record_id, - scope, - workflow_key, - source, - status, - summary, - detail_url, - certification_state, - checked_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - input.deploymentRecordId, - input.scope, - input.workflowKey, - input.source, - input.status, - input.summary, - input.detailUrl, - input.certificationState, - input.checkedAt, - ], - ); -} - -async function getRetryableGradePublicationLookupForD1( - db: D1Database, - attemptId: string, -): Promise { - const row = await queryD1First( - db, - ` - SELECT - attempts.attempt_id AS attemptId, - attempts.deployment_record_id AS deploymentRecordId, - attempts.deployment_slug AS deploymentSlug, - grade_publications.status AS publicationStatus, - grade_publications.line_item_url AS lineItemUrl, - grade_publications.platform_user_id AS platformUserId, - grade_publications.score_given AS scoreGiven, - grade_publications.score_maximum AS scoreMaximum, - grade_publications.activity_progress AS activityProgress, - grade_publications.grading_progress AS gradingProgress, - grade_publications.published_at AS publishedAt, - grade_publications.updated_at AS updatedAt, - grade_publications.error_code AS errorCode, - grade_publications.error_detail AS errorDetail, - deployments.canvas_environment AS bindingCanvasEnvironment, - deployments.issuer AS bindingIssuer, - deployments.client_id AS bindingClientId, - deployments.deployment_id AS bindingDeploymentId, - runtime_sessions.session_id AS sessionId, - runtime_sessions.attempt_id AS runtimeAttemptId, - runtime_sessions.deployment_record_id AS runtimeDeploymentRecordId, - runtime_sessions.deployment_slug AS runtimeDeploymentSlug, - runtime_sessions.app_id AS runtimeAppId, - runtime_sessions.package_version_id AS runtimePackageVersionId, - runtime_sessions.package_version AS runtimePackageVersion, - runtime_sessions.ags_scope AS runtimeAgsScope, - runtime_sessions.ags_lineitems_url AS runtimeAgsLineitemsUrl, - runtime_sessions.ags_lineitem_url AS runtimeAgsLineitemUrl, - runtime_sessions.nrps_context_memberships_url AS runtimeNrpsContextMembershipsUrl, - runtime_sessions.nrps_service_versions AS runtimeNrpsServiceVersions, - runtime_sessions.created_at AS runtimeCreatedAt, - runtime_sessions.expires_at AS runtimeExpiresAt - FROM attempts - INNER JOIN grade_publications - ON grade_publications.attempt_id = attempts.attempt_id - INNER JOIN deployments - ON deployments.id = attempts.deployment_record_id - LEFT JOIN runtime_sessions - ON runtime_sessions.session_id = ( - SELECT session_id - FROM runtime_sessions AS candidate_sessions - WHERE candidate_sessions.attempt_id = attempts.attempt_id - ORDER BY candidate_sessions.created_at DESC, candidate_sessions.session_id DESC - LIMIT 1 - ) - WHERE attempts.attempt_id = ? - AND grade_publications.status = 'failed' - LIMIT 1 - `, - [attemptId], - ); - - return row === null ? null : mapRetryLookupRow(mapD1RetryLookupRow(row)); -} - -async function listAttemptEvidenceArtifacts( - db: D1Database, - attemptId: string, -): Promise { - const rows = await queryD1Objects( - db, - ` - SELECT - artifact_id AS artifactId, - attempt_id AS attemptId, - sequence, - kind, - content_type AS contentType, - file_name AS fileName, - storage_key AS storageKey, - byte_size AS byteSize, - sha256, - created_at AS createdAt - FROM attempt_evidence_artifacts - WHERE attempt_id = ? - ORDER BY sequence ASC - `, - [attemptId], - ); - - return rows.map((row) => mapAttemptEvidenceArtifactRow(mapD1AttemptEvidenceArtifactRow(row))); -} - -function deriveLatestAnonymousEvidence( - appId: string, - evidenceArtifacts: AttemptEvidenceArtifactRecord[], -): ControlPlaneAnonymousEvidenceArtifact[] { - return evidenceArtifacts.map((artifact) => ({ - artifactId: artifact.artifactId, - kind: artifact.kind, - fileName: artifact.fileName, - contentType: artifact.contentType, - byteSize: artifact.byteSize, - sha256: artifact.sha256, - createdAt: artifact.createdAt, - artifactUrl: `/admin/packages/${appId}/deployment/evidence/${artifact.artifactId}`, - })); -} - -type Nullable = T | null; - -interface D1InventoryQueryRow extends Record { - deploymentId: unknown; - deploymentSlug: unknown; - deploymentLabel: unknown; - appId: unknown; - appTitle: unknown; - ownerId: unknown; - enabledPackageVersionId: unknown; - enabledPackageVersion: unknown; - approvalStatus: unknown; - reviewedAt: unknown; - bindingLmsType: unknown; - installEvidenceStatus: unknown; - installEvidenceSummary: unknown; - installEvidenceDetail: unknown; - installEvidenceOccurredAt: unknown; - internalBrokerVerificationScope: unknown; - internalBrokerVerificationSource: unknown; - internalBrokerVerificationStatus: unknown; - internalBrokerVerificationSummary: unknown; - internalBrokerVerificationDetailUrl: unknown; - internalBrokerVerificationCheckedAt: unknown; - officialBrokerVerificationScope: unknown; - officialBrokerVerificationStatus: unknown; - officialBrokerVerificationCertificationState: unknown; - officialBrokerVerificationDetailUrl: unknown; - officialBrokerVerificationCheckedAt: unknown; - bindingCanvasEnvironment: unknown; - bindingIssuer: unknown; - bindingClientId: unknown; - bindingDeploymentId: unknown; - bindingAuthorizationEndpoint: unknown; - bindingAccessTokenUrl: unknown; - bindingJwksUrl: unknown; - updatedAt: unknown; - lastLaunchAt: unknown; - lastLaunchStatus: unknown; - lastNrpsReadAt: unknown; - lastNrpsReadStatus: unknown; - lastGradePublishAt: unknown; - lastGradePublishStatus: unknown; - totalLaunches: unknown; - attemptsStarted: unknown; - attemptsCompleted: unknown; - gradePublishesSucceeded: unknown; - gradePublishesFailed: unknown; - recentActiveUsers: unknown; - usageLastLaunchAt: unknown; - measuredAt: unknown; -} - -type D1ActivitySnapshotRow = Record; -type D1RecentLaunchRow = Record; -type D1GradePublicationSnapshotRow = Record; -type D1DiagnosticRow = Record; -type D1InternalBrokerVerificationRow = Record; -type D1OfficialBrokerVerificationRow = Record; -type D1CertificationWorkflowStatusRow = Record; -type D1LatestOfficialCertificationEvidenceRow = Record; -type D1RetryLookupRow = Record; -type D1AttemptEvidenceArtifactRow = Record; - -function mapD1InventoryRow(row: D1InventoryQueryRow): InventoryQueryRow { - return { - deploymentId: expectNumber(row.deploymentId, 'deploymentId'), - deploymentSlug: expectString(row.deploymentSlug, 'deploymentSlug'), - deploymentLabel: expectString(row.deploymentLabel, 'deploymentLabel'), - appId: expectString(row.appId, 'appId'), - appTitle: expectString(row.appTitle, 'appTitle'), - ownerId: expectNullableString(row.ownerId, 'ownerId'), - enabledPackageVersionId: expectNullableNumber( - row.enabledPackageVersionId, - 'enabledPackageVersionId', - ), - enabledPackageVersion: expectNullableString(row.enabledPackageVersion, 'enabledPackageVersion'), - approvalStatus: expectNullableStringLiteral(row.approvalStatus, 'approvalStatus', [ - 'pending', - 'approved', - 'rejected', - ]), - reviewedAt: expectNullableString(row.reviewedAt, 'reviewedAt'), - bindingLmsType: expectNullableStringLiteral(row.bindingLmsType, 'bindingLmsType', [ - 'canvas', - 'moodle', - 'sakai', - ]), - installEvidenceStatus: expectNullableStringLiteral( - row.installEvidenceStatus, - 'installEvidenceStatus', - ['accepted', 'succeeded', 'failed'], - ), - installEvidenceSummary: expectNullableString( - row.installEvidenceSummary, - 'installEvidenceSummary', - ), - installEvidenceDetail: parseNullableJsonField( - row.installEvidenceDetail, - 'installEvidenceDetail', - ) as Nullable>, - installEvidenceOccurredAt: expectNullableString( - row.installEvidenceOccurredAt, - 'installEvidenceOccurredAt', - ), - internalBrokerVerificationScope: expectNullableSupportedPath( - row.internalBrokerVerificationScope, - 'internalBrokerVerificationScope', - ), - internalBrokerVerificationSource: expectNullableStringLiteral( - row.internalBrokerVerificationSource, - 'internalBrokerVerificationSource', - ['manual', 'ci', '1edtech'], - ), - internalBrokerVerificationStatus: expectNullableStringLiteral( - row.internalBrokerVerificationStatus, - 'internalBrokerVerificationStatus', - ['passed', 'failed', 'notRun'], - ), - internalBrokerVerificationSummary: expectNullableString( - row.internalBrokerVerificationSummary, - 'internalBrokerVerificationSummary', - ), - internalBrokerVerificationDetailUrl: expectNullableString( - row.internalBrokerVerificationDetailUrl, - 'internalBrokerVerificationDetailUrl', - ), - internalBrokerVerificationCheckedAt: expectNullableString( - row.internalBrokerVerificationCheckedAt, - 'internalBrokerVerificationCheckedAt', - ), - officialBrokerVerificationScope: expectNullableSupportedPath( - row.officialBrokerVerificationScope, - 'officialBrokerVerificationScope', - ), - officialBrokerVerificationStatus: expectNullableStringLiteral( - row.officialBrokerVerificationStatus, - 'officialBrokerVerificationStatus', - ['passed', 'failed', 'notRun', 'notCertified'], - ), - officialBrokerVerificationCertificationState: expectNullableStringLiteral( - row.officialBrokerVerificationCertificationState, - 'officialBrokerVerificationCertificationState', - ['ltiAdvantageCertified', 'ltiAdvantageComplete'], - ), - officialBrokerVerificationDetailUrl: expectNullableString( - row.officialBrokerVerificationDetailUrl, - 'officialBrokerVerificationDetailUrl', - ), - officialBrokerVerificationCheckedAt: expectNullableString( - row.officialBrokerVerificationCheckedAt, - 'officialBrokerVerificationCheckedAt', - ), - bindingCanvasEnvironment: expectNullableString( - row.bindingCanvasEnvironment, - 'bindingCanvasEnvironment', - ), - bindingIssuer: expectNullableString(row.bindingIssuer, 'bindingIssuer'), - bindingClientId: expectNullableString(row.bindingClientId, 'bindingClientId'), - bindingDeploymentId: expectNullableString(row.bindingDeploymentId, 'bindingDeploymentId'), - bindingAuthorizationEndpoint: expectNullableString( - row.bindingAuthorizationEndpoint, - 'bindingAuthorizationEndpoint', - ), - bindingAccessTokenUrl: expectNullableString(row.bindingAccessTokenUrl, 'bindingAccessTokenUrl'), - bindingJwksUrl: expectNullableString(row.bindingJwksUrl, 'bindingJwksUrl'), - updatedAt: expectString(row.updatedAt, 'updatedAt'), - lastLaunchAt: expectNullableString(row.lastLaunchAt, 'lastLaunchAt'), - lastLaunchStatus: expectNullableStringLiteral(row.lastLaunchStatus, 'lastLaunchStatus', [ - 'succeeded', - 'failed', - 'pending', - ]), - lastNrpsReadAt: expectNullableString(row.lastNrpsReadAt, 'lastNrpsReadAt'), - lastNrpsReadStatus: expectNullableStringLiteral(row.lastNrpsReadStatus, 'lastNrpsReadStatus', [ - 'succeeded', - 'failed', - 'pending', - ]), - lastGradePublishAt: expectNullableString(row.lastGradePublishAt, 'lastGradePublishAt'), - lastGradePublishStatus: expectNullableStringLiteral( - row.lastGradePublishStatus, - 'lastGradePublishStatus', - ['pending', 'published', 'failed'], - ), - totalLaunches: expectNumber(row.totalLaunches, 'totalLaunches'), - attemptsStarted: expectNumber(row.attemptsStarted, 'attemptsStarted'), - attemptsCompleted: expectNumber(row.attemptsCompleted, 'attemptsCompleted'), - gradePublishesSucceeded: expectNumber(row.gradePublishesSucceeded, 'gradePublishesSucceeded'), - gradePublishesFailed: expectNumber(row.gradePublishesFailed, 'gradePublishesFailed'), - recentActiveUsers: expectNumber(row.recentActiveUsers, 'recentActiveUsers'), - usageLastLaunchAt: expectNullableString(row.usageLastLaunchAt, 'usageLastLaunchAt'), - measuredAt: expectString(row.measuredAt, 'measuredAt'), - }; -} - -function mapD1ActivitySnapshotRow(row: D1ActivitySnapshotRow): ActivitySnapshotRow { - return { - eventType: expectString(row.eventType, 'eventType'), - status: expectStringLiteral(row.status, 'status', ['accepted', 'succeeded', 'failed']), - summary: expectString(row.summary, 'summary'), - attemptId: expectNullableString(row.attemptId, 'attemptId'), - detail: parseJsonField(row.detail, 'detail') as Record, - occurredAt: expectString(row.occurredAt, 'occurredAt'), - }; -} - -function mapD1RecentLaunchRow(row: D1RecentLaunchRow): RecentLaunchRow { - return { - summary: expectString(row.summary, 'summary'), - actorId: expectNullableString(row.actorId, 'actorId'), - userId: expectNullableString(row.userId, 'userId'), - userDisplayName: expectNullableString(row.userDisplayName, 'userDisplayName'), - userEmail: expectNullableString(row.userEmail, 'userEmail'), - userLogin: expectNullableString(row.userLogin, 'userLogin'), - attemptId: expectNullableString(row.attemptId, 'attemptId'), - detail: parseJsonField(row.detail, 'detail') as Record, - occurredAt: expectString(row.occurredAt, 'occurredAt'), - }; -} - -function mapD1GradePublicationRow(row: D1GradePublicationSnapshotRow): GradePublicationSnapshotRow { - return { - attemptId: expectString(row.attemptId, 'attemptId'), - status: expectStringLiteral(row.status, 'status', ['pending', 'published', 'failed']), - lineItemUrl: expectString(row.lineItemUrl, 'lineItemUrl'), - platformUserId: expectString(row.platformUserId, 'platformUserId'), - scoreGiven: expectNumber(row.scoreGiven, 'scoreGiven'), - scoreMaximum: expectNumber(row.scoreMaximum, 'scoreMaximum'), - activityProgress: expectStringLiteral(row.activityProgress, 'activityProgress', [ - 'Completed', - 'InProgress', - 'Initialized', - ]), - gradingProgress: expectStringLiteral(row.gradingProgress, 'gradingProgress', [ - 'Pending', - 'PendingManual', - 'FullyGraded', - 'Failed', - ]), - publishedAt: expectNullableString(row.publishedAt, 'publishedAt'), - updatedAt: expectString(row.updatedAt, 'updatedAt'), - errorCode: expectNullableString(row.errorCode, 'errorCode'), - errorDetail: parseNullableJsonField(row.errorDetail, 'errorDetail') as Nullable< - Record - >, - }; -} - -function mapD1DiagnosticRow(row: D1DiagnosticRow): DiagnosticRow { - return { - id: expectNumber(row.id, 'id'), - eventType: expectString(row.eventType, 'eventType'), - actorType: expectStringLiteral(row.actorType, 'actorType', ['user', 'system', 'platform']), - status: expectStringLiteral(row.status, 'status', ['accepted', 'succeeded', 'failed']), - deploymentRecordId: expectNullableNumber(row.deploymentRecordId, 'deploymentRecordId'), - attemptId: expectNullableString(row.attemptId, 'attemptId'), - summary: expectString(row.summary, 'summary'), - detail: parseJsonField(row.detail, 'detail') as Record, - occurredAt: expectString(row.occurredAt, 'occurredAt'), - }; -} - -function mapD1InternalBrokerVerificationRow( - row: D1InternalBrokerVerificationRow, -): InternalBrokerVerificationRow { - return { - scope: expectSupportedPath(row.scope, 'scope'), - source: expectStringLiteral(row.source, 'source', ['manual', 'ci', '1edtech']), - status: expectStringLiteral(row.status, 'status', ['passed', 'failed', 'notRun']), - summary: expectString(row.summary, 'summary'), - detailUrl: expectNullableString(row.detailUrl, 'detailUrl'), - checkedAt: expectString(row.checkedAt, 'checkedAt'), - }; -} - -function mapD1OfficialBrokerVerificationRow( - row: D1OfficialBrokerVerificationRow, -): OfficialBrokerVerificationRow { - return { - scope: expectSupportedPath(row.scope, 'scope'), - status: expectStringLiteral(row.status, 'status', [ - 'passed', - 'failed', - 'notRun', - 'notCertified', - ]), - certificationState: expectNullableStringLiteral(row.certificationState, 'certificationState', [ - 'ltiAdvantageCertified', - 'ltiAdvantageComplete', - ]), - detailUrl: expectNullableString(row.detailUrl, 'detailUrl'), - checkedAt: expectString(row.checkedAt, 'checkedAt'), - }; -} - -function mapD1CertificationWorkflowStatusRow( - row: D1CertificationWorkflowStatusRow, -): CertificationWorkflowStatusRow { - return { - workflowKey: expectStringLiteral(row.workflowKey, 'workflowKey', [ - 'core', - 'deepLinking', - 'nrps', - 'ags', - ]), - deploymentRecordId: expectNullableNumber(row.deploymentRecordId, 'deploymentRecordId'), - deploymentLabel: expectNullableString(row.deploymentLabel, 'deploymentLabel'), - status: expectNullableStringLiteral(row.status, 'status', ['passed', 'failed', 'pending']), - summary: expectNullableString(row.summary, 'summary'), - detailUrl: expectNullableString(row.detailUrl, 'detailUrl'), - checkedAt: expectNullableString(row.checkedAt, 'checkedAt'), - }; -} - -function mapD1LatestOfficialCertificationEvidenceRow( - row: D1LatestOfficialCertificationEvidenceRow, -): LatestOfficialCertificationEvidenceRow { - return { - workflowKey: expectStringLiteral(row.workflowKey, 'workflowKey', [ - 'core', - 'deepLinking', - 'nrps', - 'ags', - ]), - status: expectStringLiteral(row.status, 'status', [ - 'passed', - 'failed', - 'notRun', - 'notCertified', - ]), - certificationState: expectNullableStringLiteral(row.certificationState, 'certificationState', [ - 'ltiAdvantageCertified', - 'ltiAdvantageComplete', - ]), - summary: expectString(row.summary, 'summary'), - detailUrl: expectNullableString(row.detailUrl, 'detailUrl'), - checkedAt: expectString(row.checkedAt, 'checkedAt'), - }; -} - -function mapD1RetryLookupRow(row: D1RetryLookupRow): RetryLookupRow { - return { - attemptId: expectString(row.attemptId, 'attemptId'), - deploymentRecordId: expectNumber(row.deploymentRecordId, 'deploymentRecordId'), - deploymentSlug: expectString(row.deploymentSlug, 'deploymentSlug'), - publicationStatus: expectStringLiteral(row.publicationStatus, 'publicationStatus', [ - 'pending', - 'published', - 'failed', - ]), - lineItemUrl: expectString(row.lineItemUrl, 'lineItemUrl'), - platformUserId: expectString(row.platformUserId, 'platformUserId'), - scoreGiven: expectNumber(row.scoreGiven, 'scoreGiven'), - scoreMaximum: expectNumber(row.scoreMaximum, 'scoreMaximum'), - activityProgress: expectStringLiteral(row.activityProgress, 'activityProgress', [ - 'Completed', - 'InProgress', - 'Initialized', - ]), - gradingProgress: expectStringLiteral(row.gradingProgress, 'gradingProgress', [ - 'Pending', - 'PendingManual', - 'FullyGraded', - 'Failed', - ]), - publishedAt: expectNullableString(row.publishedAt, 'publishedAt'), - updatedAt: expectString(row.updatedAt, 'updatedAt'), - errorCode: expectNullableString(row.errorCode, 'errorCode'), - errorDetail: parseNullableJsonField(row.errorDetail, 'errorDetail') as Nullable< - Record - >, - bindingCanvasEnvironment: expectNullableString( - row.bindingCanvasEnvironment, - 'bindingCanvasEnvironment', - ), - bindingIssuer: expectNullableString(row.bindingIssuer, 'bindingIssuer'), - bindingClientId: expectNullableString(row.bindingClientId, 'bindingClientId'), - bindingDeploymentId: expectNullableString(row.bindingDeploymentId, 'bindingDeploymentId'), - sessionId: expectNullableString(row.sessionId, 'sessionId'), - runtimeAttemptId: expectNullableString(row.runtimeAttemptId, 'runtimeAttemptId'), - runtimeDeploymentRecordId: expectNullableNumber( - row.runtimeDeploymentRecordId, - 'runtimeDeploymentRecordId', - ), - runtimeDeploymentSlug: expectNullableString(row.runtimeDeploymentSlug, 'runtimeDeploymentSlug'), - runtimeAppId: expectNullableString(row.runtimeAppId, 'runtimeAppId'), - runtimePackageVersionId: expectNullableNumber( - row.runtimePackageVersionId, - 'runtimePackageVersionId', - ), - runtimePackageVersion: expectNullableString(row.runtimePackageVersion, 'runtimePackageVersion'), - runtimeAgsScope: parseNullableJsonField(row.runtimeAgsScope, 'runtimeAgsScope') as Nullable< - string[] - >, - runtimeAgsLineitemsUrl: expectNullableString( - row.runtimeAgsLineitemsUrl, - 'runtimeAgsLineitemsUrl', - ), - runtimeAgsLineitemUrl: expectNullableString(row.runtimeAgsLineitemUrl, 'runtimeAgsLineitemUrl'), - runtimeNrpsContextMembershipsUrl: expectNullableString( - row.runtimeNrpsContextMembershipsUrl, - 'runtimeNrpsContextMembershipsUrl', - ), - runtimeNrpsServiceVersions: parseNullableJsonField( - row.runtimeNrpsServiceVersions, - 'runtimeNrpsServiceVersions', - ) as Nullable, - runtimeCreatedAt: expectNullableString(row.runtimeCreatedAt, 'runtimeCreatedAt'), - runtimeExpiresAt: expectNullableString(row.runtimeExpiresAt, 'runtimeExpiresAt'), - }; -} - -function mapD1AttemptEvidenceArtifactRow( - row: D1AttemptEvidenceArtifactRow, -): AttemptEvidenceArtifactRow { - return { - artifactId: expectString(row.artifactId, 'artifactId'), - attemptId: expectString(row.attemptId, 'attemptId'), - sequence: expectNumber(row.sequence, 'sequence'), - kind: expectStringLiteral(row.kind, 'kind', ['screenshot_png', 'structured_json']), - contentType: expectString(row.contentType, 'contentType'), - fileName: expectString(row.fileName, 'fileName'), - storageKey: expectString(row.storageKey, 'storageKey'), - byteSize: expectNumber(row.byteSize, 'byteSize'), - sha256: expectString(row.sha256, 'sha256'), - createdAt: expectString(row.createdAt, 'createdAt'), - }; -} - -function parseJsonField(value: unknown, fieldName: string): unknown { - if (typeof value !== 'string') { - throw new TypeError(`Expected D1 ${fieldName} to be JSON text.`); - } - - return JSON.parse(value); -} - -function parseNullableJsonField(value: unknown, fieldName: string): unknown | null { - if (value === null) { - return null; - } - - return parseJsonField(value, fieldName); -} - -function expectString(value: unknown, fieldName: string): string { - if (typeof value !== 'string') { - throw new TypeError(`Expected D1 ${fieldName} to be text.`); - } - - return value; -} - -function expectNullableString(value: unknown, fieldName: string): string | null { - if (value === null) { - return null; - } - - return expectString(value, fieldName); -} - -function expectNumber(value: unknown, fieldName: string): number { - if (typeof value !== 'number') { - throw new TypeError(`Expected D1 ${fieldName} to be numeric.`); - } - - return value; -} - -function expectNullableNumber(value: unknown, fieldName: string): number | null { - if (value === null) { - return null; - } - - return expectNumber(value, fieldName); -} - -function expectSupportedPath(value: unknown, fieldName: string) { - return expectStringLiteral(value, fieldName, ['lti13LaunchAgsNrps', 'lti13LaunchAgsScore']); -} - -function expectNullableSupportedPath(value: unknown, fieldName: string) { - if (value === null) { - return null; - } - - return expectSupportedPath(value, fieldName); -} - -function expectNullableStringLiteral( - value: unknown, - fieldName: string, - allowed: readonly T[], -): T | null { - if (value === null) { - return null; - } - - return expectStringLiteral(value, fieldName, allowed); -} - -function expectStringLiteral( - value: unknown, - fieldName: string, - allowed: readonly T[], -): T { - if (typeof value !== 'string' || !allowed.includes(value as T)) { - throw new Error(`Unexpected D1 ${fieldName} value.`); - } - - return value as T; -} diff --git a/src/ops/repository_d1_broker.ts b/src/ops/repository_d1_broker.ts new file mode 100644 index 0000000..b48e65b --- /dev/null +++ b/src/ops/repository_d1_broker.ts @@ -0,0 +1,193 @@ +import type { D1Database } from '../db/d1.ts'; +import { queryD1First, runD1 } from '../db/d1.ts'; +import { + type D1CertificationWorkflowStatusRow, + type D1InternalBrokerVerificationRow, + type D1LatestOfficialCertificationEvidenceRow, + type D1OfficialBrokerVerificationRow, + mapD1CertificationWorkflowStatusRow, + mapD1InternalBrokerVerificationRow, + mapD1LatestOfficialCertificationEvidenceRow, + mapD1OfficialBrokerVerificationRow, +} from './repository_d1_mapping.ts'; +import { + assertBrokerVerificationRunInput, + mapBrokerVerificationStatusRows, + mapCertificationWorkflowStatusRow, + mapLatestOfficialCertificationEvidenceRow, +} from './repository_mapping.ts'; +import type { + CertificationWorkflowStatusRow, + RecordBrokerVerificationRunInput, +} from './repository_types.ts'; + +export async function listCertificationWorkflowStatusesForD1(db: D1Database) { + const workflowKeys = ['core', 'deepLinking', 'nrps', 'ags'] as const; + const rows: CertificationWorkflowStatusRow[] = []; + + for (const workflowKey of workflowKeys) { + const row = await queryD1First( + db, + ` + SELECT + ? AS workflowKey, + broker_verification_runs.deployment_record_id AS deploymentRecordId, + deployments.label AS deploymentLabel, + broker_verification_runs.status, + broker_verification_runs.summary, + broker_verification_runs.detail_url AS detailUrl, + broker_verification_runs.checked_at AS checkedAt + FROM broker_verification_runs + LEFT JOIN deployments + ON deployments.id = broker_verification_runs.deployment_record_id + WHERE broker_verification_runs.workflow_key = ? + AND broker_verification_runs.deployment_record_id IS NOT NULL + AND broker_verification_runs.source IN ('manual', 'ci') + ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC + LIMIT 1 + `, + [workflowKey, workflowKey], + ); + + rows.push( + row === null + ? { + workflowKey, + deploymentRecordId: null, + deploymentLabel: null, + status: null, + summary: null, + detailUrl: null, + checkedAt: null, + } + : mapD1CertificationWorkflowStatusRow(row), + ); + } + + return rows.map((row) => mapCertificationWorkflowStatusRow(row)); +} + +export async function getLatestOfficialCertificationEvidenceForD1(db: D1Database) { + const row = await queryD1First( + db, + ` + SELECT + workflow_key AS workflowKey, + status, + certification_state AS certificationState, + summary, + detail_url AS detailUrl, + checked_at AS checkedAt + FROM broker_verification_runs + WHERE source = '1edtech' + AND workflow_key IS NOT NULL + ORDER BY checked_at DESC, id DESC + LIMIT 1 + `, + ); + + return mapLatestOfficialCertificationEvidenceRow( + row === null ? null : mapD1LatestOfficialCertificationEvidenceRow(row), + ); +} + +export async function getLatestBrokerVerificationStatusForD1(db: D1Database) { + const internalRow = await queryD1First( + db, + ` + SELECT + scope, + source, + status, + summary, + detail_url AS detailUrl, + checked_at AS checkedAt + FROM broker_verification_runs + WHERE deployment_record_id IS NOT NULL + AND source IN ('manual', 'ci') + ORDER BY checked_at DESC, id DESC + LIMIT 1 + `, + ); + + if (internalRow !== null) { + const internal = mapD1InternalBrokerVerificationRow(internalRow); + const officialRow = await queryD1First( + db, + ` + SELECT + scope, + status, + certification_state AS certificationState, + detail_url AS detailUrl, + checked_at AS checkedAt + FROM broker_verification_runs + WHERE scope = ? + AND source = '1edtech' + ORDER BY checked_at DESC, id DESC + LIMIT 1 + `, + [internal.scope], + ); + + return mapBrokerVerificationStatusRows( + internal, + officialRow === null ? null : mapD1OfficialBrokerVerificationRow(officialRow), + ); + } + + const officialRow = await queryD1First( + db, + ` + SELECT + scope, + status, + certification_state AS certificationState, + detail_url AS detailUrl, + checked_at AS checkedAt + FROM broker_verification_runs + WHERE source = '1edtech' + ORDER BY checked_at DESC, id DESC + LIMIT 1 + `, + ); + + return mapBrokerVerificationStatusRows( + null, + officialRow === null ? null : mapD1OfficialBrokerVerificationRow(officialRow), + ); +} + +export async function recordBrokerVerificationRunForD1( + db: D1Database, + input: RecordBrokerVerificationRunInput, +): Promise { + assertBrokerVerificationRunInput(input); + await runD1( + db, + ` + INSERT INTO broker_verification_runs ( + deployment_record_id, + scope, + workflow_key, + source, + status, + summary, + detail_url, + certification_state, + checked_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + input.deploymentRecordId, + input.scope, + input.workflowKey, + input.source, + input.status, + input.summary, + input.detailUrl, + input.certificationState, + input.checkedAt, + ], + ); +} diff --git a/src/ops/repository_d1_detail.ts b/src/ops/repository_d1_detail.ts new file mode 100644 index 0000000..a2f9b58 --- /dev/null +++ b/src/ops/repository_d1_detail.ts @@ -0,0 +1,364 @@ +import type { D1Database } from '../db/d1.ts'; +import { queryD1First, queryD1Objects } from '../db/d1.ts'; +import { mapAttemptEvidenceArtifactRow } from '../package_review/repository_mappers_attempts.ts'; +import type { AttemptEvidenceArtifactRecord } from '../package_review/types.ts'; +import { + type D1ActivitySnapshotRow, + type D1AttemptEvidenceArtifactRow, + type D1DiagnosticRow, + type D1GradePublicationSnapshotRow, + type D1InventoryQueryRow, + type D1RecentLaunchRow, + type D1RetryLookupRow, + mapD1ActivitySnapshotRow, + mapD1AttemptEvidenceArtifactRow, + mapD1DiagnosticRow, + mapD1GradePublicationRow, + mapD1InventoryRow, + mapD1RecentLaunchRow, + mapD1RetryLookupRow, +} from './repository_d1_mapping.ts'; +import { + D1_INVENTORY_QUERY, + D1_LATEST_AGS_SMOKE_QUERY, + D1_LATEST_COMPATIBILITY_PATH_QUERY, + D1_LATEST_LAUNCH_QUERY, + D1_LATEST_NRPS_QUERY, + D1_LATEST_RUNTIME_OUTCOME_QUERY, + D1_LATEST_RUNTIME_SESSION_QUERY, +} from './repository_d1_queries.ts'; +import { + mapActivitySnapshotRow, + mapDiagnosticRows, + mapGradePublicationSnapshotRow, + mapInventoryRow, + mapRecentLaunchRows, + mapRuntimeEvidenceSnapshotRow, +} from './repository_mapping.ts'; +import { mapRetryLookupRow } from './repository_retry_mapping.ts'; +import type { + ControlPlaneAnonymousEvidenceArtifact, + ControlPlaneDiagnosticItem, + ControlPlaneRuntimeEvidenceSnapshot, + DeploymentActivitySnapshot, + DeploymentGradePublicationSnapshot, + RetryableGradePublicationLookup, +} from './types.ts'; + +export async function getControlPlaneDeploymentDetailForD1( + db: D1Database, + deploymentRecordId: number, +) { + const inventory = await getInventoryRow(db, deploymentRecordId); + + if (inventory === null) { + return null; + } + + const latestLaunch = await getActivitySnapshot(db, D1_LATEST_LAUNCH_QUERY, deploymentRecordId); + const latestRuntimeSession = await getRuntimeEvidenceSnapshot( + db, + D1_LATEST_RUNTIME_SESSION_QUERY, + deploymentRecordId, + ); + const latestRuntimeOutcome = await getRuntimeEvidenceSnapshot( + db, + D1_LATEST_RUNTIME_OUTCOME_QUERY, + deploymentRecordId, + ); + const latestGradePublish = await getLatestGradePublication(db, deploymentRecordId); + const retryableGradePublication = latestGradePublish?.status === 'failed' + ? await getRetryableGradePublicationLookupForD1(db, latestGradePublish.attemptId) + : null; + const latestRuntimeAttemptId = latestRuntimeOutcome?.attemptId ?? + latestRuntimeSession?.attemptId ?? null; + + return { + inventory, + latestInstallEvidence: inventory.installEvidence, + latestLaunch, + latestRuntimeSession, + latestRuntimeOutcome, + latestAnonymousEvidence: deriveLatestAnonymousEvidence( + inventory.appId, + latestRuntimeAttemptId === null + ? [] + : await listAttemptEvidenceArtifacts(db, latestRuntimeAttemptId), + ), + recentLaunches: await listRecentAcceptedLaunches(db, deploymentRecordId), + latestCompatibilityPath: await getActivitySnapshot( + db, + D1_LATEST_COMPATIBILITY_PATH_QUERY, + deploymentRecordId, + ), + latestAgsSmoke: await getActivitySnapshot( + db, + D1_LATEST_AGS_SMOKE_QUERY, + deploymentRecordId, + ), + latestNrpsRead: await getActivitySnapshot(db, D1_LATEST_NRPS_QUERY, deploymentRecordId), + latestGradePublish, + pilotUsage: inventory.pilotUsage, + diagnostics: await listDiagnostics( + db, + deploymentRecordId, + retryableGradePublication?.attemptId ?? null, + ), + retryableGradePublication, + brokerVerification: inventory.brokerVerification, + }; +} + +export async function getInventoryRow(db: D1Database, deploymentRecordId: number) { + const row = await queryD1First( + db, + ` + ${D1_INVENTORY_QUERY} + AND deployments.id = ? + ORDER BY deployments.updated_at DESC, deployments.id DESC + `, + [deploymentRecordId], + ); + + return row === null ? null : mapInventoryRow(mapD1InventoryRow(row)); +} + +export async function getRetryableGradePublicationLookupForD1( + db: D1Database, + attemptId: string, +): Promise { + const row = await queryD1First( + db, + ` + SELECT + attempts.attempt_id AS attemptId, + attempts.deployment_record_id AS deploymentRecordId, + attempts.deployment_slug AS deploymentSlug, + grade_publications.status AS publicationStatus, + grade_publications.line_item_url AS lineItemUrl, + grade_publications.platform_user_id AS platformUserId, + grade_publications.score_given AS scoreGiven, + grade_publications.score_maximum AS scoreMaximum, + grade_publications.activity_progress AS activityProgress, + grade_publications.grading_progress AS gradingProgress, + grade_publications.published_at AS publishedAt, + grade_publications.updated_at AS updatedAt, + grade_publications.error_code AS errorCode, + grade_publications.error_detail AS errorDetail, + deployments.canvas_environment AS bindingCanvasEnvironment, + deployments.issuer AS bindingIssuer, + deployments.client_id AS bindingClientId, + deployments.deployment_id AS bindingDeploymentId, + runtime_sessions.session_id AS sessionId, + runtime_sessions.lti_tool_session_id AS runtimeLtiToolSessionId, + runtime_sessions.attempt_id AS runtimeAttemptId, + runtime_sessions.deployment_record_id AS runtimeDeploymentRecordId, + runtime_sessions.deployment_slug AS runtimeDeploymentSlug, + runtime_sessions.app_id AS runtimeAppId, + runtime_sessions.package_version_id AS runtimePackageVersionId, + runtime_sessions.package_version AS runtimePackageVersion, + runtime_sessions.ags_scope AS runtimeAgsScope, + runtime_sessions.ags_lineitems_url AS runtimeAgsLineitemsUrl, + runtime_sessions.ags_lineitem_url AS runtimeAgsLineitemUrl, + runtime_sessions.nrps_context_memberships_url AS runtimeNrpsContextMembershipsUrl, + runtime_sessions.nrps_service_versions AS runtimeNrpsServiceVersions, + runtime_sessions.created_at AS runtimeCreatedAt, + runtime_sessions.expires_at AS runtimeExpiresAt + FROM attempts + INNER JOIN grade_publications + ON grade_publications.attempt_id = attempts.attempt_id + INNER JOIN deployments + ON deployments.id = attempts.deployment_record_id + LEFT JOIN runtime_sessions + ON runtime_sessions.session_id = ( + SELECT session_id + FROM runtime_sessions AS candidate_sessions + WHERE candidate_sessions.attempt_id = attempts.attempt_id + ORDER BY candidate_sessions.created_at DESC, candidate_sessions.session_id DESC + LIMIT 1 + ) + WHERE attempts.attempt_id = ? + AND grade_publications.status = 'failed' + LIMIT 1 + `, + [attemptId], + ); + + return row === null ? null : mapRetryLookupRow(mapD1RetryLookupRow(row)); +} + +async function getActivitySnapshot( + db: D1Database, + sql: string, + deploymentRecordId: number, +): Promise { + const row = await queryD1First(db, sql, [deploymentRecordId]); + + return row === null ? null : mapActivitySnapshotRow(mapD1ActivitySnapshotRow(row)); +} + +async function getRuntimeEvidenceSnapshot( + db: D1Database, + sql: string, + deploymentRecordId: number, +): Promise { + const row = await queryD1First(db, sql, [deploymentRecordId]); + + return row === null ? null : mapRuntimeEvidenceSnapshotRow(mapD1ActivitySnapshotRow(row)); +} + +async function listRecentAcceptedLaunches(db: D1Database, deploymentRecordId: number) { + const rows = await queryD1Objects( + db, + ` + SELECT + audit_events.summary, + audit_events.actor_id AS actorId, + COALESCE( + attempts.user_id, + NULLIF(json_extract(audit_events.detail, '$.userId'), ''), + audit_events.actor_id + ) AS userId, + COALESCE( + attempts.user_display_name, + NULLIF(json_extract(audit_events.detail, '$.userDisplayName'), '') + ) AS userDisplayName, + COALESCE( + attempts.user_email, + NULLIF(json_extract(audit_events.detail, '$.userEmail'), '') + ) AS userEmail, + COALESCE( + attempts.user_login, + NULLIF(json_extract(audit_events.detail, '$.userLogin'), '') + ) AS userLogin, + audit_events.attempt_id AS attemptId, + audit_events.detail, + audit_events.occurred_at AS occurredAt + FROM audit_events + LEFT JOIN attempts + ON attempts.attempt_id = audit_events.attempt_id + WHERE audit_events.deployment_record_id = ? + AND audit_events.event_type = 'launch.accepted' + ORDER BY audit_events.occurred_at DESC, audit_events.id DESC + LIMIT 10 + `, + [deploymentRecordId], + ); + + return mapRecentLaunchRows(rows.map(mapD1RecentLaunchRow)); +} + +async function getLatestGradePublication( + db: D1Database, + deploymentRecordId: number, +): Promise { + const row = await queryD1First( + db, + ` + SELECT + grade_publications.attempt_id AS attemptId, + grade_publications.status, + grade_publications.line_item_url AS lineItemUrl, + grade_publications.platform_user_id AS platformUserId, + grade_publications.score_given AS scoreGiven, + grade_publications.score_maximum AS scoreMaximum, + grade_publications.activity_progress AS activityProgress, + grade_publications.grading_progress AS gradingProgress, + grade_publications.published_at AS publishedAt, + grade_publications.updated_at AS updatedAt, + grade_publications.error_code AS errorCode, + grade_publications.error_detail AS errorDetail + FROM attempts + INNER JOIN grade_publications + ON grade_publications.attempt_id = attempts.attempt_id + WHERE attempts.deployment_record_id = ? + ORDER BY grade_publications.updated_at DESC, grade_publications.id DESC + LIMIT 1 + `, + [deploymentRecordId], + ); + + return row === null ? null : mapGradePublicationSnapshotRow(mapD1GradePublicationRow(row)); +} + +async function listDiagnostics( + db: D1Database, + deploymentRecordId: number, + retryableAttemptId: string | null, +): Promise { + const rows = await queryD1Objects( + db, + ` + SELECT + id, + event_type AS eventType, + actor_type AS actorType, + status, + deployment_record_id AS deploymentRecordId, + attempt_id AS attemptId, + summary, + detail, + occurred_at AS occurredAt + FROM audit_events + WHERE deployment_record_id = ? + AND status = 'failed' + AND ( + event_type LIKE 'launch.%' + OR event_type = 'deep_linking.request.rejected' + OR event_type = 'deployment.nrps_verified' + OR event_type LIKE 'grade_publish.%' + OR event_type LIKE 'broker_verification.%' + OR event_type LIKE 'runtime.%' + OR event_type LIKE 'reviewer.%' + ) + ORDER BY occurred_at DESC, id DESC + `, + [deploymentRecordId], + ); + + return mapDiagnosticRows(rows.map(mapD1DiagnosticRow), retryableAttemptId); +} + +async function listAttemptEvidenceArtifacts( + db: D1Database, + attemptId: string, +): Promise { + const rows = await queryD1Objects( + db, + ` + SELECT + artifact_id AS artifactId, + attempt_id AS attemptId, + sequence, + kind, + content_type AS contentType, + file_name AS fileName, + storage_key AS storageKey, + byte_size AS byteSize, + sha256, + created_at AS createdAt + FROM attempt_evidence_artifacts + WHERE attempt_id = ? + ORDER BY sequence ASC + `, + [attemptId], + ); + + return rows.map((row) => mapAttemptEvidenceArtifactRow(mapD1AttemptEvidenceArtifactRow(row))); +} + +function deriveLatestAnonymousEvidence( + appId: string, + evidenceArtifacts: AttemptEvidenceArtifactRecord[], +): ControlPlaneAnonymousEvidenceArtifact[] { + return evidenceArtifacts.map((artifact) => ({ + artifactId: artifact.artifactId, + kind: artifact.kind, + fileName: artifact.fileName, + contentType: artifact.contentType, + byteSize: artifact.byteSize, + sha256: artifact.sha256, + createdAt: artifact.createdAt, + artifactUrl: `/admin/packages/${appId}/deployment/evidence/${artifact.artifactId}`, + })); +} diff --git a/src/ops/repository_d1_mapping.ts b/src/ops/repository_d1_mapping.ts new file mode 100644 index 0000000..16dba85 --- /dev/null +++ b/src/ops/repository_d1_mapping.ts @@ -0,0 +1,592 @@ +import { + expectD1JsonRecord, + expectD1NullableNumber, + expectD1NullableString, + expectD1NullableStringLiteral, + expectD1Number, + expectD1String, + expectD1StringArray, + expectD1StringLiteral, + parseD1JsonField, + parseD1NullableJsonField, +} from '../db/d1_row.ts'; +export { + type D1AttemptEvidenceArtifactRow, + mapD1AttemptEvidenceArtifactRow, +} from '../package_review/repository_grading_d1_mapping.ts'; +import type { + ActivitySnapshotRow, + CertificationWorkflowStatusRow, + DiagnosticRow, + GradePublicationSnapshotRow, + InternalBrokerVerificationRow, + InventoryQueryRow, + LatestOfficialCertificationEvidenceRow, + OfficialBrokerVerificationRow, + RecentLaunchRow, + RetryLookupRow, +} from './repository_types.ts'; + +export interface D1InventoryQueryRow { + deploymentId: unknown; + deploymentSlug: unknown; + deploymentLabel: unknown; + appId: unknown; + appTitle: unknown; + ownerId: unknown; + enabledPackageVersionId: unknown; + enabledPackageVersion: unknown; + approvalStatus: unknown; + reviewedAt: unknown; + bindingLmsType: unknown; + installEvidenceStatus: unknown; + installEvidenceSummary: unknown; + installEvidenceDetail: unknown; + installEvidenceOccurredAt: unknown; + internalBrokerVerificationScope: unknown; + internalBrokerVerificationSource: unknown; + internalBrokerVerificationStatus: unknown; + internalBrokerVerificationSummary: unknown; + internalBrokerVerificationDetailUrl: unknown; + internalBrokerVerificationCheckedAt: unknown; + officialBrokerVerificationScope: unknown; + officialBrokerVerificationStatus: unknown; + officialBrokerVerificationCertificationState: unknown; + officialBrokerVerificationDetailUrl: unknown; + officialBrokerVerificationCheckedAt: unknown; + bindingCanvasEnvironment: unknown; + bindingIssuer: unknown; + bindingClientId: unknown; + bindingDeploymentId: unknown; + bindingAuthorizationEndpoint: unknown; + bindingAccessTokenUrl: unknown; + bindingJwksUrl: unknown; + updatedAt: unknown; + lastLaunchAt: unknown; + lastLaunchStatus: unknown; + lastNrpsReadAt: unknown; + lastNrpsReadStatus: unknown; + lastGradePublishAt: unknown; + lastGradePublishStatus: unknown; + totalLaunches: unknown; + attemptsStarted: unknown; + attemptsCompleted: unknown; + gradePublishesSucceeded: unknown; + gradePublishesFailed: unknown; + recentActiveUsers: unknown; + usageLastLaunchAt: unknown; + measuredAt: unknown; +} + +export interface D1ActivitySnapshotRow { + eventType: unknown; + status: unknown; + summary: unknown; + attemptId: unknown; + detail: unknown; + occurredAt: unknown; +} + +export interface D1RecentLaunchRow { + summary: unknown; + actorId: unknown; + userId: unknown; + userDisplayName: unknown; + userEmail: unknown; + userLogin: unknown; + attemptId: unknown; + detail: unknown; + occurredAt: unknown; +} + +export interface D1GradePublicationSnapshotRow { + attemptId: unknown; + status: unknown; + lineItemUrl: unknown; + platformUserId: unknown; + scoreGiven: unknown; + scoreMaximum: unknown; + activityProgress: unknown; + gradingProgress: unknown; + publishedAt: unknown; + updatedAt: unknown; + errorCode: unknown; + errorDetail: unknown; +} + +export interface D1DiagnosticRow { + id: unknown; + eventType: unknown; + actorType: unknown; + status: unknown; + deploymentRecordId: unknown; + attemptId: unknown; + summary: unknown; + detail: unknown; + occurredAt: unknown; +} + +export interface D1InternalBrokerVerificationRow { + scope: unknown; + source: unknown; + status: unknown; + summary: unknown; + detailUrl: unknown; + checkedAt: unknown; +} + +export interface D1OfficialBrokerVerificationRow { + scope: unknown; + status: unknown; + certificationState: unknown; + detailUrl: unknown; + checkedAt: unknown; +} + +export interface D1CertificationWorkflowStatusRow { + workflowKey: unknown; + deploymentRecordId: unknown; + deploymentLabel: unknown; + status: unknown; + summary: unknown; + detailUrl: unknown; + checkedAt: unknown; +} + +export interface D1LatestOfficialCertificationEvidenceRow { + workflowKey: unknown; + status: unknown; + certificationState: unknown; + summary: unknown; + detailUrl: unknown; + checkedAt: unknown; +} + +export interface D1RetryLookupRow { + attemptId: unknown; + deploymentRecordId: unknown; + deploymentSlug: unknown; + publicationStatus: unknown; + lineItemUrl: unknown; + platformUserId: unknown; + scoreGiven: unknown; + scoreMaximum: unknown; + activityProgress: unknown; + gradingProgress: unknown; + publishedAt: unknown; + updatedAt: unknown; + errorCode: unknown; + errorDetail: unknown; + bindingCanvasEnvironment: unknown; + bindingIssuer: unknown; + bindingClientId: unknown; + bindingDeploymentId: unknown; + sessionId: unknown; + runtimeLtiToolSessionId: unknown; + runtimeAttemptId: unknown; + runtimeDeploymentRecordId: unknown; + runtimeDeploymentSlug: unknown; + runtimeAppId: unknown; + runtimePackageVersionId: unknown; + runtimePackageVersion: unknown; + runtimeAgsScope: unknown; + runtimeAgsLineitemsUrl: unknown; + runtimeAgsLineitemUrl: unknown; + runtimeNrpsContextMembershipsUrl: unknown; + runtimeNrpsServiceVersions: unknown; + runtimeCreatedAt: unknown; + runtimeExpiresAt: unknown; +} +export function mapD1InventoryRow(row: D1InventoryQueryRow): InventoryQueryRow { + return { + deploymentId: expectD1Number(row.deploymentId, 'deploymentId'), + deploymentSlug: expectD1String(row.deploymentSlug, 'deploymentSlug'), + deploymentLabel: expectD1String(row.deploymentLabel, 'deploymentLabel'), + appId: expectD1String(row.appId, 'appId'), + appTitle: expectD1String(row.appTitle, 'appTitle'), + ownerId: expectD1NullableString(row.ownerId, 'ownerId'), + enabledPackageVersionId: expectD1NullableNumber( + row.enabledPackageVersionId, + 'enabledPackageVersionId', + ), + enabledPackageVersion: expectD1NullableString( + row.enabledPackageVersion, + 'enabledPackageVersion', + ), + approvalStatus: expectD1NullableStringLiteral(row.approvalStatus, 'approvalStatus', [ + 'pending', + 'approved', + 'rejected', + ]), + reviewedAt: expectD1NullableString(row.reviewedAt, 'reviewedAt'), + bindingLmsType: expectD1NullableStringLiteral(row.bindingLmsType, 'bindingLmsType', [ + 'canvas', + 'moodle', + 'sakai', + ]), + installEvidenceStatus: expectD1NullableStringLiteral( + row.installEvidenceStatus, + 'installEvidenceStatus', + ['accepted', 'succeeded', 'failed'], + ), + installEvidenceSummary: expectD1NullableString( + row.installEvidenceSummary, + 'installEvidenceSummary', + ), + installEvidenceDetail: parseD1NullableJsonField( + row.installEvidenceDetail, + 'installEvidenceDetail', + expectD1JsonRecord, + ), + installEvidenceOccurredAt: expectD1NullableString( + row.installEvidenceOccurredAt, + 'installEvidenceOccurredAt', + ), + internalBrokerVerificationScope: expectNullableSupportedPath( + row.internalBrokerVerificationScope, + 'internalBrokerVerificationScope', + ), + internalBrokerVerificationSource: expectD1NullableStringLiteral( + row.internalBrokerVerificationSource, + 'internalBrokerVerificationSource', + ['manual', 'ci', '1edtech'], + ), + internalBrokerVerificationStatus: expectD1NullableStringLiteral( + row.internalBrokerVerificationStatus, + 'internalBrokerVerificationStatus', + ['passed', 'failed', 'notRun'], + ), + internalBrokerVerificationSummary: expectD1NullableString( + row.internalBrokerVerificationSummary, + 'internalBrokerVerificationSummary', + ), + internalBrokerVerificationDetailUrl: expectD1NullableString( + row.internalBrokerVerificationDetailUrl, + 'internalBrokerVerificationDetailUrl', + ), + internalBrokerVerificationCheckedAt: expectD1NullableString( + row.internalBrokerVerificationCheckedAt, + 'internalBrokerVerificationCheckedAt', + ), + officialBrokerVerificationScope: expectNullableSupportedPath( + row.officialBrokerVerificationScope, + 'officialBrokerVerificationScope', + ), + officialBrokerVerificationStatus: expectD1NullableStringLiteral( + row.officialBrokerVerificationStatus, + 'officialBrokerVerificationStatus', + ['passed', 'failed', 'notRun', 'notCertified'], + ), + officialBrokerVerificationCertificationState: expectD1NullableStringLiteral( + row.officialBrokerVerificationCertificationState, + 'officialBrokerVerificationCertificationState', + ['ltiAdvantageCertified', 'ltiAdvantageComplete'], + ), + officialBrokerVerificationDetailUrl: expectD1NullableString( + row.officialBrokerVerificationDetailUrl, + 'officialBrokerVerificationDetailUrl', + ), + officialBrokerVerificationCheckedAt: expectD1NullableString( + row.officialBrokerVerificationCheckedAt, + 'officialBrokerVerificationCheckedAt', + ), + bindingCanvasEnvironment: expectD1NullableString( + row.bindingCanvasEnvironment, + 'bindingCanvasEnvironment', + ), + bindingIssuer: expectD1NullableString(row.bindingIssuer, 'bindingIssuer'), + bindingClientId: expectD1NullableString(row.bindingClientId, 'bindingClientId'), + bindingDeploymentId: expectD1NullableString(row.bindingDeploymentId, 'bindingDeploymentId'), + bindingAuthorizationEndpoint: expectD1NullableString( + row.bindingAuthorizationEndpoint, + 'bindingAuthorizationEndpoint', + ), + bindingAccessTokenUrl: expectD1NullableString( + row.bindingAccessTokenUrl, + 'bindingAccessTokenUrl', + ), + bindingJwksUrl: expectD1NullableString(row.bindingJwksUrl, 'bindingJwksUrl'), + updatedAt: expectD1String(row.updatedAt, 'updatedAt'), + lastLaunchAt: expectD1NullableString(row.lastLaunchAt, 'lastLaunchAt'), + lastLaunchStatus: expectD1NullableStringLiteral(row.lastLaunchStatus, 'lastLaunchStatus', [ + 'succeeded', + 'failed', + 'pending', + ]), + lastNrpsReadAt: expectD1NullableString(row.lastNrpsReadAt, 'lastNrpsReadAt'), + lastNrpsReadStatus: expectD1NullableStringLiteral( + row.lastNrpsReadStatus, + 'lastNrpsReadStatus', + [ + 'succeeded', + 'failed', + 'pending', + ], + ), + lastGradePublishAt: expectD1NullableString(row.lastGradePublishAt, 'lastGradePublishAt'), + lastGradePublishStatus: expectD1NullableStringLiteral( + row.lastGradePublishStatus, + 'lastGradePublishStatus', + ['pending', 'published', 'failed'], + ), + totalLaunches: expectD1Number(row.totalLaunches, 'totalLaunches'), + attemptsStarted: expectD1Number(row.attemptsStarted, 'attemptsStarted'), + attemptsCompleted: expectD1Number(row.attemptsCompleted, 'attemptsCompleted'), + gradePublishesSucceeded: expectD1Number(row.gradePublishesSucceeded, 'gradePublishesSucceeded'), + gradePublishesFailed: expectD1Number(row.gradePublishesFailed, 'gradePublishesFailed'), + recentActiveUsers: expectD1Number(row.recentActiveUsers, 'recentActiveUsers'), + usageLastLaunchAt: expectD1NullableString(row.usageLastLaunchAt, 'usageLastLaunchAt'), + measuredAt: expectD1String(row.measuredAt, 'measuredAt'), + }; +} + +export function mapD1ActivitySnapshotRow(row: D1ActivitySnapshotRow): ActivitySnapshotRow { + return { + eventType: expectD1String(row.eventType, 'eventType'), + status: expectD1StringLiteral(row.status, 'status', ['accepted', 'succeeded', 'failed']), + summary: expectD1String(row.summary, 'summary'), + attemptId: expectD1NullableString(row.attemptId, 'attemptId'), + detail: parseD1JsonField(row.detail, 'detail', expectD1JsonRecord), + occurredAt: expectD1String(row.occurredAt, 'occurredAt'), + }; +} + +export function mapD1RecentLaunchRow(row: D1RecentLaunchRow): RecentLaunchRow { + return { + summary: expectD1String(row.summary, 'summary'), + actorId: expectD1NullableString(row.actorId, 'actorId'), + userId: expectD1NullableString(row.userId, 'userId'), + userDisplayName: expectD1NullableString(row.userDisplayName, 'userDisplayName'), + userEmail: expectD1NullableString(row.userEmail, 'userEmail'), + userLogin: expectD1NullableString(row.userLogin, 'userLogin'), + attemptId: expectD1NullableString(row.attemptId, 'attemptId'), + detail: parseD1JsonField(row.detail, 'detail', expectD1JsonRecord), + occurredAt: expectD1String(row.occurredAt, 'occurredAt'), + }; +} + +export function mapD1GradePublicationRow( + row: D1GradePublicationSnapshotRow, +): GradePublicationSnapshotRow { + return { + attemptId: expectD1String(row.attemptId, 'attemptId'), + status: expectD1StringLiteral(row.status, 'status', ['pending', 'published', 'failed']), + lineItemUrl: expectD1String(row.lineItemUrl, 'lineItemUrl'), + platformUserId: expectD1String(row.platformUserId, 'platformUserId'), + scoreGiven: expectD1Number(row.scoreGiven, 'scoreGiven'), + scoreMaximum: expectD1Number(row.scoreMaximum, 'scoreMaximum'), + activityProgress: expectD1StringLiteral(row.activityProgress, 'activityProgress', [ + 'Completed', + 'InProgress', + 'Initialized', + ]), + gradingProgress: expectD1StringLiteral(row.gradingProgress, 'gradingProgress', [ + 'Pending', + 'PendingManual', + 'FullyGraded', + 'Failed', + ]), + publishedAt: expectD1NullableString(row.publishedAt, 'publishedAt'), + updatedAt: expectD1String(row.updatedAt, 'updatedAt'), + errorCode: expectD1NullableString(row.errorCode, 'errorCode'), + errorDetail: parseD1NullableJsonField(row.errorDetail, 'errorDetail', expectD1JsonRecord), + }; +} + +export function mapD1DiagnosticRow(row: D1DiagnosticRow): DiagnosticRow { + return { + id: expectD1Number(row.id, 'id'), + eventType: expectD1String(row.eventType, 'eventType'), + actorType: expectD1StringLiteral(row.actorType, 'actorType', ['user', 'system', 'platform']), + status: expectD1StringLiteral(row.status, 'status', ['accepted', 'succeeded', 'failed']), + deploymentRecordId: expectD1NullableNumber(row.deploymentRecordId, 'deploymentRecordId'), + attemptId: expectD1NullableString(row.attemptId, 'attemptId'), + summary: expectD1String(row.summary, 'summary'), + detail: parseD1JsonField(row.detail, 'detail', expectD1JsonRecord), + occurredAt: expectD1String(row.occurredAt, 'occurredAt'), + }; +} + +export function mapD1InternalBrokerVerificationRow( + row: D1InternalBrokerVerificationRow, +): InternalBrokerVerificationRow { + return { + scope: expectSupportedPath(row.scope, 'scope'), + source: expectD1StringLiteral(row.source, 'source', ['manual', 'ci', '1edtech']), + status: expectD1StringLiteral(row.status, 'status', ['passed', 'failed', 'notRun']), + summary: expectD1String(row.summary, 'summary'), + detailUrl: expectD1NullableString(row.detailUrl, 'detailUrl'), + checkedAt: expectD1String(row.checkedAt, 'checkedAt'), + }; +} + +export function mapD1OfficialBrokerVerificationRow( + row: D1OfficialBrokerVerificationRow, +): OfficialBrokerVerificationRow { + return { + scope: expectSupportedPath(row.scope, 'scope'), + status: expectD1StringLiteral(row.status, 'status', [ + 'passed', + 'failed', + 'notRun', + 'notCertified', + ]), + certificationState: expectD1NullableStringLiteral( + row.certificationState, + 'certificationState', + [ + 'ltiAdvantageCertified', + 'ltiAdvantageComplete', + ], + ), + detailUrl: expectD1NullableString(row.detailUrl, 'detailUrl'), + checkedAt: expectD1String(row.checkedAt, 'checkedAt'), + }; +} + +export function mapD1CertificationWorkflowStatusRow( + row: D1CertificationWorkflowStatusRow, +): CertificationWorkflowStatusRow { + return { + workflowKey: expectD1StringLiteral(row.workflowKey, 'workflowKey', [ + 'core', + 'deepLinking', + 'nrps', + 'ags', + ]), + deploymentRecordId: expectD1NullableNumber(row.deploymentRecordId, 'deploymentRecordId'), + deploymentLabel: expectD1NullableString(row.deploymentLabel, 'deploymentLabel'), + status: expectD1NullableStringLiteral(row.status, 'status', ['passed', 'failed', 'pending']), + summary: expectD1NullableString(row.summary, 'summary'), + detailUrl: expectD1NullableString(row.detailUrl, 'detailUrl'), + checkedAt: expectD1NullableString(row.checkedAt, 'checkedAt'), + }; +} + +export function mapD1LatestOfficialCertificationEvidenceRow( + row: D1LatestOfficialCertificationEvidenceRow, +): LatestOfficialCertificationEvidenceRow { + return { + workflowKey: expectD1StringLiteral(row.workflowKey, 'workflowKey', [ + 'core', + 'deepLinking', + 'nrps', + 'ags', + ]), + status: expectD1StringLiteral(row.status, 'status', [ + 'passed', + 'failed', + 'notRun', + 'notCertified', + ]), + certificationState: expectD1NullableStringLiteral( + row.certificationState, + 'certificationState', + [ + 'ltiAdvantageCertified', + 'ltiAdvantageComplete', + ], + ), + summary: expectD1String(row.summary, 'summary'), + detailUrl: expectD1NullableString(row.detailUrl, 'detailUrl'), + checkedAt: expectD1String(row.checkedAt, 'checkedAt'), + }; +} + +export function mapD1RetryLookupRow(row: D1RetryLookupRow): RetryLookupRow { + return { + attemptId: expectD1String(row.attemptId, 'attemptId'), + deploymentRecordId: expectD1Number(row.deploymentRecordId, 'deploymentRecordId'), + deploymentSlug: expectD1String(row.deploymentSlug, 'deploymentSlug'), + publicationStatus: expectD1StringLiteral(row.publicationStatus, 'publicationStatus', [ + 'pending', + 'published', + 'failed', + ]), + lineItemUrl: expectD1String(row.lineItemUrl, 'lineItemUrl'), + platformUserId: expectD1String(row.platformUserId, 'platformUserId'), + scoreGiven: expectD1Number(row.scoreGiven, 'scoreGiven'), + scoreMaximum: expectD1Number(row.scoreMaximum, 'scoreMaximum'), + activityProgress: expectD1StringLiteral(row.activityProgress, 'activityProgress', [ + 'Completed', + 'InProgress', + 'Initialized', + ]), + gradingProgress: expectD1StringLiteral(row.gradingProgress, 'gradingProgress', [ + 'Pending', + 'PendingManual', + 'FullyGraded', + 'Failed', + ]), + publishedAt: expectD1NullableString(row.publishedAt, 'publishedAt'), + updatedAt: expectD1String(row.updatedAt, 'updatedAt'), + errorCode: expectD1NullableString(row.errorCode, 'errorCode'), + errorDetail: parseD1NullableJsonField(row.errorDetail, 'errorDetail', expectD1JsonRecord), + bindingCanvasEnvironment: expectD1NullableString( + row.bindingCanvasEnvironment, + 'bindingCanvasEnvironment', + ), + bindingIssuer: expectD1NullableString(row.bindingIssuer, 'bindingIssuer'), + bindingClientId: expectD1NullableString(row.bindingClientId, 'bindingClientId'), + bindingDeploymentId: expectD1NullableString(row.bindingDeploymentId, 'bindingDeploymentId'), + sessionId: expectD1NullableString(row.sessionId, 'sessionId'), + runtimeLtiToolSessionId: expectD1NullableString( + row.runtimeLtiToolSessionId, + 'runtimeLtiToolSessionId', + ), + runtimeAttemptId: expectD1NullableString(row.runtimeAttemptId, 'runtimeAttemptId'), + runtimeDeploymentRecordId: expectD1NullableNumber( + row.runtimeDeploymentRecordId, + 'runtimeDeploymentRecordId', + ), + runtimeDeploymentSlug: expectD1NullableString( + row.runtimeDeploymentSlug, + 'runtimeDeploymentSlug', + ), + runtimeAppId: expectD1NullableString(row.runtimeAppId, 'runtimeAppId'), + runtimePackageVersionId: expectD1NullableNumber( + row.runtimePackageVersionId, + 'runtimePackageVersionId', + ), + runtimePackageVersion: expectD1NullableString( + row.runtimePackageVersion, + 'runtimePackageVersion', + ), + runtimeAgsScope: parseD1NullableJsonField( + row.runtimeAgsScope, + 'runtimeAgsScope', + expectD1StringArray, + ), + runtimeAgsLineitemsUrl: expectD1NullableString( + row.runtimeAgsLineitemsUrl, + 'runtimeAgsLineitemsUrl', + ), + runtimeAgsLineitemUrl: expectD1NullableString( + row.runtimeAgsLineitemUrl, + 'runtimeAgsLineitemUrl', + ), + runtimeNrpsContextMembershipsUrl: expectD1NullableString( + row.runtimeNrpsContextMembershipsUrl, + 'runtimeNrpsContextMembershipsUrl', + ), + runtimeNrpsServiceVersions: parseD1NullableJsonField( + row.runtimeNrpsServiceVersions, + 'runtimeNrpsServiceVersions', + expectD1StringArray, + ), + runtimeCreatedAt: expectD1NullableString(row.runtimeCreatedAt, 'runtimeCreatedAt'), + runtimeExpiresAt: expectD1NullableString(row.runtimeExpiresAt, 'runtimeExpiresAt'), + }; +} + +function expectSupportedPath(value: unknown, fieldName: string) { + return expectD1StringLiteral(value, fieldName, ['lti13LaunchAgsNrps', 'lti13LaunchAgsScore']); +} + +function expectNullableSupportedPath(value: unknown, fieldName: string) { + if (value === null) { + return null; + } + + return expectSupportedPath(value, fieldName); +} diff --git a/src/ops/repository_d1_queries.ts b/src/ops/repository_d1_queries.ts new file mode 100644 index 0000000..1e72cda --- /dev/null +++ b/src/ops/repository_d1_queries.ts @@ -0,0 +1,324 @@ +import { BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS } from './broker_verification_paths.ts'; + +export const DEPLOYMENT_SUPPORTED_PATH_SQL = ` + CASE deployments.lms_type + WHEN 'canvas' THEN '${BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS.canvas}' + WHEN 'moodle' THEN '${BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS.moodle}' + WHEN 'sakai' THEN '${BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS.sakai}' + ELSE NULL + END +`; + +const INTERNAL_BROKER_VERIFICATION_FILTER = ` + broker_verification_runs.deployment_record_id = deployments.id + AND broker_verification_runs.scope = ${DEPLOYMENT_SUPPORTED_PATH_SQL} + AND broker_verification_runs.source IN ('manual', 'ci') +`; + +const OFFICIAL_BROKER_VERIFICATION_FILTER = ` + broker_verification_runs.scope = ${DEPLOYMENT_SUPPORTED_PATH_SQL} + AND broker_verification_runs.source = '1edtech' +`; + +function latestBrokerVerificationColumn(column: string, filter: string): string { + return ` + ( + SELECT broker_verification_runs.${column} + FROM broker_verification_runs + WHERE ${filter} + ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC + LIMIT 1 + ) + `; +} + +export const D1_INVENTORY_QUERY = ` + SELECT + deployments.id AS deploymentId, + deployments.slug AS deploymentSlug, + deployments.label AS deploymentLabel, + deployments.app_id AS appId, + COALESCE( + enabled_package.title, + ( + SELECT package_versions.title + FROM package_versions + WHERE package_versions.app_id = deployments.app_id + ORDER BY package_versions.imported_at DESC, package_versions.id DESC + LIMIT 1 + ), + deployments.label + ) AS appTitle, + COALESCE( + enabled_package.owner_id, + ( + SELECT package_versions.owner_id + FROM package_versions + WHERE package_versions.app_id = deployments.app_id + ORDER BY package_versions.imported_at DESC, package_versions.id DESC + LIMIT 1 + ) + ) AS ownerId, + deployments.enabled_package_version_id AS enabledPackageVersionId, + enabled_package.version AS enabledPackageVersion, + enabled_package.approval_status AS approvalStatus, + enabled_package.reviewed_at AS reviewedAt, + deployments.lms_type AS bindingLmsType, + ( + SELECT audit_events.status + FROM audit_events + WHERE audit_events.deployment_record_id = deployments.id + AND audit_events.event_type = 'deployment.binding_saved' + ORDER BY audit_events.occurred_at DESC, audit_events.id DESC + LIMIT 1 + ) AS installEvidenceStatus, + ( + SELECT audit_events.summary + FROM audit_events + WHERE audit_events.deployment_record_id = deployments.id + AND audit_events.event_type = 'deployment.binding_saved' + ORDER BY audit_events.occurred_at DESC, audit_events.id DESC + LIMIT 1 + ) AS installEvidenceSummary, + ( + SELECT audit_events.detail + FROM audit_events + WHERE audit_events.deployment_record_id = deployments.id + AND audit_events.event_type = 'deployment.binding_saved' + ORDER BY audit_events.occurred_at DESC, audit_events.id DESC + LIMIT 1 + ) AS installEvidenceDetail, + ( + SELECT audit_events.occurred_at + FROM audit_events + WHERE audit_events.deployment_record_id = deployments.id + AND audit_events.event_type = 'deployment.binding_saved' + ORDER BY audit_events.occurred_at DESC, audit_events.id DESC + LIMIT 1 + ) AS installEvidenceOccurredAt, + ${ + latestBrokerVerificationColumn('scope', INTERNAL_BROKER_VERIFICATION_FILTER) +} AS internalBrokerVerificationScope, + ${ + latestBrokerVerificationColumn('source', INTERNAL_BROKER_VERIFICATION_FILTER) +} AS internalBrokerVerificationSource, + ${ + latestBrokerVerificationColumn('status', INTERNAL_BROKER_VERIFICATION_FILTER) +} AS internalBrokerVerificationStatus, + ${ + latestBrokerVerificationColumn('summary', INTERNAL_BROKER_VERIFICATION_FILTER) +} AS internalBrokerVerificationSummary, + ${ + latestBrokerVerificationColumn('detail_url', INTERNAL_BROKER_VERIFICATION_FILTER) +} AS internalBrokerVerificationDetailUrl, + ${ + latestBrokerVerificationColumn('checked_at', INTERNAL_BROKER_VERIFICATION_FILTER) +} AS internalBrokerVerificationCheckedAt, + ${ + latestBrokerVerificationColumn('scope', OFFICIAL_BROKER_VERIFICATION_FILTER) +} AS officialBrokerVerificationScope, + ${ + latestBrokerVerificationColumn('status', OFFICIAL_BROKER_VERIFICATION_FILTER) +} AS officialBrokerVerificationStatus, + ${ + latestBrokerVerificationColumn('certification_state', OFFICIAL_BROKER_VERIFICATION_FILTER) +} AS officialBrokerVerificationCertificationState, + ${ + latestBrokerVerificationColumn('detail_url', OFFICIAL_BROKER_VERIFICATION_FILTER) +} AS officialBrokerVerificationDetailUrl, + ${ + latestBrokerVerificationColumn('checked_at', OFFICIAL_BROKER_VERIFICATION_FILTER) +} AS officialBrokerVerificationCheckedAt, + deployments.canvas_environment AS bindingCanvasEnvironment, + deployments.issuer AS bindingIssuer, + deployments.client_id AS bindingClientId, + deployments.deployment_id AS bindingDeploymentId, + deployments.authorization_endpoint AS bindingAuthorizationEndpoint, + deployments.access_token_url AS bindingAccessTokenUrl, + deployments.jwks_url AS bindingJwksUrl, + deployments.updated_at AS updatedAt, + ( + SELECT audit_events.occurred_at + FROM audit_events + WHERE audit_events.deployment_record_id = deployments.id + AND audit_events.event_type LIKE 'launch.%' + ORDER BY audit_events.occurred_at DESC, audit_events.id DESC + LIMIT 1 + ) AS lastLaunchAt, + ( + SELECT CASE + WHEN audit_events.event_type = 'launch.rejected' + OR audit_events.status = 'failed' + THEN 'failed' + ELSE 'succeeded' + END + FROM audit_events + WHERE audit_events.deployment_record_id = deployments.id + AND audit_events.event_type LIKE 'launch.%' + ORDER BY audit_events.occurred_at DESC, audit_events.id DESC + LIMIT 1 + ) AS lastLaunchStatus, + ( + SELECT audit_events.occurred_at + FROM audit_events + WHERE audit_events.deployment_record_id = deployments.id + AND audit_events.event_type = 'deployment.nrps_verified' + ORDER BY audit_events.occurred_at DESC, audit_events.id DESC + LIMIT 1 + ) AS lastNrpsReadAt, + ( + SELECT CASE + WHEN audit_events.status = 'failed' THEN 'failed' + WHEN audit_events.status = 'succeeded' THEN 'succeeded' + ELSE 'pending' + END + FROM audit_events + WHERE audit_events.deployment_record_id = deployments.id + AND audit_events.event_type = 'deployment.nrps_verified' + ORDER BY audit_events.occurred_at DESC, audit_events.id DESC + LIMIT 1 + ) AS lastNrpsReadStatus, + ( + SELECT grade_publications.updated_at + FROM attempts + INNER JOIN grade_publications + ON grade_publications.attempt_id = attempts.attempt_id + WHERE attempts.deployment_record_id = deployments.id + ORDER BY grade_publications.updated_at DESC, grade_publications.id DESC + LIMIT 1 + ) AS lastGradePublishAt, + ( + SELECT grade_publications.status + FROM attempts + INNER JOIN grade_publications + ON grade_publications.attempt_id = attempts.attempt_id + WHERE attempts.deployment_record_id = deployments.id + ORDER BY grade_publications.updated_at DESC, grade_publications.id DESC + LIMIT 1 + ) AS lastGradePublishStatus, + ( + SELECT COUNT(*) + FROM audit_events + WHERE audit_events.deployment_record_id = deployments.id + AND audit_events.event_type = 'launch.accepted' + ) AS totalLaunches, + ( + SELECT COUNT(*) + FROM attempts + WHERE attempts.deployment_record_id = deployments.id + ) AS attemptsStarted, + ( + SELECT COUNT(*) + FROM attempts + WHERE attempts.deployment_record_id = deployments.id + AND attempts.completion_state = 'completed' + ) AS attemptsCompleted, + ( + SELECT COUNT(*) + FROM attempts + INNER JOIN grade_publications + ON grade_publications.attempt_id = attempts.attempt_id + WHERE attempts.deployment_record_id = deployments.id + AND grade_publications.status = 'published' + ) AS gradePublishesSucceeded, + ( + SELECT COUNT(*) + FROM attempts + INNER JOIN grade_publications + ON grade_publications.attempt_id = attempts.attempt_id + WHERE attempts.deployment_record_id = deployments.id + AND grade_publications.status = 'failed' + ) AS gradePublishesFailed, + ( + SELECT COUNT(DISTINCT attempts.user_id) + FROM attempts + WHERE attempts.deployment_record_id = deployments.id + ) AS recentActiveUsers, + COALESCE( + ( + SELECT MAX(audit_events.occurred_at) + FROM audit_events + WHERE audit_events.deployment_record_id = deployments.id + AND audit_events.event_type = 'launch.accepted' + ), + ( + SELECT audit_events.occurred_at + FROM audit_events + WHERE audit_events.deployment_record_id = deployments.id + AND audit_events.event_type LIKE 'launch.%' + ORDER BY audit_events.occurred_at DESC, audit_events.id DESC + LIMIT 1 + ) + ) AS usageLastLaunchAt, + CURRENT_TIMESTAMP AS measuredAt + FROM deployments + LEFT JOIN package_versions AS enabled_package + ON enabled_package.id = deployments.enabled_package_version_id + WHERE deployments.lms_type <> 'preview' +`; + +const D1_ACTIVITY_SELECT = ` + SELECT + event_type AS eventType, + status, + summary, + attempt_id AS attemptId, + detail, + occurred_at AS occurredAt + FROM audit_events +`; + +export const D1_LATEST_LAUNCH_QUERY = ` + ${D1_ACTIVITY_SELECT} + WHERE deployment_record_id = ? + AND event_type LIKE 'launch.%' + ORDER BY occurred_at DESC, id DESC + LIMIT 1 +`; + +export const D1_LATEST_RUNTIME_SESSION_QUERY = ` + ${D1_ACTIVITY_SELECT} + WHERE deployment_record_id = ? + AND event_type = 'runtime.session.started' + ORDER BY occurred_at DESC, id DESC + LIMIT 1 +`; + +export const D1_LATEST_RUNTIME_OUTCOME_QUERY = ` + ${D1_ACTIVITY_SELECT} + WHERE deployment_record_id = ? + AND event_type LIKE 'runtime.%' + AND event_type NOT IN ('runtime.session.started', 'runtime.capability.allowed') + ORDER BY occurred_at DESC, id DESC + LIMIT 1 +`; + +export const D1_LATEST_COMPATIBILITY_PATH_QUERY = ` + ${D1_ACTIVITY_SELECT} + WHERE deployment_record_id = ? + AND event_type = 'interop.path_used' + AND COALESCE(json_extract(detail, '$.scope'), '') IN ( + 'login', + 'launch', + 'deep_linking', + 'service' + ) + ORDER BY occurred_at DESC, id DESC + LIMIT 1 +`; + +export const D1_LATEST_NRPS_QUERY = ` + ${D1_ACTIVITY_SELECT} + WHERE deployment_record_id = ? + AND event_type = 'deployment.nrps_verified' + ORDER BY occurred_at DESC, id DESC + LIMIT 1 +`; + +export const D1_LATEST_AGS_SMOKE_QUERY = ` + ${D1_ACTIVITY_SELECT} + WHERE deployment_record_id = ? + AND event_type = 'deployment.ags_smoke_verified' + ORDER BY occurred_at DESC, id DESC + LIMIT 1 +`; diff --git a/src/ops/repository_retry_mapping.ts b/src/ops/repository_retry_mapping.ts index 3d236bd..4d8c60d 100644 --- a/src/ops/repository_retry_mapping.ts +++ b/src/ops/repository_retry_mapping.ts @@ -53,6 +53,7 @@ function mapRetryRuntimeSession(row: RetryLookupRow): RetryRuntimeSessionLookup return { sessionId: row.sessionId, + ltiToolSessionId: row.runtimeLtiToolSessionId, attemptId: row.runtimeAttemptId ?? row.attemptId, deploymentRecordId: row.runtimeDeploymentRecordId, deploymentSlug: row.runtimeDeploymentSlug, diff --git a/src/ops/repository_types.ts b/src/ops/repository_types.ts index a058a04..f5893e6 100644 --- a/src/ops/repository_types.ts +++ b/src/ops/repository_types.ts @@ -195,6 +195,7 @@ export interface RetryLookupRow { bindingClientId: string | null; bindingDeploymentId: string | null; sessionId: string | null; + runtimeLtiToolSessionId: string | null; runtimeAttemptId: string | null; runtimeDeploymentRecordId: number | null; runtimeDeploymentSlug: string | null; @@ -217,7 +218,6 @@ export interface OpsRepository { ): Promise; listCertificationWorkflowStatuses(): Promise; getLatestOfficialCertificationEvidence(): Promise; - getLatestBrokerVerification(): Promise; getLatestBrokerVerificationStatus(): Promise; recordBrokerVerificationRun(input: RecordBrokerVerificationRunInput): Promise; getRetryableGradePublicationLookup( diff --git a/src/ops/retry.ts b/src/ops/retry.ts index 9ea765c..3ce0893 100644 --- a/src/ops/retry.ts +++ b/src/ops/retry.ts @@ -1,34 +1,13 @@ -import { getLtiProfileDefinition } from '../lti/profile.ts'; -import { resolveLtiProfileForDeployment } from '../lti/profile_resolution.ts'; -import type { PublishFinalScoreInput, PublishFinalScoreResult } from '../lti/services.ts'; -import { requestCanvasServiceAccessToken } from '../lti/services.ts'; -import { recordInteropPathUsed } from '../interop_audit.ts'; -import type { EnvReader } from '../platform/env.ts'; +import { requireLtiToolSession } from '../lti/lti_tool_session.ts'; +import type { LanternLtiToolContext } from '../lti/tool_provider.ts'; +import { withAgsLineItemTarget } from '../lti/ags_session.ts'; import type { PackageReviewRepository } from '../package_review/repository.ts'; -import { publishGovernedGradePublication } from '../runtime/gateway.ts'; import type { RetryableGradePublicationLookup } from './types.ts'; -export interface RetryScorePublisher { - (input: PublishFinalScoreInput): Promise; -} - -export interface RetryAccessTokenRequester { - (input: { - issuer: string; - clientId: string; - deploymentId?: string; - scopes: string[]; - env: EnvReader; - }): Promise<{ accessToken: string }>; -} - export interface RetryLookupRepository extends Pick< PackageReviewRepository, - | 'getDeploymentByBinding' - | 'getLanternLtiProfileSettings' - | 'recordAuditEvent' - | 'updateGradePublication' + 'updateGradePublication' > { getRetryableGradePublicationLookup( attemptId: string, @@ -38,10 +17,8 @@ export interface RetryLookupRepository extends export async function retryFailedGradePublication(input: { repository: RetryLookupRepository; attemptId: string; - env: EnvReader; + getLtiToolContext: () => Promise; now?: () => Date; - requestAccessToken?: RetryAccessTokenRequester; - publishScore?: RetryScorePublisher; }): Promise { const lookup = await input.repository.getRetryableGradePublicationLookup(input.attemptId); @@ -67,81 +44,67 @@ export async function retryFailedGradePublication(input: { ); } - if (lookup.binding === null) { - throw new Error( - 'Retry blocked: Lantern no longer has the saved Canvas binding for this grade publication.', - ); - } - const deployment = await input.repository.getDeploymentByBinding(lookup.binding); + let ltiContext: LanternLtiToolContext | undefined; + const ltiSession = await requireLtiToolSession({ + getStorage: async () => { + ltiContext = await input.getLtiToolContext(); + return ltiContext.storage; + }, + ltiToolSessionId: runtimeSession.ltiToolSessionId, + missingMessage: + 'Retry blocked: the saved runtime session does not include a verified LTI session.', + notFoundMessage: + 'Retry blocked: the verified LTI session for this grade publication was not found.', + }); - if (deployment === null || deployment.id !== lookup.deploymentRecordId) { - throw new Error( - 'Retry blocked: Lantern could not resolve the saved deployment profile for this grade publication.', - ); + if (ltiContext === undefined) { + throw new Error('Retry blocked: the lti-tool context was not loaded.'); } - const ltiProfile = await resolveLtiProfileForDeployment({ - repository: input.repository, - deployment, - }); - - const requestAccessToken = input.requestAccessToken ?? requestCanvasServiceAccessToken; const now = input.now ?? (() => new Date()); - const token = await requestAccessToken({ - issuer: lookup.binding.issuer, - clientId: lookup.binding.clientId, - deploymentId: lookup.binding.deploymentId, - scopes: ags.scope, - env: input.env, - }); - const retryUnauthorized = getLtiProfileDefinition(ltiProfile.id).behavior - .retryServiceUnauthorizedOnce - ? async () => { - await recordInteropPathUsed({ - repository: input.repository, - scope: 'service', - path: 'service_401_retry', - actorType: 'system', - deploymentRecordId: runtimeSession.deploymentRecordId, - packageVersionId: runtimeSession.packageVersionId, - attemptId: lookup.attemptId, - summary: 'Lantern retried an LMS service request after a 401.', - detail: { - lms: lookup.binding!.lms, - deploymentSlug: runtimeSession.deploymentSlug, - }, - ltiProfile, - }); - const refreshed = await requestAccessToken({ - issuer: lookup.binding!.issuer, - clientId: lookup.binding!.clientId, - deploymentId: lookup.binding!.deploymentId, - scopes: ags.scope, - env: input.env, - }); - - return refreshed.accessToken; - } - : undefined; - const published = await publishGovernedGradePublication({ - repository: input.repository, - attemptId: lookup.attemptId, - publication: lookup.publication, - accessToken: token.accessToken, - now, - ...(retryUnauthorized === undefined ? {} : { retryUnauthorized }), - ...(input.publishScore === undefined ? {} : { publishScore: input.publishScore }), + const timestamp = now().toISOString(); + const result = await ltiContext.tool.createAdvantage( + withAgsLineItemTarget(ltiSession, lookup.publication.lineItemUrl), + ).submitScore({ + userId: lookup.publication.platformUserId, + scoreGiven: lookup.publication.scoreGiven, + scoreMaximum: lookup.publication.scoreMaximum, + activityProgress: lookup.publication.activityProgress, + gradingProgress: 'FullyGraded', + timestamp, }); + const gradePublication = result.success + ? await input.repository.updateGradePublication({ + attemptId: lookup.attemptId, + status: 'published', + updatedAt: timestamp, + publishedAt: timestamp, + errorCode: null, + errorDetail: null, + }) + : await input.repository.updateGradePublication({ + attemptId: lookup.attemptId, + status: 'failed', + updatedAt: timestamp, + publishedAt: null, + errorCode: 'score_publish_failed', + errorDetail: { + message: result.error.message, + code: result.error.code, + serviceKind: result.error.serviceKind, + operation: result.error.operation, + }, + }); return { ...lookup, publication: { ...lookup.publication, - status: published.gradePublication.status, - publishedAt: published.gradePublication.publishedAt, - updatedAt: published.gradePublication.updatedAt, - errorCode: published.gradePublication.errorCode, - errorDetail: published.gradePublication.errorDetail, + status: gradePublication.status, + publishedAt: gradePublication.publishedAt, + updatedAt: gradePublication.updatedAt, + errorCode: gradePublication.errorCode, + errorDetail: gradePublication.errorDetail, }, }; } diff --git a/src/ops/service.ts b/src/ops/service.ts index 6fe63e9..ad9266f 100644 --- a/src/ops/service.ts +++ b/src/ops/service.ts @@ -1,9 +1,4 @@ export { formatDiagnosticItem } from './diagnostics.ts'; export { deriveDeploymentHealth, summarizePilotUsage } from './health.ts'; export type { DeploymentHealthInput } from './health.ts'; -export { - type RetryAccessTokenRequester, - retryFailedGradePublication, - type RetryLookupRepository, - type RetryScorePublisher, -} from './retry.ts'; +export { retryFailedGradePublication, type RetryLookupRepository } from './retry.ts'; diff --git a/src/ops/service_test.ts b/src/ops/service_test.ts index 64b126a..5b135bd 100644 --- a/src/ops/service_test.ts +++ b/src/ops/service_test.ts @@ -1,5 +1,4 @@ import { assertEquals, assertRejects } from '@std/assert'; -import { restoreEnv, withFetchStub } from '../test_helpers/fetch_stub.ts'; import { buildAttemptRecord, buildControlPlaneDiagnosticItem, @@ -13,19 +12,15 @@ import { buildDeploymentBinding, buildLaunchServiceClaims, buildRuntimeSessionRecord, - getTestToolPrivateJwkEnvValue, } from '../test_helpers/lti.ts'; -import { formatDiagnosticItem } from './service.ts'; +import { createTestLtiToolContext } from '../test_helpers/lti_tool_context.ts'; +import { formatDiagnosticItem, retryFailedGradePublication } from './service.ts'; +import { publishRuntimeAttemptScore } from '../runtime/gateway_publication.ts'; -const TEST_ENV = { - get(name: string): string | undefined { - return name === 'LTI_TOOL_PRIVATE_JWK' ? getTestToolPrivateJwkEnvValue() : undefined; - }, -}; - -Deno.test('ops service retries failed grade publication against the attempt-scoped runtime session and updates the existing ledger row', async () => { - const modulePath = `./${'service.ts'}`; - const opsServiceModule = await import(modulePath); +Deno.test('ops service retries failed grade publication through the verified lti-tool session', async () => { + const ltiToolContext = createTestLtiToolContext({ + sessionId: 'lti-tool-session-123', + }); const repository = createInMemoryPackageReviewRepository({ deployments: [ buildDeploymentRecord({ @@ -41,12 +36,6 @@ Deno.test('ops service retries failed grade publication against the attempt-scop completionState: 'completed', finalizedAt: '2026-03-24T12:31:00Z', }), - buildAttemptRecord({ - id: 2, - attemptId: 'attempt-999', - userId: 'canvas-user-999', - startedAt: '2026-03-24T12:40:00Z', - }), ], gradePublications: [ buildGradePublicationRecord({ @@ -55,103 +44,36 @@ Deno.test('ops service retries failed grade publication against the attempt-scop gradingProgress: 'Failed', publishedAt: null, updatedAt: '2026-03-24T12:35:00Z', - errorCode: 'canvas_score_rejected', - errorDetail: { - httpStatus: 422, - }, }), ], runtimeSessions: [ buildRuntimeSessionRecord({ + ltiToolSessionId: 'lti-tool-session-123', sessionId: 'runtime-session-123', attemptId: 'attempt-123', createdAt: '2026-03-24T12:30:00Z', expiresAt: '2026-03-25T12:30:00Z', }), - buildRuntimeSessionRecord({ - sessionId: 'runtime-session-999', - sessionToken: 'runtime-token-999', - attemptId: 'attempt-999', - createdAt: '2026-03-24T12:40:00Z', - expiresAt: '2026-03-25T12:40:00Z', - services: buildLaunchServiceClaims({ - ags: { - scope: [ - 'https://purl.imsglobal.org/spec/lti-ags/scope/score', - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', - ], - lineitemsUrl: 'https://canvas.example/api/lti/courses/42/line_items', - lineitemUrl: 'https://canvas.example/api/lti/courses/42/line_items/99', - }, - }), - }), ], }); - const publishCalls: Array<{ - accessToken: string; - lineItemUrl: string; - platformUserId: string; - }> = []; - const tokenRequests: Array<{ deploymentId?: string }> = []; - const result = await opsServiceModule.retryFailedGradePublication({ + const result = await retryFailedGradePublication({ repository, attemptId: 'attempt-123', - env: TEST_ENV, + getLtiToolContext: () => Promise.resolve(ltiToolContext.context), now: () => new Date('2026-03-24T12:45:00Z'), - requestAccessToken: (request: { deploymentId?: string }) => { - tokenRequests.push(request); - - return Promise.resolve({ - accessToken: `canvas-access-token-${tokenRequests.length}`, - }); - }, - publishScore: (input: { - accessToken: string; - lineItemUrl: string; - platformUserId: string; - retryUnauthorized?: () => Promise; - }) => { - publishCalls.push(input); - if (publishCalls.length === 1 && input.retryUnauthorized !== undefined) { - return input.retryUnauthorized().then(() => ({ - accepted: true, - status: 200, - })); - } - return Promise.resolve({ accepted: true, status: 200 }); - }, }); const savedPublication = await repository.getGradePublicationByAttemptId('attempt-123'); - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - assertEquals(publishCalls.length, 1); - assertEquals(publishCalls[0]?.accessToken, 'canvas-access-token-1'); - assertEquals( - publishCalls[0]?.lineItemUrl, - 'https://canvas.example/api/lti/courses/42/line_items/9', - ); - assertEquals(publishCalls[0]?.platformUserId, 'canvas-user-123'); - assertEquals(tokenRequests[0]?.deploymentId, 'deployment-123'); - assertEquals(tokenRequests[1]?.deploymentId, 'deployment-123'); + assertEquals(ltiToolContext.submittedScores.length, 1); assertEquals(result.attemptId, 'attempt-123'); assertEquals(result.publication.status, 'published'); assertEquals(savedPublication?.id, 1); assertEquals(savedPublication?.status, 'published'); assertEquals(savedPublication?.publishedAt, '2026-03-24T12:45:00.000Z'); - assertEquals( - interopEvents.some((event) => event.detail.path === 'service_401_retry'), - true, - ); - assertEquals( - interopEvents.some((event) => event.detail.ltiProfileId === 'governedCompatibility'), - true, - ); }); Deno.test('ops service blocks retry when no failed publication or AGS context remains for the attempt', async () => { - const modulePath = `./${'service.ts'}`; - const opsServiceModule = await import(modulePath); const missingPublicationRepository = createInMemoryPackageReviewRepository({ gradePublications: [ buildGradePublicationRecord({ @@ -162,6 +84,7 @@ Deno.test('ops service blocks retry when no failed publication or AGS context re ], runtimeSessions: [ buildRuntimeSessionRecord({ + ltiToolSessionId: 'lti-tool-session-123', attemptId: 'attempt-123', expiresAt: '2026-03-25T12:30:00Z', }), @@ -189,6 +112,7 @@ Deno.test('ops service blocks retry when no failed publication or AGS context re ], runtimeSessions: [ buildRuntimeSessionRecord({ + ltiToolSessionId: 'lti-tool-session-123', attemptId: 'attempt-456', services: buildLaunchServiceClaims({ ags: null, @@ -196,18 +120,14 @@ Deno.test('ops service blocks retry when no failed publication or AGS context re }), ], }); + const ltiToolContext = createTestLtiToolContext(); await assertRejects( () => - opsServiceModule.retryFailedGradePublication({ + retryFailedGradePublication({ repository: missingPublicationRepository, attemptId: 'attempt-123', - env: TEST_ENV, - requestAccessToken: () => - Promise.resolve({ - accessToken: 'unused', - }), - publishScore: () => Promise.resolve({ accepted: true, status: 200 }), + getLtiToolContext: () => Promise.resolve(ltiToolContext.context), }), Error, 'could not find a failed grade publication', @@ -215,15 +135,10 @@ Deno.test('ops service blocks retry when no failed publication or AGS context re await assertRejects( () => - opsServiceModule.retryFailedGradePublication({ + retryFailedGradePublication({ repository: missingAgsRepository, attemptId: 'attempt-456', - env: TEST_ENV, - requestAccessToken: () => - Promise.resolve({ - accessToken: 'unused', - }), - publishScore: () => Promise.resolve({ accepted: true, status: 200 }), + getLtiToolContext: () => Promise.resolve(ltiToolContext.context), }), Error, 'does not include AGS service context', @@ -285,46 +200,10 @@ Deno.test('ops service formats governed runtime failures with runtime-specific o ); }); -Deno.test('runtime gateway exposes the shared governed grade-publication helper used by finalize and retry', async () => { - const modulePath = `../runtime/${'gateway.ts'}`; - const gatewayModule = await import(modulePath); - const repository = createInMemoryPackageReviewRepository({ - gradePublications: [ - buildGradePublicationRecord({ - id: 1, - attemptId: 'attempt-123', - status: 'failed', - publishedAt: null, - updatedAt: '2026-03-24T12:35:00Z', - }), - ], - }); - const result = await gatewayModule.publishGovernedGradePublication({ - repository, - attemptId: 'attempt-123', - publication: buildGradePublicationRecord({ - id: 1, - attemptId: 'attempt-123', - status: 'failed', - publishedAt: null, - updatedAt: '2026-03-24T12:35:00Z', - }), - accessToken: 'canvas-access-token', - now: () => new Date('2026-03-24T12:45:00Z'), - publishScore: () => Promise.resolve({ accepted: true, status: 200 }), +Deno.test('runtime publication uses lti-tool to publish final scores', async () => { + const ltiToolContext = createTestLtiToolContext({ + sessionId: 'lti-tool-session-123', }); - const savedPublication = await repository.getGradePublicationByAttemptId('attempt-123'); - - assertEquals(result.gradePublishedNow, true); - assertEquals(savedPublication?.id, 1); - assertEquals(savedPublication?.status, 'published'); - assertEquals(savedPublication?.publishedAt, '2026-03-24T12:45:00.000Z'); -}); - -Deno.test('runtime publication retries one Canvas 401 when governed compatibility allows the saved service retry path', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const modulePath = `../runtime/${'gateway_publication.ts'}`; - const gatewayPublicationModule = await import(modulePath); const deployment = buildDeploymentRecord({ id: 1, enabledPackageVersionId: 1, @@ -342,16 +221,12 @@ Deno.test('runtime publication retries one Canvas 401 when governed compatibilit finalizedAt: '2026-03-24T12:31:00Z', }); const session = buildRuntimeSessionRecord({ + ltiToolSessionId: 'lti-tool-session-123', attemptId: attempt.attemptId, deploymentRecordId: deployment.id, deploymentSlug: deployment.slug, packageVersionId: 1, packageVersion: '0.1.0', - services: buildLaunchServiceClaims({ - ags: { - lineitemUrl: 'https://canvas.example/api/lti/courses/42/line_items/9', - }, - }), }); const packageVersion = buildPackageVersionRecord({ id: 1, @@ -372,91 +247,33 @@ Deno.test('runtime publication retries one Canvas 401 when governed compatibilit activityId: attempt.activityId, }), ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'governedCompatibility', - updatedAt: '2026-03-24T12:25:00Z', - }, }); - const scoreAuthorizations: string[] = []; - let tokenRequests = 0; - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - const result = await withFetchStub( - (input, init) => { - const url = String(input); - const method = init?.method ?? 'GET'; - const headers = new Headers(init?.headers); - - if (url === 'https://sso.canvaslms.com/login/oauth2/token') { - tokenRequests += 1; - - return new Response( - JSON.stringify({ - access_token: `canvas-access-token-${tokenRequests}`, - token_type: 'bearer', - }), - { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }, - ); - } - - if ( - url === 'https://canvas.example/api/lti/courses/42/line_items/9/scores' && - method === 'POST' - ) { - scoreAuthorizations.push(headers.get('authorization') ?? ''); - - if (headers.get('authorization') === 'Bearer canvas-access-token-1') { - return new Response(null, { status: 401 }); - } - - return new Response(null, { status: 202 }); - } - - throw new Error(`Unexpected runtime request ${method} ${url}`); - }, - async () => - await gatewayPublicationModule.publishRuntimeAttemptScore({ - repository, - session, - attempt, - packageVersion, - score: { - scoreGiven: 85, - scoreMaximum: 100, - }, - env: TEST_ENV, - now: () => new Date('2026-03-24T12:45:00Z'), - }), - ); - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); + const result = await publishRuntimeAttemptScore({ + repository, + session, + attempt, + packageVersion, + score: { + scoreGiven: 85, + scoreMaximum: 100, + }, + getLtiToolContext: () => Promise.resolve(ltiToolContext.context), + now: () => new Date('2026-03-24T12:45:00Z'), + }); - assertEquals(result.gradePublishedNow, true); - assertEquals(result.publishError, null); - assertEquals(result.gradePublication?.status, 'published'); - assertEquals(tokenRequests, 2); - assertEquals(scoreAuthorizations, [ - 'Bearer canvas-access-token-1', - 'Bearer canvas-access-token-2', - ]); - assertEquals(interopEvents.length, 1); - assertEquals(interopEvents[0]?.detail.path, 'service_401_retry'); - assertEquals(interopEvents[0]?.detail.ltiProfileId, 'governedCompatibility'); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } + assertEquals(result.gradePublishedNow, true); + assertEquals(result.publishError, null); + assertEquals(result.gradePublication?.status, 'published'); + assertEquals(ltiToolContext.submittedScores.length, 1); }); -Deno.test('runtime publication fails on Canvas 401 without retry when certification disables the saved service retry path', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const modulePath = `../runtime/${'gateway_publication.ts'}`; - const gatewayPublicationModule = await import(modulePath); +Deno.test('runtime publication records lti-tool score publish failures', async () => { + const ltiToolContext = createTestLtiToolContext({ + sessionId: 'lti-tool-session-123', + submitScoreSuccess: false, + submitScoreMessage: 'score publish failed', + }); const deployment = buildDeploymentRecord({ id: 1, enabledPackageVersionId: 1, @@ -474,16 +291,12 @@ Deno.test('runtime publication fails on Canvas 401 without retry when certificat finalizedAt: '2026-03-24T12:31:00Z', }); const session = buildRuntimeSessionRecord({ + ltiToolSessionId: 'lti-tool-session-123', attemptId: attempt.attemptId, deploymentRecordId: deployment.id, deploymentSlug: deployment.slug, packageVersionId: 1, packageVersion: '0.1.0', - services: buildLaunchServiceClaims({ - ags: { - lineitemUrl: 'https://canvas.example/api/lti/courses/42/line_items/9', - }, - }), }); const packageVersion = buildPackageVersionRecord({ id: 1, @@ -504,169 +317,23 @@ Deno.test('runtime publication fails on Canvas 401 without retry when certificat activityId: attempt.activityId, }), ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-24T12:25:00Z', - }, }); - const scoreAuthorizations: string[] = []; - let tokenRequests = 0; - - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - const result = await withFetchStub( - (input, init) => { - const url = String(input); - const method = init?.method ?? 'GET'; - const headers = new Headers(init?.headers); - - if (url === 'https://sso.canvaslms.com/login/oauth2/token') { - tokenRequests += 1; - - return new Response( - JSON.stringify({ - access_token: 'canvas-access-token-1', - token_type: 'bearer', - }), - { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }, - ); - } - if ( - url === 'https://canvas.example/api/lti/courses/42/line_items/9/scores' && - method === 'POST' - ) { - scoreAuthorizations.push(headers.get('authorization') ?? ''); - return new Response(null, { status: 401 }); - } - - throw new Error(`Unexpected runtime request ${method} ${url}`); - }, - async () => - await gatewayPublicationModule.publishRuntimeAttemptScore({ - repository, - session, - attempt, - packageVersion, - score: { - scoreGiven: 85, - scoreMaximum: 100, - }, - env: TEST_ENV, - now: () => new Date('2026-03-24T12:45:00Z'), - }), - ); - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - - assertEquals(result.gradePublishedNow, false); - assertEquals(result.publishError?.code, 'score_publish_failed'); - assertEquals(result.publishError?.message, 'Canvas score publish failed with status 401.'); - assertEquals(result.gradePublication?.status, 'failed'); - assertEquals(tokenRequests, 1); - assertEquals(scoreAuthorizations, ['Bearer canvas-access-token-1']); - assertEquals(interopEvents.length, 0); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } -}); - -Deno.test('ops service fails a manual retry Canvas 401 without retry when certification disables the saved service retry path', async () => { - const modulePath = `./${'service.ts'}`; - const opsServiceModule = await import(modulePath); - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - id: 1, - binding: buildDeploymentBinding(), - }), - ], - attempts: [ - buildAttemptRecord({ - id: 1, - attemptId: 'attempt-123', - status: 'completed', - completionState: 'completed', - finalizedAt: '2026-03-24T12:31:00Z', - }), - ], - gradePublications: [ - buildGradePublicationRecord({ - attemptId: 'attempt-123', - status: 'failed', - gradingProgress: 'Failed', - publishedAt: null, - updatedAt: '2026-03-24T12:35:00Z', - errorCode: 'canvas_score_rejected', - errorDetail: { - httpStatus: 422, - }, - }), - ], - runtimeSessions: [ - buildRuntimeSessionRecord({ - sessionId: 'runtime-session-123', - attemptId: 'attempt-123', - createdAt: '2026-03-24T12:30:00Z', - expiresAt: '2026-03-25T12:30:00Z', - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-24T12:25:00Z', - }, - }); - const publishCalls: Array<{ - accessToken: string; - lineItemUrl: string; - platformUserId: string; - retryUnauthorized?: () => Promise; - }> = []; - const tokenRequests: Array<{ deploymentId?: string }> = []; - - const result = await opsServiceModule.retryFailedGradePublication({ + const result = await publishRuntimeAttemptScore({ repository, - attemptId: 'attempt-123', - env: TEST_ENV, - now: () => new Date('2026-03-24T12:45:00Z'), - requestAccessToken: (request: { deploymentId?: string }) => { - tokenRequests.push(request); - - return Promise.resolve({ - accessToken: `canvas-access-token-${tokenRequests.length}`, - }); - }, - publishScore: (input: { - accessToken: string; - lineItemUrl: string; - platformUserId: string; - retryUnauthorized?: () => Promise; - }) => { - publishCalls.push(input); - if (input.retryUnauthorized !== undefined) { - return input.retryUnauthorized().then(() => ({ - accepted: true, - status: 200, - })); - } - - return Promise.reject(new Error('Canvas score publish failed with status 401.')); + session, + attempt, + packageVersion, + score: { + scoreGiven: 85, + scoreMaximum: 100, }, + getLtiToolContext: () => Promise.resolve(ltiToolContext.context), + now: () => new Date('2026-03-24T12:45:00Z'), }); - const savedPublication = await repository.getGradePublicationByAttemptId('attempt-123'); - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - assertEquals(publishCalls.length, 1); - assertEquals(publishCalls[0]?.retryUnauthorized === undefined, true); - assertEquals(tokenRequests.length, 1); - assertEquals(result.publication.status, 'failed'); - assertEquals(savedPublication?.status, 'failed'); - assertEquals(savedPublication?.publishedAt, null); - assertEquals(savedPublication?.errorCode, 'score_publish_failed'); - assertEquals(interopEvents.length, 0); + assertEquals(result.gradePublishedNow, false); + assertEquals(result.publishError?.code, 'score_publish_failed'); + assertEquals(result.publishError?.message, 'score publish failed'); + assertEquals(result.gradePublication?.status, 'failed'); }); diff --git a/src/ops/types.ts b/src/ops/types.ts index 924dea5..86c8b9d 100644 --- a/src/ops/types.ts +++ b/src/ops/types.ts @@ -188,6 +188,7 @@ export interface ControlPlaneAnonymousEvidenceArtifact { export interface RetryRuntimeSessionLookup { sessionId: string; + ltiToolSessionId: string | null; attemptId: string; deploymentRecordId: number; deploymentSlug: string; diff --git a/src/package_review/repository.ts b/src/package_review/repository.ts index 99b68c1..c4d371f 100644 --- a/src/package_review/repository.ts +++ b/src/package_review/repository.ts @@ -21,7 +21,6 @@ import type { DeepLinkingSessionRecord, DeploymentBinding, DynamicRegistrationStateRecord, - LoginStateRecord, LtiPlacement, PersistedDeploymentLmsType, RuntimeSessionRecord, @@ -63,9 +62,6 @@ export interface PackageReviewRepository { clientId: string; deploymentId: string; }): Promise; - createLoginState(record: LoginStateRecord): Promise; - getLoginStateByState(state: string): Promise; - consumeLoginState(input: { state: string; usedAt: string }): Promise; createDynamicRegistrationState( record: DynamicRegistrationStateRecord, ): Promise; @@ -217,7 +213,7 @@ export interface PackageReviewRepository { label: string; appId: string; packageVersionId: number; - lmsType?: PersistedDeploymentLmsType; + lmsType: PersistedDeploymentLmsType; }): Promise; getLanternLtiProfileSettings(): Promise; saveLanternDefaultLtiProfile(input: { diff --git a/src/package_review/repository_authoring_d1.ts b/src/package_review/repository_authoring_d1.ts index c43a4f0..4120261 100644 --- a/src/package_review/repository_authoring_d1.ts +++ b/src/package_review/repository_authoring_d1.ts @@ -1,5 +1,13 @@ import type { D1Database } from '../db/d1.ts'; import { queryD1First, queryD1Objects, runD1 } from '../db/d1.ts'; +import { + expectD1JsonRecord, + expectD1NullableString, + expectD1Number, + expectD1String, + expectD1StringLiteral, + parseD1JsonField, +} from '../db/d1_row.ts'; import { normalizeAuthoringDraftPath, readBrowserAutograderContract, @@ -268,17 +276,17 @@ async function requireAuthoringPackageVersion( } const packageVersion = { - id: expectNumber(row.id, 'id'), - appId: expectString(row.appId, 'appId'), - version: expectString(row.version, 'version'), - title: expectString(row.title, 'title'), - approvalStatus: expectStringLiteral(row.approvalStatus, 'approvalStatus', [ + id: expectD1Number(row.id, 'id'), + appId: expectD1String(row.appId, 'appId'), + version: expectD1String(row.version, 'version'), + title: expectD1String(row.title, 'title'), + approvalStatus: expectD1StringLiteral(row.approvalStatus, 'approvalStatus', [ 'pending', 'approved', 'rejected', ]), - manifestJson: parseJsonField(row.manifestJson, 'manifestJson') as Record, - artifactRoot: expectString(row.artifactRoot, 'artifactRoot'), + manifestJson: parseD1JsonField(row.manifestJson, 'manifestJson', expectD1JsonRecord), + artifactRoot: expectD1String(row.artifactRoot, 'artifactRoot'), }; if (packageVersion.approvalStatus !== 'approved') { @@ -375,29 +383,35 @@ async function hydrateAuthoringDraft( function mapD1AuthoringDraftFields(row: D1AuthoringDraftRow): AuthoringDraftRow { return { - draftId: expectString(row.draftId, 'draftId'), - packageVersionId: expectNumber(row.packageVersionId, 'packageVersionId'), - appId: expectString(row.appId, 'appId'), - packageVersion: expectString(row.packageVersion, 'packageVersion'), - packageTitle: expectString(row.packageTitle, 'packageTitle'), - authoringKind: expectStringLiteral(row.authoringKind, 'authoringKind', ['browser_autograder']), - authoringPaths: parseJsonField(row.authoringPaths, 'authoringPaths'), - baseSnapshotRoot: expectString(row.baseSnapshotRoot, 'baseSnapshotRoot'), - latestPromptText: expectNullableString(row.latestPromptText, 'latestPromptText'), - latestGenerationNotes: parseJsonField(row.latestGenerationNotes, 'latestGenerationNotes'), - savedSource: expectStringLiteral(row.savedSource, 'savedSource', ['manual', 'ai']), - lastPreviewedAt: expectNullableString(row.lastPreviewedAt, 'lastPreviewedAt'), - createdAt: expectString(row.createdAt, 'createdAt'), - updatedAt: expectString(row.updatedAt, 'updatedAt'), + draftId: expectD1String(row.draftId, 'draftId'), + packageVersionId: expectD1Number(row.packageVersionId, 'packageVersionId'), + appId: expectD1String(row.appId, 'appId'), + packageVersion: expectD1String(row.packageVersion, 'packageVersion'), + packageTitle: expectD1String(row.packageTitle, 'packageTitle'), + authoringKind: expectD1StringLiteral(row.authoringKind, 'authoringKind', [ + 'browser_autograder', + ]), + authoringPaths: parseD1JsonField(row.authoringPaths, 'authoringPaths', readStringArray), + baseSnapshotRoot: expectD1String(row.baseSnapshotRoot, 'baseSnapshotRoot'), + latestPromptText: expectD1NullableString(row.latestPromptText, 'latestPromptText'), + latestGenerationNotes: parseD1JsonField( + row.latestGenerationNotes, + 'latestGenerationNotes', + readStringArray, + ), + savedSource: expectD1StringLiteral(row.savedSource, 'savedSource', ['manual', 'ai']), + lastPreviewedAt: expectD1NullableString(row.lastPreviewedAt, 'lastPreviewedAt'), + createdAt: expectD1String(row.createdAt, 'createdAt'), + updatedAt: expectD1String(row.updatedAt, 'updatedAt'), }; } function mapD1AuthoringDraftFileFields(row: D1AuthoringDraftFileRow): AuthoringDraftFileRow { return { - draftId: expectString(row.draftId, 'draftId'), - relativePath: expectString(row.relativePath, 'relativePath'), - contents: expectString(row.contents, 'contents'), - sequence: expectNumber(row.sequence, 'sequence'), + draftId: expectD1String(row.draftId, 'draftId'), + relativePath: expectD1String(row.relativePath, 'relativePath'), + contents: expectD1String(row.contents, 'contents'), + sequence: expectD1Number(row.sequence, 'sequence'), }; } @@ -418,47 +432,3 @@ function readStringArray(value: unknown, field: string): string[] { function isD1UniqueViolation(error: unknown): boolean { return error instanceof Error && error.message.includes('UNIQUE constraint failed'); } - -function parseJsonField(value: unknown, fieldName: string): unknown { - if (typeof value !== 'string') { - throw new TypeError(`Expected D1 ${fieldName} to be JSON text.`); - } - - return JSON.parse(value); -} - -function expectString(value: unknown, fieldName: string): string { - if (typeof value !== 'string') { - throw new TypeError(`Expected D1 ${fieldName} to be text.`); - } - - return value; -} - -function expectNullableString(value: unknown, fieldName: string): string | null { - if (value === null) { - return null; - } - - return expectString(value, fieldName); -} - -function expectNumber(value: unknown, fieldName: string): number { - if (typeof value !== 'number') { - throw new TypeError(`Expected D1 ${fieldName} to be numeric.`); - } - - return value; -} - -function expectStringLiteral( - value: unknown, - fieldName: string, - allowed: readonly T[], -): T { - if (typeof value !== 'string' || !allowed.includes(value as T)) { - throw new Error(`Unexpected D1 ${fieldName} value.`); - } - - return value as T; -} diff --git a/src/package_review/repository_deep_linking_d1.ts b/src/package_review/repository_deep_linking_d1.ts index bb6d80d..e83d21d 100644 --- a/src/package_review/repository_deep_linking_d1.ts +++ b/src/package_review/repository_deep_linking_d1.ts @@ -1,5 +1,13 @@ import type { D1Database } from '../db/d1.ts'; import { queryD1First, queryD1Objects, runD1 } from '../db/d1.ts'; +import { + type D1DeepLinkingSessionRow, + type D1PlacementAuditSnapshotRow, + type D1ReviewedPlacementRow, + mapD1DeepLinkingSessionFields, + mapD1PlacementAuditSnapshotFields, + mapD1ReviewedPlacementFields, +} from './repository_deep_linking_d1_mapping.ts'; import { mapOptionalPlacementAuditSnapshot } from './repository_mappers_review.ts'; import { mapDeepLinkingSessionRow, @@ -7,11 +15,6 @@ import { mapOptionalReviewedPlacement, mapReviewedPlacementRow, } from './repository_mappers_sessions.ts'; -import type { - DeepLinkingSessionRow, - PlacementAuditSnapshotRow, - ReviewedPlacementRow, -} from './repository_row_types.ts'; import { buildDeepLinkingResourceOptions, sortPackageVersions, @@ -61,6 +64,7 @@ export function createD1DeepLinkingRepositoryMethods( INSERT INTO deep_linking_sessions ( session_id, session_token, + lti_tool_session_id, deployment_record_id, deployment_slug, app_id, @@ -82,11 +86,12 @@ export function createD1DeepLinkingRepositoryMethods( created_at, expires_at, used_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ record.sessionId, record.sessionToken, + record.ltiToolSessionId, record.deploymentRecordId, record.deploymentSlug, record.appId, @@ -357,6 +362,7 @@ const D1_DEEP_LINKING_SESSION_SELECT = ` SELECT session_id AS sessionId, session_token AS sessionToken, + lti_tool_session_id AS ltiToolSessionId, deployment_record_id AS deploymentRecordId, deployment_slug AS deploymentSlug, app_id AS appId, @@ -476,61 +482,6 @@ const D1_PLACEMENT_AUDIT_SNAPSHOT_SELECT = ` FROM reviewed_placements `; -interface D1DeepLinkingSessionRow extends Record { - sessionId: unknown; - sessionToken: unknown; - deploymentRecordId: unknown; - deploymentSlug: unknown; - appId: unknown; - userId: unknown; - userRole: unknown; - contextId: unknown; - contextTitle: unknown; - deepLinkReturnUrl: unknown; - data: unknown; - placement: unknown; - acceptTypes: unknown; - acceptMultiple: unknown; - acceptPresentationDocumentTargets: unknown; - acceptLineItem: unknown; - selectedPackageVersionId: unknown; - selectedPackageVersion: unknown; - selectedActivityId: unknown; - selectedContentPath: unknown; - createdAt: unknown; - expiresAt: unknown; - usedAt: unknown; -} - -interface D1ReviewedPlacementRow extends Record { - placementId: unknown; - deploymentRecordId: unknown; - deploymentSlug: unknown; - appId: unknown; - contextId: unknown; - contextTitle: unknown; - packageVersionId: unknown; - packageVersion: unknown; - packageTitle: unknown; - activityId: unknown; - contentPath: unknown; - contentTitle: unknown; - createdByUserId: unknown; - resourceLinkId: unknown; - createdAt: unknown; - boundAt: unknown; -} - -interface D1PlacementAuditSnapshotRow extends D1ReviewedPlacementRow { - latestPreviewSessionId: unknown; - latestPreviewOccurredAt: unknown; - previewEvidenceCount: unknown; - deepLinkingRequestCount: unknown; - placementEventCount: unknown; - reviewerEventCount: unknown; - latestAuditOccurredAt: unknown; -} - async function requireDeepLinkingSession(db: D1Database, sessionId: string) { const row = await queryD1First( db, @@ -559,155 +510,6 @@ async function requireReviewedPlacement(db: D1Database, placementId: string) { return mapReviewedPlacementRow(mapD1ReviewedPlacementFields(row)); } -function mapD1DeepLinkingSessionFields(row: D1DeepLinkingSessionRow): DeepLinkingSessionRow { - return { - sessionId: expectString(row.sessionId, 'sessionId'), - sessionToken: expectString(row.sessionToken, 'sessionToken'), - deploymentRecordId: expectNumber(row.deploymentRecordId, 'deploymentRecordId'), - deploymentSlug: expectString(row.deploymentSlug, 'deploymentSlug'), - appId: expectString(row.appId, 'appId'), - userId: expectNullableString(row.userId, 'userId'), - userRole: expectStringLiteral(row.userRole, 'userRole', ['learner', 'instructor']), - contextId: expectNullableString(row.contextId, 'contextId'), - contextTitle: expectNullableString(row.contextTitle, 'contextTitle'), - deepLinkReturnUrl: expectString(row.deepLinkReturnUrl, 'deepLinkReturnUrl'), - data: expectNullableString(row.data, 'data'), - placement: expectStringLiteral(row.placement, 'placement', [ - 'assignment_selection', - 'resource_selection', - ]), - acceptTypes: parseJsonField( - row.acceptTypes, - 'acceptTypes', - ) as DeepLinkingSessionRow['acceptTypes'], - acceptMultiple: expectBooleanInteger(row.acceptMultiple, 'acceptMultiple'), - acceptPresentationDocumentTargets: parseJsonField( - row.acceptPresentationDocumentTargets, - 'acceptPresentationDocumentTargets', - ) as DeepLinkingSessionRow['acceptPresentationDocumentTargets'], - acceptLineItem: expectBooleanInteger(row.acceptLineItem, 'acceptLineItem'), - selectedPackageVersionId: expectNullableNumber( - row.selectedPackageVersionId, - 'selectedPackageVersionId', - ), - selectedPackageVersion: expectNullableString( - row.selectedPackageVersion, - 'selectedPackageVersion', - ), - selectedActivityId: expectNullableString(row.selectedActivityId, 'selectedActivityId'), - selectedContentPath: expectNullableString(row.selectedContentPath, 'selectedContentPath'), - createdAt: expectString(row.createdAt, 'createdAt'), - expiresAt: expectString(row.expiresAt, 'expiresAt'), - usedAt: expectNullableString(row.usedAt, 'usedAt'), - }; -} - -function mapD1ReviewedPlacementFields(row: D1ReviewedPlacementRow): ReviewedPlacementRow { - return { - placementId: expectString(row.placementId, 'placementId'), - deploymentRecordId: expectNumber(row.deploymentRecordId, 'deploymentRecordId'), - deploymentSlug: expectString(row.deploymentSlug, 'deploymentSlug'), - appId: expectString(row.appId, 'appId'), - contextId: expectNullableString(row.contextId, 'contextId'), - contextTitle: expectNullableString(row.contextTitle, 'contextTitle'), - packageVersionId: expectNumber(row.packageVersionId, 'packageVersionId'), - packageVersion: expectString(row.packageVersion, 'packageVersion'), - packageTitle: expectString(row.packageTitle, 'packageTitle'), - activityId: expectString(row.activityId, 'activityId'), - contentPath: expectString(row.contentPath, 'contentPath'), - contentTitle: expectNullableString(row.contentTitle, 'contentTitle'), - createdByUserId: expectNullableString(row.createdByUserId, 'createdByUserId'), - resourceLinkId: expectNullableString(row.resourceLinkId, 'resourceLinkId'), - createdAt: expectString(row.createdAt, 'createdAt'), - boundAt: expectNullableString(row.boundAt, 'boundAt'), - }; -} - -function mapD1PlacementAuditSnapshotFields( - row: D1PlacementAuditSnapshotRow, -): PlacementAuditSnapshotRow { - return { - ...mapD1ReviewedPlacementFields(row), - latestPreviewSessionId: expectNullableString( - row.latestPreviewSessionId, - 'latestPreviewSessionId', - ), - latestPreviewOccurredAt: expectNullableString( - row.latestPreviewOccurredAt, - 'latestPreviewOccurredAt', - ), - previewEvidenceCount: expectNumber(row.previewEvidenceCount, 'previewEvidenceCount'), - deepLinkingRequestCount: expectNumber(row.deepLinkingRequestCount, 'deepLinkingRequestCount'), - placementEventCount: expectNumber(row.placementEventCount, 'placementEventCount'), - reviewerEventCount: expectNumber(row.reviewerEventCount, 'reviewerEventCount'), - latestAuditOccurredAt: expectNullableString(row.latestAuditOccurredAt, 'latestAuditOccurredAt'), - }; -} - function isD1UniqueViolation(error: unknown): boolean { return error instanceof Error && error.message.includes('UNIQUE constraint failed'); } - -function parseJsonField(value: unknown, fieldName: string): unknown { - if (typeof value !== 'string') { - throw new TypeError(`Expected D1 ${fieldName} to be JSON text.`); - } - - return JSON.parse(value); -} - -function expectString(value: unknown, fieldName: string): string { - if (typeof value !== 'string') { - throw new TypeError(`Expected D1 ${fieldName} to be text.`); - } - - return value; -} - -function expectNullableString(value: unknown, fieldName: string): string | null { - if (value === null) { - return null; - } - - return expectString(value, fieldName); -} - -function expectNumber(value: unknown, fieldName: string): number { - if (typeof value !== 'number') { - throw new TypeError(`Expected D1 ${fieldName} to be numeric.`); - } - - return value; -} - -function expectNullableNumber(value: unknown, fieldName: string): number | null { - if (value === null) { - return null; - } - - return expectNumber(value, fieldName); -} - -function expectBooleanInteger(value: unknown, fieldName: string): boolean { - if (value === 0) { - return false; - } - - if (value === 1) { - return true; - } - - throw new TypeError(`Expected D1 ${fieldName} to be 0 or 1.`); -} - -function expectStringLiteral( - value: unknown, - fieldName: string, - allowed: readonly T[], -): T { - if (typeof value !== 'string' || !allowed.includes(value as T)) { - throw new Error(`Unexpected D1 ${fieldName} value.`); - } - - return value as T; -} diff --git a/src/package_review/repository_deep_linking_d1_mapping.ts b/src/package_review/repository_deep_linking_d1_mapping.ts new file mode 100644 index 0000000..3cc67c5 --- /dev/null +++ b/src/package_review/repository_deep_linking_d1_mapping.ts @@ -0,0 +1,193 @@ +import { + expectD1NullableNumber, + expectD1NullableString, + expectD1Number, + expectD1String, + expectD1StringArray, + expectD1StringLiteral, + parseD1JsonField, +} from '../db/d1_row.ts'; +import type { + DeepLinkingSessionRow, + PlacementAuditSnapshotRow, + ReviewedPlacementRow, +} from './repository_row_types.ts'; + +export interface D1DeepLinkingSessionRow extends Record { + sessionId: unknown; + sessionToken: unknown; + ltiToolSessionId: unknown; + deploymentRecordId: unknown; + deploymentSlug: unknown; + appId: unknown; + userId: unknown; + userRole: unknown; + contextId: unknown; + contextTitle: unknown; + deepLinkReturnUrl: unknown; + data: unknown; + placement: unknown; + acceptTypes: unknown; + acceptMultiple: unknown; + acceptPresentationDocumentTargets: unknown; + acceptLineItem: unknown; + selectedPackageVersionId: unknown; + selectedPackageVersion: unknown; + selectedActivityId: unknown; + selectedContentPath: unknown; + createdAt: unknown; + expiresAt: unknown; + usedAt: unknown; +} + +export interface D1ReviewedPlacementRow extends Record { + placementId: unknown; + deploymentRecordId: unknown; + deploymentSlug: unknown; + appId: unknown; + contextId: unknown; + contextTitle: unknown; + packageVersionId: unknown; + packageVersion: unknown; + packageTitle: unknown; + activityId: unknown; + contentPath: unknown; + contentTitle: unknown; + createdByUserId: unknown; + resourceLinkId: unknown; + createdAt: unknown; + boundAt: unknown; +} + +export interface D1PlacementAuditSnapshotRow extends D1ReviewedPlacementRow { + latestPreviewSessionId: unknown; + latestPreviewOccurredAt: unknown; + previewEvidenceCount: unknown; + deepLinkingRequestCount: unknown; + placementEventCount: unknown; + reviewerEventCount: unknown; + latestAuditOccurredAt: unknown; +} + +export function mapD1DeepLinkingSessionFields( + row: D1DeepLinkingSessionRow, +): DeepLinkingSessionRow { + return { + sessionId: expectD1String(row.sessionId, 'sessionId'), + sessionToken: expectD1String(row.sessionToken, 'sessionToken'), + ltiToolSessionId: expectD1NullableString(row.ltiToolSessionId, 'ltiToolSessionId'), + deploymentRecordId: expectD1Number(row.deploymentRecordId, 'deploymentRecordId'), + deploymentSlug: expectD1String(row.deploymentSlug, 'deploymentSlug'), + appId: expectD1String(row.appId, 'appId'), + userId: expectD1NullableString(row.userId, 'userId'), + userRole: expectD1StringLiteral(row.userRole, 'userRole', ['learner', 'instructor']), + contextId: expectD1NullableString(row.contextId, 'contextId'), + contextTitle: expectD1NullableString(row.contextTitle, 'contextTitle'), + deepLinkReturnUrl: expectD1String(row.deepLinkReturnUrl, 'deepLinkReturnUrl'), + data: expectD1NullableString(row.data, 'data'), + placement: expectD1StringLiteral(row.placement, 'placement', [ + 'assignment_selection', + 'resource_selection', + ]), + acceptTypes: parseD1JsonField( + row.acceptTypes, + 'acceptTypes', + parseDeepLinkingAcceptTypes, + ), + acceptMultiple: expectBooleanInteger(row.acceptMultiple, 'acceptMultiple'), + acceptPresentationDocumentTargets: parseD1JsonField( + row.acceptPresentationDocumentTargets, + 'acceptPresentationDocumentTargets', + parseDeepLinkingPresentationTargets, + ), + acceptLineItem: expectBooleanInteger(row.acceptLineItem, 'acceptLineItem'), + selectedPackageVersionId: expectD1NullableNumber( + row.selectedPackageVersionId, + 'selectedPackageVersionId', + ), + selectedPackageVersion: expectD1NullableString( + row.selectedPackageVersion, + 'selectedPackageVersion', + ), + selectedActivityId: expectD1NullableString(row.selectedActivityId, 'selectedActivityId'), + selectedContentPath: expectD1NullableString(row.selectedContentPath, 'selectedContentPath'), + createdAt: expectD1String(row.createdAt, 'createdAt'), + expiresAt: expectD1String(row.expiresAt, 'expiresAt'), + usedAt: expectD1NullableString(row.usedAt, 'usedAt'), + }; +} + +export function mapD1ReviewedPlacementFields(row: D1ReviewedPlacementRow): ReviewedPlacementRow { + return { + placementId: expectD1String(row.placementId, 'placementId'), + deploymentRecordId: expectD1Number(row.deploymentRecordId, 'deploymentRecordId'), + deploymentSlug: expectD1String(row.deploymentSlug, 'deploymentSlug'), + appId: expectD1String(row.appId, 'appId'), + contextId: expectD1NullableString(row.contextId, 'contextId'), + contextTitle: expectD1NullableString(row.contextTitle, 'contextTitle'), + packageVersionId: expectD1Number(row.packageVersionId, 'packageVersionId'), + packageVersion: expectD1String(row.packageVersion, 'packageVersion'), + packageTitle: expectD1String(row.packageTitle, 'packageTitle'), + activityId: expectD1String(row.activityId, 'activityId'), + contentPath: expectD1String(row.contentPath, 'contentPath'), + contentTitle: expectD1NullableString(row.contentTitle, 'contentTitle'), + createdByUserId: expectD1NullableString(row.createdByUserId, 'createdByUserId'), + resourceLinkId: expectD1NullableString(row.resourceLinkId, 'resourceLinkId'), + createdAt: expectD1String(row.createdAt, 'createdAt'), + boundAt: expectD1NullableString(row.boundAt, 'boundAt'), + }; +} + +export function mapD1PlacementAuditSnapshotFields( + row: D1PlacementAuditSnapshotRow, +): PlacementAuditSnapshotRow { + return { + ...mapD1ReviewedPlacementFields(row), + latestPreviewSessionId: expectD1NullableString( + row.latestPreviewSessionId, + 'latestPreviewSessionId', + ), + latestPreviewOccurredAt: expectD1NullableString( + row.latestPreviewOccurredAt, + 'latestPreviewOccurredAt', + ), + previewEvidenceCount: expectD1Number(row.previewEvidenceCount, 'previewEvidenceCount'), + deepLinkingRequestCount: expectD1Number(row.deepLinkingRequestCount, 'deepLinkingRequestCount'), + placementEventCount: expectD1Number(row.placementEventCount, 'placementEventCount'), + reviewerEventCount: expectD1Number(row.reviewerEventCount, 'reviewerEventCount'), + latestAuditOccurredAt: expectD1NullableString( + row.latestAuditOccurredAt, + 'latestAuditOccurredAt', + ), + }; +} + +function expectBooleanInteger(value: unknown, fieldName: string): boolean { + if (value === 0) { + return false; + } + + if (value === 1) { + return true; + } + + throw new TypeError(`Expected D1 ${fieldName} to be 0 or 1.`); +} + +function parseDeepLinkingAcceptTypes( + value: unknown, + fieldName: string, +): DeepLinkingSessionRow['acceptTypes'] { + return expectD1StringArray(value, fieldName).map((item, index) => + expectD1StringLiteral(item, `${fieldName}[${index}]`, ['ltiResourceLink']) + ); +} + +function parseDeepLinkingPresentationTargets( + value: unknown, + fieldName: string, +): DeepLinkingSessionRow['acceptPresentationDocumentTargets'] { + return expectD1StringArray(value, fieldName).map((item, index) => + expectD1StringLiteral(item, `${fieldName}[${index}]`, ['iframe', 'window', 'embed']) + ); +} diff --git a/src/package_review/repository_deep_linking_d1_test.ts b/src/package_review/repository_deep_linking_d1_test.ts index ea39a2f..ec16fd4 100644 --- a/src/package_review/repository_deep_linking_d1_test.ts +++ b/src/package_review/repository_deep_linking_d1_test.ts @@ -35,8 +35,8 @@ Deno.test('D1 deep linking repository creates and parses JSON-backed session set assertEquals(session.acceptMultiple, true); assertEquals(session.acceptPresentationDocumentTargets, ['iframe', 'window']); assertEquals(session.selection?.activityId, 'asteroids'); - assertEquals(db.statements[0]?.parameters[12], '["ltiResourceLink"]'); - assertEquals(db.statements[0]?.parameters[13], 1); + assertEquals(db.statements[0]?.parameters[13], '["ltiResourceLink"]'); + assertEquals(db.statements[0]?.parameters[14], 1); }); Deno.test('D1 deep linking repository rejects already-used sessions', async () => { @@ -240,6 +240,7 @@ function buildDeepLinkingSessionRow( return { sessionId: 'deep-linking-session-123', sessionToken: 'deep-linking-token-123', + ltiToolSessionId: null, deploymentRecordId: 1, deploymentSlug: 'chapter-4-asteroids-pilot', appId: 'chapter-4-asteroids', diff --git a/src/package_review/repository_deployments_d1.ts b/src/package_review/repository_deployments_d1.ts index fd61e8c..fdb167d 100644 --- a/src/package_review/repository_deployments_d1.ts +++ b/src/package_review/repository_deployments_d1.ts @@ -5,7 +5,13 @@ import { mapDeploymentRow } from './repository_mappers_package.ts'; import type { DeploymentRow } from './repository_row_types.ts'; import type { PackageReviewRepository } from './repository.ts'; import type { LanternLtiProfileSettingsRecord } from './types.ts'; -import { normalizeTimestamp } from './repository_value_support.ts'; +import { + D1_DEPLOYMENT_SELECT, + type D1DeploymentRow, + type D1LanternSettingsRow, + mapD1DeploymentFields, + mapD1LanternLtiProfileSettingsRow, +} from './repository_deployments_d1_mapping.ts'; export function createD1DeploymentRepositoryMethods( db: D1Database, @@ -40,7 +46,7 @@ export function createD1DeploymentRepositoryMethods( [appId], ); - return rows.map(mapD1DeploymentRow); + return rows.map((row) => mapDeploymentRow(mapD1DeploymentFields(row))); }, async getDeploymentByBinding(binding) { @@ -86,7 +92,7 @@ export function createD1DeploymentRepositoryMethods( ); } - return mapD1DeploymentRow(rows[0]!); + return mapDeploymentRow(mapD1DeploymentFields(rows[0]!)); }, async completePendingCanvasBinding(input) { @@ -249,7 +255,6 @@ export function createD1DeploymentRepositoryMethods( }, async pinDeploymentVersion(input) { - const lmsType = input.lmsType ?? 'canvas'; const packageVersion = await queryD1First<{ appId: string; approvalStatus: string; @@ -273,7 +278,7 @@ export function createD1DeploymentRepositoryMethods( if ( packageVersion.approvalStatus !== 'approved' && - !(lmsType === 'preview' && packageVersion.approvalStatus === 'pending') + !(input.lmsType === 'preview' && packageVersion.approvalStatus === 'pending') ) { throw new Error('Only approved package versions can be enabled.'); } @@ -282,7 +287,7 @@ export function createD1DeploymentRepositoryMethods( db, input.slug, input.appId, - lmsType, + input.lmsType, ); const deploymentAppId = existingDeployment?.appId ?? input.appId; @@ -308,7 +313,7 @@ export function createD1DeploymentRepositoryMethods( enabled_package_version_id = excluded.enabled_package_version_id, updated_at = CURRENT_TIMESTAMP `, - [input.slug, input.label, deploymentAppId, lmsType, input.packageVersionId], + [input.slug, input.label, deploymentAppId, input.lmsType, input.packageVersionId], ); return await requireDeploymentBySlug(db, input.slug); @@ -360,53 +365,6 @@ export function createD1DeploymentRepositoryMethods( }; } -const D1_DEPLOYMENT_SELECT = ` - SELECT - deployments.id, - deployments.slug, - deployments.label, - deployments.app_id AS appId, - deployments.enabled_package_version_id AS enabledPackageVersionId, - package_versions.version AS enabledPackageVersion, - deployments.lms_type AS lmsType, - deployments.canvas_environment AS canvasEnvironment, - deployments.issuer, - deployments.client_id AS clientId, - deployments.deployment_id AS deploymentId, - deployments.authorization_endpoint AS authorizationEndpoint, - deployments.access_token_url AS accessTokenUrl, - deployments.jwks_url AS jwksUrl, - deployments.lti_profile_override AS ltiProfileOverride, - deployments.updated_at AS updatedAt - FROM deployments - LEFT JOIN package_versions - ON package_versions.id = deployments.enabled_package_version_id -`; - -interface D1DeploymentRow extends Record { - id: unknown; - slug: unknown; - label: unknown; - appId: unknown; - enabledPackageVersionId: unknown; - enabledPackageVersion: unknown; - lmsType: unknown; - canvasEnvironment: unknown; - issuer: unknown; - clientId: unknown; - deploymentId: unknown; - authorizationEndpoint: unknown; - accessTokenUrl: unknown; - jwksUrl: unknown; - ltiProfileOverride: unknown; - updatedAt: unknown; -} - -interface D1LanternSettingsRow extends Record { - defaultLtiProfile: unknown; - updatedAt: unknown; -} - async function getDeploymentByField( db: D1Database, whereClause: string, @@ -418,7 +376,7 @@ async function getDeploymentByField( parameters, ); - return row === null ? null : mapD1DeploymentRow(row); + return row === null ? null : mapDeploymentRow(mapD1DeploymentFields(row)); } async function requireDeploymentBySlug(db: D1Database, slug: string) { @@ -638,111 +596,6 @@ function bindingColumns( } } -function mapD1DeploymentRow(row: D1DeploymentRow) { - return mapDeploymentRow(mapD1DeploymentFields(row)); -} - -function mapD1DeploymentFields(row: D1DeploymentRow): DeploymentRow { - return { - id: expectNumber(row.id, 'id'), - slug: expectString(row.slug, 'slug'), - label: expectString(row.label, 'label'), - appId: expectString(row.appId, 'appId'), - enabledPackageVersionId: expectNullableNumber( - row.enabledPackageVersionId, - 'enabledPackageVersionId', - ), - enabledPackageVersion: expectNullableString(row.enabledPackageVersion, 'enabledPackageVersion'), - lmsType: expectStringLiteral(row.lmsType, 'lmsType', ['canvas', 'moodle', 'sakai', 'preview']), - canvasEnvironment: expectNullableStringLiteral(row.canvasEnvironment, 'canvasEnvironment', [ - 'production', - 'beta', - 'test', - ]), - issuer: expectNullableString(row.issuer, 'issuer'), - clientId: expectNullableString(row.clientId, 'clientId'), - deploymentId: expectNullableString(row.deploymentId, 'deploymentId'), - authorizationEndpoint: expectNullableString(row.authorizationEndpoint, 'authorizationEndpoint'), - accessTokenUrl: expectNullableString(row.accessTokenUrl, 'accessTokenUrl'), - jwksUrl: expectNullableString(row.jwksUrl, 'jwksUrl'), - ltiProfileOverride: expectNullableStringLiteral(row.ltiProfileOverride, 'ltiProfileOverride', [ - 'certification', - 'governedCompatibility', - ]), - updatedAt: expectString(row.updatedAt, 'updatedAt'), - }; -} - -function mapD1LanternLtiProfileSettingsRow( - row: D1LanternSettingsRow, -): LanternLtiProfileSettingsRecord { - const defaultLtiProfile = expectString(row.defaultLtiProfile, 'defaultLtiProfile'); - - return { - defaultLtiProfile: requireLtiProfileId( - defaultLtiProfile, - `Unsupported saved LTI profile ${defaultLtiProfile}.`, - ), - updatedAt: normalizeTimestamp(expectString(row.updatedAt, 'updatedAt')), - }; -} - function formatBindingLabel(lms: DeploymentRow['lmsType']): string { return `${lms[0]?.toUpperCase() ?? ''}${lms.slice(1)} binding`; } - -function expectString(value: unknown, fieldName: string): string { - if (typeof value !== 'string') { - throw new TypeError(`Expected D1 deployments.${fieldName} to be text.`); - } - - return value; -} - -function expectNullableString(value: unknown, fieldName: string): string | null { - if (value === null) { - return null; - } - - return expectString(value, fieldName); -} - -function expectNumber(value: unknown, fieldName: string): number { - if (typeof value !== 'number') { - throw new TypeError(`Expected D1 deployments.${fieldName} to be numeric.`); - } - - return value; -} - -function expectNullableNumber(value: unknown, fieldName: string): number | null { - if (value === null) { - return null; - } - - return expectNumber(value, fieldName); -} - -function expectStringLiteral( - value: unknown, - fieldName: string, - allowed: readonly T[], -): T { - if (typeof value !== 'string' || !allowed.includes(value as T)) { - throw new Error(`Unexpected D1 deployments.${fieldName} value.`); - } - - return value as T; -} - -function expectNullableStringLiteral( - value: unknown, - fieldName: string, - allowed: readonly T[], -): T | null { - if (value === null) { - return null; - } - - return expectStringLiteral(value, fieldName, allowed); -} diff --git a/src/package_review/repository_deployments_d1_mapping.ts b/src/package_review/repository_deployments_d1_mapping.ts new file mode 100644 index 0000000..42ef4e5 --- /dev/null +++ b/src/package_review/repository_deployments_d1_mapping.ts @@ -0,0 +1,126 @@ +import { + expectD1NullableNumber, + expectD1NullableString, + expectD1NullableStringLiteral, + expectD1Number, + expectD1String, + expectD1StringLiteral, +} from '../db/d1_row.ts'; +import { requireLtiProfileId } from '../lti/profile.ts'; +import type { DeploymentRow } from './repository_row_types.ts'; +import { normalizeTimestamp } from './repository_value_support.ts'; +import type { LanternLtiProfileSettingsRecord } from './types.ts'; + +export const D1_DEPLOYMENT_SELECT = ` + SELECT + deployments.id, + deployments.slug, + deployments.label, + deployments.app_id AS appId, + deployments.enabled_package_version_id AS enabledPackageVersionId, + package_versions.version AS enabledPackageVersion, + deployments.lms_type AS lmsType, + deployments.canvas_environment AS canvasEnvironment, + deployments.issuer, + deployments.client_id AS clientId, + deployments.deployment_id AS deploymentId, + deployments.authorization_endpoint AS authorizationEndpoint, + deployments.access_token_url AS accessTokenUrl, + deployments.jwks_url AS jwksUrl, + deployments.lti_profile_override AS ltiProfileOverride, + deployments.updated_at AS updatedAt + FROM deployments + LEFT JOIN package_versions + ON package_versions.id = deployments.enabled_package_version_id +`; + +export interface D1DeploymentRow extends Record { + id: unknown; + slug: unknown; + label: unknown; + appId: unknown; + enabledPackageVersionId: unknown; + enabledPackageVersion: unknown; + lmsType: unknown; + canvasEnvironment: unknown; + issuer: unknown; + clientId: unknown; + deploymentId: unknown; + authorizationEndpoint: unknown; + accessTokenUrl: unknown; + jwksUrl: unknown; + ltiProfileOverride: unknown; + updatedAt: unknown; +} + +export interface D1LanternSettingsRow extends Record { + defaultLtiProfile: unknown; + updatedAt: unknown; +} + +export function mapD1DeploymentFields(row: D1DeploymentRow): DeploymentRow { + return { + id: expectD1Number(row.id, 'deployments.id'), + slug: expectD1String(row.slug, 'deployments.slug'), + label: expectD1String(row.label, 'deployments.label'), + appId: expectD1String(row.appId, 'deployments.appId'), + enabledPackageVersionId: expectD1NullableNumber( + row.enabledPackageVersionId, + 'deployments.enabledPackageVersionId', + ), + enabledPackageVersion: expectD1NullableString( + row.enabledPackageVersion, + 'deployments.enabledPackageVersion', + ), + lmsType: expectD1StringLiteral(row.lmsType, 'deployments.lmsType', [ + 'canvas', + 'moodle', + 'sakai', + 'preview', + ]), + canvasEnvironment: expectD1NullableStringLiteral( + row.canvasEnvironment, + 'deployments.canvasEnvironment', + [ + 'production', + 'beta', + 'test', + ], + ), + issuer: expectD1NullableString(row.issuer, 'deployments.issuer'), + clientId: expectD1NullableString(row.clientId, 'deployments.clientId'), + deploymentId: expectD1NullableString(row.deploymentId, 'deployments.deploymentId'), + authorizationEndpoint: expectD1NullableString( + row.authorizationEndpoint, + 'deployments.authorizationEndpoint', + ), + accessTokenUrl: expectD1NullableString(row.accessTokenUrl, 'deployments.accessTokenUrl'), + jwksUrl: expectD1NullableString(row.jwksUrl, 'deployments.jwksUrl'), + ltiProfileOverride: expectD1NullableStringLiteral( + row.ltiProfileOverride, + 'deployments.ltiProfileOverride', + [ + 'certification', + 'governedCompatibility', + ], + ), + updatedAt: expectD1String(row.updatedAt, 'deployments.updatedAt'), + }; +} + +export function mapD1LanternLtiProfileSettingsRow( + row: D1LanternSettingsRow, +): LanternLtiProfileSettingsRecord { + const defaultLtiProfile = expectD1String( + row.defaultLtiProfile, + 'lantern_settings.defaultLtiProfile', + ); + + return { + defaultLtiProfile: requireLtiProfileId( + defaultLtiProfile, + `Unsupported saved LTI profile ${defaultLtiProfile}.`, + ), + updatedAt: normalizeTimestamp(expectD1String(row.updatedAt, 'lantern_settings.updatedAt')), + }; +} diff --git a/src/package_review/repository_deployments_d1_test.ts b/src/package_review/repository_deployments_d1_test.ts index 13632af..18465ed 100644 --- a/src/package_review/repository_deployments_d1_test.ts +++ b/src/package_review/repository_deployments_d1_test.ts @@ -21,6 +21,19 @@ Deno.test('D1 deployment repository maps deployment rows', async () => { assertEquals(db.statements[0]?.parameters, ['chapter-4-asteroids']); }); +Deno.test('D1 deployment repository rejects malformed deployment row literals', async () => { + const db = createPlannedD1Database({ + allResults: [[buildD1DeploymentRow({ lmsType: 'blackboard' })]], + }); + const repository = createD1DeploymentRepositoryMethods(db); + + await assertRejects( + () => repository.listDeploymentsByApp('chapter-4-asteroids'), + Error, + 'Unexpected D1 deployments.lmsType value.', + ); +}); + Deno.test('D1 deployment repository saves exact non-Canvas bindings', async () => { const saved = buildD1DeploymentRow({ lmsType: 'moodle', @@ -77,6 +90,7 @@ Deno.test('D1 deployment repository rejects unapproved package pins', async () = label: 'Canvas', appId: 'chapter-4-asteroids', packageVersionId: 1, + lmsType: 'canvas', }), Error, 'Only approved package versions can be enabled.', diff --git a/src/package_review/repository_grading_d1.ts b/src/package_review/repository_grading_d1.ts index 33c79c6..30f71d1 100644 --- a/src/package_review/repository_grading_d1.ts +++ b/src/package_review/repository_grading_d1.ts @@ -1,5 +1,5 @@ import type { D1Database } from '../db/d1.ts'; -import { queryD1First, queryD1Objects, runD1 } from '../db/d1.ts'; +import { queryD1First, queryD1Objects, runD1, runD1Batch } from '../db/d1.ts'; import { mapAttemptEvidenceArtifactRow, mapAuditEventRow, @@ -9,12 +9,16 @@ import { mapOptionalGradePublication, mapOptionalLineItemBinding, } from './repository_mappers_attempts.ts'; -import type { - AttemptEvidenceArtifactRow, - AuditEventRow, - GradePublicationRow, - LineItemBindingRow, -} from './repository_row_types.ts'; +import { + type D1AttemptEvidenceArtifactRow, + type D1AuditEventRow, + type D1GradePublicationRow, + type D1LineItemBindingRow, + mapD1AttemptEvidenceArtifactRow, + mapD1AuditEventFields, + mapD1GradePublicationFields, + mapD1LineItemBindingFields, +} from './repository_grading_d1_mapping.ts'; import type { PackageReviewRepository } from './repository.ts'; export function createD1GradingRepositoryMethods( @@ -35,61 +39,78 @@ export function createD1GradingRepositoryMethods( > { return { async createAttemptEvidenceArtifact(input) { - const attempt = await queryD1First<{ attemptId: string }>( - db, - ` - SELECT attempt_id AS attemptId - FROM attempts - WHERE attempt_id = ? - `, - [input.attemptId], - ); - - if (attempt === null) { - throw new Error(`Attempt ${input.attemptId} was not found.`); - } - - const sequenceRow = await queryD1First<{ nextSequence: number }>( - db, - ` - SELECT COALESCE(MAX(sequence), 0) + 1 AS nextSequence - FROM attempt_evidence_artifacts - WHERE attempt_id = ? - `, - [input.attemptId], - ); - const nextSequence = sequenceRow?.nextSequence ?? 1; - try { - await runD1( + const results = await runD1Batch( db, - ` - INSERT INTO attempt_evidence_artifacts ( - artifact_id, - attempt_id, - sequence, - kind, - content_type, - file_name, - storage_key, - byte_size, - sha256, - created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, [ - input.artifactId, - input.attemptId, - nextSequence, - input.kind, - input.contentType, - input.fileName, - input.storageKey, - input.byteSize, - input.sha256, - input.createdAt, + { + sql: ` + INSERT INTO attempt_evidence_artifacts ( + artifact_id, + attempt_id, + sequence, + kind, + content_type, + file_name, + storage_key, + byte_size, + sha256, + created_at + ) + SELECT + ?, + attempts.attempt_id, + attempts.next_evidence_artifact_sequence, + ?, + ?, + ?, + ?, + ?, + ?, + ? + FROM attempts + WHERE attempts.attempt_id = ? + RETURNING + artifact_id AS artifactId, + attempt_id AS attemptId, + sequence, + kind, + content_type AS contentType, + file_name AS fileName, + storage_key AS storageKey, + byte_size AS byteSize, + sha256, + created_at AS createdAt + `, + parameters: [ + input.artifactId, + input.kind, + input.contentType, + input.fileName, + input.storageKey, + input.byteSize, + input.sha256, + input.createdAt, + input.attemptId, + ], + }, + { + sql: ` + UPDATE attempts + SET next_evidence_artifact_sequence = next_evidence_artifact_sequence + 1 + WHERE attempt_id = ? + `, + parameters: [input.attemptId], + }, ], ); + const row = results[0]?.results?.[0] ?? null; + + if (row === null) { + throw new Error(`Attempt ${input.attemptId} was not found.`); + } + + return mapAttemptEvidenceArtifactRow(mapD1AttemptEvidenceArtifactRow(row)); } catch (error) { if (isD1UniqueViolation(error)) { throw new Error( @@ -99,8 +120,6 @@ export function createD1GradingRepositoryMethods( throw error; } - - return await requireAttemptEvidenceArtifact(db, input.artifactId); }, async getAttemptEvidenceArtifactById(artifactId) { @@ -111,7 +130,7 @@ export function createD1GradingRepositoryMethods( ); return mapOptionalAttemptEvidenceArtifact( - row === null ? undefined : mapD1AttemptEvidenceArtifactFields(row), + row === null ? undefined : mapD1AttemptEvidenceArtifactRow(row), ); }, @@ -126,9 +145,7 @@ export function createD1GradingRepositoryMethods( [attemptId], ); - return rows.map((row) => - mapAttemptEvidenceArtifactRow(mapD1AttemptEvidenceArtifactFields(row)) - ); + return rows.map((row) => mapAttemptEvidenceArtifactRow(mapD1AttemptEvidenceArtifactRow(row))); }, async getLineItemBinding(input) { @@ -489,83 +506,6 @@ const D1_AUDIT_EVENT_SELECT = ` FROM audit_events `; -interface D1AttemptEvidenceArtifactRow extends Record { - artifactId: unknown; - attemptId: unknown; - sequence: unknown; - kind: unknown; - contentType: unknown; - fileName: unknown; - storageKey: unknown; - byteSize: unknown; - sha256: unknown; - createdAt: unknown; -} - -interface D1LineItemBindingRow extends Record { - id: unknown; - deploymentRecordId: unknown; - packageVersionId: unknown; - contextId: unknown; - resourceLinkId: unknown; - activityId: unknown; - lineItemsUrl: unknown; - lineItemUrl: unknown; - resourceId: unknown; - tag: unknown; - label: unknown; - scoreMaximum: unknown; - createdAt: unknown; - updatedAt: unknown; -} - -interface D1GradePublicationRow extends Record { - id: unknown; - attemptId: unknown; - lineItemBindingId: unknown; - lineItemUrl: unknown; - platformUserId: unknown; - scoreGiven: unknown; - scoreMaximum: unknown; - activityProgress: unknown; - gradingProgress: unknown; - status: unknown; - createdAt: unknown; - updatedAt: unknown; - publishedAt: unknown; - errorCode: unknown; - errorDetail: unknown; -} - -interface D1AuditEventRow extends Record { - id: unknown; - eventType: unknown; - actorType: unknown; - actorId: unknown; - deploymentRecordId: unknown; - packageVersionId: unknown; - attemptId: unknown; - lineItemBindingId: unknown; - status: unknown; - summary: unknown; - detail: unknown; - occurredAt: unknown; -} - -async function requireAttemptEvidenceArtifact(db: D1Database, artifactId: string) { - const row = await queryD1First( - db, - `${D1_ATTEMPT_EVIDENCE_ARTIFACT_SELECT} WHERE artifact_id = ?`, - [artifactId], - ); - - if (row === null) { - throw new Error(`Attempt evidence artifact ${artifactId} was not found.`); - } - - return mapAttemptEvidenceArtifactRow(mapD1AttemptEvidenceArtifactFields(row)); -} - async function requireLineItemBindingByUrl(db: D1Database, lineItemUrl: string) { const row = await queryD1First( db, @@ -594,153 +534,6 @@ async function requireGradePublicationByAttemptId(db: D1Database, attemptId: str return mapGradePublicationRow(mapD1GradePublicationFields(row)); } -function mapD1AttemptEvidenceArtifactFields( - row: D1AttemptEvidenceArtifactRow, -): AttemptEvidenceArtifactRow { - return { - artifactId: expectString(row.artifactId, 'artifactId'), - attemptId: expectString(row.attemptId, 'attemptId'), - sequence: expectNumber(row.sequence, 'sequence'), - kind: expectStringLiteral(row.kind, 'kind', ['screenshot_png', 'structured_json']), - contentType: expectString(row.contentType, 'contentType'), - fileName: expectString(row.fileName, 'fileName'), - storageKey: expectString(row.storageKey, 'storageKey'), - byteSize: expectNumber(row.byteSize, 'byteSize'), - sha256: expectString(row.sha256, 'sha256'), - createdAt: expectString(row.createdAt, 'createdAt'), - }; -} - -function mapD1LineItemBindingFields(row: D1LineItemBindingRow): LineItemBindingRow { - return { - id: expectNumber(row.id, 'id'), - deploymentRecordId: expectNumber(row.deploymentRecordId, 'deploymentRecordId'), - packageVersionId: expectNumber(row.packageVersionId, 'packageVersionId'), - contextId: expectString(row.contextId, 'contextId'), - resourceLinkId: expectString(row.resourceLinkId, 'resourceLinkId'), - activityId: expectString(row.activityId, 'activityId'), - lineItemsUrl: expectString(row.lineItemsUrl, 'lineItemsUrl'), - lineItemUrl: expectString(row.lineItemUrl, 'lineItemUrl'), - resourceId: expectString(row.resourceId, 'resourceId'), - tag: expectString(row.tag, 'tag'), - label: expectString(row.label, 'label'), - scoreMaximum: expectNumber(row.scoreMaximum, 'scoreMaximum'), - createdAt: expectString(row.createdAt, 'createdAt'), - updatedAt: expectString(row.updatedAt, 'updatedAt'), - }; -} - -function mapD1GradePublicationFields(row: D1GradePublicationRow): GradePublicationRow { - return { - id: expectNumber(row.id, 'id'), - attemptId: expectString(row.attemptId, 'attemptId'), - lineItemBindingId: expectNumber(row.lineItemBindingId, 'lineItemBindingId'), - lineItemUrl: expectString(row.lineItemUrl, 'lineItemUrl'), - platformUserId: expectString(row.platformUserId, 'platformUserId'), - scoreGiven: expectNumber(row.scoreGiven, 'scoreGiven'), - scoreMaximum: expectNumber(row.scoreMaximum, 'scoreMaximum'), - activityProgress: expectStringLiteral(row.activityProgress, 'activityProgress', [ - 'Completed', - 'InProgress', - 'Initialized', - ]), - gradingProgress: expectStringLiteral(row.gradingProgress, 'gradingProgress', [ - 'Pending', - 'PendingManual', - 'FullyGraded', - 'Failed', - ]), - status: expectStringLiteral(row.status, 'status', ['pending', 'published', 'failed']), - createdAt: expectString(row.createdAt, 'createdAt'), - updatedAt: expectString(row.updatedAt, 'updatedAt'), - publishedAt: expectNullableString(row.publishedAt, 'publishedAt'), - errorCode: expectNullableString(row.errorCode, 'errorCode'), - errorDetail: parseNullableJsonField(row.errorDetail, 'errorDetail') as - | Record< - string, - unknown - > - | null, - }; -} - -function mapD1AuditEventFields(row: D1AuditEventRow): AuditEventRow { - return { - id: expectNumber(row.id, 'id'), - eventType: expectString(row.eventType, 'eventType'), - actorType: expectStringLiteral(row.actorType, 'actorType', ['user', 'system', 'platform']), - actorId: expectNullableString(row.actorId, 'actorId'), - deploymentRecordId: expectNullableNumber(row.deploymentRecordId, 'deploymentRecordId'), - packageVersionId: expectNullableNumber(row.packageVersionId, 'packageVersionId'), - attemptId: expectNullableString(row.attemptId, 'attemptId'), - lineItemBindingId: expectNullableNumber(row.lineItemBindingId, 'lineItemBindingId'), - status: expectStringLiteral(row.status, 'status', ['accepted', 'succeeded', 'failed']), - summary: expectString(row.summary, 'summary'), - detail: parseJsonField(row.detail, 'detail') as Record, - occurredAt: expectString(row.occurredAt, 'occurredAt'), - }; -} - function isD1UniqueViolation(error: unknown): boolean { return error instanceof Error && error.message.includes('UNIQUE constraint failed'); } - -function parseJsonField(value: unknown, fieldName: string): unknown { - if (typeof value !== 'string') { - throw new TypeError(`Expected D1 ${fieldName} to be JSON text.`); - } - - return JSON.parse(value); -} - -function parseNullableJsonField(value: unknown, fieldName: string): unknown | null { - if (value === null) { - return null; - } - - return parseJsonField(value, fieldName); -} - -function expectString(value: unknown, fieldName: string): string { - if (typeof value !== 'string') { - throw new TypeError(`Expected D1 ${fieldName} to be text.`); - } - - return value; -} - -function expectNullableString(value: unknown, fieldName: string): string | null { - if (value === null) { - return null; - } - - return expectString(value, fieldName); -} - -function expectNumber(value: unknown, fieldName: string): number { - if (typeof value !== 'number') { - throw new TypeError(`Expected D1 ${fieldName} to be numeric.`); - } - - return value; -} - -function expectNullableNumber(value: unknown, fieldName: string): number | null { - if (value === null) { - return null; - } - - return expectNumber(value, fieldName); -} - -function expectStringLiteral( - value: unknown, - fieldName: string, - allowed: readonly T[], -): T { - if (typeof value !== 'string' || !allowed.includes(value as T)) { - throw new Error(`Unexpected D1 ${fieldName} value.`); - } - - return value as T; -} diff --git a/src/package_review/repository_grading_d1_mapping.ts b/src/package_review/repository_grading_d1_mapping.ts new file mode 100644 index 0000000..240806e --- /dev/null +++ b/src/package_review/repository_grading_d1_mapping.ts @@ -0,0 +1,161 @@ +import { + expectD1JsonRecord, + expectD1NullableNumber, + expectD1NullableString, + expectD1Number, + expectD1String, + expectD1StringLiteral, + parseD1JsonField, + parseD1NullableJsonField, +} from '../db/d1_row.ts'; +import type { + AttemptEvidenceArtifactRow, + AuditEventRow, + GradePublicationRow, + LineItemBindingRow, +} from './repository_row_types.ts'; + +export interface D1AttemptEvidenceArtifactRow extends Record { + artifactId: unknown; + attemptId: unknown; + sequence: unknown; + kind: unknown; + contentType: unknown; + fileName: unknown; + storageKey: unknown; + byteSize: unknown; + sha256: unknown; + createdAt: unknown; +} + +export interface D1LineItemBindingRow extends Record { + id: unknown; + deploymentRecordId: unknown; + packageVersionId: unknown; + contextId: unknown; + resourceLinkId: unknown; + activityId: unknown; + lineItemsUrl: unknown; + lineItemUrl: unknown; + resourceId: unknown; + tag: unknown; + label: unknown; + scoreMaximum: unknown; + createdAt: unknown; + updatedAt: unknown; +} + +export interface D1GradePublicationRow extends Record { + id: unknown; + attemptId: unknown; + lineItemBindingId: unknown; + lineItemUrl: unknown; + platformUserId: unknown; + scoreGiven: unknown; + scoreMaximum: unknown; + activityProgress: unknown; + gradingProgress: unknown; + status: unknown; + createdAt: unknown; + updatedAt: unknown; + publishedAt: unknown; + errorCode: unknown; + errorDetail: unknown; +} + +export interface D1AuditEventRow extends Record { + id: unknown; + eventType: unknown; + actorType: unknown; + actorId: unknown; + deploymentRecordId: unknown; + packageVersionId: unknown; + attemptId: unknown; + lineItemBindingId: unknown; + status: unknown; + summary: unknown; + detail: unknown; + occurredAt: unknown; +} + +export function mapD1AttemptEvidenceArtifactRow( + row: D1AttemptEvidenceArtifactRow, +): AttemptEvidenceArtifactRow { + return { + artifactId: expectD1String(row.artifactId, 'artifactId'), + attemptId: expectD1String(row.attemptId, 'attemptId'), + sequence: expectD1Number(row.sequence, 'sequence'), + kind: expectD1StringLiteral(row.kind, 'kind', ['screenshot_png', 'structured_json']), + contentType: expectD1String(row.contentType, 'contentType'), + fileName: expectD1String(row.fileName, 'fileName'), + storageKey: expectD1String(row.storageKey, 'storageKey'), + byteSize: expectD1Number(row.byteSize, 'byteSize'), + sha256: expectD1String(row.sha256, 'sha256'), + createdAt: expectD1String(row.createdAt, 'createdAt'), + }; +} + +export function mapD1LineItemBindingFields(row: D1LineItemBindingRow): LineItemBindingRow { + return { + id: expectD1Number(row.id, 'id'), + deploymentRecordId: expectD1Number(row.deploymentRecordId, 'deploymentRecordId'), + packageVersionId: expectD1Number(row.packageVersionId, 'packageVersionId'), + contextId: expectD1String(row.contextId, 'contextId'), + resourceLinkId: expectD1String(row.resourceLinkId, 'resourceLinkId'), + activityId: expectD1String(row.activityId, 'activityId'), + lineItemsUrl: expectD1String(row.lineItemsUrl, 'lineItemsUrl'), + lineItemUrl: expectD1String(row.lineItemUrl, 'lineItemUrl'), + resourceId: expectD1String(row.resourceId, 'resourceId'), + tag: expectD1String(row.tag, 'tag'), + label: expectD1String(row.label, 'label'), + scoreMaximum: expectD1Number(row.scoreMaximum, 'scoreMaximum'), + createdAt: expectD1String(row.createdAt, 'createdAt'), + updatedAt: expectD1String(row.updatedAt, 'updatedAt'), + }; +} + +export function mapD1GradePublicationFields(row: D1GradePublicationRow): GradePublicationRow { + return { + id: expectD1Number(row.id, 'id'), + attemptId: expectD1String(row.attemptId, 'attemptId'), + lineItemBindingId: expectD1Number(row.lineItemBindingId, 'lineItemBindingId'), + lineItemUrl: expectD1String(row.lineItemUrl, 'lineItemUrl'), + platformUserId: expectD1String(row.platformUserId, 'platformUserId'), + scoreGiven: expectD1Number(row.scoreGiven, 'scoreGiven'), + scoreMaximum: expectD1Number(row.scoreMaximum, 'scoreMaximum'), + activityProgress: expectD1StringLiteral(row.activityProgress, 'activityProgress', [ + 'Completed', + 'InProgress', + 'Initialized', + ]), + gradingProgress: expectD1StringLiteral(row.gradingProgress, 'gradingProgress', [ + 'Pending', + 'PendingManual', + 'FullyGraded', + 'Failed', + ]), + status: expectD1StringLiteral(row.status, 'status', ['pending', 'published', 'failed']), + createdAt: expectD1String(row.createdAt, 'createdAt'), + updatedAt: expectD1String(row.updatedAt, 'updatedAt'), + publishedAt: expectD1NullableString(row.publishedAt, 'publishedAt'), + errorCode: expectD1NullableString(row.errorCode, 'errorCode'), + errorDetail: parseD1NullableJsonField(row.errorDetail, 'errorDetail', expectD1JsonRecord), + }; +} + +export function mapD1AuditEventFields(row: D1AuditEventRow): AuditEventRow { + return { + id: expectD1Number(row.id, 'id'), + eventType: expectD1String(row.eventType, 'eventType'), + actorType: expectD1StringLiteral(row.actorType, 'actorType', ['user', 'system', 'platform']), + actorId: expectD1NullableString(row.actorId, 'actorId'), + deploymentRecordId: expectD1NullableNumber(row.deploymentRecordId, 'deploymentRecordId'), + packageVersionId: expectD1NullableNumber(row.packageVersionId, 'packageVersionId'), + attemptId: expectD1NullableString(row.attemptId, 'attemptId'), + lineItemBindingId: expectD1NullableNumber(row.lineItemBindingId, 'lineItemBindingId'), + status: expectD1StringLiteral(row.status, 'status', ['accepted', 'succeeded', 'failed']), + summary: expectD1String(row.summary, 'summary'), + detail: parseD1JsonField(row.detail, 'detail', expectD1JsonRecord), + occurredAt: expectD1String(row.occurredAt, 'occurredAt'), + }; +} diff --git a/src/package_review/repository_grading_d1_test.ts b/src/package_review/repository_grading_d1_test.ts index 45cacf0..56d1707 100644 --- a/src/package_review/repository_grading_d1_test.ts +++ b/src/package_review/repository_grading_d1_test.ts @@ -5,7 +5,7 @@ import { createD1GradingRepositoryMethods } from './repository_grading_d1.ts'; Deno.test('D1 grading repository stores attempt evidence with next sequence', async () => { const artifactRow = buildAttemptEvidenceArtifactRow({ sequence: 2 }); const db = createPlannedD1Database({ - firstResults: [{ attemptId: 'attempt-1' }, { nextSequence: 2 }, artifactRow], + firstResults: [artifactRow], }); const repository = createD1GradingRepositoryMethods(db); @@ -22,13 +22,26 @@ Deno.test('D1 grading repository stores attempt evidence with next sequence', as }); assertEquals(artifact.sequence, 2); - assertEquals(db.statements[2]?.parameters.slice(0, 3), ['artifact-1', 'attempt-1', 2]); + assertEquals(db.statements[0]?.query.includes('next_evidence_artifact_sequence'), true); + assertEquals(db.statements[0]?.query.includes('COALESCE(MAX'), false); + assertEquals(db.statements[0]?.parameters, [ + 'artifact-1', + 'structured_json', + 'application/json', + 'evidence.json', + 'attempts/attempt-1/evidence.json', + 128, + 'sha256', + '2026-05-13T12:00:00.000Z', + 'attempt-1', + ]); + assertEquals(db.statements[1]?.query.includes('SET next_evidence_artifact_sequence'), true); + assertEquals(db.statements[1]?.parameters, ['attempt-1']); }); Deno.test('D1 grading repository reports duplicate evidence artifacts clearly', async () => { const db = createPlannedD1Database({ - firstResults: [{ attemptId: 'attempt-1' }, { nextSequence: 1 }], - runError: new Error('UNIQUE constraint failed: attempt_evidence_artifacts.artifact_id'), + batchError: new Error('UNIQUE constraint failed: attempt_evidence_artifacts.artifact_id'), }); const repository = createD1GradingRepositoryMethods(db); @@ -145,6 +158,8 @@ Deno.test('D1 grading repository records audit events with JSON detail', async ( interface PlannedD1Options { allResults?: Array>>; firstResults?: Array | null>; + firstError?: Error; + batchError?: Error; runError?: Error; } @@ -165,8 +180,32 @@ function createPlannedD1Database( prepare(query) { return createPlannedD1Statement(query, statements, allResults, firstResults, options); }, - batch(_statements) { - return Promise.resolve([]); + batch(batchStatements: D1PreparedStatement[]): Promise[]> { + for (const statement of batchStatements) { + const recorded = statement as D1PreparedStatement & RecordedStatement; + statements.push({ + query: recorded.query, + parameters: recorded.parameters, + }); + } + + if (options.batchError !== undefined) { + return Promise.resolve( + [ + { + success: false, + error: options.batchError.message, + }, + ] satisfies D1Result[], + ); + } + + return Promise.resolve( + batchStatements.map((_statement, index) => ({ + success: true, + results: (index === 0 ? [firstResults.shift()].filter(isRecord) : []) as T[], + })) satisfies D1Result[], + ); }, exec(_query) { return Promise.resolve({ @@ -186,8 +225,12 @@ function createPlannedD1Statement( ): D1PreparedStatement { let parameters: D1Parameter[] = []; - return { - bind(...values) { + const statement = { + query, + get parameters() { + return parameters; + }, + bind(...values: D1Parameter[]) { parameters = values; return this; }, @@ -200,6 +243,10 @@ function createPlannedD1Statement( }, first() { statements.push({ query, parameters }); + if (options.firstError !== undefined) { + throw options.firstError; + } + return Promise.resolve((firstResults.shift() as T | null | undefined) ?? null); }, run() { @@ -215,6 +262,14 @@ function createPlannedD1Statement( return Promise.resolve([] as T[]); }, }; + + return statement; +} + +function isRecord( + value: Record | null | undefined, +): value is Record { + return value !== null && value !== undefined; } function buildAttemptEvidenceArtifactRow( diff --git a/src/package_review/repository_launch_state_d1.ts b/src/package_review/repository_launch_state_d1.ts index 1c4fa0e..5361003 100644 --- a/src/package_review/repository_launch_state_d1.ts +++ b/src/package_review/repository_launch_state_d1.ts @@ -1,32 +1,31 @@ import type { D1Database } from '../db/d1.ts'; -import { queryD1First, queryD1Objects, runD1 } from '../db/d1.ts'; +import { queryD1First, queryD1Objects, runD1, runD1Batch } from '../db/d1.ts'; +import { + type D1AttemptEventRow, + type D1AttemptRow, + type D1DynamicRegistrationStateRow, + type D1RuntimeSessionRow, + mapD1AttemptEventFields, + mapD1AttemptFields, + mapD1DynamicRegistrationStateFields, + mapD1RuntimeSessionFields, +} from './repository_launch_state_d1_mapping.ts'; import { mapAttemptEventRow, mapAttemptRow, mapOptionalAttempt, } from './repository_mappers_attempts.ts'; -import { mapLoginStateRow, mapOptionalLoginState } from './repository_mappers_package.ts'; import { mapDynamicRegistrationStateRow, mapOptionalDynamicRegistrationState, } from './repository_mappers_package.ts'; import { mapOptionalRuntimeSession, mapRuntimeSessionRow } from './repository_mappers_sessions.ts'; -import type { - AttemptEventRow, - AttemptRow, - DynamicRegistrationStateRow, - LoginStateRow, - RuntimeSessionRow, -} from './repository_row_types.ts'; import type { PackageReviewRepository } from './repository.ts'; export function createD1LaunchStateRepositoryMethods( db: D1Database, ): Pick< PackageReviewRepository, - | 'createLoginState' - | 'getLoginStateByState' - | 'consumeLoginState' | 'createDynamicRegistrationState' | 'getDynamicRegistrationStateByState' | 'consumeDynamicRegistrationState' @@ -42,89 +41,6 @@ export function createD1LaunchStateRepositoryMethods( | 'writeAttemptLocalState' > { return { - async createLoginState(record) { - try { - await runD1( - db, - ` - INSERT INTO lti_login_states ( - lms_type, - state, - canvas_environment, - issuer, - client_id, - deployment_id, - nonce, - login_hint, - target_link_uri, - lti_message_hint, - created_at, - expires_at, - used_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - record.lms, - record.state, - record.canvasEnvironment, - record.issuer, - record.clientId, - record.deploymentId, - record.nonce, - record.loginHint, - record.targetLinkUri, - record.ltiMessageHint, - record.createdAt, - record.expiresAt, - record.usedAt, - ], - ); - } catch (error) { - if (isD1UniqueViolation(error)) { - throw new Error(`Login state ${record.state} already exists and cannot be reused.`); - } - - throw error; - } - - return await requireLoginState(db, record.state); - }, - - async getLoginStateByState(state) { - const row = await queryD1First( - db, - `${D1_LOGIN_STATE_SELECT} WHERE state = ?`, - [state], - ); - - return mapOptionalLoginState(row === null ? undefined : mapD1LoginStateFields(row)); - }, - - async consumeLoginState(input) { - await runD1( - db, - ` - UPDATE lti_login_states - SET used_at = ? - WHERE state = ? - AND used_at IS NULL - `, - [input.usedAt, input.state], - ); - - const existing = await requireLoginState(db, input.state); - - if (existing.usedAt === input.usedAt) { - return existing; - } - - if (existing.usedAt !== null) { - throw new Error(`Login state ${input.state} has already been used.`); - } - - throw new Error(`Login state ${input.state} could not be consumed.`); - }, - async createDynamicRegistrationState(record) { try { await runD1( @@ -206,6 +122,7 @@ export function createD1LaunchStateRepositoryMethods( INSERT INTO runtime_sessions ( session_id, session_token, + lti_tool_session_id, attempt_id, deployment_record_id, deployment_slug, @@ -228,11 +145,12 @@ export function createD1LaunchStateRepositoryMethods( preview_session_id, created_at, expires_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ record.sessionId, record.sessionToken, + record.ltiToolSessionId, record.attemptId, record.deploymentRecordId, record.deploymentSlug, @@ -383,60 +301,74 @@ export function createD1LaunchStateRepositoryMethods( }, async appendAttemptEvent(input) { - const attempt = await queryD1First<{ attemptId: string }>( + const results = await runD1Batch( db, - ` - SELECT attempt_id AS attemptId - FROM attempts - WHERE attempt_id = ? - `, - [input.attemptId], + [ + { + sql: ` + INSERT INTO attempt_events ( + attempt_id, + sequence, + event_type, + learning_verb, + object_id, + object_type, + result, + event, + received_at + ) + SELECT + attempts.attempt_id, + attempts.next_attempt_event_sequence, + ?, + ?, + ?, + ?, + ?, + ?, + ? + FROM attempts + WHERE attempts.attempt_id = ? + RETURNING + id, + attempt_id AS attemptId, + sequence, + event_type AS eventType, + learning_verb AS learningVerb, + object_id AS objectId, + object_type AS objectType, + result, + event, + received_at AS receivedAt + `, + parameters: [ + input.event.type, + input.normalizedEvent.learningVerb, + input.normalizedEvent.objectId, + input.normalizedEvent.objectType, + input.normalizedEvent.result, + input.event, + input.receivedAt, + input.attemptId, + ], + }, + { + sql: ` + UPDATE attempts + SET next_attempt_event_sequence = next_attempt_event_sequence + 1 + WHERE attempt_id = ? + `, + parameters: [input.attemptId], + }, + ], ); + const row = results[0]?.results?.[0] ?? null; - if (attempt === null) { + if (row === null) { throw new Error(`Attempt ${input.attemptId} was not found.`); } - const sequenceRow = await queryD1First<{ nextSequence: number }>( - db, - ` - SELECT COALESCE(MAX(sequence), 0) + 1 AS nextSequence - FROM attempt_events - WHERE attempt_id = ? - `, - [input.attemptId], - ); - const nextSequence = sequenceRow?.nextSequence ?? 1; - - await runD1( - db, - ` - INSERT INTO attempt_events ( - attempt_id, - sequence, - event_type, - learning_verb, - object_id, - object_type, - result, - event, - received_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - input.attemptId, - nextSequence, - input.event.type, - input.normalizedEvent.learningVerb, - input.normalizedEvent.objectId, - input.normalizedEvent.objectType, - input.normalizedEvent.result, - input.event, - input.receivedAt, - ], - ); - - return await requireAttemptEvent(db, input.attemptId, nextSequence); + return mapAttemptEventRow(mapD1AttemptEventFields(row)); }, async listAttemptEvents(attemptId) { @@ -491,24 +423,6 @@ export function createD1LaunchStateRepositoryMethods( }; } -const D1_LOGIN_STATE_SELECT = ` - SELECT - lms_type AS lmsType, - state, - canvas_environment AS canvasEnvironment, - issuer, - client_id AS clientId, - deployment_id AS deploymentId, - nonce, - login_hint AS loginHint, - target_link_uri AS targetLinkUri, - lti_message_hint AS ltiMessageHint, - created_at AS createdAt, - expires_at AS expiresAt, - used_at AS usedAt - FROM lti_login_states -`; - const D1_DYNAMIC_REGISTRATION_STATE_SELECT = ` SELECT state, @@ -524,6 +438,7 @@ const D1_RUNTIME_SESSION_SELECT = ` SELECT session_id AS sessionId, session_token AS sessionToken, + lti_tool_session_id AS ltiToolSessionId, attempt_id AS attemptId, deployment_record_id AS deploymentRecordId, deployment_slug AS deploymentSlug, @@ -589,106 +504,6 @@ const D1_ATTEMPT_EVENT_SELECT = ` FROM attempt_events `; -interface D1LoginStateRow extends Record { - lmsType: unknown; - state: unknown; - canvasEnvironment: unknown; - issuer: unknown; - clientId: unknown; - deploymentId: unknown; - nonce: unknown; - loginHint: unknown; - targetLinkUri: unknown; - ltiMessageHint: unknown; - createdAt: unknown; - expiresAt: unknown; - usedAt: unknown; -} - -interface D1DynamicRegistrationStateRow extends Record { - state: unknown; - appId: unknown; - lmsType: unknown; - createdAt: unknown; - expiresAt: unknown; - usedAt: unknown; -} - -interface D1RuntimeSessionRow extends Record { - sessionId: unknown; - sessionToken: unknown; - attemptId: unknown; - deploymentRecordId: unknown; - deploymentSlug: unknown; - appId: unknown; - packageVersionId: unknown; - packageVersion: unknown; - capabilities: unknown; - snapshotRoot: unknown; - entrypointPath: unknown; - contentPath: unknown; - agsScope: unknown; - agsLineitemsUrl: unknown; - agsLineitemUrl: unknown; - nrpsContextMembershipsUrl: unknown; - nrpsServiceVersions: unknown; - launchUserRole: unknown; - launchCourseId: unknown; - launchAssignmentId: unknown; - launchActivityId: unknown; - previewSessionId: unknown; - createdAt: unknown; - expiresAt: unknown; -} - -interface D1AttemptRow extends Record { - id: unknown; - attemptId: unknown; - deploymentRecordId: unknown; - deploymentSlug: unknown; - appId: unknown; - packageVersionId: unknown; - packageVersion: unknown; - userId: unknown; - userDisplayName: unknown; - userEmail: unknown; - userLogin: unknown; - userRole: unknown; - contextId: unknown; - resourceLinkId: unknown; - activityId: unknown; - status: unknown; - completionState: unknown; - localState: unknown; - startedAt: unknown; - finalizedAt: unknown; -} - -interface D1AttemptEventRow extends Record { - id: unknown; - attemptId: unknown; - sequence: unknown; - eventType: unknown; - learningVerb: unknown; - objectId: unknown; - objectType: unknown; - result: unknown; - event: unknown; - receivedAt: unknown; -} - -async function requireLoginState(db: D1Database, state: string) { - const row = await queryD1First(db, `${D1_LOGIN_STATE_SELECT} WHERE state = ?`, [ - state, - ]); - - if (row === null) { - throw new Error(`Login state ${state} was not found.`); - } - - return mapLoginStateRow(mapD1LoginStateFields(row)); -} - async function requireDynamicRegistrationState(db: D1Database, state: string) { const row = await queryD1First( db, @@ -729,215 +544,6 @@ async function requireAttempt(db: D1Database, attemptId: string) { return mapAttemptRow(mapD1AttemptFields(row)); } -async function requireAttemptEvent(db: D1Database, attemptId: string, sequence: number) { - const row = await queryD1First( - db, - ` - ${D1_ATTEMPT_EVENT_SELECT} - WHERE attempt_id = ? - AND sequence = ? - `, - [attemptId, sequence], - ); - - if (row === null) { - throw new Error(`Attempt event ${attemptId}#${sequence} was not found.`); - } - - return mapAttemptEventRow(mapD1AttemptEventFields(row)); -} - -function mapD1LoginStateFields(row: D1LoginStateRow): LoginStateRow { - return { - lmsType: expectStringLiteral(row.lmsType, 'lmsType', ['canvas', 'moodle', 'sakai']), - state: expectString(row.state, 'state'), - canvasEnvironment: expectNullableStringLiteral(row.canvasEnvironment, 'canvasEnvironment', [ - 'production', - 'beta', - 'test', - ]), - issuer: expectString(row.issuer, 'issuer'), - clientId: expectString(row.clientId, 'clientId'), - deploymentId: expectString(row.deploymentId, 'deploymentId'), - nonce: expectString(row.nonce, 'nonce'), - loginHint: expectString(row.loginHint, 'loginHint'), - targetLinkUri: expectString(row.targetLinkUri, 'targetLinkUri'), - ltiMessageHint: expectNullableString(row.ltiMessageHint, 'ltiMessageHint'), - createdAt: expectString(row.createdAt, 'createdAt'), - expiresAt: expectString(row.expiresAt, 'expiresAt'), - usedAt: expectNullableString(row.usedAt, 'usedAt'), - }; -} - -function mapD1DynamicRegistrationStateFields( - row: D1DynamicRegistrationStateRow, -): DynamicRegistrationStateRow { - return { - state: expectString(row.state, 'state'), - appId: expectString(row.appId, 'appId'), - lmsType: expectStringLiteral(row.lmsType, 'lmsType', ['canvas', 'moodle', 'sakai']), - createdAt: expectString(row.createdAt, 'createdAt'), - expiresAt: expectString(row.expiresAt, 'expiresAt'), - usedAt: expectNullableString(row.usedAt, 'usedAt'), - }; -} - -function mapD1RuntimeSessionFields(row: D1RuntimeSessionRow): RuntimeSessionRow { - return { - sessionId: expectString(row.sessionId, 'sessionId'), - sessionToken: expectString(row.sessionToken, 'sessionToken'), - attemptId: expectNullableString(row.attemptId, 'attemptId'), - deploymentRecordId: expectNumber(row.deploymentRecordId, 'deploymentRecordId'), - deploymentSlug: expectString(row.deploymentSlug, 'deploymentSlug'), - appId: expectString(row.appId, 'appId'), - packageVersionId: expectNumber(row.packageVersionId, 'packageVersionId'), - packageVersion: expectString(row.packageVersion, 'packageVersion'), - capabilities: parseJsonField( - row.capabilities, - 'capabilities', - ) as RuntimeSessionRow['capabilities'], - snapshotRoot: expectString(row.snapshotRoot, 'snapshotRoot'), - entrypointPath: expectString(row.entrypointPath, 'entrypointPath'), - contentPath: expectString(row.contentPath, 'contentPath'), - agsScope: parseJsonField(row.agsScope, 'agsScope') as RuntimeSessionRow['agsScope'], - agsLineitemsUrl: expectNullableString(row.agsLineitemsUrl, 'agsLineitemsUrl'), - agsLineitemUrl: expectNullableString(row.agsLineitemUrl, 'agsLineitemUrl'), - nrpsContextMembershipsUrl: expectNullableString( - row.nrpsContextMembershipsUrl, - 'nrpsContextMembershipsUrl', - ), - nrpsServiceVersions: parseJsonField( - row.nrpsServiceVersions, - 'nrpsServiceVersions', - ) as RuntimeSessionRow['nrpsServiceVersions'], - launchUserRole: expectStringLiteral(row.launchUserRole, 'launchUserRole', [ - 'learner', - 'instructor', - ]), - launchCourseId: expectString(row.launchCourseId, 'launchCourseId'), - launchAssignmentId: expectNullableString(row.launchAssignmentId, 'launchAssignmentId'), - launchActivityId: expectString(row.launchActivityId, 'launchActivityId'), - previewSessionId: expectNullableString(row.previewSessionId, 'previewSessionId'), - createdAt: expectString(row.createdAt, 'createdAt'), - expiresAt: expectString(row.expiresAt, 'expiresAt'), - }; -} - -function mapD1AttemptFields(row: D1AttemptRow): AttemptRow { - return { - id: expectNumber(row.id, 'id'), - attemptId: expectString(row.attemptId, 'attemptId'), - deploymentRecordId: expectNumber(row.deploymentRecordId, 'deploymentRecordId'), - deploymentSlug: expectString(row.deploymentSlug, 'deploymentSlug'), - appId: expectString(row.appId, 'appId'), - packageVersionId: expectNumber(row.packageVersionId, 'packageVersionId'), - packageVersion: expectString(row.packageVersion, 'packageVersion'), - userId: expectString(row.userId, 'userId'), - userDisplayName: expectNullableString(row.userDisplayName, 'userDisplayName'), - userEmail: expectNullableString(row.userEmail, 'userEmail'), - userLogin: expectNullableString(row.userLogin, 'userLogin'), - userRole: expectStringLiteral(row.userRole, 'userRole', ['learner', 'instructor']), - contextId: expectString(row.contextId, 'contextId'), - resourceLinkId: expectString(row.resourceLinkId, 'resourceLinkId'), - activityId: expectString(row.activityId, 'activityId'), - status: expectStringLiteral(row.status, 'status', ['in_progress', 'completed', 'abandoned']), - completionState: expectNullableStringLiteral(row.completionState, 'completionState', [ - 'completed', - 'abandoned', - ]), - localState: parseNullableJsonField(row.localState, 'localState') as AttemptRow['localState'], - startedAt: expectString(row.startedAt, 'startedAt'), - finalizedAt: expectNullableString(row.finalizedAt, 'finalizedAt'), - }; -} - -function mapD1AttemptEventFields(row: D1AttemptEventRow): AttemptEventRow { - return { - id: expectNumber(row.id, 'id'), - attemptId: expectString(row.attemptId, 'attemptId'), - sequence: expectNumber(row.sequence, 'sequence'), - eventType: expectString(row.eventType, 'eventType') as AttemptEventRow['eventType'], - learningVerb: expectStringLiteral(row.learningVerb, 'learningVerb', [ - 'answered', - 'progressed', - 'completed', - ]), - objectId: expectString(row.objectId, 'objectId'), - objectType: expectStringLiteral(row.objectType, 'objectType', [ - 'question', - 'checkpoint', - 'activity', - ]), - result: parseJsonField(row.result, 'result') as AttemptEventRow['result'], - event: parseJsonField(row.event, 'event') as AttemptEventRow['event'], - receivedAt: expectString(row.receivedAt, 'receivedAt'), - }; -} - function isD1UniqueViolation(error: unknown): boolean { return error instanceof Error && error.message.includes('UNIQUE constraint failed'); } - -function parseJsonField(value: unknown, fieldName: string): unknown { - if (typeof value !== 'string') { - throw new TypeError(`Expected D1 ${fieldName} to be JSON text.`); - } - - return JSON.parse(value); -} - -function parseNullableJsonField(value: unknown, fieldName: string): unknown | null { - if (value === null) { - return null; - } - - return parseJsonField(value, fieldName); -} - -function expectString(value: unknown, fieldName: string): string { - if (typeof value !== 'string') { - throw new TypeError(`Expected D1 ${fieldName} to be text.`); - } - - return value; -} - -function expectNullableString(value: unknown, fieldName: string): string | null { - if (value === null) { - return null; - } - - return expectString(value, fieldName); -} - -function expectNumber(value: unknown, fieldName: string): number { - if (typeof value !== 'number') { - throw new TypeError(`Expected D1 ${fieldName} to be numeric.`); - } - - return value; -} - -function expectStringLiteral( - value: unknown, - fieldName: string, - allowed: readonly T[], -): T { - if (typeof value !== 'string' || !allowed.includes(value as T)) { - throw new Error(`Unexpected D1 ${fieldName} value.`); - } - - return value as T; -} - -function expectNullableStringLiteral( - value: unknown, - fieldName: string, - allowed: readonly T[], -): T | null { - if (value === null) { - return null; - } - - return expectStringLiteral(value, fieldName, allowed); -} diff --git a/src/package_review/repository_launch_state_d1_mapping.ts b/src/package_review/repository_launch_state_d1_mapping.ts new file mode 100644 index 0000000..f9a5de2 --- /dev/null +++ b/src/package_review/repository_launch_state_d1_mapping.ts @@ -0,0 +1,337 @@ +import { + expectD1JsonRecord, + expectD1NullableString, + expectD1NullableStringLiteral, + expectD1Number, + expectD1String, + expectD1StringArray, + expectD1StringLiteral, + parseD1JsonField, + parseD1NullableJsonField, +} from '../db/d1_row.ts'; +import type { AttemptEvent, Capability, NormalizedAttemptEvent } from '../../sdk/app-sdk.ts'; +import type { + AttemptEventRow, + AttemptRow, + DynamicRegistrationStateRow, + RuntimeSessionRow, +} from './repository_row_types.ts'; + +export interface D1DynamicRegistrationStateRow extends Record { + state: unknown; + appId: unknown; + lmsType: unknown; + createdAt: unknown; + expiresAt: unknown; + usedAt: unknown; +} + +export interface D1RuntimeSessionRow extends Record { + sessionId: unknown; + sessionToken: unknown; + ltiToolSessionId: unknown; + attemptId: unknown; + deploymentRecordId: unknown; + deploymentSlug: unknown; + appId: unknown; + packageVersionId: unknown; + packageVersion: unknown; + capabilities: unknown; + snapshotRoot: unknown; + entrypointPath: unknown; + contentPath: unknown; + agsScope: unknown; + agsLineitemsUrl: unknown; + agsLineitemUrl: unknown; + nrpsContextMembershipsUrl: unknown; + nrpsServiceVersions: unknown; + launchUserRole: unknown; + launchCourseId: unknown; + launchAssignmentId: unknown; + launchActivityId: unknown; + previewSessionId: unknown; + createdAt: unknown; + expiresAt: unknown; +} + +export interface D1AttemptRow extends Record { + id: unknown; + attemptId: unknown; + deploymentRecordId: unknown; + deploymentSlug: unknown; + appId: unknown; + packageVersionId: unknown; + packageVersion: unknown; + userId: unknown; + userDisplayName: unknown; + userEmail: unknown; + userLogin: unknown; + userRole: unknown; + contextId: unknown; + resourceLinkId: unknown; + activityId: unknown; + status: unknown; + completionState: unknown; + localState: unknown; + startedAt: unknown; + finalizedAt: unknown; +} + +export interface D1AttemptEventRow extends Record { + id: unknown; + attemptId: unknown; + sequence: unknown; + eventType: unknown; + learningVerb: unknown; + objectId: unknown; + objectType: unknown; + result: unknown; + event: unknown; + receivedAt: unknown; +} + +export function mapD1DynamicRegistrationStateFields( + row: D1DynamicRegistrationStateRow, +): DynamicRegistrationStateRow { + return { + state: expectD1String(row.state, 'state'), + appId: expectD1String(row.appId, 'appId'), + lmsType: expectD1StringLiteral(row.lmsType, 'lmsType', ['canvas', 'moodle', 'sakai']), + createdAt: expectD1String(row.createdAt, 'createdAt'), + expiresAt: expectD1String(row.expiresAt, 'expiresAt'), + usedAt: expectD1NullableString(row.usedAt, 'usedAt'), + }; +} + +export function mapD1RuntimeSessionFields(row: D1RuntimeSessionRow): RuntimeSessionRow { + return { + sessionId: expectD1String(row.sessionId, 'sessionId'), + sessionToken: expectD1String(row.sessionToken, 'sessionToken'), + ltiToolSessionId: expectD1NullableString(row.ltiToolSessionId, 'ltiToolSessionId'), + attemptId: expectD1NullableString(row.attemptId, 'attemptId'), + deploymentRecordId: expectD1Number(row.deploymentRecordId, 'deploymentRecordId'), + deploymentSlug: expectD1String(row.deploymentSlug, 'deploymentSlug'), + appId: expectD1String(row.appId, 'appId'), + packageVersionId: expectD1Number(row.packageVersionId, 'packageVersionId'), + packageVersion: expectD1String(row.packageVersion, 'packageVersion'), + capabilities: parseD1JsonField( + row.capabilities, + 'capabilities', + parseCapabilityArray, + ), + snapshotRoot: expectD1String(row.snapshotRoot, 'snapshotRoot'), + entrypointPath: expectD1String(row.entrypointPath, 'entrypointPath'), + contentPath: expectD1String(row.contentPath, 'contentPath'), + agsScope: parseD1JsonField(row.agsScope, 'agsScope', expectD1StringArray), + agsLineitemsUrl: expectD1NullableString(row.agsLineitemsUrl, 'agsLineitemsUrl'), + agsLineitemUrl: expectD1NullableString(row.agsLineitemUrl, 'agsLineitemUrl'), + nrpsContextMembershipsUrl: expectD1NullableString( + row.nrpsContextMembershipsUrl, + 'nrpsContextMembershipsUrl', + ), + nrpsServiceVersions: parseD1JsonField( + row.nrpsServiceVersions, + 'nrpsServiceVersions', + expectD1StringArray, + ), + launchUserRole: expectD1StringLiteral(row.launchUserRole, 'launchUserRole', [ + 'learner', + 'instructor', + ]), + launchCourseId: expectD1String(row.launchCourseId, 'launchCourseId'), + launchAssignmentId: expectD1NullableString(row.launchAssignmentId, 'launchAssignmentId'), + launchActivityId: expectD1String(row.launchActivityId, 'launchActivityId'), + previewSessionId: expectD1NullableString(row.previewSessionId, 'previewSessionId'), + createdAt: expectD1String(row.createdAt, 'createdAt'), + expiresAt: expectD1String(row.expiresAt, 'expiresAt'), + }; +} + +export function mapD1AttemptFields(row: D1AttemptRow): AttemptRow { + return { + id: expectD1Number(row.id, 'id'), + attemptId: expectD1String(row.attemptId, 'attemptId'), + deploymentRecordId: expectD1Number(row.deploymentRecordId, 'deploymentRecordId'), + deploymentSlug: expectD1String(row.deploymentSlug, 'deploymentSlug'), + appId: expectD1String(row.appId, 'appId'), + packageVersionId: expectD1Number(row.packageVersionId, 'packageVersionId'), + packageVersion: expectD1String(row.packageVersion, 'packageVersion'), + userId: expectD1String(row.userId, 'userId'), + userDisplayName: expectD1NullableString(row.userDisplayName, 'userDisplayName'), + userEmail: expectD1NullableString(row.userEmail, 'userEmail'), + userLogin: expectD1NullableString(row.userLogin, 'userLogin'), + userRole: expectD1StringLiteral(row.userRole, 'userRole', ['learner', 'instructor']), + contextId: expectD1String(row.contextId, 'contextId'), + resourceLinkId: expectD1String(row.resourceLinkId, 'resourceLinkId'), + activityId: expectD1String(row.activityId, 'activityId'), + status: expectD1StringLiteral(row.status, 'status', ['in_progress', 'completed', 'abandoned']), + completionState: expectD1NullableStringLiteral(row.completionState, 'completionState', [ + 'completed', + 'abandoned', + ]), + localState: parseD1NullableJsonField(row.localState, 'localState', expectD1JsonRecord), + startedAt: expectD1String(row.startedAt, 'startedAt'), + finalizedAt: expectD1NullableString(row.finalizedAt, 'finalizedAt'), + }; +} + +export function mapD1AttemptEventFields(row: D1AttemptEventRow): AttemptEventRow { + return { + id: expectD1Number(row.id, 'id'), + attemptId: expectD1String(row.attemptId, 'attemptId'), + sequence: expectD1Number(row.sequence, 'sequence'), + eventType: expectD1String(row.eventType, 'eventType') as AttemptEventRow['eventType'], + learningVerb: expectD1StringLiteral(row.learningVerb, 'learningVerb', [ + 'answered', + 'progressed', + 'completed', + ]), + objectId: expectD1String(row.objectId, 'objectId'), + objectType: expectD1StringLiteral(row.objectType, 'objectType', [ + 'question', + 'checkpoint', + 'activity', + ]), + result: parseD1JsonField(row.result, 'result', parseAttemptEventResult), + event: parseD1JsonField(row.event, 'event', parseAttemptEvent), + receivedAt: expectD1String(row.receivedAt, 'receivedAt'), + }; +} + +function parseCapabilityArray(value: unknown, fieldName: string): Capability[] { + return expectD1StringArray(value, fieldName).map((capability, index) => + expectD1StringLiteral(capability, `${fieldName}[${index}]`, [ + 'read_launch_context', + 'read_activity_content', + 'submit_attempt_event', + 'submit_evidence_artifact', + 'finalize_attempt', + 'read_local_state', + 'write_local_state', + ]) + ); +} + +function parseAttemptEventResult( + value: unknown, + fieldName: string, +): NormalizedAttemptEvent['result'] { + const record = expectD1JsonRecord(value, fieldName); + + if ('completionState' in record) { + return { + completionState: expectD1StringLiteral( + record.completionState, + `${fieldName}.completionState`, + [ + 'completed', + ], + ), + }; + } + + if ('value' in record) { + return { + value: expectD1Number(record.value, `${fieldName}.value`), + }; + } + + const response = parseAttemptResponse(record.response, `${fieldName}.response`); + + return { + response, + success: parseNullableBoolean(record.success, `${fieldName}.success`), + scoreGiven: parseNullableNumber(record.scoreGiven, `${fieldName}.scoreGiven`), + scoreMaximum: parseNullableNumber(record.scoreMaximum, `${fieldName}.scoreMaximum`), + }; +} + +function parseAttemptEvent(value: unknown, fieldName: string): AttemptEvent { + const record = expectD1JsonRecord(value, fieldName); + const type = expectD1StringLiteral(record.type, `${fieldName}.type`, [ + 'answer', + 'progress', + 'complete', + ]); + + if (type === 'answer') { + const event: AttemptEvent = { + type, + questionId: expectD1String(record.questionId, `${fieldName}.questionId`), + answer: parseAttemptResponse(record.answer, `${fieldName}.answer`), + timestamp: expectD1String(record.timestamp, `${fieldName}.timestamp`), + }; + const correct = parseOptionalBoolean(record.correct, `${fieldName}.correct`); + const scoreGiven = parseOptionalNumber(record.scoreGiven, `${fieldName}.scoreGiven`); + const scoreMaximum = parseOptionalNumber(record.scoreMaximum, `${fieldName}.scoreMaximum`); + + return { + ...event, + ...(correct === undefined ? {} : { correct }), + ...(scoreGiven === undefined ? {} : { scoreGiven }), + ...(scoreMaximum === undefined ? {} : { scoreMaximum }), + }; + } + + if (type === 'progress') { + return { + type, + checkpoint: expectD1String(record.checkpoint, `${fieldName}.checkpoint`), + value: expectD1Number(record.value, `${fieldName}.value`), + timestamp: expectD1String(record.timestamp, `${fieldName}.timestamp`), + }; + } + + return { + type, + timestamp: expectD1String(record.timestamp, `${fieldName}.timestamp`), + }; +} + +function parseAttemptResponse(value: unknown, fieldName: string): string | string[] { + if (typeof value === 'string') { + return value; + } + + return expectD1StringArray(value, fieldName); +} + +function parseNullableBoolean(value: unknown, fieldName: string): boolean | null { + if (value === null) { + return null; + } + + if (typeof value !== 'boolean') { + throw new TypeError(`Expected D1 ${fieldName} JSON to be boolean or null.`); + } + + return value; +} + +function parseOptionalBoolean(value: unknown, fieldName: string): boolean | undefined { + if (value === undefined) { + return undefined; + } + + if (typeof value !== 'boolean') { + throw new TypeError(`Expected D1 ${fieldName} JSON to be boolean when present.`); + } + + return value; +} + +function parseNullableNumber(value: unknown, fieldName: string): number | null { + if (value === null) { + return null; + } + + return expectD1Number(value, fieldName); +} + +function parseOptionalNumber(value: unknown, fieldName: string): number | undefined { + if (value === undefined) { + return undefined; + } + + return expectD1Number(value, fieldName); +} diff --git a/src/package_review/repository_mappers_package.ts b/src/package_review/repository_mappers_package.ts index 510fe5e..1c270d4 100644 --- a/src/package_review/repository_mappers_package.ts +++ b/src/package_review/repository_mappers_package.ts @@ -1,8 +1,4 @@ -import type { - DeploymentBinding, - DynamicRegistrationStateRecord, - LoginStateRecord, -} from '../lti/types.ts'; +import type { DeploymentBinding, DynamicRegistrationStateRecord } from '../lti/types.ts'; import { parseReviewedRuntimeContract } from './runtime_contract.ts'; import { type DeploymentRecord, @@ -12,7 +8,6 @@ import { import type { DeploymentRow, DynamicRegistrationStateRow, - LoginStateRow, PackageVersionRow, } from './repository_row_types.ts'; import { normalizeOptionalTimestamp, normalizeTimestamp } from './repository_value_support.ts'; @@ -146,44 +141,6 @@ export function mapDeploymentBinding(row: DeploymentRow): DeploymentBinding | nu } } -export function mapOptionalLoginState(row: LoginStateRow | undefined): LoginStateRecord | null { - if (!row) { - return null; - } - - return mapLoginStateRow(row); -} - -export function mapLoginStateRow(row: LoginStateRow | undefined): LoginStateRecord { - if (!row) { - throw new Error('Expected a login state row.'); - } - - if (row.lmsType === 'canvas' && row.canvasEnvironment === null) { - throw new Error('Canvas login state rows must include canvas_environment.'); - } - - if (row.lmsType !== 'canvas' && row.canvasEnvironment !== null) { - throw new Error(`${row.lmsType} login state rows cannot include canvas_environment.`); - } - - return { - lms: row.lmsType, - state: row.state, - canvasEnvironment: row.canvasEnvironment, - issuer: row.issuer, - clientId: row.clientId, - deploymentId: row.deploymentId, - nonce: row.nonce, - loginHint: row.loginHint, - targetLinkUri: row.targetLinkUri, - ltiMessageHint: row.ltiMessageHint, - createdAt: normalizeTimestamp(row.createdAt), - expiresAt: normalizeTimestamp(row.expiresAt), - usedAt: normalizeOptionalTimestamp(row.usedAt), - }; -} - export function mapOptionalDynamicRegistrationState( row: DynamicRegistrationStateRow | undefined, ): DynamicRegistrationStateRecord | null { diff --git a/src/package_review/repository_mappers_sessions.ts b/src/package_review/repository_mappers_sessions.ts index 8fcc1ce..f1b4b3a 100644 --- a/src/package_review/repository_mappers_sessions.ts +++ b/src/package_review/repository_mappers_sessions.ts @@ -30,6 +30,7 @@ export function mapRuntimeSessionRow(row: RuntimeSessionRow | undefined): Runtim return { sessionId: row.sessionId, sessionToken: row.sessionToken, + ltiToolSessionId: row.ltiToolSessionId, attemptId: row.attemptId ?? row.sessionId, deploymentRecordId: row.deploymentRecordId, deploymentSlug: row.deploymentSlug, @@ -78,6 +79,7 @@ export function mapDeepLinkingSessionRow( return { sessionId: row.sessionId, sessionToken: row.sessionToken, + ltiToolSessionId: row.ltiToolSessionId, deploymentRecordId: row.deploymentRecordId, deploymentSlug: row.deploymentSlug, appId: row.appId, diff --git a/src/package_review/repository_mappers_sessions_test.ts b/src/package_review/repository_mappers_sessions_test.ts index 81ae472..1fe198d 100644 --- a/src/package_review/repository_mappers_sessions_test.ts +++ b/src/package_review/repository_mappers_sessions_test.ts @@ -16,6 +16,7 @@ function buildRuntimeSessionRow(overrides: Partial = {}): Run return { sessionId: 'runtime-session-123', sessionToken: 'runtime-token-123', + ltiToolSessionId: null, attemptId: 'attempt-123', deploymentRecordId: 1, deploymentSlug: 'flashcard-practice-preview', diff --git a/src/package_review/repository_package_versions_d1.ts b/src/package_review/repository_package_versions_d1.ts index 4008605..7b6b98b 100644 --- a/src/package_review/repository_package_versions_d1.ts +++ b/src/package_review/repository_package_versions_d1.ts @@ -1,10 +1,28 @@ import type { D1Database } from '../db/d1.ts'; import { queryD1First, queryD1Objects, runD1 } from '../db/d1.ts'; +import { + expectD1JsonRecord, + expectD1NullableNumber, + expectD1NullableString, + expectD1Number, + expectD1String, + expectD1StringArray, + expectD1StringLiteral, + parseD1JsonField, + parseD1NullableJsonField, +} from '../db/d1_row.ts'; +import type { Capability, UserRole } from '../../sdk/app-sdk.ts'; import type { ImportedPackageVersion } from './intake.ts'; import { mapOptionalPackageVersion, mapPackageVersionRow } from './repository_mappers_package.ts'; import type { PackageVersionRow } from './repository_row_types.ts'; import { sortPackageVersions } from './repository_resource_options.ts'; import type { PackageReviewRepository } from './repository.ts'; +import type { + AccessibilityReview, + AccessibilityReviewStatus, + ReviewedRuntimeContract, + ValidationIssue, +} from './types.ts'; import { createD1AuthoringRepositoryMethods } from './repository_authoring_d1.ts'; import { createD1DeepLinkingRepositoryMethods } from './repository_deep_linking_d1.ts'; import { createD1DeploymentRepositoryMethods } from './repository_deployments_d1.ts'; @@ -14,7 +32,7 @@ import { createD1PreviewRepositoryMethods } from './repository_preview_d1.ts'; import { createD1AppGenerationRepositoryMethods } from '../app_writer/repository_d1.ts'; export function createD1PackageReviewRepository(db: D1Database): PackageReviewRepository { - const portedMethods = { + const portedMethods: PackageReviewRepository = { ...createD1PackageVersionRepositoryMethods(db), ...createD1DeploymentRepositoryMethods(db), ...createD1LaunchStateRepositoryMethods(db), @@ -25,27 +43,7 @@ export function createD1PackageReviewRepository(db: D1Database): PackageReviewRe ...createD1AppGenerationRepositoryMethods(db), }; - return new Proxy(portedMethods, { - get(target, property, receiver) { - if (property === Symbol.toStringTag) { - return 'D1PackageReviewRepository'; - } - - if (property in target) { - return Reflect.get(target, property, receiver); - } - - return () => { - throw new Error( - `D1 repository method ${ - String( - property, - ) - } is not ported yet. Continue the Cloudflare D1 repository migration before using this route on Workers.`, - ); - }; - }, - }) as PackageReviewRepository; + return portedMethods; } export function createD1PackageVersionRepositoryMethods( @@ -310,58 +308,72 @@ export function mapD1PackageVersionRow(row: D1PackageVersionRow) { function mapD1PackageVersionFields(row: D1PackageVersionRow): PackageVersionRow { return { - id: expectNumber(row.id, 'id'), - appId: expectString(row.appId, 'appId'), - version: expectString(row.version, 'version'), - title: expectString(row.title, 'title'), - description: expectNullableString(row.description, 'description'), - ownerType: expectStringLiteral(row.ownerType, 'ownerType', ['user']), - ownerId: expectString(row.ownerId, 'ownerId'), - entrypoint: expectString(row.entrypoint, 'entrypoint'), - roles: parseJsonField(row.roles, 'roles') as PackageVersionRow['roles'], - installScope: expectStringLiteral(row.installScope, 'installScope', ['course', 'assignment']), - capabilities: parseJsonField( + id: expectD1Number(row.id, 'package_versions.id'), + appId: expectD1String(row.appId, 'package_versions.appId'), + version: expectD1String(row.version, 'package_versions.version'), + title: expectD1String(row.title, 'package_versions.title'), + description: expectD1NullableString(row.description, 'package_versions.description'), + ownerType: expectD1StringLiteral(row.ownerType, 'package_versions.ownerType', ['user']), + ownerId: expectD1String(row.ownerId, 'package_versions.ownerId'), + entrypoint: expectD1String(row.entrypoint, 'package_versions.entrypoint'), + roles: parseD1JsonField(row.roles, 'package_versions.roles', parseUserRoleArray), + installScope: expectD1StringLiteral(row.installScope, 'package_versions.installScope', [ + 'course', + 'assignment', + ]), + capabilities: parseD1JsonField( row.capabilities, - 'capabilities', - ) as PackageVersionRow['capabilities'], - gradingMode: expectStringLiteral(row.gradingMode, 'gradingMode', [ + 'package_versions.capabilities', + parseCapabilityArray, + ), + gradingMode: expectD1StringLiteral(row.gradingMode, 'package_versions.gradingMode', [ 'declarative', 'manual', 'completion', 'browser', ]), - gradingRubricFile: expectNullableString(row.gradingRubricFile, 'gradingRubricFile'), - gradingMaxScore: expectNullableNumber(row.gradingMaxScore, 'gradingMaxScore'), - approvalStatus: expectStringLiteral(row.approvalStatus, 'approvalStatus', [ + gradingRubricFile: expectD1NullableString( + row.gradingRubricFile, + 'package_versions.gradingRubricFile', + ), + gradingMaxScore: expectD1NullableNumber( + row.gradingMaxScore, + 'package_versions.gradingMaxScore', + ), + approvalStatus: expectD1StringLiteral(row.approvalStatus, 'package_versions.approvalStatus', [ 'pending', 'approved', 'rejected', ]), - reviewNotes: expectNullableString(row.reviewNotes, 'reviewNotes'), - accessibilityReview: parseNullableJsonField( + reviewNotes: expectD1NullableString(row.reviewNotes, 'package_versions.reviewNotes'), + accessibilityReview: parseD1NullableJsonField( row.accessibilityReview, - 'accessibilityReview', - ) as PackageVersionRow['accessibilityReview'], - reviewedAt: expectNullableString(row.reviewedAt, 'reviewedAt'), - validationIssues: parseJsonField( + 'package_versions.accessibilityReview', + parseAccessibilityReview, + ), + reviewedAt: expectD1NullableString(row.reviewedAt, 'package_versions.reviewedAt'), + validationIssues: parseD1JsonField( row.validationIssues, - 'validationIssues', - ) as PackageVersionRow['validationIssues'], - manifestJson: parseJsonField( + 'package_versions.validationIssues', + parseValidationIssues, + ), + manifestJson: parseD1JsonField( row.manifestJson, - 'manifestJson', - ) as PackageVersionRow['manifestJson'], - artifactRoot: expectString(row.artifactRoot, 'artifactRoot'), - artifactDigest: expectString(row.artifactDigest, 'artifactDigest'), - runtimeContract: parseNullableJsonField( + 'package_versions.manifestJson', + expectD1JsonRecord, + ), + artifactRoot: expectD1String(row.artifactRoot, 'package_versions.artifactRoot'), + artifactDigest: expectD1String(row.artifactDigest, 'package_versions.artifactDigest'), + runtimeContract: parseD1NullableJsonField( row.runtimeContract, - 'runtimeContract', - ) as PackageVersionRow['runtimeContract'], - runtimeContractSignature: expectNullableString( + 'package_versions.runtimeContract', + parseReviewedRuntimeContract, + ), + runtimeContractSignature: expectD1NullableString( row.runtimeContractSignature, - 'runtimeContractSignature', + 'package_versions.runtimeContractSignature', ), - importedAt: expectString(row.importedAt, 'importedAt'), + importedAt: expectD1String(row.importedAt, 'package_versions.importedAt'), }; } @@ -369,62 +381,89 @@ function isD1UniqueViolation(error: unknown): boolean { return error instanceof Error && error.message.includes('UNIQUE constraint failed'); } -function parseJsonField(value: unknown, fieldName: string): unknown { - if (typeof value !== 'string') { - throw new TypeError(`Expected D1 package_versions.${fieldName} to be JSON text.`); - } - - return JSON.parse(value); +function parseUserRoleArray(value: unknown, fieldName: string): UserRole[] { + return expectD1StringArray(value, fieldName).map((role, index) => + expectD1StringLiteral(role, `${fieldName}[${index}]`, ['learner', 'instructor']) + ); } -function parseNullableJsonField(value: unknown, fieldName: string): unknown | null { - if (value === null) { - return null; - } - - return parseJsonField(value, fieldName); +function parseCapabilityArray(value: unknown, fieldName: string): Capability[] { + return expectD1StringArray(value, fieldName).map((capability, index) => + expectD1StringLiteral(capability, `${fieldName}[${index}]`, [ + 'read_launch_context', + 'read_activity_content', + 'submit_attempt_event', + 'submit_evidence_artifact', + 'finalize_attempt', + 'read_local_state', + 'write_local_state', + ]) + ); } -function expectString(value: unknown, fieldName: string): string { - if (typeof value !== 'string') { - throw new TypeError(`Expected D1 package_versions.${fieldName} to be text.`); - } +function parseAccessibilityReview(value: unknown, fieldName: string): AccessibilityReview { + const record = expectD1JsonRecord(value, fieldName); - return value; + return { + keyboard: parseAccessibilityReviewStatus(record.keyboard, `${fieldName}.keyboard`), + focusVisible: parseAccessibilityReviewStatus(record.focusVisible, `${fieldName}.focusVisible`), + focusNotObscured: parseAccessibilityReviewStatus( + record.focusNotObscured, + `${fieldName}.focusNotObscured`, + ), + structure: parseAccessibilityReviewStatus(record.structure, `${fieldName}.structure`), + contrast: parseAccessibilityReviewStatus(record.contrast, `${fieldName}.contrast`), + reducedMotion: parseAccessibilityReviewStatus( + record.reducedMotion, + `${fieldName}.reducedMotion`, + ), + equivalentAlternatives: parseAccessibilityReviewStatus( + record.equivalentAlternatives, + `${fieldName}.equivalentAlternatives`, + ), + failureNotes: expectD1NullableString(record.failureNotes, `${fieldName}.failureNotes`), + exceptionNote: expectD1NullableString(record.exceptionNote, `${fieldName}.exceptionNote`), + }; } -function expectNullableString(value: unknown, fieldName: string): string | null { - if (value === null) { - return null; - } - - return expectString(value, fieldName); +function parseAccessibilityReviewStatus( + value: unknown, + fieldName: string, +): AccessibilityReviewStatus { + return expectD1StringLiteral(value, fieldName, ['pass', 'fail', 'not_applicable']); } -function expectNumber(value: unknown, fieldName: string): number { - if (typeof value !== 'number') { - throw new TypeError(`Expected D1 package_versions.${fieldName} to be numeric.`); - } - - return value; +function parseValidationIssues(value: unknown, fieldName: string): ValidationIssue[] { + return expectJsonRecordArray(value, fieldName).map((issue, index) => ({ + field: expectD1String(issue.field, `${fieldName}[${index}].field`), + message: expectD1String(issue.message, `${fieldName}[${index}].message`), + keyword: expectD1String(issue.keyword, `${fieldName}[${index}].keyword`), + severity: expectD1StringLiteral(issue.severity, `${fieldName}[${index}].severity`, ['error']), + })); } -function expectNullableNumber(value: unknown, fieldName: string): number | null { - if (value === null) { - return null; - } +function parseReviewedRuntimeContract( + value: unknown, + fieldName: string, +): ReviewedRuntimeContract { + const record = expectD1JsonRecord(value, fieldName); - return expectNumber(value, fieldName); + return { + appId: expectD1String(record.appId, `${fieldName}.appId`), + packageVersion: expectD1String(record.packageVersion, `${fieldName}.packageVersion`), + artifactDigest: expectD1String(record.artifactDigest, `${fieldName}.artifactDigest`), + entrypoint: expectD1String(record.entrypoint, `${fieldName}.entrypoint`), + capabilities: parseCapabilityArray(record.capabilities, `${fieldName}.capabilities`), + }; } -function expectStringLiteral( +function expectJsonRecordArray( value: unknown, fieldName: string, - allowed: readonly T[], -): T { - if (typeof value !== 'string' || !allowed.includes(value as T)) { - throw new Error(`Unexpected D1 package_versions.${fieldName} value.`); +): Array> { + if (!Array.isArray(value)) { + throw new TypeError(`Expected D1 ${fieldName} JSON to be an object array.`); } - return value as T; + return value.map((item, index) => expectD1JsonRecord(item, `${fieldName}[${index}]`)); } diff --git a/src/package_review/repository_preview_d1.ts b/src/package_review/repository_preview_d1.ts index 557aa2b..420c0f8 100644 --- a/src/package_review/repository_preview_d1.ts +++ b/src/package_review/repository_preview_d1.ts @@ -1,5 +1,15 @@ import type { D1Database } from '../db/d1.ts'; -import { queryD1First, queryD1Objects, runD1 } from '../db/d1.ts'; +import { queryD1First, queryD1Objects, runD1, runD1Batch } from '../db/d1.ts'; +import { + expectD1JsonRecord, + expectD1NullableString, + expectD1Number, + expectD1String, + expectD1StringArray, + expectD1StringLiteral, + parseD1JsonField, +} from '../db/d1_row.ts'; +import type { Capability } from '../../sdk/app-sdk.ts'; import { mapOptionalPreviewSession, mapPreviewEvidenceRow, @@ -7,6 +17,7 @@ import { } from './repository_mappers_review.ts'; import type { PreviewEvidenceRow, PreviewSessionRow } from './repository_row_types.ts'; import type { PackageReviewRepository } from './repository.ts'; +import type { PreviewFixtureData } from './types.ts'; export function createD1PreviewRepositoryMethods( db: D1Database, @@ -124,56 +135,66 @@ export function createD1PreviewRepositoryMethods( }, async appendPreviewEvidence(input) { - const session = await queryD1First<{ sessionId: string }>( + const results = await runD1Batch( db, - ` - SELECT session_id AS sessionId - FROM preview_sessions - WHERE session_id = ? - `, - [input.previewSessionId], + [ + { + sql: ` + INSERT INTO preview_evidence ( + preview_session_id, + sequence, + event_type, + capability, + summary, + detail, + occurred_at + ) + SELECT + preview_sessions.session_id, + preview_sessions.next_evidence_sequence, + ?, + ?, + ?, + ?, + ? + FROM preview_sessions + WHERE preview_sessions.session_id = ? + RETURNING + id, + preview_session_id AS previewSessionId, + sequence, + event_type AS eventType, + capability, + summary, + detail, + occurred_at AS occurredAt + `, + parameters: [ + input.eventType, + input.capability, + input.summary, + input.detail, + input.occurredAt, + input.previewSessionId, + ], + }, + { + sql: ` + UPDATE preview_sessions + SET next_evidence_sequence = next_evidence_sequence + 1 + WHERE session_id = ? + `, + parameters: [input.previewSessionId], + }, + ], ); + const row = results[0]?.results?.[0] ?? null; - if (session === null) { + if (row === null) { throw new Error(`Preview session ${input.previewSessionId} was not found.`); } - const sequenceRow = await queryD1First<{ nextSequence: number }>( - db, - ` - SELECT COALESCE(MAX(sequence), 0) + 1 AS nextSequence - FROM preview_evidence - WHERE preview_session_id = ? - `, - [input.previewSessionId], - ); - const nextSequence = sequenceRow?.nextSequence ?? 1; - - await runD1( - db, - ` - INSERT INTO preview_evidence ( - preview_session_id, - sequence, - event_type, - capability, - summary, - detail, - occurred_at - ) VALUES (?, ?, ?, ?, ?, ?, ?) - `, - [ - input.previewSessionId, - nextSequence, - input.eventType, - input.capability, - input.summary, - input.detail, - input.occurredAt, - ], - ); - - return await requirePreviewEvidence(db, input.previewSessionId, nextSequence); + return mapPreviewEvidenceRow(mapD1PreviewEvidenceFields(row)); }, async listPreviewEvidence(previewSessionId) { @@ -278,72 +299,52 @@ async function requirePreviewSession(db: D1Database, sessionId: string) { return mapPreviewSessionRow(mapD1PreviewSessionFields(row)); } -async function requirePreviewEvidence(db: D1Database, previewSessionId: string, sequence: number) { - const row = await queryD1First( - db, - ` - ${D1_PREVIEW_EVIDENCE_SELECT} - WHERE preview_session_id = ? - AND sequence = ? - `, - [previewSessionId, sequence], - ); - - if (row === null) { - throw new Error(`Preview evidence ${previewSessionId}#${sequence} was not found.`); - } - - return mapPreviewEvidenceRow(mapD1PreviewEvidenceFields(row)); -} - function mapD1PreviewSessionFields(row: D1PreviewSessionRow): PreviewSessionRow { return { - sessionId: expectString(row.sessionId, 'sessionId'), - packageVersionId: expectNumber(row.packageVersionId, 'packageVersionId'), - appId: expectString(row.appId, 'appId'), - packageVersion: expectString(row.packageVersion, 'packageVersion'), - packageTitle: expectString(row.packageTitle, 'packageTitle'), - origin: expectStringLiteral(row.origin, 'origin', [ + sessionId: expectD1String(row.sessionId, 'sessionId'), + packageVersionId: expectD1Number(row.packageVersionId, 'packageVersionId'), + appId: expectD1String(row.appId, 'appId'), + packageVersion: expectD1String(row.packageVersion, 'packageVersion'), + packageTitle: expectD1String(row.packageTitle, 'packageTitle'), + origin: expectD1StringLiteral(row.origin, 'origin', [ 'adminTestLaunch', 'deepLinkingAuthoring', 'adminAuthoringDraft', ]), - contentPath: expectString(row.contentPath, 'contentPath'), - deepLinkingSessionId: expectNullableString(row.deepLinkingSessionId, 'deepLinkingSessionId'), - capabilities: parseJsonField( + contentPath: expectD1String(row.contentPath, 'contentPath'), + deepLinkingSessionId: expectD1NullableString(row.deepLinkingSessionId, 'deepLinkingSessionId'), + capabilities: parseD1JsonField( row.capabilities, 'capabilities', - ) as PreviewSessionRow['capabilities'], - snapshotRoot: expectString(row.snapshotRoot, 'snapshotRoot'), - entrypointPath: expectString(row.entrypointPath, 'entrypointPath'), - launchUserId: expectString(row.launchUserId, 'launchUserId'), - launchUserRole: expectStringLiteral(row.launchUserRole, 'launchUserRole', [ + parseCapabilityArray, + ), + snapshotRoot: expectD1String(row.snapshotRoot, 'snapshotRoot'), + entrypointPath: expectD1String(row.entrypointPath, 'entrypointPath'), + launchUserId: expectD1String(row.launchUserId, 'launchUserId'), + launchUserRole: expectD1StringLiteral(row.launchUserRole, 'launchUserRole', [ 'learner', 'instructor', ]), - launchCourseId: expectString(row.launchCourseId, 'launchCourseId'), - launchAssignmentId: expectNullableString(row.launchAssignmentId, 'launchAssignmentId'), - launchActivityId: expectString(row.launchActivityId, 'launchActivityId'), - fakeAttemptId: expectString(row.fakeAttemptId, 'fakeAttemptId'), - fakeScoreMaximum: expectNumber(row.fakeScoreMaximum, 'fakeScoreMaximum'), - fixtureData: parseJsonField(row.fixtureData, 'fixtureData') as PreviewSessionRow['fixtureData'], - createdAt: expectString(row.createdAt, 'createdAt'), + launchCourseId: expectD1String(row.launchCourseId, 'launchCourseId'), + launchAssignmentId: expectD1NullableString(row.launchAssignmentId, 'launchAssignmentId'), + launchActivityId: expectD1String(row.launchActivityId, 'launchActivityId'), + fakeAttemptId: expectD1String(row.fakeAttemptId, 'fakeAttemptId'), + fakeScoreMaximum: expectD1Number(row.fakeScoreMaximum, 'fakeScoreMaximum'), + fixtureData: parseD1JsonField(row.fixtureData, 'fixtureData', parsePreviewFixtureData), + createdAt: expectD1String(row.createdAt, 'createdAt'), }; } function mapD1PreviewEvidenceFields(row: D1PreviewEvidenceRow): PreviewEvidenceRow { return { - id: expectNumber(row.id, 'id'), - previewSessionId: expectString(row.previewSessionId, 'previewSessionId'), - sequence: expectNumber(row.sequence, 'sequence'), - eventType: expectString(row.eventType, 'eventType'), - capability: expectNullableString( - row.capability, - 'capability', - ) as PreviewEvidenceRow['capability'], - summary: expectString(row.summary, 'summary'), - detail: parseJsonField(row.detail, 'detail') as PreviewEvidenceRow['detail'], - occurredAt: expectString(row.occurredAt, 'occurredAt'), + id: expectD1Number(row.id, 'id'), + previewSessionId: expectD1String(row.previewSessionId, 'previewSessionId'), + sequence: expectD1Number(row.sequence, 'sequence'), + eventType: expectD1String(row.eventType, 'eventType'), + capability: parseNullableCapability(row.capability, 'capability'), + summary: expectD1String(row.summary, 'summary'), + detail: parseD1JsonField(row.detail, 'detail', expectD1JsonRecord), + occurredAt: expectD1String(row.occurredAt, 'occurredAt'), }; } @@ -351,46 +352,60 @@ function isD1UniqueViolation(error: unknown): boolean { return error instanceof Error && error.message.includes('UNIQUE constraint failed'); } -function parseJsonField(value: unknown, fieldName: string): unknown { - if (typeof value !== 'string') { - throw new TypeError(`Expected D1 ${fieldName} to be JSON text.`); - } - - return JSON.parse(value); -} - -function expectString(value: unknown, fieldName: string): string { - if (typeof value !== 'string') { - throw new TypeError(`Expected D1 ${fieldName} to be text.`); - } - - return value; +function parseCapabilityArray(value: unknown, fieldName: string): Capability[] { + return expectD1StringArray(value, fieldName).map((capability, index) => + expectD1StringLiteral(capability, `${fieldName}[${index}]`, [ + 'read_launch_context', + 'read_activity_content', + 'submit_attempt_event', + 'submit_evidence_artifact', + 'finalize_attempt', + 'read_local_state', + 'write_local_state', + ]) + ); } -function expectNullableString(value: unknown, fieldName: string): string | null { +function parseNullableCapability(value: unknown, fieldName: string): Capability | null { if (value === null) { return null; } - return expectString(value, fieldName); -} - -function expectNumber(value: unknown, fieldName: string): number { - if (typeof value !== 'number') { - throw new TypeError(`Expected D1 ${fieldName} to be numeric.`); - } - - return value; + return expectD1StringLiteral( + value, + fieldName, + [ + 'read_launch_context', + 'read_activity_content', + 'submit_attempt_event', + 'submit_evidence_artifact', + 'finalize_attempt', + 'read_local_state', + 'write_local_state', + ] as const, + ); } -function expectStringLiteral( - value: unknown, - fieldName: string, - allowed: readonly T[], -): T { - if (typeof value !== 'string' || !allowed.includes(value as T)) { - throw new Error(`Unexpected D1 ${fieldName} value.`); - } +function parsePreviewFixtureData(value: unknown, fieldName: string): PreviewFixtureData { + const record = expectD1JsonRecord(value, fieldName); + const launch = expectD1JsonRecord(record.launch, `${fieldName}.launch`); - return value as T; + return { + launch: { + user_role: expectD1StringLiteral(launch.user_role, `${fieldName}.launch.user_role`, [ + 'learner', + 'instructor', + ]), + course_id: expectD1String(launch.course_id, `${fieldName}.launch.course_id`), + assignment_id: expectD1NullableString( + launch.assignment_id, + `${fieldName}.launch.assignment_id`, + ), + activity_id: expectD1String(launch.activity_id, `${fieldName}.launch.activity_id`), + }, + attempt_id: expectD1String(record.attempt_id, `${fieldName}.attempt_id`), + local_state: record.local_state === null + ? null + : expectD1JsonRecord(record.local_state, `${fieldName}.local_state`), + }; } diff --git a/src/package_review/repository_preview_d1_test.ts b/src/package_review/repository_preview_d1_test.ts index dec2759..d2da9a1 100644 --- a/src/package_review/repository_preview_d1_test.ts +++ b/src/package_review/repository_preview_d1_test.ts @@ -50,11 +50,7 @@ Deno.test('D1 preview repository gets latest preview with optional origin filter Deno.test('D1 preview repository appends evidence with the next sequence', async () => { const db = createPlannedD1Database({ - firstResults: [ - { sessionId: 'preview-session-123' }, - { nextSequence: 3 }, - buildPreviewEvidenceRow({ sequence: 3 }), - ], + firstResults: [buildPreviewEvidenceRow({ sequence: 3 })], }); const repository = createD1PreviewRepositoryMethods(db); @@ -68,7 +64,18 @@ Deno.test('D1 preview repository appends evidence with the next sequence', async }); assertEquals(evidence.sequence, 3); - assertEquals(db.statements[2]?.parameters[5], '{"route":"/admin/preview"}'); + assertEquals(db.statements[0]?.query.includes('next_evidence_sequence'), true); + assertEquals(db.statements[0]?.query.includes('COALESCE(MAX'), false); + assertEquals(db.statements[0]?.parameters, [ + 'preview.launch', + 'read_launch_context', + 'Preview launched.', + '{"route":"/admin/preview"}', + '2026-05-13T12:00:00.000Z', + 'preview-session-123', + ]); + assertEquals(db.statements[1]?.query.includes('SET next_evidence_sequence'), true); + assertEquals(db.statements[1]?.parameters, ['preview-session-123']); }); Deno.test('D1 preview repository lists parsed evidence in sequence order', async () => { @@ -110,8 +117,21 @@ function createPlannedD1Database( prepare(query) { return createPlannedD1Statement(query, statements, allResults, firstResults, options); }, - batch(_statements) { - return Promise.resolve([]); + batch(batchStatements: D1PreparedStatement[]): Promise[]> { + for (const statement of batchStatements) { + const recorded = statement as D1PreparedStatement & RecordedStatement; + statements.push({ + query: recorded.query, + parameters: recorded.parameters, + }); + } + + return Promise.resolve( + batchStatements.map((_statement, index) => ({ + success: true, + results: (index === 0 ? [firstResults.shift()].filter(isRecord) : []) as T[], + })) satisfies D1Result[], + ); }, exec(_query) { return Promise.resolve({ @@ -131,8 +151,12 @@ function createPlannedD1Statement( ): D1PreparedStatement { let parameters: D1Parameter[] = []; - return { - bind(...values) { + const statement = { + query, + get parameters() { + return parameters; + }, + bind(...values: D1Parameter[]) { parameters = values; return this; }, @@ -160,6 +184,14 @@ function createPlannedD1Statement( return Promise.resolve([] as T[]); }, }; + + return statement; +} + +function isRecord( + value: Record | null | undefined, +): value is Record { + return value !== null && value !== undefined; } function buildPreviewSessionRow( diff --git a/src/package_review/repository_row_types.ts b/src/package_review/repository_row_types.ts index 1f84df7..02c166c 100644 --- a/src/package_review/repository_row_types.ts +++ b/src/package_review/repository_row_types.ts @@ -16,11 +16,9 @@ import type { } from './types.ts'; import type { LtiProfileId } from '../lti/profile.ts'; import type { - CanvasDeploymentBinding, CanvasEnvironment, DeepLinkingSessionRecord, DynamicRegistrationStateRecord, - LmsType, PersistedDeploymentLmsType, RuntimeSessionRecord, } from '../lti/types.ts'; @@ -77,22 +75,6 @@ export interface LanternLtiProfileSettingsRow { updatedAt: Date | string; } -export interface LoginStateRow { - lmsType: LmsType; - state: string; - canvasEnvironment: CanvasDeploymentBinding['canvasEnvironment'] | null; - issuer: CanvasDeploymentBinding['issuer']; - clientId: CanvasDeploymentBinding['clientId']; - deploymentId: CanvasDeploymentBinding['deploymentId']; - nonce: string; - loginHint: string; - targetLinkUri: string; - ltiMessageHint: string | null; - createdAt: Date | string; - expiresAt: Date | string; - usedAt: Date | string | null; -} - export interface DynamicRegistrationStateRow { state: string; appId: string; @@ -105,6 +87,7 @@ export interface DynamicRegistrationStateRow { export interface RuntimeSessionRow { sessionId: string; sessionToken: string; + ltiToolSessionId: string | null; attemptId?: string | null; deploymentRecordId: number; deploymentSlug: string; @@ -132,6 +115,7 @@ export interface RuntimeSessionRow { export interface DeepLinkingSessionRow { sessionId: string; sessionToken: string; + ltiToolSessionId: string | null; deploymentRecordId: number; deploymentSlug: string; appId: string; diff --git a/src/preview/service.ts b/src/preview/service.ts index 9eb379d..931bca1 100644 --- a/src/preview/service.ts +++ b/src/preview/service.ts @@ -120,6 +120,7 @@ export async function launchPreviewRuntimeSession(input: { const runtimeSession = await input.repository.createRuntimeSession({ sessionId: `preview-runtime-${createOpaqueToken()}`, sessionToken: createOpaqueToken(), + ltiToolSessionId: null, attemptId: runtimeAttemptId, deploymentRecordId: previewDeployment.id, deploymentSlug: previewDeployment.slug, diff --git a/src/runtime/gateway.ts b/src/runtime/gateway.ts index 723a97a..dcc4b58 100644 --- a/src/runtime/gateway.ts +++ b/src/runtime/gateway.ts @@ -6,6 +6,7 @@ import type { import type { RuntimeSessionRecord } from '../lti/types.ts'; import { createOpaqueToken } from '../lti/token_support.ts'; import type { EnvReader } from '../platform/env.ts'; +import type { LanternLtiToolContext } from '../lti/tool_provider.ts'; import type { PackageReviewRepository } from '../package_review/repository.ts'; import type { AttemptEvidenceArtifactRecord, @@ -40,8 +41,6 @@ import type { export type { FinalizeAttemptInput, FinalizeAttemptResult, - GovernedGradePublicationInput, - GovernedGradePublicationResult, RuntimeBoundary, RuntimeBrokerDenial, RuntimeBrokerDenialCategory, @@ -61,7 +60,6 @@ export { parseScoreProposal, requireRuntimeCapability, } from './gateway_parsing.ts'; -export { publishGovernedGradePublication } from './gateway_publication.ts'; export async function readAttemptLocalState(input: { repository: PackageReviewRepository; @@ -249,6 +247,7 @@ export async function finalizeRuntimeAttempt(input: { session: RuntimeSessionRecord; payload: unknown; env: EnvReader; + getLtiToolContext?: () => Promise; artifactStore: RuntimeArtifactStore; now?: () => Date; }): Promise { @@ -416,7 +415,7 @@ export async function finalizeRuntimeAttempt(input: { attempt: finalizedAttempt, packageVersion, score, - env: input.env, + getLtiToolContext: requireLtiToolContext(input.getLtiToolContext), now, }); @@ -448,6 +447,16 @@ export async function finalizeRuntimeAttempt(input: { } } +function requireLtiToolContext( + getLtiToolContext: (() => Promise) | undefined, +): () => Promise { + if (getLtiToolContext === undefined) { + throw new Error('LTI tool context is required for live score publication.'); + } + + return getLtiToolContext; +} + function validateBrowserGraderResult(input: { browserGraderResult: BrowserGraderResult | null; packageVersion: Pick; diff --git a/src/runtime/gateway_finalize_edge_cases_test.ts b/src/runtime/gateway_finalize_edge_cases_test.ts index ef8ef2b..c1a919f 100644 --- a/src/runtime/gateway_finalize_edge_cases_test.ts +++ b/src/runtime/gateway_finalize_edge_cases_test.ts @@ -20,8 +20,9 @@ import { TEST_RUNTIME_ENV, withFetchStub, } from './gateway_test_helpers.ts'; +import { createTestLtiToolContext } from '../test_helpers/lti_tool_context.ts'; -Deno.test('runtime gateway surfaces Canvas token failures clearly after the durable attempt is finalized', async () => { +Deno.test('runtime gateway surfaces lti-tool score failures clearly after the durable attempt is finalized', async () => { const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); const repository = createInMemoryPackageReviewRepository({ packageVersions: [ @@ -54,8 +55,13 @@ Deno.test('runtime gateway surfaces Canvas token failures clearly after the dura ], }); const session = buildRuntimeSessionRecord({ + ltiToolSessionId: 'lti-tool-session-123', expiresAt: '2099-03-26T02:45:00Z', }); + const ltiToolContext = createTestLtiToolContext({ + submitScoreSuccess: false, + submitScoreMessage: 'score publish failed', + }); Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); @@ -81,6 +87,7 @@ Deno.test('runtime gateway surfaces Canvas token failures clearly after the dura completionState: 'completed', }, env: TEST_RUNTIME_ENV, + getLtiToolContext: () => Promise.resolve(ltiToolContext.context), artifactStore: FILE_SYSTEM_RUNTIME_ARTIFACT_STORE, now: () => new Date('2026-03-24T02:35:00Z'), }), @@ -88,8 +95,8 @@ Deno.test('runtime gateway surfaces Canvas token failures clearly after the dura assertEquals(result.finalizedNow, true); assertEquals(result.gradePublishedNow, false); - assertEquals(result.publishError?.code, 'token_request_failed'); - assertEquals(result.gradePublication, null); + assertEquals(result.publishError?.code, 'score_publish_failed'); + assertEquals(result.gradePublication?.status, 'failed'); const attempt = await repository.getAttemptById('attempt-123'); diff --git a/src/runtime/gateway_finalize_test.ts b/src/runtime/gateway_finalize_test.ts index f93ee68..8270066 100644 --- a/src/runtime/gateway_finalize_test.ts +++ b/src/runtime/gateway_finalize_test.ts @@ -21,6 +21,7 @@ import { TEST_RUNTIME_ENV, withFetchStub, } from './gateway_test_helpers.ts'; +import { createTestLtiToolContext } from '../test_helpers/lti_tool_context.ts'; const WEB_CHECKUP_SNAPSHOT_ROOT = 'examples/apps/web-checkup'; @@ -77,8 +78,10 @@ Deno.test('runtime gateway finalizes declarative attempts from the reviewed rubr ], }); const session = buildRuntimeSessionRecord({ + ltiToolSessionId: 'lti-tool-session-123', expiresAt: '2099-03-26T02:45:00Z', }); + const ltiToolContext = createTestLtiToolContext(); Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); @@ -115,6 +118,7 @@ Deno.test('runtime gateway finalizes declarative attempts from the reviewed rubr completionState: 'completed', }, env: TEST_RUNTIME_ENV, + getLtiToolContext: () => Promise.resolve(ltiToolContext.context), artifactStore: FILE_SYSTEM_RUNTIME_ARTIFACT_STORE, now: () => new Date('2026-03-24T02:35:00Z'), }); @@ -125,6 +129,7 @@ Deno.test('runtime gateway finalizes declarative attempts from the reviewed rubr completionState: 'abandoned', }, env: TEST_RUNTIME_ENV, + getLtiToolContext: () => Promise.resolve(ltiToolContext.context), artifactStore: FILE_SYSTEM_RUNTIME_ARTIFACT_STORE, now: () => new Date('2026-03-24T02:40:00Z'), }); @@ -207,6 +212,7 @@ Deno.test('runtime gateway finalizes browser grading through the reviewed browse ], }); const session = buildRuntimeSessionRecord({ + ltiToolSessionId: 'lti-tool-session-123', appId: 'web-checkup', deploymentSlug: 'web-checkup-pilot', snapshotRoot: WEB_CHECKUP_SNAPSHOT_ROOT, @@ -214,6 +220,7 @@ Deno.test('runtime gateway finalizes browser grading through the reviewed browse contentPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/content/activity.json`, expiresAt: '2099-03-26T02:45:00Z', }); + const ltiToolContext = createTestLtiToolContext(); Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); @@ -266,6 +273,7 @@ Deno.test('runtime gateway finalizes browser grading through the reviewed browse }, }, env: TEST_RUNTIME_ENV, + getLtiToolContext: () => Promise.resolve(ltiToolContext.context), artifactStore: FILE_SYSTEM_RUNTIME_ARTIFACT_STORE, now: () => new Date('2026-04-08T18:05:00Z'), }); @@ -330,6 +338,7 @@ Deno.test('runtime gateway rejects anonymous finalize when no evidence artifacts ], }); const session = buildRuntimeSessionRecord({ + ltiToolSessionId: 'lti-tool-session-123', appId: 'web-checkup', deploymentSlug: 'web-checkup-pilot', capabilities: [ @@ -453,6 +462,7 @@ Deno.test('runtime gateway finalizes anonymous browser attempts only after evide ], }); const session = buildRuntimeSessionRecord({ + ltiToolSessionId: 'lti-tool-session-123', appId: 'web-checkup', deploymentSlug: 'web-checkup-pilot', capabilities: [ @@ -467,6 +477,7 @@ Deno.test('runtime gateway finalizes anonymous browser attempts only after evide contentPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/content/activity.json`, expiresAt: '2099-03-26T02:45:00Z', }); + const ltiToolContext = createTestLtiToolContext(); Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); @@ -519,6 +530,7 @@ Deno.test('runtime gateway finalizes anonymous browser attempts only after evide }, }, env: TEST_RUNTIME_ENV, + getLtiToolContext: () => Promise.resolve(ltiToolContext.context), artifactStore: FILE_SYSTEM_RUNTIME_ARTIFACT_STORE, now: () => new Date('2026-04-08T19:02:00Z'), }); @@ -574,6 +586,7 @@ Deno.test('runtime gateway keeps the AGS line-item resource id aligned with the ], }); const session = buildRuntimeSessionRecord({ + ltiToolSessionId: 'lti-tool-session-123', contentPath: `${EXAMPLE_SNAPSHOT_ROOT}/content/bonus.json`, launch: { userRole: 'learner', @@ -584,6 +597,7 @@ Deno.test('runtime gateway keeps the AGS line-item resource id aligned with the }, expiresAt: '2099-03-26T02:45:00Z', }); + const ltiToolContext = createTestLtiToolContext(); Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); @@ -620,6 +634,7 @@ Deno.test('runtime gateway keeps the AGS line-item resource id aligned with the completionState: 'completed', }, env: TEST_RUNTIME_ENV, + getLtiToolContext: () => Promise.resolve(ltiToolContext.context), artifactStore: FILE_SYSTEM_RUNTIME_ARTIFACT_STORE, now: () => new Date('2026-03-24T02:35:00Z'), }); diff --git a/src/runtime/gateway_publication.ts b/src/runtime/gateway_publication.ts index 440606b..00bb492 100644 --- a/src/runtime/gateway_publication.ts +++ b/src/runtime/gateway_publication.ts @@ -1,85 +1,21 @@ import { type AttemptScoreResult } from '../grading/service.ts'; -import { publishFinalScore, requestServiceAccessToken } from '../lti/services.ts'; -import type { EnvReader } from '../platform/env.ts'; +import { withAgsLineItemTarget } from '../lti/ags_session.ts'; +import { requireLtiToolSession } from '../lti/lti_tool_session.ts'; +import type { LanternLtiToolContext } from '../lti/tool_provider.ts'; +import type { LTISession } from '@longsightgroup/lti-tool'; import { LTI_AGS_LINEITEM_SCOPE, LTI_AGS_SCORE_SCOPE, type RuntimeSessionRecord, } from '../lti/types.ts'; -import { getLtiProfileDefinition } from '../lti/profile.ts'; -import { resolveLtiProfileForDeployment } from '../lti/profile_resolution.ts'; import type { PackageReviewRepository } from '../package_review/repository.ts'; import type { AttemptRecord, PackageVersionRecord } from '../package_review/types.ts'; -import { recordInteropPathUsed } from '../interop_audit.ts'; import { requireRuntimeDeployment } from './gateway_context.ts'; -import { errorMessage } from './gateway_errors.ts'; import { - ensureLineItemBinding, - requestAccessToken, + buildFinalGradeLineItemSpec, resolveActivityProgress, } from './gateway_publication_support.ts'; -import type { - FinalizeAttemptResult, - GovernedGradePublicationInput, - GovernedGradePublicationResult, -} from './gateway_types.ts'; - -export async function publishGovernedGradePublication( - input: GovernedGradePublicationInput, -): Promise { - const publishScore = input.publishScore ?? publishFinalScore; - const timestamp = input.now().toISOString(); - - try { - await publishScore({ - accessToken: input.accessToken, - lineItemUrl: input.publication.lineItemUrl, - platformUserId: input.publication.platformUserId, - scoreGiven: input.publication.scoreGiven, - scoreMaximum: input.publication.scoreMaximum, - activityProgress: input.publication.activityProgress, - gradingProgress: 'FullyGraded', - timestamp, - ...(input.retryUnauthorized === undefined - ? {} - : { retryUnauthorized: input.retryUnauthorized }), - }); - - return { - gradePublication: await input.repository.updateGradePublication({ - attemptId: input.attemptId, - status: 'published', - updatedAt: timestamp, - publishedAt: timestamp, - errorCode: null, - errorDetail: null, - }), - gradePublishedNow: true, - publishError: null, - }; - } catch (error) { - return { - gradePublication: await input.repository.updateGradePublication({ - attemptId: input.attemptId, - status: 'failed', - updatedAt: timestamp, - publishedAt: null, - errorCode: 'score_publish_failed', - errorDetail: { - message: errorMessage(error), - }, - }), - gradePublishedNow: false, - publishError: { - code: 'score_publish_failed', - message: errorMessage(error), - detail: { - lineItemUrl: input.publication.lineItemUrl, - }, - }, - }; - } -} +import type { FinalizeAttemptResult } from './gateway_types.ts'; export async function publishRuntimeAttemptScore(input: { repository: PackageReviewRepository; @@ -87,7 +23,7 @@ export async function publishRuntimeAttemptScore(input: { attempt: AttemptRecord; packageVersion: PackageVersionRecord; score: AttemptScoreResult; - env: EnvReader; + getLtiToolContext: () => Promise; now: () => Date; }): Promise< Pick< @@ -96,10 +32,6 @@ export async function publishRuntimeAttemptScore(input: { > > { const deployment = await requireRuntimeDeployment(input.repository, input.session); - const ltiProfile = await resolveLtiProfileForDeployment({ - repository: input.repository, - deployment, - }); if (deployment.binding === null) { return { @@ -116,6 +48,21 @@ export async function publishRuntimeAttemptScore(input: { }; } + if (input.session.ltiToolSessionId === null) { + return { + lineItemBinding: null, + gradePublication: null, + gradePublishedNow: false, + publishError: { + code: 'lti_session_missing', + message: 'A verified lti-tool session is required before score publish can continue.', + detail: { + attemptId: input.attempt.attemptId, + }, + }, + }; + } + const ags = input.session.services.ags; if (ags === null) { @@ -158,49 +105,77 @@ export async function publishRuntimeAttemptScore(input: { }; } - const accessToken = await requestAccessToken({ - scope: ags.scope, - binding: deployment.binding, - lineItemBinding: existingBinding, - env: input.env, + return await publishRuntimeAttemptScoreWithLtiTool({ + ...input, + existingBinding, }); +} + +async function publishRuntimeAttemptScoreWithLtiTool(input: { + repository: PackageReviewRepository; + session: RuntimeSessionRecord; + attempt: AttemptRecord; + packageVersion: PackageVersionRecord; + score: AttemptScoreResult; + getLtiToolContext: () => Promise; + existingBinding: Awaited>; + now: () => Date; +}): Promise< + Pick< + FinalizeAttemptResult, + 'lineItemBinding' | 'gradePublication' | 'gradePublishedNow' | 'publishError' + > +> { + const ags = input.session.services.ags; - if (typeof accessToken !== 'string') { - return accessToken; + if (ags === null) { + return { + lineItemBinding: input.existingBinding, + gradePublication: null, + gradePublishedNow: false, + publishError: { + code: 'missing_ags_context', + message: 'Launch did not provide AGS service context for this attempt.', + detail: { + attemptId: input.attempt.attemptId, + }, + }, + }; } - const retryUnauthorized = getLtiProfileDefinition(ltiProfile.id).behavior - .retryServiceUnauthorizedOnce - ? async () => { - await recordInteropPathUsed({ - repository: input.repository, - scope: 'service', - path: 'service_401_retry', - actorType: 'system', - deploymentRecordId: deployment.id, - packageVersionId: deployment.enabledPackageVersionId, - attemptId: input.attempt.attemptId, - summary: 'Lantern retried an LMS service request after a 401.', + const ltiContext = await input.getLtiToolContext(); + let ltiSession: LTISession; + + try { + ltiSession = await requireLtiToolSession({ + storage: ltiContext.storage, + ltiToolSessionId: input.session.ltiToolSessionId, + missingMessage: 'A verified lti-tool session is required before score publish can continue.', + notFoundMessage: 'The verified LTI session for this runtime attempt was not found.', + }); + } catch (error) { + return { + lineItemBinding: input.existingBinding, + gradePublication: null, + gradePublishedNow: false, + publishError: { + code: 'lti_session_missing', + message: error instanceof Error + ? error.message + : 'The verified LTI session is unavailable.', detail: { - lms: deployment.binding!.lms, - deploymentSlug: deployment.slug, + ltiToolSessionId: input.session.ltiToolSessionId, }, - ltiProfile, - }); - const refreshed = await requestServiceAccessToken({ - binding: deployment.binding!, - scopes: ags.scope, - env: input.env, - }); - - return refreshed.accessToken; - } - : undefined; - - const lineItemBinding = await ensureLineItemBinding(existingBinding, accessToken, { - ...input, - ...(retryUnauthorized === undefined ? {} : { retryUnauthorized }), - }); + }, + }; + } + + const lineItemBinding = input.existingBinding ?? + await createLineItemBindingWithLtiTool({ + ...input, + ltiContext, + ltiSession, + }); if ('publishError' in lineItemBinding) { return lineItemBinding; @@ -220,7 +195,7 @@ export async function publishRuntimeAttemptScore(input: { } const gradePublication = existingPublication ?? - (await input.repository.createGradePublication({ + await input.repository.createGradePublication({ attemptId: input.attempt.attemptId, lineItemBindingId: lineItemBinding.id, lineItemUrl: lineItemBinding.lineItemUrl, @@ -235,18 +210,124 @@ export async function publishRuntimeAttemptScore(input: { publishedAt: null, errorCode: null, errorDetail: null, - })); - const published = await publishGovernedGradePublication({ - repository: input.repository, - attemptId: input.attempt.attemptId, - publication: gradePublication, - accessToken, - now: input.now, - ...(retryUnauthorized === undefined ? {} : { retryUnauthorized }), + }); + const scoreSession = withAgsLineItemTarget(ltiSession, lineItemBinding.lineItemUrl); + const result = await ltiContext.tool.createAdvantage(scoreSession).submitScore({ + userId: gradePublication.platformUserId, + scoreGiven: gradePublication.scoreGiven, + scoreMaximum: gradePublication.scoreMaximum, + activityProgress: gradePublication.activityProgress, + gradingProgress: 'FullyGraded', + timestamp: input.now().toISOString(), }); + if (!result.success) { + return { + lineItemBinding, + gradePublication: await input.repository.updateGradePublication({ + attemptId: input.attempt.attemptId, + status: 'failed', + updatedAt: input.now().toISOString(), + publishedAt: null, + errorCode: 'score_publish_failed', + errorDetail: { + message: result.error.message, + code: result.error.code, + serviceKind: result.error.serviceKind, + operation: result.error.operation, + }, + }), + gradePublishedNow: false, + publishError: { + code: 'score_publish_failed', + message: result.error.message, + detail: { + lineItemUrl: lineItemBinding.lineItemUrl, + serviceKind: result.error.serviceKind, + operation: result.error.operation, + }, + }, + }; + } + return { lineItemBinding, - ...published, + gradePublication: await input.repository.updateGradePublication({ + attemptId: input.attempt.attemptId, + status: 'published', + updatedAt: input.now().toISOString(), + publishedAt: input.now().toISOString(), + errorCode: null, + errorDetail: null, + }), + gradePublishedNow: true, + publishError: null, }; } + +async function createLineItemBindingWithLtiTool(input: { + repository: PackageReviewRepository; + session: RuntimeSessionRecord; + attempt: AttemptRecord; + packageVersion: PackageVersionRecord; + score: AttemptScoreResult; + ltiContext: LanternLtiToolContext; + ltiSession: LTISession; + now: () => Date; +}): Promise< + | Awaited> + | Pick< + FinalizeAttemptResult, + 'lineItemBinding' | 'gradePublication' | 'gradePublishedNow' | 'publishError' + > +> { + const spec = buildFinalGradeLineItemSpec({ + session: input.session, + packageVersion: input.packageVersion, + scoreMaximum: input.score.scoreMaximum, + }); + const result = await input.ltiContext.tool.createAdvantage(input.ltiSession).findOrCreateLineItem( + { + resourceLinkId: input.attempt.resourceLinkId, + resourceId: spec.resourceId, + tag: spec.tag, + create: { + label: spec.label, + scoreMaximum: spec.scoreMaximum, + }, + }, + ); + + if (!result.success) { + return { + lineItemBinding: null, + gradePublication: null, + gradePublishedNow: false, + publishError: { + code: 'line_item_failed', + message: result.error.message, + detail: { + attemptId: input.attempt.attemptId, + serviceKind: result.error.serviceKind, + operation: result.error.operation, + }, + }, + }; + } + + return await input.repository.saveLineItemBinding({ + deploymentRecordId: input.session.deploymentRecordId, + packageVersionId: input.session.packageVersionId, + contextId: input.attempt.contextId, + resourceLinkId: input.attempt.resourceLinkId, + activityId: input.attempt.activityId, + lineItemsUrl: input.session.services.ags?.lineitemsUrl ?? '', + lineItemUrl: result.data.id, + resourceId: result.data.resourceId ?? spec.resourceId, + tag: result.data.tag ?? spec.tag, + label: result.data.label, + scoreMaximum: result.data.scoreMaximum, + createdAt: input.now().toISOString(), + updatedAt: input.now().toISOString(), + }); +} diff --git a/src/runtime/gateway_publication_support.ts b/src/runtime/gateway_publication_support.ts index 9d28277..0533dad 100644 --- a/src/runtime/gateway_publication_support.ts +++ b/src/runtime/gateway_publication_support.ts @@ -1,26 +1,13 @@ -import { type AttemptScoreResult } from '../grading/service.ts'; -import { - ensureLineItem, - type EnsureLineItemInput, - type EnsureLineItemResult, - requestServiceAccessToken, -} from '../lti/services.ts'; -import type { EnvReader } from '../platform/env.ts'; import { buildLtiActivityResourceId, - type DeploymentBinding, - type LaunchAssignmentAndGradeServices, type LmsType, type RuntimeSessionRecord, } from '../lti/types.ts'; -import type { PackageReviewRepository } from '../package_review/repository.ts'; import type { AttemptRecord, GradePublicationRecord, PackageVersionRecord, } from '../package_review/types.ts'; -import { errorMessage } from './gateway_errors.ts'; -import type { FinalizeAttemptResult } from './gateway_types.ts'; export interface ManagedLineItemSpec { resourceId: string; @@ -29,15 +16,6 @@ export interface ManagedLineItemSpec { scoreMaximum: number; } -interface EnsureManagedLineItemInput { - accessToken: string; - retryUnauthorized?: () => Promise; - ags: LaunchAssignmentAndGradeServices; - resourceLinkId: string; - spec: ManagedLineItemSpec; - ensureLineItemFn?: (input: EnsureLineItemInput) => Promise; -} - export function buildFinalGradeLineItemSpec(input: { session: RuntimeSessionRecord; packageVersion: Pick; @@ -64,142 +42,6 @@ export function buildSmokeVerificationLineItemSpec(input: { }; } -export async function ensureManagedLineItem( - input: EnsureManagedLineItemInput, -): Promise { - const ensureLineItemFn = input.ensureLineItemFn ?? ensureLineItem; - - return await ensureLineItemFn({ - accessToken: input.accessToken, - lineitemsUrl: input.ags.lineitemsUrl, - lineitemUrl: input.ags.lineitemUrl, - resourceLinkId: input.resourceLinkId, - resourceId: input.spec.resourceId, - tag: input.spec.tag, - label: input.spec.label, - scoreMaximum: input.spec.scoreMaximum, - ...(input.retryUnauthorized === undefined - ? {} - : { retryUnauthorized: input.retryUnauthorized }), - }); -} - -export async function requestAccessToken(input: { - scope: string[]; - binding: DeploymentBinding; - lineItemBinding: Awaited>; - env: EnvReader; - requestToken?: typeof requestServiceAccessToken; -}): Promise< - | string - | Pick< - FinalizeAttemptResult, - 'lineItemBinding' | 'gradePublication' | 'gradePublishedNow' | 'publishError' - > -> { - try { - const requestToken = input.requestToken ?? requestServiceAccessToken; - const token = await requestToken({ - binding: input.binding, - scopes: input.scope, - env: input.env, - }); - - return token.accessToken; - } catch (error) { - return { - lineItemBinding: input.lineItemBinding, - gradePublication: null, - gradePublishedNow: false, - publishError: { - code: 'token_request_failed', - message: errorMessage(error), - detail: { - lms: input.binding.lms, - issuer: input.binding.issuer, - clientId: input.binding.clientId, - deploymentId: input.binding.deploymentId, - }, - }, - }; - } -} - -export async function ensureLineItemBinding( - existingBinding: Awaited>, - accessToken: string, - input: { - repository: PackageReviewRepository; - session: RuntimeSessionRecord; - attempt: AttemptRecord; - packageVersion: PackageVersionRecord; - score: AttemptScoreResult; - now: () => Date; - retryUnauthorized?: () => Promise; - }, -): Promise< - | Awaited> - | Pick< - FinalizeAttemptResult, - 'lineItemBinding' | 'gradePublication' | 'gradePublishedNow' | 'publishError' - > -> { - if (existingBinding !== null) { - return existingBinding; - } - - try { - const ags = input.session.services.ags; - - if (ags === null) { - throw new Error('Launch did not provide AGS service context for this attempt.'); - } - - const ensuredLineItem = await ensureManagedLineItem({ - accessToken, - ags, - resourceLinkId: input.attempt.resourceLinkId, - spec: buildFinalGradeLineItemSpec({ - session: input.session, - packageVersion: input.packageVersion, - scoreMaximum: input.score.scoreMaximum, - }), - ...(input.retryUnauthorized === undefined - ? {} - : { retryUnauthorized: input.retryUnauthorized }), - }); - - return await input.repository.saveLineItemBinding({ - deploymentRecordId: input.session.deploymentRecordId, - packageVersionId: input.session.packageVersionId, - contextId: input.attempt.contextId, - resourceLinkId: input.attempt.resourceLinkId, - activityId: input.attempt.activityId, - lineItemsUrl: ensuredLineItem.lineItemsUrl, - lineItemUrl: ensuredLineItem.lineItemUrl, - resourceId: ensuredLineItem.resourceId, - tag: ensuredLineItem.tag, - label: ensuredLineItem.label, - scoreMaximum: ensuredLineItem.scoreMaximum, - createdAt: input.now().toISOString(), - updatedAt: input.now().toISOString(), - }); - } catch (error) { - return { - lineItemBinding: null, - gradePublication: null, - gradePublishedNow: false, - publishError: { - code: 'line_item_failed', - message: errorMessage(error), - detail: { - attemptId: input.attempt.attemptId, - }, - }, - }; - } -} - export function resolveActivityProgress( attempt: AttemptRecord, ): GradePublicationRecord['activityProgress'] { diff --git a/src/runtime/gateway_types.ts b/src/runtime/gateway_types.ts index 212e790..0160b92 100644 --- a/src/runtime/gateway_types.ts +++ b/src/runtime/gateway_types.ts @@ -1,6 +1,4 @@ import type { AttemptScoreResult } from '../grading/service.ts'; -import type { PublishFinalScoreInput, PublishFinalScoreResult } from '../lti/services.ts'; -import type { PackageReviewRepository } from '../package_review/repository.ts'; import type { AttemptEvidenceArtifactRecord, AttemptRecord, @@ -71,22 +69,3 @@ export interface FinalizeAttemptResult { detail: Record; } | null; } - -export interface GovernedGradePublicationInput { - repository: Pick; - attemptId: string; - publication: Pick< - GradePublicationRecord, - 'lineItemUrl' | 'platformUserId' | 'scoreGiven' | 'scoreMaximum' | 'activityProgress' - >; - accessToken: string; - retryUnauthorized?: () => Promise; - now: () => Date; - publishScore?: (input: PublishFinalScoreInput) => Promise; -} - -export interface GovernedGradePublicationResult { - gradePublication: GradePublicationRecord; - gradePublishedNow: boolean; - publishError: FinalizeAttemptResult['publishError']; -} diff --git a/src/runtime/route_commands.ts b/src/runtime/route_commands.ts new file mode 100644 index 0000000..e533f7f --- /dev/null +++ b/src/runtime/route_commands.ts @@ -0,0 +1,204 @@ +import { + recordRuntimeCapabilityAllowed, + recordRuntimeScoreProposalAccepted, + recordRuntimeSessionExited, +} from '../app_runtime_support.ts'; +import type { EnvReader } from '../platform/env.ts'; +import type { LanternLtiToolContext } from '../lti/tool_provider.ts'; +import type { PackageReviewRepository } from '../package_review/repository.ts'; +import type { RuntimeSessionRecord } from '../lti/types.ts'; +import type { PackageVersionRecord } from '../package_review/types.ts'; +import type { RequestAuditEnvelope } from '../request_audit.ts'; +import type { RuntimeArtifactStore } from './artifact_store.ts'; +import type { RuntimeDeliveryDescriptor } from './delivery.ts'; +import { acceptAttemptEvent, finalizeRuntimeAttempt, submitScoreProposal } from './gateway.ts'; + +export async function acceptRuntimeAttemptEvent(input: { + readonly repository: PackageReviewRepository; + readonly session: RuntimeSessionRecord; + readonly payload: unknown; + readonly now?: () => Date; +}): Promise { + const now = input.now ?? (() => new Date()); + const occurredAt = now().toISOString(); + const attemptEvent = await acceptAttemptEvent({ + repository: input.repository, + session: input.session, + payload: input.payload, + now, + }); + + await input.repository.recordAuditEvent({ + eventType: 'attempt.submitted', + actorType: 'system', + actorId: null, + deploymentRecordId: input.session.deploymentRecordId, + packageVersionId: input.session.packageVersionId, + attemptId: input.session.attemptId, + lineItemBindingId: null, + status: 'accepted', + summary: 'Accepted attempt submission through the runtime gateway.', + detail: { + sequence: attemptEvent.sequence, + eventType: attemptEvent.eventType, + }, + occurredAt, + }); + await recordRuntimeCapabilityAllowed({ + repository: input.repository, + session: input.session, + capability: 'submit_attempt_event', + route: 'attempt-events', + detail: { + eventType: attemptEvent.eventType, + sequence: attemptEvent.sequence, + }, + occurredAt, + }); +} + +export async function acceptRuntimeScoreProposal(input: { + readonly repository: PackageReviewRepository; + readonly session: RuntimeSessionRecord; + readonly payload: unknown; + readonly now?: () => Date; +}) { + const now = input.now ?? (() => new Date()); + const occurredAt = now().toISOString(); + const result = await submitScoreProposal({ + repository: input.repository, + session: input.session, + payload: input.payload, + now, + }); + + if (result.accepted) { + await recordRuntimeScoreProposalAccepted({ + repository: input.repository, + session: input.session, + scoreProposal: result.scoreProposal, + route: 'score-proposal', + occurredAt, + }); + } + + return result; +} + +export async function finalizeRuntimeSession(input: { + readonly env: EnvReader; + readonly getLtiToolContext?: () => Promise; + readonly artifactStore: RuntimeArtifactStore; + readonly repository: PackageReviewRepository; + readonly session: RuntimeSessionRecord; + readonly payload: unknown; + readonly reviewedPackage: PackageVersionRecord | null; + readonly delivery: RuntimeDeliveryDescriptor; + readonly request: RequestAuditEnvelope; + readonly now?: () => Date; +}) { + const now = input.now ?? (() => new Date()); + const occurredAt = now().toISOString(); + const result = await finalizeRuntimeAttempt({ + repository: input.repository, + session: input.session, + payload: input.payload, + env: input.env, + ...(input.getLtiToolContext === undefined ? {} : { + getLtiToolContext: input.getLtiToolContext, + }), + artifactStore: input.artifactStore, + now, + }); + + if (result.finalizedNow) { + await input.repository.recordAuditEvent({ + eventType: 'attempt.finalized', + actorType: 'system', + actorId: null, + deploymentRecordId: input.session.deploymentRecordId, + packageVersionId: input.session.packageVersionId, + attemptId: input.session.attemptId, + lineItemBindingId: null, + status: 'accepted', + summary: 'Finalized the durable attempt inside the runtime gateway.', + detail: buildFinalizeAuditDetail(result), + occurredAt, + }); + await recordRuntimeSessionExited({ + repository: input.repository, + session: input.session, + reviewedPackage: input.reviewedPackage, + delivery: input.delivery, + completionState: result.attempt.completionState, + scoreGiven: result.score.scoreGiven, + scoreMaximum: result.score.scoreMaximum, + gradePublished: result.gradePublication?.status === 'published', + submissionMode: result.submissionMode, + evidenceArtifactCount: result.evidenceArtifacts.length, + evidenceArtifacts: result.evidenceArtifacts, + browserGraderResult: result.browserGraderResult, + route: 'finalize', + occurredAt, + }); + } + + if (result.gradePublishedNow && result.gradePublication !== null) { + await input.repository.recordAuditEvent({ + eventType: 'grade_publish.succeeded', + actorType: 'system', + actorId: null, + deploymentRecordId: input.session.deploymentRecordId, + packageVersionId: input.session.packageVersionId, + attemptId: input.session.attemptId, + lineItemBindingId: result.lineItemBinding?.id ?? null, + status: 'succeeded', + summary: 'Published the final score to Canvas through AGS.', + detail: { + lineItemUrl: result.gradePublication.lineItemUrl, + scoreGiven: result.gradePublication.scoreGiven, + scoreMaximum: result.gradePublication.scoreMaximum, + }, + occurredAt, + }); + } + + if (result.publishError !== null) { + await input.repository.recordAuditEvent({ + eventType: 'grade_publish.failed', + actorType: 'system', + actorId: null, + deploymentRecordId: input.session.deploymentRecordId, + packageVersionId: input.session.packageVersionId, + attemptId: input.session.attemptId, + lineItemBindingId: result.lineItemBinding?.id ?? null, + status: 'failed', + summary: 'Canvas AGS score publish failed.', + detail: { + code: result.publishError.code, + message: result.publishError.message, + request: input.request, + ...result.publishError.detail, + }, + occurredAt, + }); + } + + return result; +} + +function buildFinalizeAuditDetail( + result: Awaited>, +): Record { + return { + completionState: result.attempt.completionState, + scoreGiven: result.score.scoreGiven, + scoreMaximum: result.score.scoreMaximum, + submissionMode: result.submissionMode, + evidenceArtifactCount: result.evidenceArtifacts.length, + evidenceArtifacts: result.evidenceArtifacts, + ...(result.browserGraderResult === null + ? {} + : { browserGraderResult: result.browserGraderResult }), + }; +} diff --git a/src/test_helpers/lti_identity_builders.ts b/src/test_helpers/lti_identity_builders.ts index f9d21ef..0a8e3c1 100644 --- a/src/test_helpers/lti_identity_builders.ts +++ b/src/test_helpers/lti_identity_builders.ts @@ -4,9 +4,8 @@ import type { CanvasPlatformConfig, DeploymentBinding, LmsType, - LoginStateRecord, } from '../lti/types.ts'; -import type { LoginRequest } from '../lti/login.ts'; +import type { LoginRequest } from '../lti/login_request.ts'; export type { AgsShape } from './lti_service_claim_builders.ts'; export type { TestAgsLaunchServiceOverrides, @@ -106,7 +105,13 @@ export function buildPlatformIdentity( clientId: string; deploymentId: string; }> = {}, -): Pick { +): { + lms: LmsType; + canvasEnvironment: CanvasEnvironment | null; + issuer: string; + clientId: string; + deploymentId: string; +} { const lms = overrides.lms ?? 'canvas'; switch (lms) { @@ -204,27 +209,3 @@ export function buildSakaiLoginRequest(overrides: Partial = {}): L ltiMessageHint: overrides.ltiMessageHint ?? 'message-hint-123', }; } - -export function buildLoginStateRecord(overrides: Partial = {}): LoginStateRecord { - const identity = buildPlatformIdentity({ - ...(overrides.lms === undefined ? {} : { lms: overrides.lms }), - ...(overrides.canvasEnvironment === undefined - ? {} - : { canvasEnvironment: overrides.canvasEnvironment }), - ...(overrides.issuer === undefined ? {} : { issuer: overrides.issuer }), - ...(overrides.clientId === undefined ? {} : { clientId: overrides.clientId }), - ...(overrides.deploymentId === undefined ? {} : { deploymentId: overrides.deploymentId }), - }); - - return { - state: overrides.state ?? 'state-123', - nonce: overrides.nonce ?? 'nonce-123', - loginHint: overrides.loginHint ?? 'opaque-login-hint', - targetLinkUri: overrides.targetLinkUri ?? 'http://localhost:8417/lti/launch', - ltiMessageHint: overrides.ltiMessageHint ?? 'message-hint-123', - createdAt: overrides.createdAt ?? TEST_NOW, - expiresAt: overrides.expiresAt ?? '2026-03-23T22:50:00Z', - usedAt: overrides.usedAt ?? null, - ...identity, - }; -} diff --git a/src/test_helpers/lti_session_builders.ts b/src/test_helpers/lti_session_builders.ts index 62e5538..296b845 100644 --- a/src/test_helpers/lti_session_builders.ts +++ b/src/test_helpers/lti_session_builders.ts @@ -88,6 +88,7 @@ export function buildValidatedDeepLinkingRequest( }); return { + ltiToolSessionId: overrides.ltiToolSessionId ?? null, internalDeploymentId: overrides.internalDeploymentId ?? 1, internalDeploymentSlug: overrides.internalDeploymentSlug ?? 'chapter-4-asteroids-pilot', appId: overrides.appId ?? 'chapter-4-asteroids', @@ -119,6 +120,7 @@ export function buildRuntimeSessionRecord( return { sessionId: sessionOverrides.sessionId ?? 'runtime-session-123', sessionToken: sessionOverrides.sessionToken ?? 'runtime-token-123', + ltiToolSessionId: sessionOverrides.ltiToolSessionId ?? null, attemptId: sessionOverrides.attemptId ?? 'attempt-123', deploymentRecordId: sessionOverrides.deploymentRecordId ?? 1, deploymentSlug: sessionOverrides.deploymentSlug ?? 'chapter-4-asteroids-pilot', @@ -170,6 +172,7 @@ export function buildDeepLinkingSessionRecord( return { sessionId: overrides.sessionId ?? 'deep-linking-session-123', sessionToken: overrides.sessionToken ?? 'deep-linking-token-123', + ltiToolSessionId: overrides.ltiToolSessionId ?? null, deploymentRecordId: overrides.deploymentRecordId ?? 1, deploymentSlug: overrides.deploymentSlug ?? 'chapter-4-asteroids-pilot', appId: overrides.appId ?? 'chapter-4-asteroids', diff --git a/src/test_helpers/lti_signing.ts b/src/test_helpers/lti_signing.ts index f2a0ae5..50cd7f8 100644 --- a/src/test_helpers/lti_signing.ts +++ b/src/test_helpers/lti_signing.ts @@ -1,5 +1,5 @@ import { importJWK, SignJWT } from 'jose'; -import type { CanvasDeploymentBinding } from '../lti/types.ts'; +import type { CanvasDeploymentBinding, DeploymentBinding } from '../lti/types.ts'; import { LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE as DEFAULT_DEEP_LINKING_REQUEST_MESSAGE_TYPE } from '../lti/types.ts'; import { type AgsShape, @@ -9,10 +9,12 @@ import { TEST_NOW, } from './lti_identity_builders.ts'; import { buildDeepLinkingSettingsClaimValue } from './lti_session_builders.ts'; +import { createMemoryLtiStorage, syncLtiToolDeploymentBinding } from '../lti/tool_provider.ts'; export const TEST_TOOL_PRIVATE_JWK = { kty: 'RSA', alg: 'RS256', + kid: 'lantern-test-tool-key', n: 'uqAz51P9MxAXrvGI_6QMTdFQhpUqjLtialtYuIvFkJaB98npMnFuBMfMpXH0vTEWlyTWNencpsco4t0X4PBVEk7n3kol3JvRSGqNSotQ0lx4omi9qeCNljAWiSc6FyEn-FfWOxwKwQZxhbgBclBxsVQqrHAtcADA2pUzG9HzZaPmNLHyZ7zzr3D8PNIEM2qmqhUp9cm1UTZu8iJg6B7X-Jcazpa5N_nbkYIwAjyE6iep4IP4HoRIxOSoewdmxyEhtii7a-wz6bGVICht0cOegTe1mmZIo7xQHvz0I4v0y2xkhfCBCOEI8HeGWiGU95_KazbNdC_qZyIsGDqLndTigw', e: 'AQAB', d: 'G38vJo5BaBye4K_Ft6S8C1sjujCQLFwPjAwZbldprHwAejnNmhkMVLf7dwTrQlTRN0O-LANg91GwvHxG4GWIo0Hs99uE6JQsqbbJSopsRhJJ0-QKzdTAB7jeGScmb_H6qaSHc_4Vt4rzfvg1flpL5gy4nN-KUk5KG-qPtTGh4v-aqkfzkxVnEBb-VfB_o5nafMimKp1TMXSPcL-4NjjwFioP8YcQDYxwKJGHHm7_CqSfCu5AMx7UByVxsVFNTsaua0lFUQDaVR13eHFFOdUZiAUe97Ua_JYec6Gxe4-3g1bCmGbGm9U_35fGM5kt4NXWND0ULgJfI8alAUDjxblQQQ', @@ -27,6 +29,8 @@ export const TEST_TOOL_PRIVATE_JWK = { use: 'sig', } as const; +export const TEST_LTI_STATE_SECRET = 'lantern-test-lti-state-secret-32-bytes'; + export const TEST_CANVAS_PRIVATE_JWK = { kty: 'EC', crv: 'P-256', @@ -170,6 +174,42 @@ export async function signCanvasIdToken(input: CanvasLaunchTokenInput = {}): Pro return await token.sign(signingKey); } +export async function signLtiToolState(input: { + nonce?: string; + issuer?: string; + clientId?: string; + targetLinkUri?: string; + expiresAt?: string; +} = {}): Promise { + return await new SignJWT({ + nonce: input.nonce ?? 'nonce-123', + iss: input.issuer ?? 'https://canvas.instructure.com', + client_id: input.clientId ?? '10000000000001', + target_link_uri: input.targetLinkUri ?? 'http://localhost:8417/lti/launch', + exp: Math.floor(Date.parse(input.expiresAt ?? '2030-03-26T02:45:00Z') / 1000), + }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(new TextEncoder().encode(TEST_LTI_STATE_SECRET)); +} + +export async function createSyncedTestLtiStorage(input: { + binding?: DeploymentBinding; + appTitle?: string; + deploymentSlug?: string; +} = {}) { + const binding = input.binding ?? buildDeploymentBinding(); + const storage = createMemoryLtiStorage(); + + await syncLtiToolDeploymentBinding({ + storage, + appTitle: input.appTitle ?? 'Chapter 4 Asteroids', + deploymentSlug: input.deploymentSlug ?? 'chapter-4-asteroids-pilot', + binding, + }); + + return storage; +} + export function getTestCanvasJwks(): { keys: [ { diff --git a/src/test_helpers/lti_tool_context.ts b/src/test_helpers/lti_tool_context.ts new file mode 100644 index 0000000..05f16dd --- /dev/null +++ b/src/test_helpers/lti_tool_context.ts @@ -0,0 +1,148 @@ +import type { LTISession } from '@longsightgroup/lti-tool'; +import type { LanternLtiToolContext } from '../lti/tool_provider.ts'; + +export interface TestLtiToolContextInput { + sessionId?: string; + lineItemUrl?: string; + members?: unknown[]; + submitScoreSuccess?: boolean; + submitScoreMessage?: string; +} + +export interface TestLtiToolContext { + context: LanternLtiToolContext; + submittedScores: unknown[]; + lineItemRequests: unknown[]; +} + +export function createTestLtiToolContext( + input: TestLtiToolContextInput = {}, +): TestLtiToolContext { + const submittedScores: unknown[] = []; + const lineItemRequests: unknown[] = []; + const session = buildTestLtiSession({ + sessionId: input.sessionId ?? 'lti-tool-session-123', + }); + + const context = { + storage: { + getSession: (sessionId: string) => + Promise.resolve(sessionId === session.id ? session : undefined), + }, + tool: { + createAdvantage: () => ({ + findOrCreateLineItem: (request: unknown) => { + lineItemRequests.push(request); + + return Promise.resolve({ + success: true, + data: { + id: input.lineItemUrl ?? 'https://canvas.example/api/lti/courses/42/line_items/9', + label: 'Chapter 4 Asteroids Final Grade', + scoreMaximum: 100, + resourceId: readRequestString(request, 'resourceId'), + tag: readRequestString(request, 'tag'), + }, + }); + }, + submitScore: (score: unknown) => { + submittedScores.push(score); + + return Promise.resolve( + input.submitScoreSuccess === false + ? { + success: false, + error: { + message: input.submitScoreMessage ?? 'score publish failed', + code: 'score_publish_failed', + serviceKind: 'ags', + operation: 'submitScore', + }, + } + : { + success: true, + data: undefined, + }, + ); + }, + getMembers: () => + Promise.resolve({ + success: true, + data: { + members: input.members ?? [{ userId: 'learner-1' }, { userId: 'learner-2' }], + pagination: { + pagesFetched: 1, + truncated: false, + }, + }, + }), + }), + }, + dynamicRegistration: {}, + logger: {}, + } as unknown as LanternLtiToolContext; + + return { + context, + submittedScores, + lineItemRequests, + }; +} + +function buildTestLtiSession(input: { sessionId: string }): LTISession { + return { + id: input.sessionId, + jwtPayload: {}, + user: { + id: 'canvas-user-123', + roles: ['http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'], + }, + context: { + id: 'course-42', + label: 'Physics 101', + title: 'Physics 101', + }, + platform: { + issuer: 'https://canvas.instructure.com', + clientId: '10000000000001', + deploymentId: 'deployment-123', + name: 'Canvas', + }, + launch: { + target: 'http://localhost:8417/lti/launch', + }, + resourceLink: { + id: 'resource-link-123', + title: 'Chapter 4 Asteroids', + }, + services: { + ags: { + lineitems: 'https://canvas.example/api/lti/courses/42/line_items', + scopes: [ + 'https://purl.imsglobal.org/spec/lti-ags/scope/score', + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', + ], + }, + nrps: { + membershipUrl: 'https://canvas.example/api/lti/courses/42/names_and_roles', + versions: ['2.0'], + }, + }, + customParameters: {}, + isAdmin: false, + isInstructor: false, + isStudent: true, + isAssignmentAndGradesAvailable: true, + isDeepLinkingAvailable: false, + isNameAndRolesAvailable: true, + }; +} + +function readRequestString(request: unknown, key: string): string | undefined { + if (typeof request !== 'object' || request === null || !(key in request)) { + return undefined; + } + + const value = (request as Record)[key]; + return typeof value === 'string' ? value : undefined; +} diff --git a/src/test_helpers/package_review_in_memory_deployments.ts b/src/test_helpers/package_review_in_memory_deployments.ts index e0a4533..c811bd2 100644 --- a/src/test_helpers/package_review_in_memory_deployments.ts +++ b/src/test_helpers/package_review_in_memory_deployments.ts @@ -214,7 +214,6 @@ export function createInMemoryDeploymentRepository( }, pinDeploymentVersion(input) { - const lmsType = input.lmsType ?? 'canvas'; const packageVersion = state.packageVersions.find( (candidate) => candidate.id === input.packageVersionId, ); @@ -225,7 +224,7 @@ export function createInMemoryDeploymentRepository( if ( packageVersion.approvalStatus !== 'approved' && - !(lmsType === 'preview' && packageVersion.approvalStatus === 'pending') + !(input.lmsType === 'preview' && packageVersion.approvalStatus === 'pending') ) { throw new Error('Only approved package versions can be enabled.'); } @@ -242,9 +241,9 @@ export function createInMemoryDeploymentRepository( throw new Error(`Deployment ${input.slug} belongs to app ${existing.appId}.`); } - if (existing && existing.lmsType !== lmsType) { + if (existing && existing.lmsType !== input.lmsType) { throw new Error( - `Deployment ${input.slug} is already reserved as ${existing.lmsType} and cannot change to ${lmsType}.`, + `Deployment ${input.slug} is already reserved as ${existing.lmsType} and cannot change to ${input.lmsType}.`, ); } @@ -255,7 +254,7 @@ export function createInMemoryDeploymentRepository( appId: input.appId, enabledPackageVersionId: packageVersion.id, enabledPackageVersion: packageVersion.version, - lmsType, + lmsType: input.lmsType, binding: existing?.binding ?? null, ltiProfileOverride: existing?.ltiProfileOverride ?? null, updatedAt: DEFAULT_UPDATED_AT, diff --git a/src/test_helpers/package_review_in_memory_ops.ts b/src/test_helpers/package_review_in_memory_ops.ts index c1af617..30640c5 100644 --- a/src/test_helpers/package_review_in_memory_ops.ts +++ b/src/test_helpers/package_review_in_memory_ops.ts @@ -27,7 +27,6 @@ type OpsRepository = Pick< | 'listControlPlaneDiagnostics' | 'listCertificationWorkflowStatuses' | 'getLatestOfficialCertificationEvidence' - | 'getLatestBrokerVerification' | 'getLatestBrokerVerificationStatus' | 'recordBrokerVerificationRun' | 'getRetryableGradePublicationLookup' @@ -86,11 +85,6 @@ export function createInMemoryOpsRepositorySection(state: InMemoryRepositoryStat ); }, - getLatestBrokerVerification() { - const record = getLatestBrokerVerificationRecord(state.brokerVerifications); - return Promise.resolve(record ? cloneRecord(record) : null); - }, - getLatestBrokerVerificationStatus() { const record = getLatestBrokerVerificationRecord(state.brokerVerifications); return Promise.resolve(record ? cloneRecord(record) : null); @@ -250,6 +244,7 @@ export function createInMemoryOpsRepositorySection(state: InMemoryRepositoryStat runtimeSession: runtimeSession ? buildRetryRuntimeSessionLookup({ sessionId: runtimeSession.sessionId, + ltiToolSessionId: runtimeSession.ltiToolSessionId, attemptId: runtimeSession.attemptId, deploymentRecordId: runtimeSession.deploymentRecordId, deploymentSlug: runtimeSession.deploymentSlug, diff --git a/src/test_helpers/package_review_in_memory_sessions.ts b/src/test_helpers/package_review_in_memory_sessions.ts index 8d204b7..8982369 100644 --- a/src/test_helpers/package_review_in_memory_sessions.ts +++ b/src/test_helpers/package_review_in_memory_sessions.ts @@ -9,9 +9,6 @@ import { cloneRecord } from './package_review_in_memory_shared.ts'; type SessionRepository = & Pick< PackageReviewRepository, - | 'createLoginState' - | 'getLoginStateByState' - | 'consumeLoginState' | 'createDynamicRegistrationState' | 'getDynamicRegistrationStateByState' | 'consumeDynamicRegistrationState' @@ -24,44 +21,6 @@ type SessionRepository = export function createInMemorySessionRepository(state: InMemoryRepositoryState): SessionRepository { return { - createLoginState(record) { - const existing = state.loginStates.find((candidate) => candidate.state === record.state); - - if (existing) { - throw new Error(`Login state ${record.state} already exists and cannot be reused.`); - } - - state.loginStates.push(cloneRecord(record)); - return Promise.resolve(cloneRecord(record)); - }, - - getLoginStateByState(stateId) { - const record = state.loginStates.find((candidate) => candidate.state === stateId); - return Promise.resolve(record ? cloneRecord(record) : null); - }, - - consumeLoginState(input) { - const index = state.loginStates.findIndex((candidate) => candidate.state === input.state); - - if (index < 0) { - throw new Error(`Login state ${input.state} was not found.`); - } - - const existing = state.loginStates[index]; - - if (!existing) { - throw new Error(`Login state ${input.state} was not found.`); - } - - if (existing.usedAt !== null) { - throw new Error(`Login state ${input.state} has already been used.`); - } - - const nextRecord = cloneRecord({ ...existing, usedAt: input.usedAt }); - state.loginStates.splice(index, 1, nextRecord); - return Promise.resolve(cloneRecord(nextRecord)); - }, - createDynamicRegistrationState(record) { const existing = state.dynamicRegistrationStates.find( (candidate) => candidate.state === record.state, diff --git a/src/test_helpers/package_review_in_memory_shared.ts b/src/test_helpers/package_review_in_memory_shared.ts index 0ca8919..6f48608 100644 --- a/src/test_helpers/package_review_in_memory_shared.ts +++ b/src/test_helpers/package_review_in_memory_shared.ts @@ -3,7 +3,6 @@ import { CERTIFICATION_WORKFLOW_KEYS } from '../ops/types.ts'; import type { DeepLinkingSessionRecord, DynamicRegistrationStateRecord, - LoginStateRecord, LtiPlacement, RuntimeSessionRecord, } from '../lti/types.ts'; @@ -51,7 +50,6 @@ export interface InMemoryOpsRepository { listControlPlaneDiagnostics(deploymentRecordId: number): Promise; listCertificationWorkflowStatuses(): Promise; getLatestOfficialCertificationEvidence(): Promise; - getLatestBrokerVerification(): Promise; getLatestBrokerVerificationStatus(): Promise; recordBrokerVerificationRun(input: { deploymentRecordId: number | null; @@ -109,7 +107,6 @@ export type InMemoryPackageReviewRepositoryOptions = { lineItemBindings?: LineItemBindingRecord[]; gradePublications?: GradePublicationRecord[]; auditEvents?: AuditEventRecord[]; - loginStates?: LoginStateRecord[]; dynamicRegistrationStates?: DynamicRegistrationStateRecord[]; runtimeSessions?: RuntimeSessionRecord[]; deepLinkingSessions?: DeepLinkingSessionRecord[]; @@ -149,7 +146,6 @@ export function createState( lineItemBindings: cloneRecord(options.lineItemBindings ?? []), gradePublications: cloneRecord(options.gradePublications ?? []), auditEvents: cloneRecord(options.auditEvents ?? []), - loginStates: cloneRecord(options.loginStates ?? []), dynamicRegistrationStates: cloneRecord(options.dynamicRegistrationStates ?? []), runtimeSessions: cloneRecord(options.runtimeSessions ?? []), deepLinkingSessions: cloneRecord(options.deepLinkingSessions ?? []), diff --git a/src/test_helpers/package_review_test_builder_ops.ts b/src/test_helpers/package_review_test_builder_ops.ts index d99d5d6..627d756 100644 --- a/src/test_helpers/package_review_test_builder_ops.ts +++ b/src/test_helpers/package_review_test_builder_ops.ts @@ -154,6 +154,7 @@ export function buildRetryRuntimeSessionLookup( ): RetryRuntimeSessionLookup { return { sessionId: overrides.sessionId ?? 'runtime-session-123', + ltiToolSessionId: overrides.ltiToolSessionId ?? 'lti-tool-session-123', attemptId: overrides.attemptId ?? 'attempt-123', deploymentRecordId: overrides.deploymentRecordId ?? 1, deploymentSlug: overrides.deploymentSlug ?? 'chapter-4-asteroids-pilot', diff --git a/src/worker.ts b/src/worker.ts index 81b6657..dc35b55 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -9,7 +9,11 @@ import { runAppGenerationPlanningWorkflowStep, } from './app_writer/workflow_runner.ts'; import { resolveWorkerServices, type WorkerBindings } from './app_worker_services.ts'; +import { statusForError } from './app_status_support.ts'; +import { buildCanvasConfigDocument } from './lti/config.ts'; import { createObjectEnvReader } from './platform/env.ts'; +import { readEnv } from './platform/env.ts'; +import { resolveConfiguredPublicOrigin } from './public_origin.ts'; interface WorkflowEntrypointRuntimeClass { new (): object; @@ -55,12 +59,64 @@ export default { env: WorkerBindings = {}, executionContext?: WorkerExecutionContext, ): Response | Promise { + const publicResponse = handlePublicWorkerRequest(request, env); + + if (publicResponse !== null) { + return publicResponse; + } + const app = buildApp(resolveWorkerServices(env, createObjectEnvReader(env))); return app.fetch(request, env, toHonoExecutionContext(executionContext)); }, }; +function handlePublicWorkerRequest( + request: Request, + env: WorkerBindings, +): Response | Promise | null { + const url = new URL(request.url); + + if (url.pathname === '/health') { + return Response.json({ ok: true, appWriterHarness: 'workspace-runner-v1' }); + } + + if (url.pathname !== '/lti/canvas/config.json') { + return null; + } + + return buildWorkerCanvasConfigResponse(request, env); +} + +async function buildWorkerCanvasConfigResponse( + request: Request, + env: WorkerBindings, +): Promise { + const envReader = createObjectEnvReader(env); + + try { + return Response.json( + await buildCanvasConfigDocument( + resolveConfiguredPublicOrigin({ + requestUrl: request.url, + forwardedHeader: request.headers.get('forwarded'), + xForwardedHost: request.headers.get('x-forwarded-host'), + xForwardedProto: request.headers.get('x-forwarded-proto'), + configuredOrigin: readEnv('APP_ORIGIN', envReader), + }), + envReader, + ), + ); + } catch (error) { + return Response.json( + { + error: error instanceof Error ? error.message : 'Config unavailable.', + }, + { status: statusForError(error) }, + ); + } +} + export class AppGenerationWorkflow extends WorkflowEntrypointBase { declare readonly env: WorkerBindings; diff --git a/tools/local_init.ts b/tools/local_init.ts index 03ef023..2ebb14a 100644 --- a/tools/local_init.ts +++ b/tools/local_init.ts @@ -1,5 +1,6 @@ import { appendMissingEnvAssignments, + createLocalLtiStateSecret, createLocalToolPrivateJwkString, DEFAULT_LOCAL_ENV_PATH, defaultLocalEnvAssignments, @@ -16,6 +17,7 @@ if (import.meta.main) { existingText, assignments: { ...assignments, + LTI_STATE_SECRET: createLocalLtiStateSecret(), LTI_TOOL_PRIVATE_JWK: generatedJwk, }, }); diff --git a/tools/local_support.ts b/tools/local_support.ts index e9626dd..8e622cf 100644 --- a/tools/local_support.ts +++ b/tools/local_support.ts @@ -71,6 +71,13 @@ export async function createLocalToolPrivateJwkString(): Promise { }); } +export function createLocalLtiStateSecret(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + + return encodeBase64Url(bytes); +} + export function defaultLocalEnvAssignments(): Record { return { PORT: DEFAULT_LOCAL_PORT, @@ -79,6 +86,16 @@ export function defaultLocalEnvAssignments(): Record { }; } +function encodeBase64Url(bytes: Uint8Array): string { + let binary = ''; + + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); +} + function defaultLocalEnvHeader(): string { return [ '# Local Lantern development defaults.', diff --git a/tools/local_support_test.ts b/tools/local_support_test.ts index 6d96116..0e0ede4 100644 --- a/tools/local_support_test.ts +++ b/tools/local_support_test.ts @@ -1,6 +1,7 @@ import { assert, assertEquals } from '@std/assert'; import { appendMissingEnvAssignments, + createLocalLtiStateSecret, createLocalToolPrivateJwkString, defaultLocalEnvAssignments, listDeclaredEnvKeys, @@ -12,6 +13,7 @@ Deno.test('appendMissingEnvAssignments creates the default local env file', asyn existingText: null, assignments: { ...defaultLocalEnvAssignments(), + LTI_STATE_SECRET: createLocalLtiStateSecret(), LTI_TOOL_PRIVATE_JWK: generatedJwk, }, }); @@ -21,9 +23,11 @@ Deno.test('appendMissingEnvAssignments creates the default local env file', asyn 'PORT', 'APP_ORIGIN', 'APP_RUNTIME_ORIGIN', + 'LTI_STATE_SECRET', 'LTI_TOOL_PRIVATE_JWK', ]); assert(result.text.includes('APP_ORIGIN=http://localhost:8417')); + assert(result.text.includes('LTI_STATE_SECRET=')); assert(result.text.includes('LTI_TOOL_PRIVATE_JWK="{')); }); @@ -32,12 +36,18 @@ Deno.test('appendMissingEnvAssignments preserves existing keys and appends only existingText: ['APP_ORIGIN=http://localhost:9000', ''].join('\n'), assignments: { ...defaultLocalEnvAssignments(), + LTI_STATE_SECRET: 'local-state-secret-local-state-secret', LTI_TOOL_PRIVATE_JWK: '{"kty":"RSA"}', }, }); assertEquals(result.created, false); - assertEquals(result.addedKeys, ['PORT', 'APP_RUNTIME_ORIGIN', 'LTI_TOOL_PRIVATE_JWK']); + assertEquals(result.addedKeys, [ + 'PORT', + 'APP_RUNTIME_ORIGIN', + 'LTI_STATE_SECRET', + 'LTI_TOOL_PRIVATE_JWK', + ]); assert(result.text.includes('APP_ORIGIN=http://localhost:9000')); }); From d3507b24f493ec0ff906f6ea7d8b88b9d30c6a27 Mon Sep 17 00:00:00 2001 From: Sam Ottenhoff Date: Wed, 8 Jul 2026 12:46:13 -0400 Subject: [PATCH 02/12] Adopt lti-tool 0.1.4 --- deno.json | 2 +- deno.lock | 8 +- src/app_admin_grade_smoke_support.ts | 40 +++--- ...app_deep_linking_authoring_preview_test.ts | 6 +- src/app_deep_linking_submit_test.ts | 6 +- src/app_launch_routes.ts | 14 -- src/app_lti_config_login_test.ts | 27 +++- src/lti/ags_session.ts | 18 --- src/lti/canvas_platform.ts | 33 +++-- src/lti/config.ts | 121 ++++-------------- src/lti/config_test.ts | 45 ++++--- src/lti/deep_linking_content_item.ts | 30 ++--- src/lti/deep_linking_launch_settings.ts | 72 ----------- src/lti/deep_linking_response_tool.ts | 6 +- src/lti/dynamic_registration_config.ts | 43 +++++++ src/lti/dynamic_registration_tool.ts | 13 +- src/lti/launch_mapping.ts | 14 +- src/lti/lti_tool_session.ts | 27 ---- src/lti/nrps_roster_verification.ts | 19 +-- src/lti/platform_binding.ts | 16 +-- src/lti/session_boundary.ts | 50 ++++++++ src/lti/tool_key.ts | 120 ++--------------- src/lti/tool_key_test.ts | 10 +- src/lti/tool_provider.ts | 40 +----- src/lti/types.ts | 20 +-- src/ops/retry.ts | 38 +++--- src/package_review/runtime_contract.ts | 4 +- src/runtime/gateway_publication.ts | 34 ++--- src/runtime/session.ts | 4 +- src/test_helpers/lti_session_builders.ts | 35 +++++ src/test_helpers/lti_tool_context.ts | 3 +- src/worker_test.ts | 15 ++- 32 files changed, 366 insertions(+), 567 deletions(-) delete mode 100644 src/lti/ags_session.ts delete mode 100644 src/lti/deep_linking_launch_settings.ts create mode 100644 src/lti/dynamic_registration_config.ts delete mode 100644 src/lti/lti_tool_session.ts create mode 100644 src/lti/session_boundary.ts diff --git a/deno.json b/deno.json index bb33c02..9c834c7 100644 --- a/deno.json +++ b/deno.json @@ -53,7 +53,7 @@ "@cloudflare/puppeteer": "npm:@cloudflare/puppeteer@1.1.0", "@happy-dom": "npm:happy-dom@20.10.6", "@hono/hono": "jsr:@hono/hono@^4.12.18", - "@longsightgroup/lti-tool": "npm:@longsightgroup/lti-tool@0.1.3", + "@longsightgroup/lti-tool": "npm:@longsightgroup/lti-tool@0.1.4", "drizzle-orm": "npm:drizzle-orm@0.45.2", "hono": "npm:hono@4.12.27", "@std/assert": "jsr:@std/assert@^1.0.19", diff --git a/deno.lock b/deno.lock index a4e3d82..cb2f2f8 100644 --- a/deno.lock +++ b/deno.lock @@ -7,7 +7,7 @@ "jsr:@std/semver@1.0.8": "1.0.8", "npm:@cloudflare/puppeteer@1.1.0": "1.1.0", "npm:@cloudflare/puppeteer@latest": "1.1.0", - "npm:@longsightgroup/lti-tool@0.1.3": "0.1.3_drizzle-orm@0.45.2_hono@4.12.27", + "npm:@longsightgroup/lti-tool@0.1.4": "0.1.4_drizzle-orm@0.45.2_hono@4.12.27", "npm:ajv@8.20.0": "8.20.0", "npm:drizzle-orm@0.45.2": "0.45.2", "npm:happy-dom@20.10.6": "20.10.6", @@ -43,8 +43,8 @@ "ws" ] }, - "@longsightgroup/lti-tool@0.1.3_drizzle-orm@0.45.2_hono@4.12.27": { - "integrity": "sha512-18FHLfLhfj/cacVq81i9cr+kdIayg8JAzGS8CNdDw3Kl0pBDAIknHBtsJ9dPkX8dbnjFnw3yCist3ELZkrkHsQ==", + "@longsightgroup/lti-tool@0.1.4_drizzle-orm@0.45.2_hono@4.12.27": { + "integrity": "sha512-9A/CzLz3roa9ZVHH3PiZs+mj4H0uWDOeW6boY4XjpxQ01Fv17wiilTHxlEzYn2FLtY0GU5ibl8WtmO0WC+oIEQ==", "dependencies": [ "drizzle-orm", "hono", @@ -584,7 +584,7 @@ "jsr:@std/assert@^1.0.19", "jsr:@std/semver@1.0.8", "npm:@cloudflare/puppeteer@1.1.0", - "npm:@longsightgroup/lti-tool@0.1.3", + "npm:@longsightgroup/lti-tool@0.1.4", "npm:ajv@8.20.0", "npm:drizzle-orm@0.45.2", "npm:happy-dom@20.10.6", diff --git a/src/app_admin_grade_smoke_support.ts b/src/app_admin_grade_smoke_support.ts index ed5081f..f068cb8 100644 --- a/src/app_admin_grade_smoke_support.ts +++ b/src/app_admin_grade_smoke_support.ts @@ -1,12 +1,11 @@ import { errorMessage } from './app_status_support.ts'; -import { withAgsLineItemTarget } from './lti/ags_session.ts'; -import { requireLtiToolSession } from './lti/lti_tool_session.ts'; import { buildResolvedLtiProfileDetail } from './lti/profile_resolution.ts'; import type { ResolvedLtiProfile } from './lti/profile.ts'; import type { DeploymentBinding, RuntimeSessionRecord } from './lti/types.ts'; import { LTI_AGS_LINEITEM_SCOPE, LTI_AGS_SCORE_SCOPE } from './lti/types.ts'; import type { LanternLtiToolContext } from './lti/tool_provider.ts'; import type { LTISession } from '@longsightgroup/lti-tool'; +import { requireVerifiedLtiSessionWithContext } from './lti/session_boundary.ts'; import type { PackageReviewRepository } from './package_review/repository.ts'; import type { AttemptRecord, DeploymentRecord } from './package_review/types.ts'; import { buildSmokeVerificationLineItemSpec } from './runtime/gateway_publication_support.ts'; @@ -127,15 +126,17 @@ export async function runGradeSmokeVerification(input: { } try { - const scoreResult = await ltiSession.ltiContext.tool.createAdvantage( - withAgsLineItemTarget(ltiSession.session, lineItemUrl), - ).submitScore({ - userId: input.attempt.userId, - scoreGiven: 1, - scoreMaximum: 1, - activityProgress: 'Completed', - gradingProgress: 'FullyGraded', - }); + const scoreResult = await ltiSession.ltiContext.tool.createAdvantage(ltiSession.session) + .submitScore( + { + userId: input.attempt.userId, + scoreGiven: 1, + scoreMaximum: 1, + activityProgress: 'Completed', + gradingProgress: 'FullyGraded', + }, + { lineItemUrl }, + ); if (!scoreResult.success) { throw new Error(scoreResult.error.message); @@ -183,24 +184,15 @@ async function loadSmokeVerificationLtiSession(input: { }; } > { - let ltiContext: LanternLtiToolContext | undefined; - try { - const session = await requireLtiToolSession({ - getStorage: async () => { - ltiContext = await input.getLtiToolContext(); - return ltiContext.storage; - }, - ltiToolSessionId: input.session.ltiToolSessionId, + const loaded = await requireVerifiedLtiSessionWithContext({ + getContext: input.getLtiToolContext, + sessionId: input.session.ltiToolSessionId, missingMessage: 'A verified lti-tool session is required for this grade return check.', notFoundMessage: 'The verified LTI session for this grade return check was not found.', }); - if (ltiContext === undefined) { - throw new Error('The lti-tool context for this grade return check was not loaded.'); - } - - return { ltiContext, session }; + return { ltiContext: loaded.context, session: loaded.session }; } catch (error) { return { error: { diff --git a/src/app_deep_linking_authoring_preview_test.ts b/src/app_deep_linking_authoring_preview_test.ts index 0476e3f..6bb90fa 100644 --- a/src/app_deep_linking_authoring_preview_test.ts +++ b/src/app_deep_linking_authoring_preview_test.ts @@ -14,6 +14,7 @@ import { buildDeploymentBinding, createSyncedTestLtiStorage, } from './test_helpers/lti.ts'; +import { buildTestLtiJwtPayload } from './test_helpers/lti_session_builders.ts'; Deno.test('Deep Linking picker shows preview only after a reviewed selection is saved and posts it in a new tab', async () => { const repository = createInMemoryPackageReviewRepository({ @@ -222,7 +223,10 @@ Deno.test('POST /lti/deep-linking/sessions/:id/preview does not consume the Deep const ltiStorage = await createSyncedTestLtiStorage({ binding }); await ltiStorage.addSession({ id: 'lti-tool-session-preview-submit', - jwtPayload: {}, + jwtPayload: buildTestLtiJwtPayload({ + messageType: 'LtiDeepLinkingRequest', + targetLinkUri: 'https://lantern.example/lti/deep-linking', + }), user: { id: 'canvas-user-123', roles: ['http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'], diff --git a/src/app_deep_linking_submit_test.ts b/src/app_deep_linking_submit_test.ts index f7f1681..3197894 100644 --- a/src/app_deep_linking_submit_test.ts +++ b/src/app_deep_linking_submit_test.ts @@ -12,6 +12,7 @@ import { buildDeploymentBinding, createSyncedTestLtiStorage, } from './test_helpers/lti.ts'; +import { buildTestLtiJwtPayload } from './test_helpers/lti_session_builders.ts'; import { extractHiddenInputValue, verifyDeepLinkingResponseJwt, @@ -23,7 +24,10 @@ Deno.test('POST /lti/deep-linking/sessions/:id/submit creates one reviewed place const ltiStorage = await createSyncedTestLtiStorage({ binding }); await ltiStorage.addSession({ id: 'lti-tool-session-submit', - jwtPayload: {}, + jwtPayload: buildTestLtiJwtPayload({ + messageType: 'LtiDeepLinkingRequest', + targetLinkUri: 'https://lantern.example/lti/deep-linking', + }), user: { id: 'canvas-user-123', roles: ['http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'], diff --git a/src/app_launch_routes.ts b/src/app_launch_routes.ts index c816ea6..0808260 100644 --- a/src/app_launch_routes.ts +++ b/src/app_launch_routes.ts @@ -25,10 +25,6 @@ import { mapDeepLinkingLaunch, mapResourceLinkLaunch, } from './lti/launch_mapping.ts'; -import { - readLaunchIdToken, - readVerifiedDeepLinkingAcceptLineItemClaim, -} from './lti/deep_linking_launch_settings.ts'; import { adaptLtiToolHonoHandler } from './lti/hono_handler_adapter.ts'; import { recordLaunchRejectedAudit } from './lti/launch_audit.ts'; import { isLtiBoundaryDenialError } from './lti/lti_boundary_denial.ts'; @@ -47,7 +43,6 @@ export function registerLaunchRoutes(app: Hono, services: AppServices): void { const ltiContext = await services.getLtiToolContext(appOrigin); const repository = services.getRepository(); const verificationAuditWrites: Array> = []; - const launchIdToken = await readLaunchIdToken(context.req.raw.clone()); const handler = customLaunchRouteHandler({ ltiTool: ltiContext.tool, logger: ltiContext.logger, @@ -63,7 +58,6 @@ export function registerLaunchRoutes(app: Hono, services: AppServices): void { renderLanternDeepLinkingLaunch({ repository, launchContext, - launchIdToken, }), onVerificationFailure: async ({ hono, error }) => { await recordLaunchRejectedAudit({ @@ -197,19 +191,11 @@ async function renderLanternResourceLinkLaunch(input: { async function renderLanternDeepLinkingLaunch(input: { repository: PackageReviewRepository; launchContext: CustomDeepLinkingLaunchContext>; - launchIdToken: string | null; }): Promise { - const acceptLineItemClaim = readVerifiedDeepLinkingAcceptLineItemClaim(input.launchIdToken); - - if (!acceptLineItemClaim.success) { - throw new Error(acceptLineItemClaim.message); - } - const request = mapDeepLinkingLaunch({ verifiedLaunch: input.launchContext.launch, session: input.launchContext.session, message: input.launchContext.message, - acceptLineItem: acceptLineItemClaim.value, }); const session = await createDeepLinkingSession({ repository: input.repository, diff --git a/src/app_lti_config_login_test.ts b/src/app_lti_config_login_test.ts index b4dc3d2..c9b94b1 100644 --- a/src/app_lti_config_login_test.ts +++ b/src/app_lti_config_login_test.ts @@ -8,7 +8,6 @@ import { type LanternLtiToolContext, syncLtiToolDeploymentBinding, } from './lti/tool_provider.ts'; -import { CANVAS_LTI_SCOPES } from './lti/types.ts'; import { buildDeploymentRecord, createInMemoryPackageReviewRepository, @@ -32,6 +31,13 @@ interface LtiToolStateClaims { readonly targetLinkUri: string; } +const EXPECTED_CANVAS_STATIC_SCOPES = [ + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', + 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', + 'https://purl.imsglobal.org/spec/lti-ags/scope/score', + 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly', +]; + Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); Deno.env.set('LTI_STATE_SECRET', TEST_LTI_STATE_SECRET); @@ -91,7 +97,7 @@ Deno.test('GET /lti/canvas/config.json publishes the pilot Canvas config documen }; assertEquals(typeof body.oidc_initiation_url, 'string'); - assertEquals(body.scopes, [...CANVAS_LTI_SCOPES]); + assertEquals(body.scopes, EXPECTED_CANVAS_STATIC_SCOPES); assertEquals(body.extensions[0]?.settings.placements[0]?.placement, 'course_navigation'); assertEquals( body.extensions[0]?.settings.placements.some( @@ -125,15 +131,24 @@ Deno.test('GET /lti/canvas/config.json uses the forwarded public origin when Lan const body = (await response.json()) as { oidc_initiation_url: string; target_link_uri: string; - redirect_uris: string[]; + extensions: Array<{ + settings: { + placements: Array<{ + placement: string; + target_link_uri: string; + }>; + }; + }>; }; assertEquals(body.oidc_initiation_url, 'https://lantern.example/lti/login'); assertEquals(body.target_link_uri, 'https://lantern.example/lti/launch'); - assertEquals(body.redirect_uris.includes('https://lantern.example/lti/launch'), true); + const placements = body.extensions.flatMap((extension) => extension.settings.placements); + assertEquals( - body.redirect_uris.includes( - 'https://lantern.example/lti/deep-linking?placement=resource_selection', + placements.some((placement) => + placement.target_link_uri === + 'https://lantern.example/lti/deep-linking?placement=resource_selection' ), true, ); diff --git a/src/lti/ags_session.ts b/src/lti/ags_session.ts deleted file mode 100644 index fafc4d3..0000000 --- a/src/lti/ags_session.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { LTISession } from '@longsightgroup/lti-tool'; - -export function withAgsLineItemTarget(session: LTISession, lineItemUrl: string): LTISession { - const lineItemsUrl = session.services?.ags?.lineitems; - - return { - ...session, - services: { - ...session.services, - ags: { - lineitem: lineItemUrl, - scopes: session.services?.ags?.scopes ?? [], - ...(lineItemsUrl === undefined ? {} : { lineitems: lineItemsUrl }), - }, - }, - isAssignmentAndGradesAvailable: true, - }; -} diff --git a/src/lti/canvas_platform.ts b/src/lti/canvas_platform.ts index 0f0ad5b..37e48ff 100644 --- a/src/lti/canvas_platform.ts +++ b/src/lti/canvas_platform.ts @@ -1,24 +1,10 @@ +import { resolveCanvasPlatformBinding } from '@longsightgroup/lti-tool'; import type { CanvasEnvironment, CanvasPlatformConfig } from './types.ts'; const CANVAS_PLATFORMS: Record = { - production: { - environment: 'production', - issuer: 'https://canvas.instructure.com', - authorizationEndpoint: 'https://sso.canvaslms.com/api/lti/authorize_redirect', - jwksUrl: 'https://sso.canvaslms.com/api/lti/security/jwks', - }, - beta: { - environment: 'beta', - issuer: 'https://canvas.beta.instructure.com', - authorizationEndpoint: 'https://sso.beta.canvaslms.com/api/lti/authorize_redirect', - jwksUrl: 'https://sso.beta.canvaslms.com/api/lti/security/jwks', - }, - test: { - environment: 'test', - issuer: 'https://canvas.test.instructure.com', - authorizationEndpoint: 'https://sso.test.canvaslms.com/api/lti/authorize_redirect', - jwksUrl: 'https://sso.test.canvaslms.com/api/lti/security/jwks', - }, + production: buildCanvasPlatformConfig('production'), + beta: buildCanvasPlatformConfig('beta'), + test: buildCanvasPlatformConfig('test'), }; export function listCanvasPlatforms(): CanvasPlatformConfig[] { @@ -38,3 +24,14 @@ export function resolveCanvasPlatform(value: CanvasEnvironment | string): Canvas throw new Error(`Canvas issuer ${value} is not supported by Lantern.`); } + +function buildCanvasPlatformConfig(environment: CanvasEnvironment): CanvasPlatformConfig { + const binding = resolveCanvasPlatformBinding(environment); + + return { + environment, + issuer: binding.iss, + authorizationEndpoint: binding.authUrl, + jwksUrl: binding.jwksUrl, + }; +} diff --git a/src/lti/config.ts b/src/lti/config.ts index 102ea68..473751b 100644 --- a/src/lti/config.ts +++ b/src/lti/config.ts @@ -1,15 +1,14 @@ -import type { CanvasEnvironment } from './types.ts'; import { - CANVAS_LTI_SCOPES, - LTI_ASSIGNMENT_SELECTION_PLACEMENT, - LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - LTI_RESOURCE_LINK_REQUEST_MESSAGE_TYPE, - LTI_RESOURCE_SELECTION_PLACEMENT, -} from './types.ts'; -import { getPublicJwkSet } from './tool_key.ts'; + buildCanvasStaticRegistrationConfig, + type CanvasStaticRegistrationConfig, + type DynamicRegistrationConfig, +} from '@longsightgroup/lti-tool'; +import type { CanvasEnvironment } from './types.ts'; import { listCanvasPlatforms, resolveCanvasPlatform } from './canvas_platform.ts'; import { type EnvReader, readEnv } from '../platform/env.ts'; import { normalizePublicOrigin } from '../public_origin.ts'; +import { buildDynamicRegistrationConfig } from './dynamic_registration_config.ts'; +import { getPublicJwkSet } from './tool_key.ts'; export interface CanvasEnvironmentOption { id: CanvasEnvironment; @@ -17,48 +16,10 @@ export interface CanvasEnvironmentOption { issuer: string; } -export interface CanvasConfigDocument { - title: string; - description: string; - oidc_initiation_url: string; - target_link_uri: string; - public_jwk_url: string; - redirect_uris: string[]; - scopes: string[]; - extensions: Array<{ - domain: string; - tool_id: string; - platform: string; - privacy_level: 'public'; - settings: { - text: string; - placements: CanvasConfigPlacement[]; - }; - }>; -} +export type CanvasConfigDocument = CanvasStaticRegistrationConfig; const APP_ORIGIN_ENV = 'APP_ORIGIN'; -export type CanvasConfigPlacement = - | { - placement: 'course_navigation'; - message_type: typeof LTI_RESOURCE_LINK_REQUEST_MESSAGE_TYPE; - target_link_uri: string; - text: string; - } - | { - placement: typeof LTI_ASSIGNMENT_SELECTION_PLACEMENT; - message_type: typeof LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE; - target_link_uri: string; - text: string; - } - | { - placement: typeof LTI_RESOURCE_SELECTION_PLACEMENT; - message_type: typeof LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE; - target_link_uri: string; - text: string; - }; - export function requireAppOrigin(env?: EnvReader): string { const appOrigin = readEnv(APP_ORIGIN_ENV, env)?.trim(); @@ -103,61 +64,35 @@ export function buildCanvasDeepLinkingUrl(appOrigin = requireAppOrigin()): strin return `${normalizePublicOrigin(appOrigin)}/lti/deep-linking`; } -function buildCanvasResourceSelectionUrl(appOrigin = requireAppOrigin()): string { - return `${buildCanvasDeepLinkingUrl(appOrigin)}?placement=resource_selection`; -} - export async function buildCanvasConfigDocument( appOrigin: string, env: EnvReader, ): Promise { const normalizedOrigin = normalizePublicOrigin(appOrigin); - const origin = new URL(normalizedOrigin); - const launchUrl = buildCanvasLaunchUrl(normalizedOrigin); - const deepLinkingUrl = buildCanvasDeepLinkingUrl(normalizedOrigin); - const resourceSelectionUrl = buildCanvasResourceSelectionUrl(normalizedOrigin); await getPublicJwkSet(env); + return buildCanvasStaticRegistrationDocument(normalizedOrigin); +} + +function buildCanvasStaticRegistrationDocument( + normalizedOrigin: string, +): CanvasConfigDocument { + const registrationConfig = buildDynamicRegistrationConfig(normalizedOrigin); + + return buildCanvasStaticRegistrationConfig({ + config: buildCanvasStaticRegistrationConfigShape(registrationConfig, normalizedOrigin), + selectedServices: ['ags', 'nrps', 'deep_linking'], + }); +} + +function buildCanvasStaticRegistrationConfigShape( + registrationConfig: DynamicRegistrationConfig, + normalizedOrigin: string, +): DynamicRegistrationConfig { return { - title: 'Lantern Demo Broker', - description: 'Launch one reviewed Lantern app through a governed Canvas LTI 1.3 path.', - oidc_initiation_url: `${normalizedOrigin}/lti/login`, - target_link_uri: launchUrl, - public_jwk_url: buildCanvasJwksUrl(normalizedOrigin), - redirect_uris: [launchUrl, deepLinkingUrl, resourceSelectionUrl], - scopes: [...CANVAS_LTI_SCOPES], - extensions: [ - { - domain: origin.host, - tool_id: 'lantern-demo-broker', - platform: 'canvas.instructure.com', - privacy_level: 'public', - settings: { - text: 'Lantern Demo', - placements: [ - { - placement: 'course_navigation', - message_type: LTI_RESOURCE_LINK_REQUEST_MESSAGE_TYPE, - target_link_uri: launchUrl, - text: 'Lantern Demo', - }, - { - placement: LTI_ASSIGNMENT_SELECTION_PLACEMENT, - message_type: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - target_link_uri: deepLinkingUrl, - text: 'Select Lantern activity', - }, - { - placement: LTI_RESOURCE_SELECTION_PLACEMENT, - message_type: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - target_link_uri: resourceSelectionUrl, - text: 'Select Lantern activity', - }, - ], - }, - }, - ], + ...registrationConfig, + url: buildCanvasLaunchUrl(normalizedOrigin), }; } diff --git a/src/lti/config_test.ts b/src/lti/config_test.ts index c280675..1f2213b 100644 --- a/src/lti/config_test.ts +++ b/src/lti/config_test.ts @@ -1,7 +1,13 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { CANVAS_LTI_SCOPES } from './types.ts'; +import { assertEquals, assertRejects, assertStringIncludes } from '@std/assert'; import { getTestToolPrivateJwkEnvValue, TEST_LTI_STATE_SECRET } from '../test_helpers/lti.ts'; +const EXPECTED_CANVAS_STATIC_SCOPES = [ + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', + 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', + 'https://purl.imsglobal.org/spec/lti-ags/scope/score', + 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly', +]; + const TEST_CONFIG_ENV = { get(name: string): string | undefined { return name === 'APP_ORIGIN' @@ -40,7 +46,7 @@ Deno.test('GET /lti/canvas/config.json publishes one supported Canvas config doc assertEquals(typeof body.oidc_initiation_url, 'string'); assertEquals(typeof body.target_link_uri, 'string'); assertEquals(typeof body.public_jwk_url, 'string'); - assertEquals(body.scopes, [...CANVAS_LTI_SCOPES]); + assertEquals(body.scopes, EXPECTED_CANVAS_STATIC_SCOPES); assertEquals(Array.isArray(body.extensions), true); } finally { restoreEnv('APP_ORIGIN', previousOrigin); @@ -84,17 +90,26 @@ Deno.test('config document advertises exactly the Phase 3 AGS and NRPS scopes', const document = await buildCanvasConfigDocument('http://localhost:8417', TEST_CONFIG_ENV); assertEquals(Array.isArray(document.extensions), true); - assertEquals(document.scopes, [...CANVAS_LTI_SCOPES]); - assertEquals( - document.scopes.includes('https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly'), - false, - ); + assertEquals(document.scopes, EXPECTED_CANVAS_STATIC_SCOPES); } finally { restoreEnv('APP_ORIGIN', previousOrigin); restoreEnv('LTI_TOOL_PRIVATE_JWK', previousJwk); } }); +Deno.test('config document fails before publishing when the tool JWK is missing', async () => { + const { buildCanvasConfigDocument } = await import('./config.ts'); + + await assertRejects( + () => + buildCanvasConfigDocument('http://localhost:8417', { + get: () => undefined, + }), + Error, + 'Missing LTI_TOOL_PRIVATE_JWK', + ); +}); + Deno.test('config document advertises assignment-selection Deep Linking on a dedicated route', async () => { const previousOrigin = Deno.env.get('APP_ORIGIN'); const previousJwk = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); @@ -112,16 +127,14 @@ Deno.test('config document advertises assignment-selection Deep Linking on a ded assertEquals(assignmentSelection?.message_type, 'LtiDeepLinkingRequest'); assertEquals(assignmentSelection?.target_link_uri, 'http://localhost:8417/lti/deep-linking'); - assertEquals(document.redirect_uris.includes('http://localhost:8417/lti/deep-linking'), true); - assertEquals( - document.redirect_uris.includes( - 'http://localhost:8417/lti/deep-linking?placement=resource_selection', - ), - true, + const resourceSelection = placements.find( + (placement) => placement.placement === 'resource_selection', ); + + assertEquals(resourceSelection?.message_type, 'LtiDeepLinkingRequest'); assertEquals( - placements.some((placement) => placement.placement === 'resource_selection'), - true, + resourceSelection?.target_link_uri, + 'http://localhost:8417/lti/deep-linking?placement=resource_selection', ); } finally { restoreEnv('APP_ORIGIN', previousOrigin); diff --git a/src/lti/deep_linking_content_item.ts b/src/lti/deep_linking_content_item.ts index 890164f..60f2985 100644 --- a/src/lti/deep_linking_content_item.ts +++ b/src/lti/deep_linking_content_item.ts @@ -25,6 +25,7 @@ export function buildDeepLinkingResourceLinkContentItem(input: { custom: { [LANTERN_PLACEMENT_CUSTOM_KEY]: input.placement.placementId, }, + ...buildOptionalPresentation(input.session.acceptPresentationDocumentTargets), ...buildOptionalLineItem({ session: input.session, placement: input.placement, @@ -33,19 +34,8 @@ export function buildDeepLinkingResourceLinkContentItem(input: { }), }; const validated = createLtiResourceLinkContentItem(baseContentItem); - const presentationDocumentTarget = resolvePresentationDocumentTarget( - input.session.acceptPresentationDocumentTargets, - ); - return { - type: validated.type, - ...baseContentItem, - ...(presentationDocumentTarget === null ? {} : { - presentation: { - documentTarget: presentationDocumentTarget, - }, - }), - }; + return validated; } function buildPlacementTitle(placement: ReviewedPlacementRecord): string { @@ -82,14 +72,22 @@ function buildOptionalLineItem(input: { }; } -function resolvePresentationDocumentTarget( +function buildOptionalPresentation( targets: DeepLinkingSessionRecord['acceptPresentationDocumentTargets'], -): DeepLinkingPresentationDocumentTarget | null { +): { + presentation?: { + documentTarget: DeepLinkingPresentationDocumentTarget; + }; +} { if (targets.length === 0) { - return null; + return {}; } - return targets.includes('iframe') ? 'iframe' : (targets[0] ?? null); + return { + presentation: { + documentTarget: targets.includes('iframe') ? 'iframe' : (targets[0] ?? 'iframe'), + }, + }; } export function buildReviewedPlacementLineItemResourceId( diff --git a/src/lti/deep_linking_launch_settings.ts b/src/lti/deep_linking_launch_settings.ts deleted file mode 100644 index c6bb68b..0000000 --- a/src/lti/deep_linking_launch_settings.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { decodeJwt } from 'jose'; - -const CLAIM_DEEP_LINKING_SETTINGS = - 'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'; - -export type DeepLinkingAcceptLineItemClaim = - | { - success: true; - value: boolean; - } - | { - success: false; - message: string; - }; - -export async function readLaunchIdToken(request: Request): Promise { - let formData: FormData; - - try { - formData = await request.formData(); - } catch { - return null; - } - - const idToken = formData.get('id_token'); - - return typeof idToken === 'string' && idToken.trim() !== '' ? idToken : null; -} - -export function readVerifiedDeepLinkingAcceptLineItemClaim( - idToken: string | null, -): DeepLinkingAcceptLineItemClaim { - if (idToken === null) { - return { success: true, value: false }; - } - - try { - return readDeepLinkingAcceptLineItemFromPayload(decodeJwt(idToken)); - } catch { - return { - success: false, - message: 'Verified Deep Linking id_token could not be decoded.', - }; - } -} - -function readDeepLinkingAcceptLineItemFromPayload( - payload: Record, -): DeepLinkingAcceptLineItemClaim { - const settings = payload[CLAIM_DEEP_LINKING_SETTINGS]; - - if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) { - return { success: true, value: false }; - } - - // SAFETY: The object/null/array guard above narrows the JWT claim to a record-like shape; - // this only reads one optional extension field not exposed by lti-tool@0.1.3. - const acceptLineItem = (settings as Record).accept_lineitem; - - if (acceptLineItem === undefined) { - return { success: true, value: false }; - } - - if (typeof acceptLineItem !== 'boolean') { - return { - success: false, - message: 'Deep Linking accept_lineitem must be a boolean when provided.', - }; - } - - return { success: true, value: acceptLineItem }; -} diff --git a/src/lti/deep_linking_response_tool.ts b/src/lti/deep_linking_response_tool.ts index 6886a00..34c1d70 100644 --- a/src/lti/deep_linking_response_tool.ts +++ b/src/lti/deep_linking_response_tool.ts @@ -1,8 +1,8 @@ import { buildDeepLinkingResourceLinkContentItem } from './deep_linking_content_item.ts'; -import { requireLtiToolSession } from './lti_tool_session.ts'; import type { DeepLinkingSessionRecord } from './types.ts'; import type { LanternLtiToolContext } from './tool_provider.ts'; import type { PackageVersionRecord, ReviewedPlacementRecord } from '../package_review/types.ts'; +import { requireVerifiedLtiSession } from './session_boundary.ts'; export async function createDeepLinkingReturnResponseWithLtiTool(input: { ltiContext: LanternLtiToolContext; @@ -11,9 +11,9 @@ export async function createDeepLinkingReturnResponseWithLtiTool(input: { packageVersion: PackageVersionRecord; appOrigin?: string; }): Promise { - const ltiSession = await requireLtiToolSession({ + const ltiSession = await requireVerifiedLtiSession({ storage: input.ltiContext.storage, - ltiToolSessionId: input.session.ltiToolSessionId, + sessionId: input.session.ltiToolSessionId, missingMessage: 'Deep Linking return requires a verified lti-tool session.', notFoundMessage: 'The verified LTI session for this Deep Linking return was not found.', }); diff --git a/src/lti/dynamic_registration_config.ts b/src/lti/dynamic_registration_config.ts new file mode 100644 index 0000000..7dab027 --- /dev/null +++ b/src/lti/dynamic_registration_config.ts @@ -0,0 +1,43 @@ +import type { DynamicRegistrationConfig } from '@longsightgroup/lti-tool'; + +export function buildDynamicRegistrationConfig(appOrigin: string): DynamicRegistrationConfig { + return { + url: appOrigin, + name: 'Lantern', + description: 'Launch approved Lantern learning activities through the governed runtime.', + loginUri: `${appOrigin}/lti/login`, + launchUri: `${appOrigin}/lti/launch`, + jwksUri: `${appOrigin}/lti/jwks.json`, + deepLinkingUri: `${appOrigin}/lti/deep-linking`, + redirectUris: [ + `${appOrigin}/lti/launch`, + `${appOrigin}/lti/deep-linking`, + `${appOrigin}/lti/deep-linking?placement=resource_selection`, + ], + platforms: { + canvas: { + privacyLevel: 'public', + toolId: 'lantern-demo-broker', + resourceLinkPlacements: ['course_navigation'], + deepLinkPlacements: ['assignment_selection', 'resource_selection'], + }, + moodle: { + deepLinkPlacements: ['assignment_selection'], + }, + sakai: { + resourceLinkPlacements: ['course_navigation'], + deepLinkPlacements: ['assignment_selection'], + }, + }, + customizeMessages: (_context, messages) => + messages.map((message) => + message.type === 'LtiDeepLinkingRequest' && + message.placements?.includes('resource_selection') + ? { + ...message, + target_link_uri: `${appOrigin}/lti/deep-linking?placement=resource_selection`, + } + : message + ), + }; +} diff --git a/src/lti/dynamic_registration_tool.ts b/src/lti/dynamic_registration_tool.ts index a81c0e2..a87e9a0 100644 --- a/src/lti/dynamic_registration_tool.ts +++ b/src/lti/dynamic_registration_tool.ts @@ -75,7 +75,7 @@ export async function completeLanternDynamicRegistrationWithLtiTool(input: { const completed = await input.ltiContext.dynamicRegistration.completeDynamicRegistration({ services: DEFAULT_DYNAMIC_REGISTRATION_SERVICES, - sessionToken: readRegistrationSessionToken(initiated.data), + sessionToken: initiated.data.sessionToken, }); if (!completed.success) { @@ -119,17 +119,6 @@ function buildDeploymentBinding( }; } -function readRegistrationSessionToken(html: string): string { - const match = html.match(/\bname=["']sessionToken["'][^>]*\bvalue=["']([^"']+)["']/i) ?? - html.match(/\bvalue=["']([^"']+)["'][^>]*\bname=["']sessionToken["']/i); - - if (match?.[1] === undefined || match[1].trim() === '') { - throw new Error('lti-tool dynamic registration did not return a registration session token.'); - } - - return match[1]; -} - function readLanternDynamicRegistrationAppState( value: LtiDynamicRegistrationCompletionResult['appState'], ): LanternDynamicRegistrationAppState { diff --git a/src/lti/launch_mapping.ts b/src/lti/launch_mapping.ts index a096dbe..ca909cb 100644 --- a/src/lti/launch_mapping.ts +++ b/src/lti/launch_mapping.ts @@ -87,10 +87,10 @@ export async function mapResourceLinkLaunch(input: { packageVersion: resolved.packageVersion.version, contentPath: resolved.contentPath, attemptId: createOpaqueToken(), - userId: input.session.user.id, + userId: requireString(input.session.user.id, 'Launch user.id is required.'), userDisplayName: input.session.user.name ?? null, userEmail: input.session.user.email ?? null, - userLogin: readOptionalString(input.session.jwtPayload.preferred_username), + userLogin: readVerifiedUserLogin(input.session.jwtPayload), userRole: resolveUserRole(input.message.roles), resourceLinkId: input.message.resourceLink.id, resourceLinkTitle: input.message.resourceLink.title ?? null, @@ -108,7 +108,6 @@ export function mapDeepLinkingLaunch(input: { verifiedLaunch: LtiAuthorizedLaunch; session: LTISession; message: ResolvedLtiDeepLinkingLaunchMessage; - acceptLineItem: boolean; }): ValidatedDeepLinkingRequest { const deployment = input.verifiedLaunch.authorization.deployment; const binding = deployment.binding; @@ -127,7 +126,7 @@ export function mapDeepLinkingLaunch(input: { internalDeploymentId: deployment.id, internalDeploymentSlug: deployment.slug, appId: deployment.appId, - userId: input.session.user.id, + userId: input.session.user.id ?? null, userRole: resolveUserRole(input.message.roles), contextId: input.message.context?.id ?? null, contextTitle: input.message.context?.title ?? null, @@ -145,7 +144,7 @@ export function mapDeepLinkingLaunch(input: { ): value is DeepLinkingPresentationDocumentTarget => value === 'iframe' || value === 'window' || value === 'embed' ), - acceptLineItem: input.acceptLineItem, + acceptLineItem: input.message.deepLinkingSettings.acceptLineItem ?? false, }, issuedAt: issuedAtIso(input.session), }; @@ -198,6 +197,11 @@ function readOptionalString(value: unknown): string | null { return typeof value === 'string' && value.trim() !== '' ? value : null; } +function readVerifiedUserLogin(_payload: LTISession['jwtPayload']): string | null { + // lti-tool verifies launches through a schema that does not expose Canvas preferred_username. + return null; +} + function requireString(value: unknown, message: string): string { if (typeof value !== 'string' || value.trim() === '') { throw new Error(message); diff --git a/src/lti/lti_tool_session.ts b/src/lti/lti_tool_session.ts deleted file mode 100644 index a8bbc5a..0000000 --- a/src/lti/lti_tool_session.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { LTISession, LTIStorage } from '@longsightgroup/lti-tool'; - -export async function requireLtiToolSession(input: { - storage?: LTIStorage; - getStorage?: () => LTIStorage | Promise; - ltiToolSessionId: string | null; - missingMessage: string; - notFoundMessage: string; -}): Promise { - if (input.ltiToolSessionId === null) { - throw new Error(input.missingMessage); - } - - const storage = input.storage ?? await input.getStorage?.(); - - if (storage === undefined) { - throw new Error('LTI storage is required to load a verified lti-tool session.'); - } - - const session = await storage.getSession(input.ltiToolSessionId); - - if (session === undefined) { - throw new Error(input.notFoundMessage); - } - - return session; -} diff --git a/src/lti/nrps_roster_verification.ts b/src/lti/nrps_roster_verification.ts index 3f15a59..1b4c575 100644 --- a/src/lti/nrps_roster_verification.ts +++ b/src/lti/nrps_roster_verification.ts @@ -1,28 +1,19 @@ import type { AppServices } from '../app_services.ts'; -import { requireLtiToolSession } from './lti_tool_session.ts'; -import type { LanternLtiToolContext } from './tool_provider.ts'; +import { requireVerifiedLtiSessionWithContext } from './session_boundary.ts'; import type { RuntimeSessionRecord } from './types.ts'; export async function readNrpsMemberCount(input: { services: AppServices; session: RuntimeSessionRecord; }): Promise { - let ltiContext: LanternLtiToolContext | undefined; - const ltiSession = await requireLtiToolSession({ - getStorage: async () => { - ltiContext = await input.services.getLtiToolContext(); - return ltiContext.storage; - }, - ltiToolSessionId: input.session.ltiToolSessionId, + const { context, session } = await requireVerifiedLtiSessionWithContext({ + getContext: () => input.services.getLtiToolContext(), + sessionId: input.session.ltiToolSessionId, missingMessage: 'The verified LTI session for roster verification is required.', notFoundMessage: 'The verified LTI session for roster verification was not found.', }); - if (ltiContext === undefined) { - throw new Error('The lti-tool context for roster verification was not loaded.'); - } - - const result = await ltiContext.tool.createAdvantage(ltiSession).getMembers({ + const result = await context.tool.createAdvantage(session).getMembers({ followPagination: true, }); diff --git a/src/lti/platform_binding.ts b/src/lti/platform_binding.ts index 7728d45..2ce4cee 100644 --- a/src/lti/platform_binding.ts +++ b/src/lti/platform_binding.ts @@ -1,3 +1,4 @@ +import { resolveCanvasPlatformBinding } from '@longsightgroup/lti-tool'; import { resolveCanvasPlatform } from './canvas_platform.ts'; import type { DeploymentBinding, LmsType } from './types.ts'; @@ -24,9 +25,9 @@ export function resolveBindingJwksUrl(binding: DeploymentBinding): string { export function resolveServiceTokenEndpoint(binding: DeploymentBinding): string { switch (binding.lms) { case 'canvas': - return resolveCanvasTokenEndpoint( - resolveCanvasPlatform(binding.issuer).authorizationEndpoint, - ); + return resolveCanvasPlatformBinding( + resolveCanvasPlatform(binding.issuer).environment, + ).tokenUrl; case 'moodle': case 'sakai': return binding.accessTokenUrl; @@ -43,12 +44,3 @@ export function formatLmsLabel(lms: LmsType): string { return 'Sakai'; } } - -function resolveCanvasTokenEndpoint(authorizationEndpoint: string): string { - const url = new URL(authorizationEndpoint); - url.pathname = '/login/oauth2/token'; - url.search = ''; - url.hash = ''; - - return url.toString(); -} diff --git a/src/lti/session_boundary.ts b/src/lti/session_boundary.ts new file mode 100644 index 0000000..f5a4393 --- /dev/null +++ b/src/lti/session_boundary.ts @@ -0,0 +1,50 @@ +import { type LTISession, type LTIStorage, requireLtiSession } from '@longsightgroup/lti-tool'; + +export interface LtiSessionStorageContext { + readonly storage: LTIStorage; +} + +export async function requireVerifiedLtiSession(input: { + storage: LTIStorage; + sessionId: string | null; + missingMessage: string; + notFoundMessage: string; +}): Promise { + if (input.sessionId === null) { + throw new Error(input.missingMessage); + } + + const session = await requireLtiSession({ + storage: input.storage, + sessionId: input.sessionId, + }); + + if (!session.success) { + throw new Error(input.notFoundMessage); + } + + return session.data; +} + +export async function requireVerifiedLtiSessionWithContext< + TContext extends LtiSessionStorageContext, +>(input: { + getContext: () => Promise; + sessionId: string | null; + missingMessage: string; + notFoundMessage: string; +}): Promise<{ context: TContext; session: LTISession }> { + if (input.sessionId === null) { + throw new Error(input.missingMessage); + } + + const context = await input.getContext(); + const session = await requireVerifiedLtiSession({ + storage: context.storage, + sessionId: input.sessionId, + missingMessage: input.missingMessage, + notFoundMessage: input.notFoundMessage, + }); + + return { context, session }; +} diff --git a/src/lti/tool_key.ts b/src/lti/tool_key.ts index 0a89c48..6a8dd7c 100644 --- a/src/lti/tool_key.ts +++ b/src/lti/tool_key.ts @@ -1,30 +1,14 @@ -import { calculateJwkThumbprint, importJWK } from 'jose'; +import { importLtiToolKeyPairFromJwk, type LtiToolPublicJwk } from '@longsightgroup/lti-tool'; import type { EnvReader } from '../platform/env.ts'; const TOOL_PRIVATE_JWK_ENV = 'LTI_TOOL_PRIVATE_JWK'; -export interface ToolPublicJwk { - kty: 'RSA'; - n: string; - e: string; - alg: 'RS256'; - use: 'sig'; - kid: string; -} - -export interface ToolPrivateJwk extends ToolPublicJwk { - d: string; - p: string; - q: string; - dp: string; - dq: string; - qi: string; -} - export interface LoadedToolSigningKey { + keyPair: CryptoKeyPair; privateKey: CryptoKey; - privateJwk: ToolPrivateJwk; - publicJwk: ToolPublicJwk; + keyId: string; + alg: 'RS256'; + publicJwk: LtiToolPublicJwk; } /** Loads the raw private JWK string used by Lantern and LTI Tool. */ @@ -41,101 +25,21 @@ export function readToolPrivateJwkValue(env: EnvReader): string { } export async function loadToolSigningKey(env: EnvReader): Promise { - const privateJwk = await parseToolPrivateJwk(readToolPrivateJwkValue(env)); - const importedKey = await importJWK(privateJwk, privateJwk.alg); - - if (!(importedKey instanceof CryptoKey) || importedKey.type !== 'private') { - throw new Error( - `${TOOL_PRIVATE_JWK_ENV} must contain a private signing key, not a public-only JWK.`, - ); - } + const keyMaterial = await importLtiToolKeyPairFromJwk(readToolPrivateJwkValue(env)); return { - privateKey: importedKey, - privateJwk, - publicJwk: toPublicJwk(privateJwk), + keyPair: keyMaterial.keyPair, + privateKey: keyMaterial.keyPair.privateKey, + keyId: keyMaterial.keyId, + alg: 'RS256', + publicJwk: keyMaterial.publicJwk, }; } -export async function getPublicJwkSet(env: EnvReader): Promise<{ keys: ToolPublicJwk[] }> { +export async function getPublicJwkSet(env: EnvReader): Promise<{ keys: LtiToolPublicJwk[] }> { const toolKey = await loadToolSigningKey(env); return { keys: [toolKey.publicJwk], }; } - -async function parseToolPrivateJwk(rawValue: string): Promise { - let parsed: unknown; - - try { - parsed = JSON.parse(rawValue); - } catch { - throw new Error(`${TOOL_PRIVATE_JWK_ENV} must be valid JSON containing one RSA private JWK.`); - } - - if (!isObject(parsed)) { - throw new Error(`${TOOL_PRIVATE_JWK_ENV} must decode to a JSON object.`); - } - - readRequiredString(parsed, 'kty', 'RSA'); - - const baseJwk = { - kty: 'RSA' as const, - n: readRequiredString(parsed, 'n'), - e: readRequiredString(parsed, 'e'), - d: readRequiredString(parsed, 'd'), - p: readRequiredString(parsed, 'p'), - q: readRequiredString(parsed, 'q'), - dp: readRequiredString(parsed, 'dp'), - dq: readRequiredString(parsed, 'dq'), - qi: readRequiredString(parsed, 'qi'), - }; - - const unsignedPublicJwk = { - kty: baseJwk.kty, - n: baseJwk.n, - e: baseJwk.e, - }; - const kid = await calculateJwkThumbprint(unsignedPublicJwk, 'sha256'); - - return { - ...baseJwk, - alg: 'RS256', - use: 'sig', - kid, - }; -} - -function toPublicJwk(privateJwk: ToolPrivateJwk): ToolPublicJwk { - return { - kty: privateJwk.kty, - n: privateJwk.n, - e: privateJwk.e, - alg: privateJwk.alg, - use: privateJwk.use, - kid: privateJwk.kid, - }; -} - -function readRequiredString( - value: Record, - key: string, - expectedValue?: string, -): string { - const field = value[key]; - - if (typeof field !== 'string' || field.length === 0) { - throw new Error(`${TOOL_PRIVATE_JWK_ENV} is missing a valid "${key}" string.`); - } - - if (expectedValue && field !== expectedValue) { - throw new Error(`${TOOL_PRIVATE_JWK_ENV} must use ${key}=${expectedValue}.`); - } - - return field; -} - -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} diff --git a/src/lti/tool_key_test.ts b/src/lti/tool_key_test.ts index cc91bb8..a33ce9a 100644 --- a/src/lti/tool_key_test.ts +++ b/src/lti/tool_key_test.ts @@ -10,8 +10,10 @@ Deno.test('loadToolSigningKey accepts one RS256 private JWK and projects a publi const loaded = await loadToolSigningKey(env); const jwks = await getPublicJwkSet(env); - assertEquals(loaded.privateJwk.kty, 'RSA'); + assertEquals(loaded.alg, 'RS256'); + assertEquals(loaded.privateKey.type, 'private'); assertEquals(loaded.publicJwk.alg, 'RS256'); + assertEquals(loaded.keyId, loaded.publicJwk.kid); assertEquals(jwks.keys.length, 1); assertEquals('d' in jwks.keys[0]!, false); }); @@ -27,7 +29,11 @@ Deno.test('loadToolSigningKey rejects non-RSA tool keys', async () => { }), }); - await assertRejects(() => loadToolSigningKey(env), Error, 'must use kty=RSA'); + await assertRejects( + () => loadToolSigningKey(env), + Error, + 'LTI tool private JWK must be an RSA private JWK with private key parameters', + ); }); function createEnvReader(values: Record): { diff --git a/src/lti/tool_provider.ts b/src/lti/tool_provider.ts index e9e0930..125df45 100644 --- a/src/lti/tool_provider.ts +++ b/src/lti/tool_provider.ts @@ -1,7 +1,5 @@ import { createNoopLogger, - type DynamicRegistrationConfig, - importLtiToolKeyPairFromJwk, LtiDynamicRegistration, type LtiLogger, type LTIStorage, @@ -20,6 +18,7 @@ import { resolveServiceTokenEndpoint, } from './platform_binding.ts'; import { loadToolSigningKey } from './tool_key.ts'; +import { buildDynamicRegistrationConfig } from './dynamic_registration_config.ts'; import type { DeploymentBinding } from './types.ts'; const LTI_STATE_SECRET_ENV = 'LTI_STATE_SECRET'; @@ -56,15 +55,14 @@ export async function createLanternLtiToolContext( ): Promise { const logger = input.logger ?? createNoopLogger(); const toolSigningKey = await loadToolSigningKey(input.env); - const keyMaterial = await importLtiToolKeyPairFromJwk(toolSigningKey.privateJwk); const appOrigin = input.appOrigin ?? requireAppOrigin(input.env); const config = { stateSecret: readLtiStateSecret(input.env), - keyPair: keyMaterial.keyPair, + keyPair: toolSigningKey.keyPair, storage: input.storage, logger, security: { - keyId: keyMaterial.keyId, + keyId: toolSigningKey.keyId, remoteJwks: { timeoutDuration: 5_000, cooldownDuration: 30_000, @@ -119,35 +117,3 @@ function readLtiStateSecret(env: EnvReader): Uint8Array { return bytes; } - -function buildDynamicRegistrationConfig(appOrigin: string): DynamicRegistrationConfig { - return { - url: appOrigin, - name: 'Lantern', - description: 'Launch approved Lantern learning activities through the governed runtime.', - loginUri: `${appOrigin}/lti/login`, - launchUri: `${appOrigin}/lti/launch`, - jwksUri: `${appOrigin}/lti/jwks.json`, - deepLinkingUri: `${appOrigin}/lti/deep-linking`, - redirectUris: [ - `${appOrigin}/lti/launch`, - `${appOrigin}/lti/deep-linking`, - `${appOrigin}/lti/deep-linking?placement=resource_selection`, - ], - platforms: { - canvas: { - privacyLevel: 'public', - toolId: 'lantern-demo-broker', - resourceLinkPlacements: ['course_navigation'], - deepLinkPlacements: ['assignment_selection', 'resource_selection'], - }, - moodle: { - deepLinkPlacements: ['assignment_selection'], - }, - sakai: { - resourceLinkPlacements: ['course_navigation'], - deepLinkPlacements: ['assignment_selection'], - }, - }, - }; -} diff --git a/src/lti/types.ts b/src/lti/types.ts index 98d27e4..171038e 100644 --- a/src/lti/types.ts +++ b/src/lti/types.ts @@ -1,4 +1,5 @@ import type { Capability, SubmissionMode, UserRole } from '../../sdk/app-sdk.ts'; +import type { DeepLinkingLtiResourceLink } from '@longsightgroup/lti-tool'; export type CanvasEnvironment = 'production' | 'beta' | 'test'; export const LTI_AGS_SCORE_SCOPE = 'https://purl.imsglobal.org/spec/lti-ags/scope/score'; @@ -213,24 +214,7 @@ export interface DeepLinkingSessionRecord { usedAt: string | null; } -export interface DeepLinkingResponseLineItem { - scoreMaximum: number; - label: string; - resourceId: string; - tag: string; -} - -export interface DeepLinkingResponseContentItem { - type: 'ltiResourceLink'; - title: string; - text: string; - url: string; - custom: Record; - lineItem?: DeepLinkingResponseLineItem; - presentation?: { - documentTarget: DeepLinkingPresentationDocumentTarget; - }; -} +export type DeepLinkingResponseContentItem = DeepLinkingLtiResourceLink; export interface DeepLinkingResponseSubmission { returnUrl: string; diff --git a/src/ops/retry.ts b/src/ops/retry.ts index 3ce0893..e961f5f 100644 --- a/src/ops/retry.ts +++ b/src/ops/retry.ts @@ -1,6 +1,5 @@ -import { requireLtiToolSession } from '../lti/lti_tool_session.ts'; +import { requireVerifiedLtiSessionWithContext } from '../lti/session_boundary.ts'; import type { LanternLtiToolContext } from '../lti/tool_provider.ts'; -import { withAgsLineItemTarget } from '../lti/ags_session.ts'; import type { PackageReviewRepository } from '../package_review/repository.ts'; import type { RetryableGradePublicationLookup } from './types.ts'; @@ -44,35 +43,28 @@ export async function retryFailedGradePublication(input: { ); } - let ltiContext: LanternLtiToolContext | undefined; - const ltiSession = await requireLtiToolSession({ - getStorage: async () => { - ltiContext = await input.getLtiToolContext(); - return ltiContext.storage; - }, - ltiToolSessionId: runtimeSession.ltiToolSessionId, + const { context, session } = await requireVerifiedLtiSessionWithContext({ + getContext: input.getLtiToolContext, + sessionId: runtimeSession.ltiToolSessionId, missingMessage: 'Retry blocked: the saved runtime session does not include a verified LTI session.', notFoundMessage: 'Retry blocked: the verified LTI session for this grade publication was not found.', }); - if (ltiContext === undefined) { - throw new Error('Retry blocked: the lti-tool context was not loaded.'); - } - const now = input.now ?? (() => new Date()); const timestamp = now().toISOString(); - const result = await ltiContext.tool.createAdvantage( - withAgsLineItemTarget(ltiSession, lookup.publication.lineItemUrl), - ).submitScore({ - userId: lookup.publication.platformUserId, - scoreGiven: lookup.publication.scoreGiven, - scoreMaximum: lookup.publication.scoreMaximum, - activityProgress: lookup.publication.activityProgress, - gradingProgress: 'FullyGraded', - timestamp, - }); + const result = await context.tool.createAdvantage(session).submitScore( + { + userId: lookup.publication.platformUserId, + scoreGiven: lookup.publication.scoreGiven, + scoreMaximum: lookup.publication.scoreMaximum, + activityProgress: lookup.publication.activityProgress, + gradingProgress: 'FullyGraded', + timestamp, + }, + { lineItemUrl: lookup.publication.lineItemUrl }, + ); const gradePublication = result.success ? await input.repository.updateGradePublication({ attemptId: lookup.attemptId, diff --git a/src/package_review/runtime_contract.ts b/src/package_review/runtime_contract.ts index 5b0ad6b..4a2db4d 100644 --- a/src/package_review/runtime_contract.ts +++ b/src/package_review/runtime_contract.ts @@ -108,8 +108,8 @@ async function signReviewedRuntimeContract(input: { textEncoder.encode(serializeReviewedRuntimeContract(input.runtimeContract)), ) .setProtectedHeader({ - alg: toolKey.privateJwk.alg, - kid: toolKey.publicJwk.kid, + alg: toolKey.alg, + kid: toolKey.keyId, typ: REVIEWED_RUNTIME_CONTRACT_JWS_TYPE, }) .sign(toolKey.privateKey); diff --git a/src/runtime/gateway_publication.ts b/src/runtime/gateway_publication.ts index 00bb492..35b7ce6 100644 --- a/src/runtime/gateway_publication.ts +++ b/src/runtime/gateway_publication.ts @@ -1,6 +1,4 @@ import { type AttemptScoreResult } from '../grading/service.ts'; -import { withAgsLineItemTarget } from '../lti/ags_session.ts'; -import { requireLtiToolSession } from '../lti/lti_tool_session.ts'; import type { LanternLtiToolContext } from '../lti/tool_provider.ts'; import type { LTISession } from '@longsightgroup/lti-tool'; import { @@ -8,6 +6,7 @@ import { LTI_AGS_SCORE_SCOPE, type RuntimeSessionRecord, } from '../lti/types.ts'; +import { requireVerifiedLtiSessionWithContext } from '../lti/session_boundary.ts'; import type { PackageReviewRepository } from '../package_review/repository.ts'; import type { AttemptRecord, PackageVersionRecord } from '../package_review/types.ts'; import { requireRuntimeDeployment } from './gateway_context.ts'; @@ -143,16 +142,19 @@ async function publishRuntimeAttemptScoreWithLtiTool(input: { }; } - const ltiContext = await input.getLtiToolContext(); + let ltiContext: LanternLtiToolContext; let ltiSession: LTISession; try { - ltiSession = await requireLtiToolSession({ - storage: ltiContext.storage, - ltiToolSessionId: input.session.ltiToolSessionId, + const loaded = await requireVerifiedLtiSessionWithContext({ + getContext: input.getLtiToolContext, + sessionId: input.session.ltiToolSessionId, missingMessage: 'A verified lti-tool session is required before score publish can continue.', notFoundMessage: 'The verified LTI session for this runtime attempt was not found.', }); + + ltiContext = loaded.context; + ltiSession = loaded.session; } catch (error) { return { lineItemBinding: input.existingBinding, @@ -211,15 +213,17 @@ async function publishRuntimeAttemptScoreWithLtiTool(input: { errorCode: null, errorDetail: null, }); - const scoreSession = withAgsLineItemTarget(ltiSession, lineItemBinding.lineItemUrl); - const result = await ltiContext.tool.createAdvantage(scoreSession).submitScore({ - userId: gradePublication.platformUserId, - scoreGiven: gradePublication.scoreGiven, - scoreMaximum: gradePublication.scoreMaximum, - activityProgress: gradePublication.activityProgress, - gradingProgress: 'FullyGraded', - timestamp: input.now().toISOString(), - }); + const result = await ltiContext.tool.createAdvantage(ltiSession).submitScore( + { + userId: gradePublication.platformUserId, + scoreGiven: gradePublication.scoreGiven, + scoreMaximum: gradePublication.scoreMaximum, + activityProgress: gradePublication.activityProgress, + gradingProgress: 'FullyGraded', + timestamp: input.now().toISOString(), + }, + { lineItemUrl: lineItemBinding.lineItemUrl }, + ); if (!result.success) { return { diff --git a/src/runtime/session.ts b/src/runtime/session.ts index eb7ba18..2978a6b 100644 --- a/src/runtime/session.ts +++ b/src/runtime/session.ts @@ -223,8 +223,8 @@ async function signRuntimeBootstrapPayload(input: { return await new CompactSign(textEncoder.encode(JSON.stringify(input.bootstrap))) .setProtectedHeader({ - alg: toolKey.privateJwk.alg, - kid: toolKey.publicJwk.kid, + alg: toolKey.alg, + kid: toolKey.keyId, typ: RUNTIME_BOOTSTRAP_JWS_TYPE, }) .sign(toolKey.privateKey); diff --git a/src/test_helpers/lti_session_builders.ts b/src/test_helpers/lti_session_builders.ts index 296b845..06aa887 100644 --- a/src/test_helpers/lti_session_builders.ts +++ b/src/test_helpers/lti_session_builders.ts @@ -1,4 +1,5 @@ import { resolveSubmissionMode } from '../../sdk/app-sdk.ts'; +import type { LTISession } from '@longsightgroup/lti-tool'; import type { DeepLinkingSessionRecord, LmsType, @@ -13,6 +14,40 @@ import { TEST_NOW, } from './lti_identity_builders.ts'; +export function buildTestLtiJwtPayload( + input: { + messageType?: 'LtiResourceLinkRequest' | 'LtiDeepLinkingRequest'; + targetLinkUri?: string; + } = {}, +): LTISession['jwtPayload'] { + const messageType = input.messageType ?? 'LtiResourceLinkRequest'; + const targetLinkUri = input.targetLinkUri ?? 'http://localhost:8417/lti/launch'; + const base = { + iss: 'https://canvas.instructure.com', + aud: '10000000000001', + exp: 4_102_444_800, + iat: 1_735_689_600, + nonce: 'nonce-123', + 'https://purl.imsglobal.org/spec/lti/claim/version': '1.3.0', + 'https://purl.imsglobal.org/spec/lti/claim/deployment_id': 'deployment-123', + 'https://purl.imsglobal.org/spec/lti/claim/target_link_uri': targetLinkUri, + } as const; + + if (messageType === 'LtiDeepLinkingRequest') { + return { + ...base, + sub: 'canvas-user-123', + 'https://purl.imsglobal.org/spec/lti/claim/message_type': 'LtiDeepLinkingRequest', + }; + } + + return { + ...base, + sub: 'canvas-user-123', + 'https://purl.imsglobal.org/spec/lti/claim/message_type': 'LtiResourceLinkRequest', + }; +} + export function buildValidatedLaunch(overrides: Partial = {}): ValidatedLaunch { const identity = buildPlatformIdentity({ ...(overrides.lms === undefined ? {} : { lms: overrides.lms }), diff --git a/src/test_helpers/lti_tool_context.ts b/src/test_helpers/lti_tool_context.ts index 05f16dd..f317adf 100644 --- a/src/test_helpers/lti_tool_context.ts +++ b/src/test_helpers/lti_tool_context.ts @@ -1,5 +1,6 @@ import type { LTISession } from '@longsightgroup/lti-tool'; import type { LanternLtiToolContext } from '../lti/tool_provider.ts'; +import { buildTestLtiJwtPayload } from './lti_session_builders.ts'; export interface TestLtiToolContextInput { sessionId?: string; @@ -92,7 +93,7 @@ export function createTestLtiToolContext( function buildTestLtiSession(input: { sessionId: string }): LTISession { return { id: input.sessionId, - jwtPayload: {}, + jwtPayload: buildTestLtiJwtPayload(), user: { id: 'canvas-user-123', roles: ['http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'], diff --git a/src/worker_test.ts b/src/worker_test.ts index a7f8448..e80acd0 100644 --- a/src/worker_test.ts +++ b/src/worker_test.ts @@ -24,11 +24,22 @@ Deno.test('Cloudflare worker entrypoint serves Canvas config from worker env bin const body = (await response.json()) as { oidc_initiation_url: string; target_link_uri: string; - redirect_uris: string[]; + extensions: Array<{ + settings: { + placements: Array<{ + target_link_uri: string; + }>; + }; + }>; }; assertEquals(response.status, 200); assertEquals(body.oidc_initiation_url, 'https://lantern.example/lti/login'); assertEquals(body.target_link_uri, 'https://lantern.example/lti/launch'); - assertEquals(body.redirect_uris.includes('https://lantern.example/lti/launch'), true); + assertEquals( + body.extensions.flatMap((extension) => extension.settings.placements).some((placement) => + placement.target_link_uri === 'https://lantern.example/lti/launch' + ), + true, + ); }); From 7187876dd9d6c8a7f92b3c961bde15a27db13724 Mon Sep 17 00:00:00 2001 From: Sam Ottenhoff Date: Wed, 8 Jul 2026 15:43:56 -0400 Subject: [PATCH 03/12] Add Better Auth admin signup --- README.md | 6 +- deno.json | 1 + deno.lock | 158 +++++++- src/admin/app_revision_page.ts | 4 +- src/admin/app_writer_request_page.ts | 8 +- src/admin/app_writer_run_page.ts | 10 +- src/admin/authoring_page.ts | 10 +- src/admin/control_plane.ts | 6 +- src/admin/control_plane_test.ts | 8 +- src/admin/deployment_detail.ts | 4 +- src/admin/deployment_detail_install_test.ts | 10 +- src/admin/deployment_detail_multilms_test.ts | 3 + .../deployment_detail_ops_render_test.ts | 7 + src/admin/deployment_detail_render_test.ts | 7 + src/admin/deployment_detail_roster_test.ts | 8 +- .../deployment_detail_smoke_render_test.ts | 4 + src/admin/layout.ts | 11 +- src/admin/layout_support.ts | 21 +- src/admin/package_detail.ts | 10 +- src/admin/package_detail_test.ts | 9 + src/admin/package_diff_page.ts | 4 +- src/admin/package_import_page.ts | 6 +- src/admin/package_index.ts | 10 +- src/admin/package_overview.ts | 10 +- src/admin/package_overview_test.ts | 2 + src/admin/package_reference_page.ts | 6 +- src/admin/package_reports.ts | 3 + src/admin/placement_audit_page.ts | 6 +- src/admin/placement_audit_page_test.ts | 3 + src/admin/preview_page.ts | 4 +- src/admin/preview_page_test.ts | 5 + src/admin_auth/context.ts | 32 ++ src/admin_auth/middleware.ts | 37 ++ src/admin_auth/page.ts | 176 +++++++++ src/admin_auth/page_input.ts | 28 ++ src/admin_auth/service.ts | 348 ++++++++++++++++++ src/admin_auth/service_test.ts | 91 +++++ src/app_admin_app_writer_routes.ts | 23 +- src/app_admin_app_writer_test.ts | 18 +- src/app_admin_auth_routes.ts | 146 ++++++++ src/app_admin_auth_test.ts | 227 ++++++++++++ src/app_admin_authoring_preview_routes.ts | 17 +- src/app_admin_authoring_preview_test.ts | 14 +- src/app_admin_authoring_routes.ts | 25 +- src/app_admin_authoring_test.ts | 8 +- src/app_admin_certification_checklist_test.ts | 6 +- ...in_certification_official_evidence_test.ts | 6 +- src/app_admin_certification_recording_test.ts | 18 +- src/app_admin_control_plane_test.ts | 28 +- ...app_admin_deployment_detail_form_routes.ts | 9 +- src/app_admin_deployment_detail_routes.ts | 9 +- ...min_deployment_moodle_registration_test.ts | 6 +- src/app_admin_deployment_registration_test.ts | 10 +- src/app_admin_deployment_retry_routes.ts | 13 +- src/app_admin_deployment_roster_route.ts | 9 +- ...dmin_deployment_sakai_registration_test.ts | 8 +- src/app_admin_deployment_test.ts | 16 +- src/app_admin_grade_smoke_moodle_test.ts | 10 +- src/app_admin_grade_smoke_route.ts | 13 +- src/app_admin_grade_smoke_sakai_test.ts | 4 +- src/app_admin_inventory_routes.ts | 97 ++--- src/app_admin_lms_bindings_test.ts | 14 +- src/app_admin_lms_pin_validation_test.ts | 6 +- src/app_admin_operations_routes.ts | 21 +- src/app_admin_package_revision_test.ts | 4 +- src/app_admin_packages_test.ts | 7 +- src/app_admin_placement_audit_test.ts | 10 +- src/app_admin_placement_routes.ts | 13 +- src/app_admin_preview_log_test.ts | 25 +- src/app_admin_preview_routes.ts | 33 +- src/app_admin_preview_test.ts | 24 +- src/app_admin_retry_grade_publish_test.ts | 6 +- src/app_admin_runtime_evidence_test.ts | 6 +- src/app_admin_support.ts | 41 ++- src/app_admin_verification_validation_test.ts | 10 +- src/app_core.ts | 14 +- src/app_services.ts | 6 + src/app_worker_services.ts | 10 + src/app_worker_services_test.ts | 64 ++-- .../d1_migrations/0010_better_auth_tables.sql | 51 +++ src/package_review/approval_test.ts | 3 + src/package_review/enablement_test.ts | 3 + src/test_helpers/admin_auth.ts | 194 ++++++++++ tools/build_worker_bundle.ts | 16 +- tools/local_init.ts | 2 + tools/local_support.ts | 9 + tools/local_support_test.ts | 8 + 87 files changed, 2078 insertions(+), 338 deletions(-) create mode 100644 src/admin_auth/context.ts create mode 100644 src/admin_auth/middleware.ts create mode 100644 src/admin_auth/page.ts create mode 100644 src/admin_auth/page_input.ts create mode 100644 src/admin_auth/service.ts create mode 100644 src/admin_auth/service_test.ts create mode 100644 src/app_admin_auth_routes.ts create mode 100644 src/app_admin_auth_test.ts create mode 100644 src/db/d1_migrations/0010_better_auth_tables.sql create mode 100644 src/test_helpers/admin_auth.ts diff --git a/README.md b/README.md index 38ce06d..d440ebe 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,8 @@ fails if Deno-only runtime references slip back into it. The generated file is To run the Worker locally: -- set Worker vars such as `APP_ORIGIN` and `LTI_TOOL_PRIVATE_JWK` with +- set Worker vars such as `APP_ORIGIN`, `BETTER_AUTH_SECRET`, and + `LTI_TOOL_PRIVATE_JWK` with `--var ...` flags or `.dev.vars` - keep the `LOADER` Worker Loader binding present; Lantern uses it for the governed Dynamic Worker envelope around immutable reviewed runtime delivery @@ -341,6 +342,7 @@ Generated local environment: - `APP_ORIGIN`: defaults to `http://localhost:8417` - `APP_RUNTIME_ORIGIN`: defaults to `http://localhost:8417` so local preview and reviewed runtime stay on one origin +- `BETTER_AUTH_SECRET`: generated automatically for local admin sessions - `LTI_STATE_SECRET`: generated automatically for local development - `LTI_TOOL_PRIVATE_JWK`: generated automatically for local development @@ -354,6 +356,8 @@ Manual minimum environment if you do not use `local:init`: Lantern must publish fixed public URLs without proxy headers - `APP_RUNTIME_ORIGIN`: required anywhere Lantern must publish runtime URLs for attempts and app sessions +- `BETTER_AUTH_SECRET`: one stable secret with at least 32 UTF-8 bytes used for + admin auth cookies and sessions Common commands: diff --git a/deno.json b/deno.json index 9c834c7..c85710d 100644 --- a/deno.json +++ b/deno.json @@ -54,6 +54,7 @@ "@happy-dom": "npm:happy-dom@20.10.6", "@hono/hono": "jsr:@hono/hono@^4.12.18", "@longsightgroup/lti-tool": "npm:@longsightgroup/lti-tool@0.1.4", + "better-auth": "npm:better-auth@1.6.23", "drizzle-orm": "npm:drizzle-orm@0.45.2", "hono": "npm:hono@4.12.27", "@std/assert": "jsr:@std/assert@^1.0.19", diff --git a/deno.lock b/deno.lock index cb2f2f8..81c6715 100644 --- a/deno.lock +++ b/deno.lock @@ -7,9 +7,10 @@ "jsr:@std/semver@1.0.8": "1.0.8", "npm:@cloudflare/puppeteer@1.1.0": "1.1.0", "npm:@cloudflare/puppeteer@latest": "1.1.0", - "npm:@longsightgroup/lti-tool@0.1.4": "0.1.4_drizzle-orm@0.45.2_hono@4.12.27", + "npm:@longsightgroup/lti-tool@0.1.4": "0.1.4_drizzle-orm@0.45.2__kysely@0.29.2_hono@4.12.27_kysely@0.29.2", "npm:ajv@8.20.0": "8.20.0", - "npm:drizzle-orm@0.45.2": "0.45.2", + "npm:better-auth@1.6.23": "1.6.23_drizzle-orm@0.45.2__kysely@0.29.2", + "npm:drizzle-orm@0.45.2": "0.45.2_kysely@0.29.2", "npm:happy-dom@20.10.6": "20.10.6", "npm:hono@4.12.27": "4.12.27", "npm:jose@6.2.3": "6.2.3", @@ -34,6 +35,80 @@ } }, "npm": { + "@better-auth/core@1.6.23_@better-auth+utils@0.4.2_@better-fetch+fetch@1.3.1_better-call@1.3.7__zod@4.4.3_jose@6.2.3_kysely@0.29.2_nanostores@1.4.0": { + "integrity": "sha512-beEhOs0uVeOxYOZKUfIEBd/nQV2Bd4/6wyLxZ0OFkn6CMTK2Vi+hXuZLnyPBeB6RdHpebEoJWiHqwHxBIxgPDQ==", + "dependencies": [ + "@better-auth/utils", + "@better-fetch/fetch", + "@opentelemetry/semantic-conventions", + "@standard-schema/spec", + "better-call", + "jose", + "kysely", + "nanostores", + "zod" + ] + }, + "@better-auth/drizzle-adapter@1.6.23_@better-auth+core@1.6.23__@better-auth+utils@0.4.2__@better-fetch+fetch@1.3.1__better-call@1.3.7___zod@4.4.3__jose@6.2.3__kysely@0.29.2__nanostores@1.4.0_@better-auth+utils@0.4.2_drizzle-orm@0.45.2__kysely@0.29.2_@better-fetch+fetch@1.3.1_better-call@1.3.7__zod@4.4.3_jose@6.2.3_kysely@0.29.2_nanostores@1.4.0": { + "integrity": "sha512-2+/PTVfIP9E7iz6af8TB3lhnowHUj9ljC66kECmHaFEdUqPgzHoWux9epotKwO7XDg2ui4ttWQ8CMeNFLvQeKQ==", + "dependencies": [ + "@better-auth/core", + "@better-auth/utils", + "drizzle-orm" + ], + "optionalPeers": [ + "drizzle-orm" + ] + }, + "@better-auth/kysely-adapter@1.6.23_@better-auth+core@1.6.23__@better-auth+utils@0.4.2__@better-fetch+fetch@1.3.1__better-call@1.3.7___zod@4.4.3__jose@6.2.3__kysely@0.29.2__nanostores@1.4.0_@better-auth+utils@0.4.2_kysely@0.29.2_@better-fetch+fetch@1.3.1_better-call@1.3.7__zod@4.4.3_jose@6.2.3_nanostores@1.4.0": { + "integrity": "sha512-zbNJsMbG09exfkGyvFqBLLqWoMPAUWjxCuUnEK5AsjbYoZeIjj/QGZgdf4CapVWryKxjA9Q6Jlr6fbiPpC3VAg==", + "dependencies": [ + "@better-auth/core", + "@better-auth/utils", + "kysely" + ], + "optionalPeers": [ + "kysely" + ] + }, + "@better-auth/memory-adapter@1.6.23_@better-auth+core@1.6.23__@better-auth+utils@0.4.2__@better-fetch+fetch@1.3.1__better-call@1.3.7___zod@4.4.3__jose@6.2.3__kysely@0.29.2__nanostores@1.4.0_@better-auth+utils@0.4.2_@better-fetch+fetch@1.3.1_better-call@1.3.7__zod@4.4.3_jose@6.2.3_kysely@0.29.2_nanostores@1.4.0": { + "integrity": "sha512-krIiR0pIVkaKlAzm690n5bcMW4NGbqeMg0HQSD9fz/KcQF/eWLqcq9gG/BhHTj2i/y96qH+W5JWPmaSOS5iTgQ==", + "dependencies": [ + "@better-auth/core", + "@better-auth/utils" + ] + }, + "@better-auth/mongo-adapter@1.6.23_@better-auth+core@1.6.23__@better-auth+utils@0.4.2__@better-fetch+fetch@1.3.1__better-call@1.3.7___zod@4.4.3__jose@6.2.3__kysely@0.29.2__nanostores@1.4.0_@better-auth+utils@0.4.2_@better-fetch+fetch@1.3.1_better-call@1.3.7__zod@4.4.3_jose@6.2.3_kysely@0.29.2_nanostores@1.4.0": { + "integrity": "sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA==", + "dependencies": [ + "@better-auth/core", + "@better-auth/utils" + ] + }, + "@better-auth/prisma-adapter@1.6.23_@better-auth+core@1.6.23__@better-auth+utils@0.4.2__@better-fetch+fetch@1.3.1__better-call@1.3.7___zod@4.4.3__jose@6.2.3__kysely@0.29.2__nanostores@1.4.0_@better-auth+utils@0.4.2_@better-fetch+fetch@1.3.1_better-call@1.3.7__zod@4.4.3_jose@6.2.3_kysely@0.29.2_nanostores@1.4.0": { + "integrity": "sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ==", + "dependencies": [ + "@better-auth/core", + "@better-auth/utils" + ] + }, + "@better-auth/telemetry@1.6.23_@better-auth+core@1.6.23__@better-auth+utils@0.4.2__@better-fetch+fetch@1.3.1__better-call@1.3.7___zod@4.4.3__jose@6.2.3__kysely@0.29.2__nanostores@1.4.0_@better-auth+utils@0.4.2_@better-fetch+fetch@1.3.1_better-call@1.3.7__zod@4.4.3_jose@6.2.3_kysely@0.29.2_nanostores@1.4.0": { + "integrity": "sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA==", + "dependencies": [ + "@better-auth/core", + "@better-auth/utils", + "@better-fetch/fetch" + ] + }, + "@better-auth/utils@0.4.2": { + "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "dependencies": [ + "@noble/hashes" + ] + }, + "@better-fetch/fetch@1.3.1": { + "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==" + }, "@cloudflare/puppeteer@1.1.0": { "integrity": "sha512-lN10En49avRDQvz8Gpv/WiIoGvjjDiP6P+v2y9bA6rfPT5WHfnlg2O6MwjHc1POm8A9LXf1sMdgrsdW8xWapOw==", "dependencies": [ @@ -43,7 +118,7 @@ "ws" ] }, - "@longsightgroup/lti-tool@0.1.4_drizzle-orm@0.45.2_hono@4.12.27": { + "@longsightgroup/lti-tool@0.1.4_drizzle-orm@0.45.2__kysely@0.29.2_hono@4.12.27_kysely@0.29.2": { "integrity": "sha512-9A/CzLz3roa9ZVHH3PiZs+mj4H0uWDOeW6boY4XjpxQ01Fv17wiilTHxlEzYn2FLtY0GU5ibl8WtmO0WC+oIEQ==", "dependencies": [ "drizzle-orm", @@ -56,6 +131,15 @@ "hono" ] }, + "@noble/ciphers@2.2.0": { + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==" + }, + "@noble/hashes@2.2.0": { + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==" + }, + "@opentelemetry/semantic-conventions@1.41.1": { + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==" + }, "@puppeteer/browsers@2.2.4": { "integrity": "sha512-BdG2qiI1dn89OTUUsx2GZSpUzW+DRffR1wlMJyKxVHYrhnKoELSDxDd+2XImUkuWPEKk76H5FcM/gPFrEK1Tfw==", "dependencies": [ @@ -70,6 +154,9 @@ ], "bin": true }, + "@standard-schema/spec@1.1.0": { + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, "@tootallnate/quickjs-emscripten@0.23.0": { "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==" }, @@ -169,6 +256,45 @@ "basic-ftp@5.3.1": { "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==" }, + "better-auth@1.6.23_drizzle-orm@0.45.2__kysely@0.29.2": { + "integrity": "sha512-4vOaRd9UiKGKm9R+ej0jjU1es3MiJIiNc9Qq3VCnYqOZ4/nb5272QqTxWYoDxyUXl5x6A2x2we5KZKQO9teTQQ==", + "dependencies": [ + "@better-auth/core", + "@better-auth/drizzle-adapter", + "@better-auth/kysely-adapter", + "@better-auth/memory-adapter", + "@better-auth/mongo-adapter", + "@better-auth/prisma-adapter", + "@better-auth/telemetry", + "@better-auth/utils", + "@better-fetch/fetch", + "@noble/ciphers", + "@noble/hashes", + "better-call", + "defu", + "drizzle-orm", + "jose", + "kysely", + "nanostores", + "zod" + ], + "optionalPeers": [ + "drizzle-orm" + ] + }, + "better-call@1.3.7_zod@4.4.3": { + "integrity": "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==", + "dependencies": [ + "@better-auth/utils", + "@better-fetch/fetch", + "rou3", + "set-cookie-parser", + "zod" + ], + "optionalPeers": [ + "zod" + ] + }, "buffer-crc32@0.2.13": { "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==" }, @@ -211,6 +337,9 @@ "ms" ] }, + "defu@6.1.7": { + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==" + }, "degenerator@5.0.1": { "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", "dependencies": [ @@ -222,8 +351,14 @@ "devtools-protocol@0.0.1299070": { "integrity": "sha512-+qtL3eX50qsJ7c+qVyagqi7AWMoQCBGNfoyJZMwm/NSXVqLYbuitrWEEIzxfUmTNy7//Xe8yhMmQ+elj3uAqSg==" }, - "drizzle-orm@0.45.2": { - "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==" + "drizzle-orm@0.45.2_kysely@0.29.2": { + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "dependencies": [ + "kysely" + ], + "optionalPeers": [ + "kysely" + ] }, "emoji-regex@8.0.0": { "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" @@ -356,12 +491,18 @@ "json-schema-traverse@1.0.0": { "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, + "kysely@0.29.2": { + "integrity": "sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==" + }, "lru-cache@7.18.3": { "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==" }, "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "nanostores@1.4.0": { + "integrity": "sha512-i0tloweeudshAEuddpDxcg9Ik6pkPfVsHIgKyf143JrgG7/MOh0+q7BypdLXZPoOP7fOYt1eTcwGkyiVmhJFkA==" + }, "netmask@2.1.1": { "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==" }, @@ -429,10 +570,16 @@ "require-from-string@2.0.2": { "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" }, + "rou3@0.7.12": { + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==" + }, "semver@7.8.0": { "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "bin": true }, + "set-cookie-parser@3.1.1": { + "integrity": "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==" + }, "smart-buffer@4.2.0": { "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==" }, @@ -586,6 +733,7 @@ "npm:@cloudflare/puppeteer@1.1.0", "npm:@longsightgroup/lti-tool@0.1.4", "npm:ajv@8.20.0", + "npm:better-auth@1.6.23", "npm:drizzle-orm@0.45.2", "npm:happy-dom@20.10.6", "npm:hono@4.12.27", diff --git a/src/admin/app_revision_page.ts b/src/admin/app_revision_page.ts index 82abf9e..8cb2de7 100644 --- a/src/admin/app_revision_page.ts +++ b/src/admin/app_revision_page.ts @@ -1,9 +1,10 @@ import type { PackageVersionRecord } from '../package_review/types.ts'; import { buildRevisionAuthoringPrompt } from '../app_writer/revision_authoring_prompt.ts'; -import { type AdminNotice, escapeHtml, renderAdminLayout } from './layout.ts'; +import { type AdminIdentity, type AdminNotice, escapeHtml, renderAdminLayout } from './layout.ts'; import { renderPackagePageNav } from './package_navigation.ts'; export function renderAppRevisionPage(input: { + identity: AdminIdentity; packageVersion: PackageVersionRecord; history: PackageVersionRecord[]; targetVersion: string; @@ -17,6 +18,7 @@ export function renderAppRevisionPage(input: { const promptText = input.promptText ?? generatedPrompt; return renderAdminLayout({ + identity: input.identity, title: `Revise ${input.packageVersion.title}`, eyebrow: 'App writer revision', heading: `Revise ${input.packageVersion.title}`, diff --git a/src/admin/app_writer_request_page.ts b/src/admin/app_writer_request_page.ts index 6887ab1..56eb540 100644 --- a/src/admin/app_writer_request_page.ts +++ b/src/admin/app_writer_request_page.ts @@ -1,19 +1,21 @@ import type { AppGenerationPlanningResult } from '../app_writer/types.ts'; -import { type AdminNotice, escapeHtml, renderAdminLayout } from './layout.ts'; +import { type AdminIdentity, type AdminNotice, escapeHtml, renderAdminLayout } from './layout.ts'; import type { AppWriterSelectedContext } from '../app_writer/context.ts'; import { formatProgressStage, formatSelectedContext } from './app_writer_run_view_model.ts'; export function renderAppWriterPage( input: { + identity: AdminIdentity; notice?: AdminNotice | null; audience?: string; contentSummary?: string; gradingMode?: string; promptText?: string; requestedAppId?: string; - } = {}, + }, ): string { return renderAdminLayout({ + identity: input.identity, title: 'Lantern App Writer', eyebrow: 'App writer', heading: 'Create app with AI', @@ -52,6 +54,7 @@ export function renderAppWriterPage( } export function renderAppWriterPlanPreviewPage(input: { + identity: AdminIdentity; audience?: string; contentSummary?: string; gradingMode?: string; @@ -62,6 +65,7 @@ export function renderAppWriterPlanPreviewPage(input: { notice?: AdminNotice | null; }): string { return renderAdminLayout({ + identity: input.identity, title: 'Lantern App Writer Plan', eyebrow: 'App writer', heading: 'Review app plan', diff --git a/src/admin/app_writer_run_page.ts b/src/admin/app_writer_run_page.ts index 3b95464..c577d38 100644 --- a/src/admin/app_writer_run_page.ts +++ b/src/admin/app_writer_run_page.ts @@ -5,7 +5,13 @@ import type { PreviewEvidenceRecord, PreviewSessionRecord, } from '../package_review/types.ts'; -import { type AdminNotice, escapeHtml, formatDateTime, renderAdminLayout } from './layout.ts'; +import { + type AdminIdentity, + type AdminNotice, + escapeHtml, + formatDateTime, + renderAdminLayout, +} from './layout.ts'; import { formatSelectedContext, formatStatus, @@ -25,6 +31,7 @@ import { } from './app_writer_run_sections.ts'; export function renderAppGenerationRunPage(input: { + identity: AdminIdentity; run: AppGenerationRunRecord; workspace?: AppGenerationWorkspaceRecord | null; packageVersion?: PackageVersionRecord | null; @@ -41,6 +48,7 @@ export function renderAppGenerationRunPage(input: { const refreshWhileRunning = isActiveGenerationStatus(run.status); return renderAdminLayout({ + identity: input.identity, title: `Generation ${run.generationId}`, eyebrow: 'App writer run', heading: run.generatedAppId ?? run.requestedAppId ?? 'Generated app', diff --git a/src/admin/authoring_page.ts b/src/admin/authoring_page.ts index 8f699d8..542cf9f 100644 --- a/src/admin/authoring_page.ts +++ b/src/admin/authoring_page.ts @@ -5,7 +5,13 @@ import type { PreviewEvidenceRecord, PreviewSessionRecord, } from '../package_review/types.ts'; -import { type AdminNotice, escapeHtml, formatDateTime, renderAdminLayout } from './layout.ts'; +import { + type AdminIdentity, + type AdminNotice, + escapeHtml, + formatDateTime, + renderAdminLayout, +} from './layout.ts'; import { renderPackagePageNav } from './package_navigation.ts'; export interface AuthoringPageFileView { @@ -21,6 +27,7 @@ export interface AuthoringGeneratedDraftView { } export function renderAuthoringPage(input: { + identity: AdminIdentity; packageVersion: PackageVersionRecord; draft: AuthoringDraftRecord; currentFiles: AuthoringPageFileView[]; @@ -39,6 +46,7 @@ export function renderAuthoringPage(input: { }/authoring`; return renderAdminLayout({ + identity: input.identity, title: `${input.packageVersion.title} ${input.packageVersion.version} Authoring`, eyebrow: 'Authoring Draft', heading: input.packageVersion.title, diff --git a/src/admin/control_plane.ts b/src/admin/control_plane.ts index fc96293..21c8503 100644 --- a/src/admin/control_plane.ts +++ b/src/admin/control_plane.ts @@ -18,19 +18,21 @@ import { renderVerificationSummarySection, renderVerificationUpdateSection, } from './control_plane_verification_sections.ts'; -import { type AdminNotice, renderAdminLayout } from './layout.ts'; +import { type AdminIdentity, type AdminNotice, renderAdminLayout } from './layout.ts'; import { renderVerificationPageNav, type VerificationPageSection, } from './verification_navigation.ts'; export function renderDeploymentsPage(input: { + identity: AdminIdentity; deployments: ControlPlaneDeploymentInventoryRow[]; notice?: AdminNotice | null; }): string { const aggregateUsage = aggregatePilotUsage(input.deployments); return renderAdminLayout({ + identity: input.identity, title: 'Lantern Admin Connections', eyebrow: 'Connections', heading: 'Connections', @@ -44,6 +46,7 @@ export function renderDeploymentsPage(input: { } export function renderVerificationPage(input: { + identity: AdminIdentity; deployments: ControlPlaneDeploymentInventoryRow[]; latestBrokerVerification: BrokerVerificationStatus | null; certificationWorkflowStatuses?: CertificationWorkflowStatus[]; @@ -60,6 +63,7 @@ export function renderVerificationPage(input: { const page = resolveVerificationPageMeta(section); return renderAdminLayout({ + identity: input.identity, title: `Lantern Admin ${page.heading}`, eyebrow: 'Verification', heading: page.heading, diff --git a/src/admin/control_plane_test.ts b/src/admin/control_plane_test.ts index 67a8aca..4b56d1e 100644 --- a/src/admin/control_plane_test.ts +++ b/src/admin/control_plane_test.ts @@ -1,4 +1,5 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; +import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; import { buildBrokerVerificationStatus, buildCertificationWorkflowStatus, @@ -15,6 +16,7 @@ import { renderDeploymentsPage, renderVerificationPage } from './control_plane.t Deno.test('renderDeploymentsPage keeps deployment operations separate from verification entry', () => { const html = renderDeploymentsPage({ + identity: ADMIN_TEST_IDENTITY, deployments: [ buildControlPlaneDeploymentInventoryRow({ deploymentId: 1, @@ -52,9 +54,7 @@ Deno.test('renderDeploymentsPage shows one clear setup action when an LMS is not deployment.enabledPackageVersionId = null; deployment.enabledPackageVersion = null; - const html = renderDeploymentsPage({ - deployments: [deployment], - }); + const html = renderDeploymentsPage({ identity: ADMIN_TEST_IDENTITY, deployments: [deployment] }); assertStringIncludes(html, 'Next step'); assertStringIncludes(html, 'Connect LMS'); @@ -63,6 +63,7 @@ Deno.test('renderDeploymentsPage shows one clear setup action when an LMS is not Deno.test('renderVerificationPage shows deployment-scoped verification facts while keeping official evidence separate', () => { const html = renderVerificationPage({ + identity: ADMIN_TEST_IDENTITY, deployments: [ buildControlPlaneDeploymentInventoryRow({ deploymentId: 1, @@ -192,6 +193,7 @@ Deno.test('renderVerificationPage shows deployment-scoped verification facts whi Deno.test('renderVerificationPage renders official evidence on its own dedicated page', () => { const html = renderVerificationPage({ + identity: ADMIN_TEST_IDENTITY, deployments: [buildControlPlaneDeploymentInventoryRow({ deploymentId: 1 })], latestBrokerVerification: buildBrokerVerificationStatus({ supportedPath: 'lti13LaunchAgsScore', diff --git a/src/admin/deployment_detail.ts b/src/admin/deployment_detail.ts index d874e6b..23fb14d 100644 --- a/src/admin/deployment_detail.ts +++ b/src/admin/deployment_detail.ts @@ -10,7 +10,7 @@ import type { import { renderManagedDeploymentSections } from './deployment_detail_release_sections.ts'; import { renderVersionHistorySection } from './deployment_detail_history_section.ts'; import { renderOperationalEvidenceSection } from './deployment_detail_ops_sections.ts'; -import { type AdminNotice, renderAdminLayout } from './layout.ts'; +import { type AdminIdentity, type AdminNotice, renderAdminLayout } from './layout.ts'; import { renderPackagePageNav } from './package_navigation.ts'; export interface DeploymentNrpsVerificationSummary { @@ -188,6 +188,7 @@ export function buildEmptyDeploymentRecord(appId: string, appTitle: string): Dep } export function renderDeploymentDetailPage(input: { + identity: AdminIdentity; appId: string; appTitle: string; history: PackageVersionRecord[]; @@ -227,6 +228,7 @@ export function renderDeploymentDetailPage(input: { const supportedCanvasEnvironments = input.supportedCanvasEnvironments ?? []; return renderAdminLayout({ + identity: input.identity, title: `${input.appTitle} App settings`, eyebrow: 'Settings', heading: input.appTitle, diff --git a/src/admin/deployment_detail_install_test.ts b/src/admin/deployment_detail_install_test.ts index 0b9bfab..d6a147a 100644 --- a/src/admin/deployment_detail_install_test.ts +++ b/src/admin/deployment_detail_install_test.ts @@ -1,5 +1,4 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from '../app.ts'; import { buildDeploymentRecord, buildPackageVersionRecord, @@ -7,6 +6,7 @@ import { } from '../test_helpers/package_review.ts'; import { buildDeploymentBinding } from '../test_helpers/lti.ts'; import { restoreEnv } from './deployment_detail_test_helpers.ts'; +import { createAuthenticatedTestApp } from '../test_helpers/admin_auth.ts'; Deno.test('POST /admin/packages/:appId/deployment/install saves one exact Canvas binding', async () => { const repository = createInMemoryPackageReviewRepository({ @@ -34,7 +34,7 @@ Deno.test('POST /admin/packages/:appId/deployment/install saves one exact Canvas formData.set('deploymentId', 'deployment-123'); try { - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment/install', { method: 'POST', @@ -69,7 +69,7 @@ Deno.test('duplicate issuer, client_id, and deployment_id bindings are rejected formData.set('deploymentId', 'deployment-123'); try { - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => createInMemoryPackageReviewRepository({ packageVersions: [ @@ -123,7 +123,7 @@ Deno.test('POST /admin/packages/:appId/deployment/install requires the exact Moo ], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment/install', { method: 'POST', @@ -168,7 +168,7 @@ Deno.test('POST /admin/packages/:appId/deployment/install requires the exact Sak ], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment/install', { method: 'POST', diff --git a/src/admin/deployment_detail_multilms_test.ts b/src/admin/deployment_detail_multilms_test.ts index 076f3d5..72064f8 100644 --- a/src/admin/deployment_detail_multilms_test.ts +++ b/src/admin/deployment_detail_multilms_test.ts @@ -1,4 +1,5 @@ import { assertFalse, assertStringIncludes } from '@std/assert'; +import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; import { resolveCanvasIssuer } from '../lti/config.ts'; import { buildBrokerVerificationStatus, @@ -18,6 +19,7 @@ import { renderDeploymentDetailPage } from './deployment_detail.ts'; Deno.test('deployment page keeps separate Canvas, Moodle, and Sakai slots for one app', () => { const html = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', history: [ @@ -130,6 +132,7 @@ Deno.test('deployment page keeps one deployment route while rendering setup, lau for (const testCase of cases) { const html = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', selectedLms: testCase.lms, diff --git a/src/admin/deployment_detail_ops_render_test.ts b/src/admin/deployment_detail_ops_render_test.ts index 5e7e39b..9f46cb1 100644 --- a/src/admin/deployment_detail_ops_render_test.ts +++ b/src/admin/deployment_detail_ops_render_test.ts @@ -1,4 +1,5 @@ import { assertFalse, assertStringIncludes } from '@std/assert'; +import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; import { resolveCanvasIssuer } from '../lti/config.ts'; import { buildBrokerVerificationStatus, @@ -20,6 +21,7 @@ import { renderDeploymentDetailPage } from './deployment_detail.ts'; Deno.test('deployment page shows status panels and pilot usage without dropping the binding and version controls', () => { const html = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', history: [ @@ -134,6 +136,7 @@ Deno.test('deployment page shows status panels and pilot usage without dropping Deno.test('deployment page shows diagnostics and an explicit retry action for retryable AGS failures', () => { const html = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', history: [ @@ -265,6 +268,7 @@ Deno.test('deployment page shows diagnostics and an explicit retry action for re Deno.test('deployment page keeps setup history as an optional verification link-out', () => { const html = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', selectedLms: 'moodle', @@ -339,6 +343,7 @@ Deno.test('deployment page keeps setup history as an optional verification link- Deno.test('deployment page shows reviewed runtime boundary facts without exposing raw runtime tokens', () => { const html = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', history: [ @@ -429,6 +434,7 @@ Deno.test('deployment page shows reviewed runtime boundary facts without exposin Deno.test('deployment page renders anonymous submission evidence links beside the latest normalized browser outcome', () => { const html = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', history: [ @@ -516,6 +522,7 @@ Deno.test('deployment page renders anonymous submission evidence links beside th Deno.test('deployment page renders explicit reviewed-runtime troubleshooting for Dynamic Worker delivery failures', () => { const html = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', history: [ diff --git a/src/admin/deployment_detail_render_test.ts b/src/admin/deployment_detail_render_test.ts index 4008830..06c2d43 100644 --- a/src/admin/deployment_detail_render_test.ts +++ b/src/admin/deployment_detail_render_test.ts @@ -1,4 +1,5 @@ import { assertFalse, assertStringIncludes } from '@std/assert'; +import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; import { resolveCanvasIssuer } from '../lti/config.ts'; import { buildAccessibilityReview, @@ -16,6 +17,7 @@ import { renderDeploymentDetailPage } from './deployment_detail.ts'; Deno.test('deployment page keeps shared copy neutral while scoping Canvas, Moodle, and Sakai setup into tabs', () => { const binding = buildCanvasDeploymentBinding(); const html = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', history: [ @@ -79,6 +81,7 @@ Deno.test('deployment page keeps shared copy neutral while scoping Canvas, Moodl Deno.test('deployment page renders the selected Sakai tab without leaking the Canvas form', () => { const html = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', selectedLms: 'sakai', @@ -129,6 +132,7 @@ Deno.test('deployment page renders the selected Sakai tab without leaking the Ca Deno.test('deployment page shows the Canvas pending-registration state before the first launch seals deployment_id', () => { const html = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', history: [ @@ -166,6 +170,7 @@ Deno.test('deployment page shows the Canvas pending-registration state before th Deno.test('deployment page shows the latest roster verification summary and action', () => { const html = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', history: [ @@ -207,6 +212,7 @@ Deno.test('deployment page shows the latest roster verification summary and acti Deno.test('deployment page tucks LTI profile overrides behind an advanced per-setup section', () => { const html = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', selectedLms: 'moodle', @@ -254,6 +260,7 @@ Deno.test('deployment page tucks LTI profile overrides behind an advanced per-se Deno.test('deployment page shows accessibility review state before a version goes live', () => { const html = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', history: [ diff --git a/src/admin/deployment_detail_roster_test.ts b/src/admin/deployment_detail_roster_test.ts index 6183350..9620290 100644 --- a/src/admin/deployment_detail_roster_test.ts +++ b/src/admin/deployment_detail_roster_test.ts @@ -1,5 +1,4 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from '../app.ts'; import { buildDeploymentRecord, buildPackageVersionRecord, @@ -7,6 +6,7 @@ import { } from '../test_helpers/package_review.ts'; import { buildDeploymentBinding, buildRuntimeSessionRecord } from '../test_helpers/lti.ts'; import { createTestLtiToolContext } from '../test_helpers/lti_tool_context.ts'; +import { createAuthenticatedTestApp } from '../test_helpers/admin_auth.ts'; Deno.test('POST /admin/packages/:appId/deployment/verify-roster stores a deployment-level NRPS verification summary from lti-tool', async () => { const ltiToolContext = createTestLtiToolContext({ @@ -40,7 +40,7 @@ Deno.test('POST /admin/packages/:appId/deployment/verify-roster stores a deploym ], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, getLtiToolContext: () => Promise.resolve(ltiToolContext.context), }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment/verify-roster', { @@ -83,7 +83,7 @@ Deno.test('deployment roster verification fails clearly when no launch service c ], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment/verify-roster', { method: 'POST', @@ -124,7 +124,7 @@ Deno.test('deployment roster verification requires a verified lti-tool session', ], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment/verify-roster', { method: 'POST', diff --git a/src/admin/deployment_detail_smoke_render_test.ts b/src/admin/deployment_detail_smoke_render_test.ts index 2beac42..dde51f7 100644 --- a/src/admin/deployment_detail_smoke_render_test.ts +++ b/src/admin/deployment_detail_smoke_render_test.ts @@ -1,4 +1,5 @@ import { assertFalse, assertStringIncludes } from '@std/assert'; +import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; import { resolveCanvasIssuer } from '../lti/config.ts'; import { buildControlPlaneDeploymentDetailSnapshot, @@ -13,6 +14,7 @@ import { renderDeploymentDetailPage } from './deployment_detail.ts'; Deno.test('deployment page shows the latest Moodle grade-return result with plain-language check facts', () => { const smokeLineItemUrl = 'https://moodle.example/mod/lti/services.php/2/lineitems/9'; const html = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', selectedLms: 'moodle', @@ -73,6 +75,7 @@ Deno.test('deployment page shows the latest Moodle grade-return result with plai Deno.test('deployment page shows the latest Sakai grade-return result with plain-language check facts', () => { const smokeLineItemUrl = 'https://sakai.example/direct/lti/lineitems/course-42/items/9'; const html = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', selectedLms: 'sakai', @@ -132,6 +135,7 @@ Deno.test('deployment page shows the latest Sakai grade-return result with plain Deno.test('deployment page keeps failed smoke rendering bounded and readable on the existing detail page', () => { const html = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', selectedLms: 'moodle', diff --git a/src/admin/layout.ts b/src/admin/layout.ts index afef40b..fdbcfbf 100644 --- a/src/admin/layout.ts +++ b/src/admin/layout.ts @@ -1,6 +1,7 @@ import { ADMIN_LAYOUT_STYLES } from './layout_styles.ts'; import { type AdminBreadcrumb, + type AdminIdentity, type AdminNotice, defaultNav, escapeHtml, @@ -9,7 +10,13 @@ import { renderSidebar, } from './layout_support.ts'; -export type { AdminBreadcrumb, AdminNotice, NavChildItem, NavItem } from './layout_support.ts'; +export type { + AdminBreadcrumb, + AdminIdentity, + AdminNotice, + NavChildItem, + NavItem, +} from './layout_support.ts'; export { escapeHtml, formatDate, formatDateTime } from './layout_support.ts'; export function renderAdminLayout(input: { @@ -22,6 +29,7 @@ export function renderAdminLayout(input: { breadcrumbs?: AdminBreadcrumb[]; notice?: AdminNotice | null; activePath?: string; + identity: AdminIdentity; }): string { const breadcrumbs = input.breadcrumbs ?? []; const nav = defaultNav(input.activePath ?? '/admin/packages'); @@ -46,6 +54,7 @@ export function renderAdminLayout(input: { heading: input.heading, intro: input.intro, pageNav: input.pageNav ?? null, + identity: input.identity, }) }
diff --git a/src/admin/layout_support.ts b/src/admin/layout_support.ts index 2c0648a..c36f9ea 100644 --- a/src/admin/layout_support.ts +++ b/src/admin/layout_support.ts @@ -26,7 +26,7 @@ export interface NavChildItem { active?: boolean; } -interface AdminIdentity { +export interface AdminIdentity { displayName: string; detail: string; } @@ -154,7 +154,9 @@ export function renderSidebar(nav: NavItem[]): string { .join('') } - + `; } @@ -164,8 +166,9 @@ export function renderPageHeader(input: { heading: string; intro: string; pageNav?: string | null; + identity: AdminIdentity; }): string { - const identity = resolveAdminIdentity(); + const identity = input.identity; return `
`; } -function resolveAdminIdentity(): AdminIdentity { - const displayName = readOptionalEnvIdentityValue('LANTERN_OPERATOR_NAME') ?? - readOptionalEnvIdentityValue('USER') ?? - readOptionalEnvIdentityValue('LOGNAME') ?? - 'Local operator'; - - return { - displayName, - detail: 'Signed in', - }; -} - function readOptionalEnvIdentityValue(name: string): string | null { try { return normalizeIdentityValue(readEnv(name)); diff --git a/src/admin/package_detail.ts b/src/admin/package_detail.ts index 8b385ed..e29e1e6 100644 --- a/src/admin/package_detail.ts +++ b/src/admin/package_detail.ts @@ -17,7 +17,13 @@ import type { PreviewSessionRecord, ReviewedPlacementRecord, } from '../package_review/types.ts'; -import { type AdminNotice, escapeHtml, formatDateTime, renderAdminLayout } from './layout.ts'; +import { + type AdminIdentity, + type AdminNotice, + escapeHtml, + formatDateTime, + renderAdminLayout, +} from './layout.ts'; import { renderPackagePageNav } from './package_navigation.ts'; import { renderDecisionSection, renderHistoryRow } from './package_detail_sections.ts'; import { @@ -34,6 +40,7 @@ import { } from './package_detail_support.ts'; export function renderPackageDetailPage(input: { + identity: AdminIdentity; packageVersion: PackageVersionRecord; history: PackageVersionRecord[]; generationActivityEvents?: AuditEventRecord[]; @@ -51,6 +58,7 @@ export function renderPackageDetailPage(input: { const previewEvidence = input.previewEvidence ?? []; return renderAdminLayout({ + identity: input.identity, title: `${packageVersion.title} ${packageVersion.version}`, eyebrow: 'Version details', heading: packageVersion.title, diff --git a/src/admin/package_detail_test.ts b/src/admin/package_detail_test.ts index 54b319f..7e95ea9 100644 --- a/src/admin/package_detail_test.ts +++ b/src/admin/package_detail_test.ts @@ -1,4 +1,5 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; +import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; import { renderPackageDetailPage } from './package_detail.ts'; import { buildAccessibilityReview, @@ -10,6 +11,7 @@ import { Deno.test('renderPackageDetailPage shows status, exact version, owner, and access details above the fold', () => { const pendingVersion = buildPackageVersionRecord(); const body = renderPackageDetailPage({ + identity: ADMIN_TEST_IDENTITY, packageVersion: pendingVersion, history: [pendingVersion], }); @@ -63,6 +65,7 @@ Deno.test('renderPackageDetailPage gives pending reviewers changes, launch, runt }, }); const body = renderPackageDetailPage({ + identity: ADMIN_TEST_IDENTITY, packageVersion: pendingVersion, history: [pendingVersion, previousVersion], latestPreviewSession: previewSession, @@ -111,6 +114,7 @@ Deno.test('renderPackageDetailPage treats first pending version as a full capabi capabilities: ['read_launch_context', 'read_activity_content', 'finalize_attempt'], }); const body = renderPackageDetailPage({ + identity: ADMIN_TEST_IDENTITY, packageVersion: pendingVersion, history: [pendingVersion], }); @@ -142,6 +146,7 @@ Deno.test('renderPackageDetailPage highlights added sensitive capabilities again ], }); const body = renderPackageDetailPage({ + identity: ADMIN_TEST_IDENTITY, packageVersion: pendingVersion, history: [pendingVersion, previousVersion], }); @@ -178,6 +183,7 @@ Deno.test('renderPackageDetailPage shows removed capabilities against the previo capabilities: ['read_launch_context', 'read_activity_content', 'finalize_attempt'], }); const body = renderPackageDetailPage({ + identity: ADMIN_TEST_IDENTITY, packageVersion: pendingVersion, history: [pendingVersion, previousVersion], }); @@ -223,6 +229,7 @@ Deno.test('renderPackageDetailPage ignores newer approved versions when reviewin capabilities: ['read_launch_context', 'read_activity_content', 'finalize_attempt'], }); const body = renderPackageDetailPage({ + identity: ADMIN_TEST_IDENTITY, packageVersion: pendingVersion, history: [newerApprovedVersion, pendingVersion, olderApprovedVersion], }); @@ -254,6 +261,7 @@ Deno.test('renderPackageDetailPage distinguishes ordinary progress from sensitiv reviewedAt: '2026-03-23T18:05:00Z', }); const body = renderPackageDetailPage({ + identity: ADMIN_TEST_IDENTITY, packageVersion: reviewedVersion, history: [reviewedVersion], }); @@ -288,6 +296,7 @@ Deno.test('renderPackageDetailPage keeps older reviewed versions explicit when s reviewedAt: '2026-03-23T18:05:00Z', }); const body = renderPackageDetailPage({ + identity: ADMIN_TEST_IDENTITY, packageVersion: reviewedVersion, history: [reviewedVersion], }); diff --git a/src/admin/package_diff_page.ts b/src/admin/package_diff_page.ts index 14b99e3..6d2abd6 100644 --- a/src/admin/package_diff_page.ts +++ b/src/admin/package_diff_page.ts @@ -1,10 +1,11 @@ import type { PackageFileDiff, PackageVersionDiff } from '../package_review/package_diff.ts'; import { approvalStatusClass, approvalStatusLabel } from '../package_review/summary.ts'; import type { PackageVersionRecord } from '../package_review/types.ts'; -import { escapeHtml, formatDateTime, renderAdminLayout } from './layout.ts'; +import { type AdminIdentity, escapeHtml, formatDateTime, renderAdminLayout } from './layout.ts'; import { renderPackagePageNav } from './package_navigation.ts'; export function renderPackageDiffPage(input: { + identity: AdminIdentity; diff: PackageVersionDiff; history: PackageVersionRecord[]; }): string { @@ -14,6 +15,7 @@ export function renderPackageDiffPage(input: { const changedManifestFields = input.diff.manifest.filter((field) => field.changed); return renderAdminLayout({ + identity: input.identity, title: `${targetVersion.title} ${targetVersion.version} changes`, eyebrow: 'Version changes', heading: targetVersion.title, diff --git a/src/admin/package_import_page.ts b/src/admin/package_import_page.ts index c74d451..4f40d21 100644 --- a/src/admin/package_import_page.ts +++ b/src/admin/package_import_page.ts @@ -2,7 +2,7 @@ import { groupLocalAppDiagnostics, type LocalAppValidationDiagnostic, } from '../authoring/local_app.ts'; -import { type AdminNotice, escapeHtml, renderAdminLayout } from './layout.ts'; +import { type AdminIdentity, type AdminNotice, escapeHtml, renderAdminLayout } from './layout.ts'; const PACKAGE_LAYOUT = `manifest.json dist/index.html @@ -12,11 +12,13 @@ preview/tests.json`; export function renderPackageImportPage( input: { + identity: AdminIdentity; notice?: AdminNotice | null; diagnostics?: LocalAppValidationDiagnostic[] | null; - } = {}, + }, ): string { return renderAdminLayout({ + identity: input.identity, title: 'Lantern Package Import', eyebrow: 'Apps', heading: 'Import package', diff --git a/src/admin/package_index.ts b/src/admin/package_index.ts index 476ff84..8ecf1a5 100644 --- a/src/admin/package_index.ts +++ b/src/admin/package_index.ts @@ -1,6 +1,12 @@ import { approvalStatusClass, approvalStatusLabel } from '../package_review/summary.ts'; import type { PackageVersionRecord } from '../package_review/types.ts'; -import { type AdminNotice, escapeHtml, formatDateTime, renderAdminLayout } from './layout.ts'; +import { + type AdminIdentity, + type AdminNotice, + escapeHtml, + formatDateTime, + renderAdminLayout, +} from './layout.ts'; interface PackageLibraryEntry { appId: string; @@ -14,6 +20,7 @@ interface PackageLibraryEntry { } export function renderPackageIndexPage(input: { + identity: AdminIdentity; versions: PackageVersionRecord[]; notice?: AdminNotice | null; }): string { @@ -22,6 +29,7 @@ export function renderPackageIndexPage(input: { : renderPackageLibrary(input.versions); return renderAdminLayout({ + identity: input.identity, title: 'Lantern Admin Apps', eyebrow: 'Apps', heading: 'Apps', diff --git a/src/admin/package_overview.ts b/src/admin/package_overview.ts index 0b1ffad..8600ded 100644 --- a/src/admin/package_overview.ts +++ b/src/admin/package_overview.ts @@ -5,10 +5,17 @@ import { summarizeAccessibilityReview, } from '../package_review/summary.ts'; import type { DeploymentRecord, PackageVersionRecord } from '../package_review/types.ts'; -import { type AdminNotice, escapeHtml, formatDateTime, renderAdminLayout } from './layout.ts'; +import { + type AdminIdentity, + type AdminNotice, + escapeHtml, + formatDateTime, + renderAdminLayout, +} from './layout.ts'; import { renderPackagePageNav, supportsAuthoringDrafts } from './package_navigation.ts'; export function renderPackageOverviewPage(input: { + identity: AdminIdentity; appId: string; appTitle: string; history: PackageVersionRecord[]; @@ -24,6 +31,7 @@ export function renderPackageOverviewPage(input: { const liveDeploymentCounts = buildLiveDeploymentCounts(input.deployments); return renderAdminLayout({ + identity: input.identity, title: `${input.appTitle} App`, eyebrow: 'App', heading: input.appTitle, diff --git a/src/admin/package_overview_test.ts b/src/admin/package_overview_test.ts index d15c629..9e0b528 100644 --- a/src/admin/package_overview_test.ts +++ b/src/admin/package_overview_test.ts @@ -1,4 +1,5 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; +import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; import { renderPackageOverviewPage } from './package_overview.ts'; import { buildDeploymentRecord, @@ -8,6 +9,7 @@ import { buildCanvasDeploymentBinding } from '../test_helpers/lti.ts'; Deno.test('renderPackageOverviewPage keeps latest approved distinct from live rollout', () => { const body = renderPackageOverviewPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'typescript-ladder-game', appTitle: 'TypeScript Ladder Game', history: [ diff --git a/src/admin/package_reference_page.ts b/src/admin/package_reference_page.ts index 108c342..0b5a61b 100644 --- a/src/admin/package_reference_page.ts +++ b/src/admin/package_reference_page.ts @@ -1,12 +1,14 @@ -import { type AdminNotice, renderAdminLayout } from './layout.ts'; +import { type AdminIdentity, type AdminNotice, renderAdminLayout } from './layout.ts'; import { renderReferencePackageCatalog } from './package_reference_support.ts'; export function renderReferencePackagePage( input: { + identity: AdminIdentity; notice?: AdminNotice | null; - } = {}, + }, ): string { return renderAdminLayout({ + identity: input.identity, title: 'Lantern Reference Apps', eyebrow: 'Reference apps', heading: 'Reference apps', diff --git a/src/admin/package_reports.ts b/src/admin/package_reports.ts index 7ff7011..af9fe35 100644 --- a/src/admin/package_reports.ts +++ b/src/admin/package_reports.ts @@ -9,6 +9,7 @@ import type { InstructorReportStudentSignal, } from '../reports/instructor_report.ts'; import { + type AdminIdentity, type AdminNotice, escapeHtml, formatDate, @@ -18,6 +19,7 @@ import { import { renderPackagePageNav } from './package_navigation.ts'; export function renderPackageReportsPage(input: { + identity: AdminIdentity; appId: string; appTitle: string; history: PackageVersionRecord[]; @@ -25,6 +27,7 @@ export function renderPackageReportsPage(input: { notice?: AdminNotice | null; }): string { return renderAdminLayout({ + identity: input.identity, title: `${input.appTitle} Reports`, eyebrow: 'Instructor reports', heading: input.appTitle, diff --git a/src/admin/placement_audit_page.ts b/src/admin/placement_audit_page.ts index 840ce7c..c5f08ac 100644 --- a/src/admin/placement_audit_page.ts +++ b/src/admin/placement_audit_page.ts @@ -1,7 +1,8 @@ import type { AuditEventRecord, PlacementAuditSnapshot } from '../package_review/types.ts'; -import { escapeHtml, formatDateTime, renderAdminLayout } from './layout.ts'; +import { type AdminIdentity, escapeHtml, formatDateTime, renderAdminLayout } from './layout.ts'; export function renderPlacementAuditPage(input: { + identity: AdminIdentity; snapshot: PlacementAuditSnapshot; timeline: AuditEventRecord[]; notice?: { @@ -19,6 +20,7 @@ export function renderPlacementAuditPage(input: { const deploymentLink = `/admin/packages/${placement.appId}/deployment`; return renderAdminLayout({ + identity: input.identity, title: `Placement ${placement.placementId}`, eyebrow: 'Placement audit', heading: 'Placement audit', @@ -114,6 +116,7 @@ export function renderPlacementAuditPage(input: { } export function renderPlacementAuditRequestPage(input: { + identity: AdminIdentity; notice?: { tone: 'error' | 'note' | 'success'; title: string; @@ -122,6 +125,7 @@ export function renderPlacementAuditRequestPage(input: { } | null; }): string { return renderAdminLayout({ + identity: input.identity, title: 'Placements', eyebrow: 'Placements', heading: 'Placements', diff --git a/src/admin/placement_audit_page_test.ts b/src/admin/placement_audit_page_test.ts index 44f0e7d..67ecf83 100644 --- a/src/admin/placement_audit_page_test.ts +++ b/src/admin/placement_audit_page_test.ts @@ -1,4 +1,5 @@ import { assertStringIncludes } from '@std/assert'; +import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; import { renderPlacementAuditPage } from './placement_audit_page.ts'; import { buildAuditEventRecord, @@ -7,6 +8,7 @@ import { Deno.test('renderPlacementAuditPage shows selected content, reviewed package identity, Canvas context, and placement status', () => { const html = renderPlacementAuditPage({ + identity: ADMIN_TEST_IDENTITY, snapshot: { placement: buildReviewedPlacementRecord({ placementId: 'placement-audit-123', @@ -46,6 +48,7 @@ Deno.test('renderPlacementAuditPage shows selected content, reviewed package ide Deno.test('renderPlacementAuditPage includes deep-linking, test-launch, and reviewer timeline entries when evidence exists', () => { const html = renderPlacementAuditPage({ + identity: ADMIN_TEST_IDENTITY, snapshot: { placement: buildReviewedPlacementRecord({ placementId: 'placement-audit-123', diff --git a/src/admin/preview_page.ts b/src/admin/preview_page.ts index 8f23047..c290c4e 100644 --- a/src/admin/preview_page.ts +++ b/src/admin/preview_page.ts @@ -9,7 +9,7 @@ import { summarizeCapabilities, } from '../package_review/summary.ts'; import type { PreviewRuntimeDiagnostic } from '../preview/runtime_diagnostics.ts'; -import { type AdminNotice, escapeHtml, renderAdminLayout } from './layout.ts'; +import { type AdminIdentity, type AdminNotice, escapeHtml, renderAdminLayout } from './layout.ts'; import { renderPackagePageNav } from './package_navigation.ts'; export interface TestLaunchFormValues { @@ -20,6 +20,7 @@ export interface TestLaunchFormValues { } export function renderPreviewPage(input: { + identity: AdminIdentity; packageVersion: PackageVersionRecord; savedDefaults: PreviewSessionRecord; latestSession: PreviewSessionRecord | null; @@ -35,6 +36,7 @@ export function renderPreviewPage(input: { const pageLabel = isPendingReview ? 'Review Test Launch' : 'Test Launch'; return renderAdminLayout({ + identity: input.identity, title: `${packageVersion.title} ${packageVersion.version} ${pageLabel}`, eyebrow: pageLabel, heading: packageVersion.title, diff --git a/src/admin/preview_page_test.ts b/src/admin/preview_page_test.ts index 14e2675..16f0114 100644 --- a/src/admin/preview_page_test.ts +++ b/src/admin/preview_page_test.ts @@ -1,4 +1,5 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; +import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; import { renderPreviewPage } from './preview_page.ts'; import { buildPackageVersionRecord, @@ -30,6 +31,7 @@ Deno.test('renderPreviewPage shows saved defaults, editable launch fields, and e }); const body = renderPreviewPage({ + identity: ADMIN_TEST_IDENTITY, packageVersion, savedDefaults: previewSession, latestSession: null, @@ -99,6 +101,7 @@ Deno.test('renderPreviewPage labels pending versions as review test launches', ( }); const body = renderPreviewPage({ + identity: ADMIN_TEST_IDENTITY, packageVersion, savedDefaults: previewSession, latestSession: null, @@ -172,6 +175,7 @@ Deno.test('renderPreviewPage shows durable test activity evidence in capability ]; const body = renderPreviewPage({ + identity: ADMIN_TEST_IDENTITY, packageVersion, savedDefaults: previewSession, latestSession: previewSession, @@ -245,6 +249,7 @@ Deno.test('renderPreviewPage renders screenshot evidence as supplemental on the packageTitle: packageVersion.title, }); const body = renderPreviewPage({ + identity: ADMIN_TEST_IDENTITY, packageVersion, savedDefaults: previewSession, latestSession: previewSession, diff --git a/src/admin_auth/context.ts b/src/admin_auth/context.ts new file mode 100644 index 0000000..4b8a284 --- /dev/null +++ b/src/admin_auth/context.ts @@ -0,0 +1,32 @@ +import type { Context } from '@hono/hono'; +import type { AdminIdentity } from '../admin/layout_support.ts'; +import type { AdminAuthSession } from './service.ts'; + +export const ADMIN_SESSION_KEY = 'adminSession'; + +export function setAdminSession(context: Context, session: AdminAuthSession): void { + context.set(ADMIN_SESSION_KEY, session); +} + +export function getAdminSession(context: Context): AdminAuthSession { + const session = context.get(ADMIN_SESSION_KEY); + + if (!session) { + throw new Error('Protected admin route reached without an authenticated session.'); + } + + return session; +} + +export function adminIdentityFromSession(session: AdminAuthSession): AdminIdentity { + const displayName = session.user.name.trim() || session.user.email; + + return { + displayName, + detail: session.user.email, + }; +} + +export function adminIdentityFromContext(context: Context): AdminIdentity { + return adminIdentityFromSession(getAdminSession(context)); +} diff --git a/src/admin_auth/middleware.ts b/src/admin_auth/middleware.ts new file mode 100644 index 0000000..94ae163 --- /dev/null +++ b/src/admin_auth/middleware.ts @@ -0,0 +1,37 @@ +import type { MiddlewareHandler } from '@hono/hono'; +import type { AppServices } from '../app_services.ts'; +import { setAdminSession } from './context.ts'; + +export function createAdminAuthMiddleware(services: AppServices): MiddlewareHandler { + return async (context, next) => { + const session = await services.adminAuth.getSession(context.req.raw); + + if (session) { + setAdminSession(context, session); + await next(); + return; + } + + if (wantsHtml(context.req.raw)) { + const url = new URL(context.req.raw.url); + const returnTo = `${url.pathname}${url.search}`; + return context.redirect(`/admin/login?returnTo=${encodeURIComponent(returnTo)}`, 303); + } + + return context.text('Authentication required.', 401); + }; +} + +function wantsHtml(request: Request): boolean { + const accept = request.headers.get('accept')?.toLowerCase(); + + if (!accept) { + return true; + } + + if (accept.includes('application/json') || accept.includes('application/problem+json')) { + return false; + } + + return accept.includes('text/html'); +} diff --git a/src/admin_auth/page.ts b/src/admin_auth/page.ts new file mode 100644 index 0000000..f4a7f4f --- /dev/null +++ b/src/admin_auth/page.ts @@ -0,0 +1,176 @@ +import { escapeHtml } from '../admin/layout.ts'; +import { ADMIN_LAYOUT_STYLES } from '../admin/layout_styles.ts'; + +const AUTH_PAGE_STYLES = `${ADMIN_LAYOUT_STYLES} + body { background: var(--color-bg); } + .auth-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 40px 20px; + } + .auth-panel { + width: min(100%, 420px); + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-surface); + box-shadow: var(--shadow-soft); + padding: 28px; + } + .auth-title { + margin: 0; + color: var(--color-text); + font-size: 1.45rem; + line-height: 1.2; + } + .auth-intro { + margin: 10px 0 24px; + color: var(--color-muted); + line-height: 1.5; + } + .auth-form { + display: grid; + gap: 16px; + } + .auth-field { + display: grid; + gap: 6px; + } + .auth-field label { + color: var(--color-text); + font-size: 0.9rem; + font-weight: 650; + } + .auth-field input { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--color-border-strong); + border-radius: 6px; + padding: 10px 12px; + font: inherit; + } + .auth-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 8px; + } + .auth-link { + color: var(--color-link); + font-size: 0.92rem; + font-weight: 650; + text-decoration: none; + } + .auth-error { + border: 1px solid var(--color-danger-border); + border-radius: 6px; + background: var(--color-danger-bg); + color: var(--color-danger-text); + padding: 10px 12px; + font-size: 0.92rem; + }`; + +export function renderAdminAuthPage(input: { + mode: 'login' | 'signup'; + returnTo: string; + error?: string; + email?: string; + name?: string; +}): string { + const isSignup = input.mode === 'signup'; + const title = isSignup ? 'Create your Lantern admin account' : 'Sign in to Lantern'; + const action = isSignup ? '/admin/signup' : '/admin/login'; + const alternateHref = isSignup ? '/admin/login' : '/admin/signup'; + const alternateLabel = isSignup + ? 'Already have an account? Sign in' + : 'Need access? Create an account'; + + return renderAdminAuthShell({ + title, + intro: + 'Use a Lantern admin account to review packages, manage LMS deployments, and inspect runtime activity.', + body: ` + ${renderAuthError(input.error)} +
+ + ${isSignup ? renderNameField(input.name) : ''} + ${renderEmailField(input.email)} +
+ + +
+
+ ${alternateLabel} + +
+
`, + }); +} + +export function renderAdminLogoutPage(error?: string): string { + return renderAdminAuthShell({ + title: 'Sign out of Lantern', + intro: 'End the current admin session on this browser.', + body: ` + ${renderAuthError(error)} +
+
+ Stay signed in + +
+
`, + }); +} + +function renderAdminAuthShell(input: { + title: string; + intro: string; + body: string; +}): string { + return ` + + + + + ${escapeHtml(input.title)} + + + +
+
+

${escapeHtml(input.title)}

+

${escapeHtml(input.intro)}

+ ${input.body} +
+
+ +`; +} + +function renderAuthError(error: string | undefined): string { + return error ? `` : ''; +} + +function renderNameField(name: string | undefined): string { + return `
+ + +
`; +} + +function renderEmailField(email: string | undefined): string { + return `
+ + +
`; +} diff --git a/src/admin_auth/page_input.ts b/src/admin_auth/page_input.ts new file mode 100644 index 0000000..36084a5 --- /dev/null +++ b/src/admin_auth/page_input.ts @@ -0,0 +1,28 @@ +import type { Context } from '@hono/hono'; +import type { AdminIdentity } from '../admin/layout_support.ts'; +import { adminIdentityFromContext } from './context.ts'; + +export const ADMIN_TEST_IDENTITY: AdminIdentity = { + displayName: 'Test Admin', + detail: 'admin@example.test', +}; + +export function adminPageInput>( + context: Context, + input: T, +): T & { identity: AdminIdentity } { + return { + ...input, + identity: adminIdentityFromContext(context), + }; +} + +export function adminPageInputWithIdentity>( + identity: AdminIdentity, + input: T, +): T & { identity: AdminIdentity } { + return { + ...input, + identity, + }; +} diff --git a/src/admin_auth/service.ts b/src/admin_auth/service.ts new file mode 100644 index 0000000..b47b288 --- /dev/null +++ b/src/admin_auth/service.ts @@ -0,0 +1,348 @@ +import { betterAuth, type BetterAuthOptions } from 'better-auth'; +import type { D1Database } from '../db/d1.ts'; +import type { EnvReader } from '../platform/env.ts'; +import { readEnv } from '../platform/env.ts'; + +const BETTER_AUTH_SECRET_ENV = 'BETTER_AUTH_SECRET'; +const APP_ORIGIN_ENV = 'APP_ORIGIN'; +const ADMIN_AUTH_ALLOW_SIGNUP_ENV = 'ADMIN_AUTH_ALLOW_SIGNUP'; +const ADMIN_SESSION_COOKIE = 'better-auth.session_token'; + +export interface AdminAuthUser { + id: string; + email: string; + name: string; +} + +export interface AdminAuthSession { + user: AdminAuthUser; +} + +export interface AdminAuthFormResult { + success: true; + response: Response; +} + +export interface AdminAuthFormFailure { + success: false; + message: string; + status: 400 | 401 | 409 | 500 | 503; +} + +export type AdminAuthFormOutcome = AdminAuthFormResult | AdminAuthFormFailure; + +export interface AdminAuthService { + handleAuthRequest(request: Request): Promise; + getSession(request: Request): Promise; + signIn(input: { + request: Request; + email: string; + password: string; + returnTo: string; + }): Promise; + signUp(input: { + request: Request; + name: string; + email: string; + password: string; + returnTo: string; + }): Promise; + signOut(input: { request: Request; returnTo: string }): Promise; +} + +type BetterAuthRuntime = ReturnType; + +export function createBetterAdminAuthOptions(input: { + db: D1Database; + baseURL: string; + secret: string; + allowSignUp: boolean; +}): BetterAuthOptions { + return { + appName: 'Lantern', + database: input.db, + baseURL: input.baseURL, + basePath: '/api/auth', + secret: input.secret, + emailAndPassword: { + enabled: true, + disableSignUp: !input.allowSignUp, + }, + user: { + modelName: 'auth_users', + }, + session: { + modelName: 'auth_sessions', + }, + account: { + modelName: 'auth_accounts', + }, + verification: { + modelName: 'auth_verifications', + }, + }; +} + +export function createBetterAdminAuth(input: { + db: D1Database; + env: EnvReader; +}): AdminAuthService { + const baseURL = readRequiredEnv(input.env, APP_ORIGIN_ENV); + const secret = readRequiredEnv(input.env, BETTER_AUTH_SECRET_ENV); + const allowSignUp = readEnv(ADMIN_AUTH_ALLOW_SIGNUP_ENV, input.env) === '1'; + const auth = betterAuth( + createBetterAdminAuthOptions({ db: input.db, baseURL, secret, allowSignUp }), + ); + + return createBetterAuthService(auth); +} + +export function createUnconfiguredAdminAuthService(message: string): AdminAuthService { + return { + handleAuthRequest() { + return Promise.resolve(new Response(message, { status: 503 })); + }, + getSession() { + return Promise.resolve(null); + }, + signIn() { + return Promise.resolve(unconfiguredAuthFailure(message)); + }, + signUp() { + return Promise.resolve(unconfiguredAuthFailure(message)); + }, + signOut() { + return Promise.resolve(unconfiguredAuthFailure(message)); + }, + }; +} + +function createBetterAuthService(auth: BetterAuthRuntime): AdminAuthService { + return { + handleAuthRequest(request) { + return auth.handler(request); + }, + async getSession(request) { + const result = await auth.api.getSession({ + headers: request.headers, + }); + + if (!result) { + return null; + } + + return { + user: { + id: result.user.id, + email: result.user.email, + name: result.user.name, + }, + }; + }, + signIn(input) { + return runBetterAuthFormAction( + () => + auth.api.signInEmail({ + body: { + email: input.email, + password: input.password, + callbackURL: input.returnTo, + }, + headers: input.request.headers, + returnHeaders: true, + }), + input.returnTo, + ); + }, + signUp(input) { + return runBetterAuthFormAction( + () => + auth.api.signUpEmail({ + body: { + name: input.name, + email: input.email, + password: input.password, + callbackURL: input.returnTo, + }, + headers: input.request.headers, + returnHeaders: true, + }), + input.returnTo, + ); + }, + async signOut(input) { + return await runBetterAuthFormAction( + async () => { + const result = await auth.api.signOut({ + headers: input.request.headers, + returnHeaders: true, + }); + + if (!result.response.success) { + throw new Error('Better Auth did not invalidate the admin session.'); + } + + return result; + }, + input.returnTo, + ); + }, + }; +} + +async function runBetterAuthFormAction( + action: () => Promise<{ headers: Headers }>, + returnTo: string, +): Promise { + try { + const authResult = await action(); + return { + success: true, + response: redirectWithAuthHeaders(returnTo, authResult.headers), + }; + } catch (error) { + return parseBetterAuthFormFailure(error); + } +} + +function parseBetterAuthFormFailure(error: unknown): AdminAuthFormFailure { + return { + success: false, + status: statusForAuthError(error), + message: authErrorMessage(error), + }; +} + +function redirectWithAuthHeaders(location: string, authHeaders: Headers): Response { + const response = new Response(null, { + status: 303, + headers: { + location, + }, + }); + + copyAuthResponseHeaders(authHeaders, response.headers); + + return response; +} + +function copyAuthResponseHeaders(source: Headers, target: Headers): void { + for (const cookie of getSetCookies(source)) { + target.append('set-cookie', cookie); + } +} + +function statusForAuthError(error: unknown): 400 | 401 | 409 | 500 { + const status = readAuthErrorStatus(error); + + if (status === 401 || status === 409 || status === 500) { + return status; + } + + return 400; +} + +function authErrorMessage(error: unknown): string { + if ( + typeof error === 'object' && + error !== null && + 'body' in error && + typeof error.body === 'object' && + error.body !== null && + 'message' in error.body && + typeof error.body.message === 'string' + ) { + return error.body.message; + } + + if ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' && + error.message.trim() !== '' + ) { + return error.message; + } + + return 'Authentication failed.'; +} + +function readAuthErrorStatus(error: unknown): number | null { + if ( + typeof error === 'object' && + error !== null && + 'statusCode' in error && + typeof error.statusCode === 'number' + ) { + return error.statusCode; + } + + if ( + typeof error === 'object' && + error !== null && + 'status' in error && + typeof error.status === 'number' + ) { + return error.status; + } + + return null; +} + +function getSetCookies(headers: Headers): string[] { + const withGetter = headers as Headers & { getSetCookie?: () => string[] }; + + if (typeof withGetter.getSetCookie === 'function') { + return withGetter.getSetCookie(); + } + + const cookie = headers.get('set-cookie'); + + return cookie ? [cookie] : []; +} + +function readRequiredEnv(env: EnvReader, name: string): string { + const value = readEnv(name, env)?.trim(); + + if (!value) { + throw new Error(`${name} is required to enable Lantern admin authentication.`); + } + + return value; +} + +function unconfiguredAuthFailure(message: string): AdminAuthFormFailure { + return { + success: false, + status: 503, + message, + }; +} + +export function readAdminSessionCookie(request: Request): string | null { + const cookieHeader = request.headers.get('cookie'); + + if (!cookieHeader) { + return null; + } + + for (const part of cookieHeader.split(';')) { + const [name, ...valueParts] = part.trim().split('='); + + if (name === ADMIN_SESSION_COOKIE) { + const value = valueParts.join('=').trim(); + return value.length > 0 ? value : null; + } + } + + return null; +} + +export function adminSessionCookieValue(token: string): string { + return `${ADMIN_SESSION_COOKIE}=${token}; Path=/; HttpOnly`; +} + +export function expiredAdminSessionCookieValue(): string { + return `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly`; +} diff --git a/src/admin_auth/service_test.ts b/src/admin_auth/service_test.ts new file mode 100644 index 0000000..c974c9e --- /dev/null +++ b/src/admin_auth/service_test.ts @@ -0,0 +1,91 @@ +import { assertEquals, assertThrows } from '@std/assert'; +import { getAuthTables } from 'better-auth/db'; +import { createBetterAdminAuth, createBetterAdminAuthOptions } from './service.ts'; +import type { D1Database, D1PreparedStatement, D1Result } from '../db/d1.ts'; +import type { EnvReader } from '../platform/env.ts'; + +Deno.test('createBetterAdminAuth fails clearly when BETTER_AUTH_SECRET is missing', () => { + const env: EnvReader = { + get(name: string) { + return name === 'APP_ORIGIN' ? 'https://lantern.example' : undefined; + }, + }; + + assertThrows( + () => { + createBetterAdminAuth({ + db: createStubD1Database(), + env, + }); + }, + Error, + 'BETTER_AUTH_SECRET is required', + ); +}); + +Deno.test('Better Auth migration matches the configured auth schema tables and fields', async () => { + const migration = await Deno.readTextFile('src/db/d1_migrations/0010_better_auth_tables.sql'); + const tables = getAuthTables( + createBetterAdminAuthOptions({ + db: createStubD1Database(), + baseURL: 'https://lantern.example', + secret: 'test-better-auth-secret-test-better-auth-secret', + allowSignUp: true, + }), + ); + + for (const table of Object.values(tables)) { + const tableName = table.modelName; + assertEquals(migration.includes(`CREATE TABLE IF NOT EXISTS ${tableName}`), true); + + for (const fieldName of Object.keys(table.fields)) { + assertEquals( + migration.includes(`${fieldName} `), + true, + `Migration is missing ${tableName}.${fieldName}.`, + ); + } + } +}); + +function createStubD1Database(): D1Database { + return { + prepare() { + return createStubD1Statement(); + }, + batch() { + return Promise.resolve([]); + }, + exec() { + return Promise.resolve({ count: 0, duration: 0 }); + }, + }; +} + +function createStubD1Statement(): D1PreparedStatement { + return { + bind() { + return this; + }, + first() { + return Promise.resolve(null); + }, + run() { + return Promise.resolve(createEmptyD1Result()); + }, + all>() { + return Promise.resolve(createEmptyD1Result()); + }, + raw() { + return Promise.resolve([]); + }, + }; +} + +function createEmptyD1Result(): D1Result { + return { + results: [], + success: true, + meta: {}, + }; +} diff --git a/src/app_admin_app_writer_routes.ts b/src/app_admin_app_writer_routes.ts index 7b8e180..c946b5c 100644 --- a/src/app_admin_app_writer_routes.ts +++ b/src/app_admin_app_writer_routes.ts @@ -18,13 +18,14 @@ import { loadPreviewCapabilityLog } from './app_deployment_support.ts'; import { formValueAsString, requireTrimmedFormValue } from './app_request_support.ts'; import { statusForError } from './app_status_support.ts'; import type { AppServices } from './app_services.ts'; +import { adminPageInput } from './admin_auth/page_input.ts'; import { type AppWriterSelectedContext, selectAppWriterContext } from './app_writer/context.ts'; import { buildLanternOwnedAppGenerationPlanningResult } from './app_writer/planning.ts'; import type { AppGenerationPlanningResult, AppWriterAuthoringMode } from './app_writer/types.ts'; export function registerAdminAppWriterRoutes(app: Hono, services: AppServices): void { app.get('/admin/app-writer', (context) => { - return context.html(renderAppWriterPage()); + return context.html(renderAppWriterPage(adminPageInput(context, {}))); }); app.post('/admin/app-writer', async (context) => { @@ -60,7 +61,7 @@ export function registerAdminAppWriterRoutes(app: Hono, services: AppServices): if (action === 'preview') { return context.html( - renderAppWriterPlanPreviewPage({ + renderAppWriterPlanPreviewPage(adminPageInput(context, { promptText, audience, contentSummary, @@ -68,7 +69,7 @@ export function registerAdminAppWriterRoutes(app: Hono, services: AppServices): requestedAppId, planning: planPreview.planning, selectedContext: planPreview.selectedContext, - }), + })), ); } @@ -123,14 +124,14 @@ export function registerAdminAppWriterRoutes(app: Hono, services: AppServices): } return context.html( - renderAppWriterPage({ + renderAppWriterPage(adminPageInput(context, { promptText, audience, contentSummary, gradingMode, requestedAppId, notice: createErrorNotice('App generation blocked', error), - }), + })), statusForError(error), ); } @@ -145,13 +146,13 @@ export function registerAdminAppWriterRoutes(app: Hono, services: AppServices): if (run === null) { return context.html( - renderAppWriterPage({ + renderAppWriterPage(adminPageInput(context, { notice: { tone: 'error', title: 'Generation run not found', detail: 'Lantern could not find that app writer run.', }, - }), + })), 404, ); } @@ -169,20 +170,20 @@ export function registerAdminAppWriterRoutes(app: Hono, services: AppServices): }); return context.html( - renderAppGenerationRunPage({ + renderAppGenerationRunPage(adminPageInput(context, { run, workspace, packageVersion, latestPreviewSession: runtimeLog.session, previewEvidence: runtimeLog.evidence, activityEvents, - }), + })), ); } catch (error) { return context.html( - renderAppWriterPage({ + renderAppWriterPage(adminPageInput(context, { notice: createErrorNotice('Generation run unavailable', error), - }), + })), statusForError(error), ); } diff --git a/src/app_admin_app_writer_test.ts b/src/app_admin_app_writer_test.ts index b9507a2..acdfce0 100644 --- a/src/app_admin_app_writer_test.ts +++ b/src/app_admin_app_writer_test.ts @@ -1,5 +1,4 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; import type { AppPackageGenerationResult } from './app_writer/types.ts'; import { buildValidSimpleActivityFiles } from './test_helpers/app_writer_generated_package.ts'; import { @@ -18,9 +17,10 @@ import { buildReviewedPlacementRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; Deno.test('GET /admin/app-writer renders the app writer prompt form', async () => { - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => createInMemoryPackageReviewRepository(), }).request('http://localhost/admin/app-writer'); @@ -49,7 +49,7 @@ Deno.test('POST /admin/app-writer previews the deterministic plan before generat formData.set('gradingMode', 'completion'); formData.set('requestedAppId', 'phonics-flashcards'); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => createInMemoryPackageReviewRepository(), appWriterWorkspaceRunner: { ...createUnavailableAppWriterWorkspaceRunner('Plan preview must not call the harness.'), @@ -103,7 +103,7 @@ Deno.test('POST /admin/app-writer runs generation and redirects to the run detai formData.set('requestedAppId', 'phonics-match'); formData.set('appWriterAction', 'generate'); - const app = createApp({ + const app = createAuthenticatedTestApp({ getRepository: () => repository, appWriterWorkspaceRunner: createStaticAppWriterWorkspaceRunner(buildGenerationResult(), { authorDelay() { @@ -236,7 +236,7 @@ Deno.test('POST /admin/app-writer redirects even without a Worker execution cont formData.set('promptText', 'Create a phonics flashcard app.'); formData.set('appWriterAction', 'generate'); - const app = createApp({ + const app = createAuthenticatedTestApp({ getRepository: () => repository, appWriterWorkspaceRunner: createStaticAppWriterWorkspaceRunner(buildGenerationResult(), { authorDelay() { @@ -272,7 +272,7 @@ Deno.test('POST /admin/app-writer queues a Workflow when the scheduler is config formData.set('promptText', 'Create a phonics flashcard app.'); formData.set('appWriterAction', 'generate'); - const app = createApp({ + const app = createAuthenticatedTestApp({ getRepository: () => repository, appWriterWorkspaceRunner: { ...createUnavailableAppWriterWorkspaceRunner( @@ -381,7 +381,7 @@ Deno.test('GET /admin/app-writer/runs/:generationId shows captured workspace bef ], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('https://lantern.example/admin/app-writer/runs/generation-1'); const body = await response.text(); @@ -395,7 +395,7 @@ Deno.test('GET /admin/app-writer/runs/:generationId shows captured workspace bef }); Deno.test('GET /admin/app-writer/runs/:generationId/events streams through the app writer Agent session', async () => { - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => createInMemoryPackageReviewRepository(), appWriterAgentSessions: { observe(_input) { @@ -467,7 +467,7 @@ Deno.test('GET /admin/app-writer/runs/:generationId shows generated package runt ], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('https://lantern.example/admin/app-writer/runs/generation-1'); const body = await response.text(); diff --git a/src/app_admin_auth_routes.ts b/src/app_admin_auth_routes.ts new file mode 100644 index 0000000..05b348d --- /dev/null +++ b/src/app_admin_auth_routes.ts @@ -0,0 +1,146 @@ +import { csrf } from '@hono/hono/csrf'; +import type { Context, Hono } from '@hono/hono'; +import type { AppServices } from './app_services.ts'; +import { renderAdminAuthPage, renderAdminLogoutPage } from './admin_auth/page.ts'; + +type AuthMode = 'login' | 'signup'; + +export function registerPublicAdminAuthRoutes(app: Hono, services: AppServices): void { + app.on( + ['GET', 'POST'], + '/api/auth/*', + (context) => services.adminAuth.handleAuthRequest(context.req.raw), + ); + + app.get('/admin/login', (context) => renderAuthPage(context, services, 'login')); + app.post('/admin/login', csrf(), (context) => handleAuthForm(context, services, 'login')); + + app.get('/admin/signup', (context) => renderAuthPage(context, services, 'signup')); + app.post('/admin/signup', csrf(), (context) => handleAuthForm(context, services, 'signup')); +} + +export function registerProtectedAdminAuthRoutes(app: Hono, services: AppServices): void { + app.get('/admin/logout', (context) => context.html(renderAdminLogoutPage())); + + app.post('/admin/logout', async (context) => { + const result = await services.adminAuth.signOut({ + request: context.req.raw, + returnTo: '/admin/login', + }); + + if (result.success) { + return result.response; + } + + return context.html(renderAdminLogoutPage(result.message), result.status); + }); +} + +async function renderAuthPage(context: Context, services: AppServices, mode: AuthMode) { + const returnTo = normalizeReturnTo(context.req.query('returnTo')); + const session = await services.adminAuth.getSession(context.req.raw); + + if (session) { + return context.redirect(returnTo, 303); + } + + return context.html(renderAdminAuthPage({ mode, returnTo })); +} + +async function handleAuthForm(context: Context, services: AppServices, mode: AuthMode) { + const form = await context.req.raw.formData(); + const returnTo = normalizeReturnTo(readFormString(form, 'returnTo')); + const values = readAuthFormValues(form, mode); + const validationError = validateAuthForm(mode, values); + + if (validationError) { + return context.html( + renderAdminAuthPage({ + mode, + returnTo, + error: validationError, + email: values.email, + name: values.name, + }), + 400, + ); + } + + const result = mode === 'login' + ? await services.adminAuth.signIn({ + request: context.req.raw, + email: values.email, + password: values.password, + returnTo, + }) + : await services.adminAuth.signUp({ + request: context.req.raw, + name: values.name, + email: values.email, + password: values.password, + returnTo, + }); + + if (result.success) { + return result.response; + } + + return context.html( + renderAdminAuthPage({ + mode, + returnTo, + error: result.message, + email: values.email, + name: values.name, + }), + result.status, + ); +} + +function readAuthFormValues(form: FormData, mode: AuthMode): { + name: string; + email: string; + password: string; +} { + return { + name: mode === 'signup' ? readFormString(form, 'name').trim() : '', + email: readFormString(form, 'email').trim(), + password: readFormString(form, 'password'), + }; +} + +function validateAuthForm(mode: AuthMode, values: { + name: string; + email: string; + password: string; +}): string | null { + if (mode === 'login') { + return !values.email || !values.password ? 'Enter your email and password.' : null; + } + + return !values.name || !values.email || !values.password + ? 'Enter your name, email, and password.' + : null; +} + +function readFormString(form: FormData, name: string): string { + const value = form.get(name); + + return typeof value === 'string' ? value : ''; +} + +function normalizeReturnTo(value: string | undefined): string { + if (!value || !value.startsWith('/admin/') || value.startsWith('//')) { + return '/admin/packages'; + } + + if (value === '/admin/login' || value.startsWith('/admin/login?')) { + return '/admin/packages'; + } + + if (value === '/admin/signup' || value.startsWith('/admin/signup?')) { + return '/admin/packages'; + } + + return value; +} diff --git a/src/app_admin_auth_test.ts b/src/app_admin_auth_test.ts new file mode 100644 index 0000000..363688a --- /dev/null +++ b/src/app_admin_auth_test.ts @@ -0,0 +1,227 @@ +import { assertEquals, assertStringIncludes } from '@std/assert'; +import { createApp } from './app.ts'; +import { + ADMIN_TEST_SESSION_TOKEN, + cookieHeaderFromResponse, + createAuthenticatedAdminAuth, + createTestAdminAuth, + createUnauthenticatedAdminAuth, +} from './test_helpers/admin_auth.ts'; +import { adminSessionCookieValue } from './admin_auth/service.ts'; +import { createInMemoryPackageReviewRepository } from './test_helpers/package_review.ts'; + +Deno.test('admin pages require authentication', async () => { + const response = await createApp({ + adminAuth: createUnauthenticatedAdminAuth(), + getRepository: () => createInMemoryPackageReviewRepository(), + }).request('http://localhost/admin/packages', { + headers: { accept: 'text/html' }, + }); + + assertEquals(response.status, 303); + assertEquals( + response.headers.get('location'), + '/admin/login?returnTo=%2Fadmin%2Fpackages', + ); +}); + +Deno.test('admin JSON requests return 401 when unauthenticated', async () => { + const response = await createApp({ + adminAuth: createUnauthenticatedAdminAuth(), + getRepository: () => createInMemoryPackageReviewRepository(), + }).request('http://localhost/admin/packages', { + headers: { accept: 'application/json' }, + }); + + assertEquals(response.status, 401); + assertEquals(await response.text(), 'Authentication required.'); +}); + +Deno.test('admin JSON requests with Accept */* return 401 when unauthenticated', async () => { + const response = await createApp({ + adminAuth: createUnauthenticatedAdminAuth(), + getRepository: () => createInMemoryPackageReviewRepository(), + }).request('http://localhost/admin/packages', { + headers: { accept: '*/*' }, + }); + + assertEquals(response.status, 401); +}); + +Deno.test('login and signup pages render without a session', async () => { + const app = createApp({ + adminAuth: createUnauthenticatedAdminAuth(), + getRepository: () => createInMemoryPackageReviewRepository(), + }); + + const login = await app.request('http://localhost/admin/login'); + const signup = await app.request('http://localhost/admin/signup'); + + assertEquals(login.status, 200); + assertStringIncludes(await login.text(), 'Sign in to Lantern'); + assertEquals(signup.status, 200); + assertStringIncludes(await signup.text(), 'Create your Lantern admin account'); +}); + +Deno.test('signup creates an admin session and redirects to packages', async () => { + const auth = createUnauthenticatedAdminAuth(); + const app = createApp({ + adminAuth: auth, + getRepository: () => createInMemoryPackageReviewRepository(), + }); + + const signup = await app.request('http://localhost/admin/signup', { + method: 'POST', + headers: { origin: 'http://localhost' }, + body: new URLSearchParams({ + name: 'New Admin', + email: 'new-admin@example.test', + password: 'password-123', + returnTo: '/admin/packages', + }), + }); + const sessionCookie = cookieHeaderFromResponse(signup); + const packages = await app.request('http://localhost/admin/packages', { + headers: { + cookie: sessionCookie ?? '', + }, + }); + + assertEquals(signup.status, 303); + assertEquals(signup.headers.get('location'), '/admin/packages'); + assertEquals(packages.status, 200); +}); + +Deno.test('duplicate signup fails clearly', async () => { + const response = await createApp({ + adminAuth: createTestAdminAuth({ + session: null, + duplicateSignupEmail: 'admin@example.test', + }), + getRepository: () => createInMemoryPackageReviewRepository(), + }).request('http://localhost/admin/signup', { + method: 'POST', + headers: { origin: 'http://localhost' }, + body: new URLSearchParams({ + name: 'Admin', + email: 'admin@example.test', + password: 'password-123', + returnTo: '/admin/packages', + }), + }); + + assertEquals(response.status, 409); + assertStringIncludes(await response.text(), 'User already exists.'); +}); + +Deno.test('login creates an admin session and redirects to requested admin path', async () => { + const auth = createUnauthenticatedAdminAuth(); + const app = createApp({ + adminAuth: auth, + getRepository: () => createInMemoryPackageReviewRepository(), + }); + + const login = await app.request('http://localhost/admin/login', { + method: 'POST', + headers: { origin: 'http://localhost' }, + body: new URLSearchParams({ + email: 'admin@example.test', + password: 'password-123', + returnTo: '/admin/deployments', + }), + }); + const sessionCookie = cookieHeaderFromResponse(login); + const deployments = await app.request('http://localhost/admin/deployments', { + headers: { + cookie: sessionCookie ?? '', + }, + }); + + assertEquals(login.status, 303); + assertEquals(login.headers.get('location'), '/admin/deployments'); + assertEquals(deployments.status, 200); +}); + +Deno.test('logout invalidates the admin session', async () => { + const auth = createAuthenticatedAdminAuth(); + const app = createApp({ + adminAuth: auth, + getRepository: () => createInMemoryPackageReviewRepository(), + }); + const sessionCookie = adminSessionCookieValue(ADMIN_TEST_SESSION_TOKEN); + + const logoutPage = await app.request('http://localhost/admin/logout', { + headers: { cookie: sessionCookie }, + }); + const logout = await app.request('http://localhost/admin/logout', { + method: 'POST', + headers: { + origin: 'http://localhost', + cookie: sessionCookie, + }, + }); + const packages = await app.request('http://localhost/admin/packages', { + headers: { + accept: 'application/json', + cookie: sessionCookie, + }, + }); + + assertEquals(logoutPage.status, 200); + assertStringIncludes(await logoutPage.text(), 'Sign out of Lantern'); + assertEquals(logout.status, 303); + assertEquals(logout.headers.get('location'), '/admin/login'); + assertEquals(packages.status, 401); +}); + +Deno.test('logout failure is shown and does not clear the admin session', async () => { + const auth = createTestAdminAuth({ + session: { + user: { + id: 'test-admin-user', + email: 'admin@example.test', + name: 'Test Admin', + }, + }, + signOutFailure: { + success: false, + status: 500, + message: 'Better Auth did not invalidate the admin session.', + }, + }); + const app = createApp({ + adminAuth: auth, + getRepository: () => createInMemoryPackageReviewRepository(), + }); + const sessionCookie = adminSessionCookieValue(ADMIN_TEST_SESSION_TOKEN); + + const logout = await app.request('http://localhost/admin/logout', { + method: 'POST', + headers: { + origin: 'http://localhost', + cookie: sessionCookie, + }, + }); + const packages = await app.request('http://localhost/admin/packages', { + headers: { cookie: sessionCookie }, + }); + + assertEquals(logout.status, 500); + assertStringIncludes(await logout.text(), 'Better Auth did not invalidate the admin session.'); + assertEquals(packages.status, 200); +}); + +Deno.test('public LTI, runtime, health, and config routes do not require admin auth', async () => { + const app = createApp({ + adminAuth: createUnauthenticatedAdminAuth(), + getRepository: () => createInMemoryPackageReviewRepository(), + }); + + const health = await app.request('http://localhost/health'); + const runtime = await app.request('http://localhost/runtime/missing-session'); + const ltiLogin = await app.request('http://localhost/lti/login'); + + assertEquals(health.status, 200); + assertEquals(runtime.status, 404); + assertEquals(ltiLogin.status === 303 || ltiLogin.status === 400 || ltiLogin.status === 409, true); +}); diff --git a/src/app_admin_authoring_preview_routes.ts b/src/app_admin_authoring_preview_routes.ts index b32ae82..fbecd9c 100644 --- a/src/app_admin_authoring_preview_routes.ts +++ b/src/app_admin_authoring_preview_routes.ts @@ -5,6 +5,7 @@ import { renderPackageIndexPage } from './admin/package_index.ts'; import { createErrorNotice } from './app_notice_support.ts'; import { statusForError } from './app_status_support.ts'; import type { AppServices } from './app_services.ts'; +import { adminPageInput } from './admin_auth/page_input.ts'; import { readEnv } from './platform/env.ts'; import { trimLeadingSlash } from './package_review/snapshot_path.ts'; import type { @@ -147,14 +148,14 @@ async function renderAuthoringPreviewError( ) { if (state !== null) { return context.html( - renderAuthoringPage({ + renderAuthoringPage(adminPageInput(context, { packageVersion: state.packageVersion, draft: state.draft, currentFiles: state.currentFiles, latestPreviewSession: state.latestPreviewSession, previewEvidence: state.previewEvidence, notice: createErrorNotice('Draft preview unavailable', error), - }), + })), statusForError(error), ); } @@ -164,10 +165,10 @@ async function renderAuthoringPreviewError( if (!packageVersion) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice('Draft preview unavailable', error), - }), + })), statusForError(error), ); } @@ -175,25 +176,25 @@ async function renderAuthoringPreviewError( const history = await repository.listPackageVersionsByApp(packageVersion.appId); return context.html( - renderPackageDetailPage({ + renderPackageDetailPage(adminPageInput(context, { packageVersion, history, notice: createErrorNotice('Draft preview unavailable', error), - }), + })), statusForError(error), ); } function renderVersionNotFound(context: Context) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: { tone: 'error', title: 'Version not found', detail: 'Lantern could not find that app version.', }, - }), + })), 404, ); } diff --git a/src/app_admin_authoring_preview_test.ts b/src/app_admin_authoring_preview_test.ts index 9228770..1131b6f 100644 --- a/src/app_admin_authoring_preview_test.ts +++ b/src/app_admin_authoring_preview_test.ts @@ -1,5 +1,4 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; import { withRuntimeOriginEnv } from './app_test_support.ts'; import { buildPackageVersionRecord, @@ -8,6 +7,7 @@ import { createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; import type { AuthoringDraftFileRecord, AuthoringDraftRecord } from './package_review/types.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; const TEMPLATE_SNAPSHOT_ROOT = 'examples/apps/template'; const DRAFT_ID = 'authoring-draft-101'; @@ -23,7 +23,9 @@ Deno.test('POST /authoring/preview launches a draft snapshot through the governe authoringDrafts: [buildTemplateAuthoringDraft()], authoringDraftFiles: [buildTemplateAuthoringDraftFile()], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); try { const response = await app.request( @@ -78,7 +80,9 @@ Deno.test('POST /authoring/preview fails clearly when no saved draft files exist packageVersions: [buildTemplateAuthoringPackageVersionRecord()], authoringDrafts: [buildTemplateAuthoringDraft()], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const response = await app.request( 'https://lantern.example/admin/packages/template-app/versions/0.1.0/authoring/preview', @@ -129,7 +133,9 @@ Deno.test('GET /authoring renders the latest draft preview state and evidence li }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const response = await app.request( 'http://localhost/admin/packages/template-app/versions/0.1.0/authoring', diff --git a/src/app_admin_authoring_routes.ts b/src/app_admin_authoring_routes.ts index 5254355..76698ad 100644 --- a/src/app_admin_authoring_routes.ts +++ b/src/app_admin_authoring_routes.ts @@ -6,6 +6,7 @@ import { createErrorNotice } from './app_notice_support.ts'; import { normalizeOptionalString, requireTrimmedFormValue } from './app_request_support.ts'; import { statusForError } from './app_status_support.ts'; import type { AppServices } from './app_services.ts'; +import { adminPageInput } from './admin_auth/page_input.ts'; import type { BrowserAutograderDraftFileInput } from './authoring/browser_autograder_draft_generator.ts'; import { buildDraftDiff } from './authoring/draft_diff.ts'; import { normalizeAuthoringDraftPath } from './package_review/repository_authoring_contract.ts'; @@ -48,14 +49,14 @@ export function registerAdminAuthoringRoutes(app: Hono, services: AppServices): const saved = new URL(context.req.url).searchParams.get('saved') === '1'; return context.html( - renderAuthoringPage({ + renderAuthoringPage(adminPageInput(context, { packageVersion: state.packageVersion, draft: state.draft, currentFiles: state.currentFiles, latestPreviewSession: state.latestPreviewSession, previewEvidence: state.previewEvidence, notice: saved ? AUTHORING_SAVED_NOTICE : null, - }), + })), ); } catch (error) { return await renderAuthoringError(context, services, appId, version, error); @@ -104,14 +105,14 @@ export function registerAdminAuthoringRoutes(app: Hono, services: AppServices): }; return context.html( - renderAuthoringPage({ + renderAuthoringPage(adminPageInput(context, { packageVersion: state.packageVersion, draft: state.draft, currentFiles: state.currentFiles, latestPreviewSession: state.latestPreviewSession, previewEvidence: state.previewEvidence, generatedDraft, - }), + })), ); } catch (error) { return await renderAuthoringError( @@ -336,7 +337,7 @@ async function renderAuthoringError( ) { if (state !== null) { return context.html( - renderAuthoringPage({ + renderAuthoringPage(adminPageInput(context, { packageVersion: state.packageVersion, draft: state.draft, currentFiles: state.currentFiles, @@ -344,7 +345,7 @@ async function renderAuthoringError( previewEvidence: state.previewEvidence, generatedDraft, notice: createErrorNotice('Authoring draft unavailable', error), - }), + })), statusForError(error), ); } @@ -354,10 +355,10 @@ async function renderAuthoringError( if (!packageVersion) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice('Authoring draft unavailable', error), - }), + })), statusForError(error), ); } @@ -365,25 +366,25 @@ async function renderAuthoringError( const history = await repository.listPackageVersionsByApp(packageVersion.appId); return context.html( - renderPackageDetailPage({ + renderPackageDetailPage(adminPageInput(context, { packageVersion, history, notice: createErrorNotice('Authoring draft unavailable', error), - }), + })), statusForError(error), ); } function renderVersionNotFound(context: Context) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: { tone: 'error', title: 'Version not found', detail: 'Lantern could not find that app version.', }, - }), + })), 404, ); } diff --git a/src/app_admin_authoring_test.ts b/src/app_admin_authoring_test.ts index 204a684..0278b6d 100644 --- a/src/app_admin_authoring_test.ts +++ b/src/app_admin_authoring_test.ts @@ -1,5 +1,4 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; import type { BrowserAutograderDraftFileInput, BrowserAutograderDraftReferenceExample, @@ -8,6 +7,7 @@ import { buildPackageVersionRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; const TEMPLATE_SNAPSHOT_ROOT = 'examples/apps/template'; const AUTHORING_PROMPT = 'Write a browser_autograder check for missing alt text.'; @@ -21,7 +21,7 @@ Deno.test('GET authoring routes expose an explicit entry point and prompt-first const repository = createInMemoryPackageReviewRepository({ packageVersions: [buildTemplateAuthoringPackageVersionRecord()], }); - const app = createApp({ + const app = createAuthenticatedTestApp({ getRepository: () => repository, }); @@ -57,7 +57,7 @@ Deno.test('POST /authoring/generate returns notes and diffs without saving the d const repository = createInMemoryPackageReviewRepository({ packageVersions: [buildTemplateAuthoringPackageVersionRecord()], }); - const app = createApp({ + const app = createAuthenticatedTestApp({ getRepository: () => repository, browserAutograderDraftGenerator: { generate(input) { @@ -124,7 +124,7 @@ Deno.test('POST /authoring/save persists generated files and AI provenance into const repository = createInMemoryPackageReviewRepository({ packageVersions: [buildTemplateAuthoringPackageVersionRecord()], }); - const app = createApp({ + const app = createAuthenticatedTestApp({ getRepository: () => repository, }); const formData = new FormData(); diff --git a/src/app_admin_certification_checklist_test.ts b/src/app_admin_certification_checklist_test.ts index 6f1f8bd..030e293 100644 --- a/src/app_admin_certification_checklist_test.ts +++ b/src/app_admin_certification_checklist_test.ts @@ -1,6 +1,5 @@ import { assert, assertEquals, assertStringIncludes } from '@std/assert'; import { formatDateTime } from './admin/layout.ts'; -import { createApp } from './app.ts'; import { buildCanvasDeploymentBinding } from './test_helpers/lti.ts'; import { buildCertificationWorkflowStatus, @@ -8,6 +7,7 @@ import { buildPackageVersionRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; function assertTextAppearsInOrder(body: string, labels: string[]): void { let previousIndex = -1; @@ -54,7 +54,7 @@ Deno.test('GET /admin/verification renders one certification checklist in Core, ], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/verification'); @@ -124,7 +124,7 @@ Deno.test('GET /admin/verification shows workflow-specific evidence, checked dat ], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/verification'); diff --git a/src/app_admin_certification_official_evidence_test.ts b/src/app_admin_certification_official_evidence_test.ts index 3bfa8c2..6ecbd0b 100644 --- a/src/app_admin_certification_official_evidence_test.ts +++ b/src/app_admin_certification_official_evidence_test.ts @@ -1,6 +1,5 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; import { formatDateTime } from './admin/layout.ts'; -import { createApp } from './app.ts'; import { buildCanvasDeploymentBinding } from './test_helpers/lti.ts'; import { buildCertificationWorkflowStatus, @@ -9,6 +8,7 @@ import { buildPackageVersionRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; Deno.test('GET /admin/verification/official keeps official 1EdTech evidence on its own dated page', async () => { const repository = createInMemoryPackageReviewRepository({ @@ -56,7 +56,7 @@ Deno.test('GET /admin/verification/official keeps official 1EdTech evidence on i }), }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/verification/official'); @@ -118,7 +118,7 @@ Deno.test('GET /admin/verification/official keeps internal passed rows off the o latestOfficialCertificationEvidence: null, }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/verification/official'); diff --git a/src/app_admin_certification_recording_test.ts b/src/app_admin_certification_recording_test.ts index 1883e1e..a89bf37 100644 --- a/src/app_admin_certification_recording_test.ts +++ b/src/app_admin_certification_recording_test.ts @@ -1,11 +1,11 @@ import { assertEquals, assertExists, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; import { buildControlPlaneDeploymentInventoryRow, buildPackageVersionRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; import { buildCanvasDeploymentBinding, buildMoodleDeploymentBinding } from './test_helpers/lti.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; Deno.test('POST /admin/verification records workflow-keyed internal certification evidence and leaves unrelated workflows untouched', async () => { const repository = createInMemoryPackageReviewRepository({ @@ -33,7 +33,9 @@ Deno.test('POST /admin/verification records workflow-keyed internal certificatio }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('source', 'manual'); @@ -97,7 +99,9 @@ Deno.test('POST /admin/verification records official certification evidence glob }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const internalFormData = new FormData(); internalFormData.set('source', 'ci'); @@ -169,7 +173,9 @@ Deno.test('POST /admin/verification rejects missing certification workflow ident }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('source', 'manual'); @@ -209,7 +215,9 @@ Deno.test('POST /admin/verification rejects relative certification evidence URLs }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('source', 'manual'); diff --git a/src/app_admin_control_plane_test.ts b/src/app_admin_control_plane_test.ts index 6fbf534..0d8623d 100644 --- a/src/app_admin_control_plane_test.ts +++ b/src/app_admin_control_plane_test.ts @@ -1,5 +1,4 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; import type { OpsRepository } from './ops/repository.ts'; import { buildBrokerVerificationStatus, @@ -13,6 +12,7 @@ import { buildMoodleDeploymentBinding, buildSakaiDeploymentBinding, } from './test_helpers/lti.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; function createFailingOpsRepository(message: string): OpsRepository { return { @@ -55,7 +55,7 @@ Deno.test('GET /admin/deployments renders deployment operations on a dedicated p controlPlaneDeployments: [buildControlPlaneDeploymentInventoryRow({ deploymentId: 1 })], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/deployments'); @@ -166,7 +166,7 @@ Deno.test('GET /admin/verification renders verification on a dedicated page', as }, }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/verification'); @@ -214,7 +214,7 @@ Deno.test('GET /admin/verification/official renders official evidence on its own ], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/verification/official'); @@ -235,7 +235,7 @@ Deno.test('GET /admin/verification/new renders the dedicated verification entry controlPlaneDeployments: [buildControlPlaneDeploymentInventoryRow({ deploymentId: 1 })], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/verification/new'); @@ -259,7 +259,7 @@ Deno.test('GET /admin/verification/lti-profile renders the dedicated Lantern def }, }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/verification/lti-profile'); @@ -274,7 +274,7 @@ Deno.test('GET /admin/verification/lti-profile renders the dedicated Lantern def }); Deno.test('GET /admin/deployments keeps route failures inside the admin shell', async () => { - const app = createApp({ + const app = createAuthenticatedTestApp({ getOpsRepository: () => createFailingOpsRepository('Connections inventory is unavailable.'), }); @@ -290,7 +290,7 @@ Deno.test('GET /admin/deployments keeps route failures inside the admin shell', }); Deno.test('GET /admin/verification keeps route failures inside the admin shell', async () => { - const app = createApp({ + const app = createAuthenticatedTestApp({ getOpsRepository: () => createFailingOpsRepository('Verification history is unavailable.'), }); @@ -330,7 +330,9 @@ Deno.test('POST /admin/verification records deployment-scoped broker verificatio }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('source', 'manual'); @@ -382,7 +384,9 @@ Deno.test('POST /admin/verification records deployment-scoped broker verificatio Deno.test('POST /admin/verification/lti-profile saves the Lantern-wide default profile and redirects back to the profile page', async () => { const repository = createInMemoryPackageReviewRepository(); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('defaultLtiProfile', 'certification'); @@ -403,7 +407,9 @@ Deno.test('POST /admin/verification/lti-profile saves the Lantern-wide default p Deno.test('POST /admin/verification/lti-profile rejects unsupported profile ids on the profile page', async () => { const repository = createInMemoryPackageReviewRepository(); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('defaultLtiProfile', 'too-loose'); diff --git a/src/app_admin_deployment_detail_form_routes.ts b/src/app_admin_deployment_detail_form_routes.ts index 50336c8..95082b3 100644 --- a/src/app_admin_deployment_detail_form_routes.ts +++ b/src/app_admin_deployment_detail_form_routes.ts @@ -18,6 +18,7 @@ import { import { parseDeploymentLtiProfileOverrideForm } from './app_request_support.ts'; import type { LmsType } from './lti/types.ts'; import type { AppServices } from './app_services.ts'; +import { adminPageInput } from './admin_auth/page_input.ts'; import { readEnv } from './platform/env.ts'; import { resolveConfiguredPublicOrigin } from './public_origin.ts'; import { syncLtiToolDeploymentBinding } from './lti/tool_provider.ts'; @@ -43,7 +44,7 @@ export function registerAdminDeploymentFormRoutes(app: Hono, services: AppServic if (history.length === 0) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: { tone: 'error', @@ -54,7 +55,7 @@ export function registerAdminDeploymentFormRoutes(app: Hono, services: AppServic ) } deployment pin.`, }, - }), + })), 404, ); } @@ -141,7 +142,7 @@ export function registerAdminDeploymentFormRoutes(app: Hono, services: AppServic if (history.length === 0) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: { tone: 'error', @@ -152,7 +153,7 @@ export function registerAdminDeploymentFormRoutes(app: Hono, services: AppServic ) } binding.`, }, - }), + })), 404, ); } diff --git a/src/app_admin_deployment_detail_routes.ts b/src/app_admin_deployment_detail_routes.ts index ad0fa56..775dd69 100644 --- a/src/app_admin_deployment_detail_routes.ts +++ b/src/app_admin_deployment_detail_routes.ts @@ -14,6 +14,7 @@ import { import { createErrorNotice } from './app_notice_support.ts'; import { listCanvasEnvironments } from './lti/config.ts'; import type { AppServices } from './app_services.ts'; +import { adminPageInput } from './admin_auth/page_input.ts'; import { errorMessage, statusForError } from './app_status_support.ts'; import { readEnv } from './platform/env.ts'; import { resolveConfiguredPublicOrigin } from './public_origin.ts'; @@ -80,7 +81,7 @@ export function registerAdminDeploymentDetailRoutes(app: Hono, services: AppServ : null; return context.html( - renderDeploymentDetailPage({ + renderDeploymentDetailPage(adminPageInput(context, { appId: context.req.param('appId'), appTitle: detail.appTitle, history: detail.history, @@ -99,14 +100,14 @@ export function registerAdminDeploymentDetailRoutes(app: Hono, services: AppServ detail.canvasConfigUrl.notice, url.searchParams.get('registered'), ), - }), + })), ); } catch (error) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice('Connections page unavailable', error), - }), + })), statusForError(error), ); } diff --git a/src/app_admin_deployment_moodle_registration_test.ts b/src/app_admin_deployment_moodle_registration_test.ts index 8e4efce..06e0ed0 100644 --- a/src/app_admin_deployment_moodle_registration_test.ts +++ b/src/app_admin_deployment_moodle_registration_test.ts @@ -1,11 +1,11 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; import { buildPackageVersionRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; import { restoreEnv, withFetchStub } from './app_test_support.ts'; import { getTestToolPrivateJwkEnvValue, TEST_LTI_STATE_SECRET } from './test_helpers/lti.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; const TOOL_CONFIGURATION_CLAIM = 'https://purl.imsglobal.org/spec/lti-tool-configuration'; @@ -36,7 +36,9 @@ Deno.test('GET /admin/packages/:appId/deployment/register/moodle completes autom expiresAt: '2030-03-24T00:30:00Z', usedAt: null, }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const requests: Array<{ url: string; method: string; diff --git a/src/app_admin_deployment_registration_test.ts b/src/app_admin_deployment_registration_test.ts index 82407ae..06633c6 100644 --- a/src/app_admin_deployment_registration_test.ts +++ b/src/app_admin_deployment_registration_test.ts @@ -1,11 +1,11 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; import { buildPackageVersionRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; import { restoreEnv, withFetchStub } from './app_test_support.ts'; import { getTestToolPrivateJwkEnvValue, TEST_LTI_STATE_SECRET } from './test_helpers/lti.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; const TOOL_CONFIGURATION_CLAIM = 'https://purl.imsglobal.org/spec/lti-tool-configuration'; @@ -36,7 +36,9 @@ Deno.test('GET /admin/packages/:appId/deployment/register/canvas saves a pending expiresAt: '2030-03-24T00:30:00Z', usedAt: null, }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const requests: Array<{ url: string; method: string; @@ -188,7 +190,9 @@ Deno.test('GET /admin/packages/:appId/deployment/register/canvas uses the forwar expiresAt: '2030-03-24T00:30:00Z', usedAt: null, }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); let registrationBody: string | null = null; await withFetchStub( diff --git a/src/app_admin_deployment_retry_routes.ts b/src/app_admin_deployment_retry_routes.ts index 642d44d..2dab8b7 100644 --- a/src/app_admin_deployment_retry_routes.ts +++ b/src/app_admin_deployment_retry_routes.ts @@ -15,6 +15,7 @@ import { import { listCanvasEnvironments } from './lti/config.ts'; import { retryFailedGradePublication } from './ops/service.ts'; import type { AppServices } from './app_services.ts'; +import { adminPageInput } from './admin_auth/page_input.ts'; import { resolveRequestAppOrigin } from './app_origin.ts'; import { buildRequestAuditEnvelope } from './request_audit.ts'; @@ -97,7 +98,7 @@ export function registerAdminDeploymentRetryRoutes(app: Hono, services: AppServi ); return context.html( - renderDeploymentDetailPage({ + renderDeploymentDetailPage(adminPageInput(context, { appId, appTitle: detail.appTitle, history: detail.history, @@ -113,7 +114,7 @@ export function registerAdminDeploymentRetryRoutes(app: Hono, services: AppServi new Error(retryResult.publication.errorCode ?? 'Canvas AGS score publish failed.'), ), ), - }), + })), 500, ); } catch (error) { @@ -147,10 +148,10 @@ export function registerAdminDeploymentRetryRoutes(app: Hono, services: AppServi if (detail.history.length === 0) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice('Connections page unavailable', error), - }), + })), statusForRetryPublishError(error), ); } @@ -160,7 +161,7 @@ export function registerAdminDeploymentRetryRoutes(app: Hono, services: AppServi : await opsRepository.getControlPlaneDeploymentDetail(canvasDeployment.id); return context.html( - renderDeploymentDetailPage({ + renderDeploymentDetailPage(adminPageInput(context, { appId, appTitle: detail.appTitle, history: detail.history, @@ -173,7 +174,7 @@ export function registerAdminDeploymentRetryRoutes(app: Hono, services: AppServi detail.canvasConfigUrl.notice, createErrorNotice('Grade publish retry failed', error), ), - }), + })), statusForRetryPublishError(error), ); } diff --git a/src/app_admin_deployment_roster_route.ts b/src/app_admin_deployment_roster_route.ts index d593598..4e598cc 100644 --- a/src/app_admin_deployment_roster_route.ts +++ b/src/app_admin_deployment_roster_route.ts @@ -14,6 +14,7 @@ import { resolveLtiProfileForDeployment, } from './lti/profile_resolution.ts'; import type { AppServices } from './app_services.ts'; +import { adminPageInput } from './admin_auth/page_input.ts'; import { resolveRequestAppOrigin } from './app_origin.ts'; import { readNrpsMemberCount } from './lti/nrps_roster_verification.ts'; @@ -114,16 +115,16 @@ export function registerAdminDeploymentRosterRoute(app: Hono, services: AppServi if (detail.history.length === 0) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice('Connections page unavailable', error), - }), + })), statusForNrpsError(error), ); } return context.html( - renderDeploymentDetailPage({ + renderDeploymentDetailPage(adminPageInput(context, { appId, appTitle: detail.appTitle, history: detail.history, @@ -136,7 +137,7 @@ export function registerAdminDeploymentRosterRoute(app: Hono, services: AppServi detail.canvasConfigUrl.notice, createErrorNotice('Roster verification failed', error), ), - }), + })), statusForNrpsError(error), ); } diff --git a/src/app_admin_deployment_sakai_registration_test.ts b/src/app_admin_deployment_sakai_registration_test.ts index 4db5d1f..6357b9f 100644 --- a/src/app_admin_deployment_sakai_registration_test.ts +++ b/src/app_admin_deployment_sakai_registration_test.ts @@ -1,11 +1,11 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; import { buildPackageVersionRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; import { restoreEnv, withFetchStub } from './app_test_support.ts'; import { getTestToolPrivateJwkEnvValue, TEST_LTI_STATE_SECRET } from './test_helpers/lti.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; const TOOL_CONFIGURATION_CLAIM = 'https://purl.imsglobal.org/spec/lti-tool-configuration'; @@ -36,7 +36,9 @@ Deno.test('GET /admin/packages/:appId/deployment/register/sakai completes automa expiresAt: '2030-03-24T00:30:00Z', usedAt: null, }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const requests: Array<{ url: string; method: string; @@ -160,7 +162,7 @@ Deno.test('GET /admin/packages/:appId/deployment/register/sakai explains direct }), ], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment/register/sakai'); diff --git a/src/app_admin_deployment_test.ts b/src/app_admin_deployment_test.ts index 79f1035..7394772 100644 --- a/src/app_admin_deployment_test.ts +++ b/src/app_admin_deployment_test.ts @@ -1,5 +1,4 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; import { resolveCanvasIssuer } from './lti/config.ts'; import { buildAccessibilityReview, @@ -14,6 +13,7 @@ import { } from './test_helpers/package_review.ts'; import { buildDeploymentBinding, buildMoodleDeploymentBinding } from './test_helpers/lti.ts'; import { restoreEnv } from './app_test_support.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; Deno.test('POST /admin/packages/:appId/deployment/install saves the Canvas binding and redirects back to deployment detail', async () => { const previousOrigin = Deno.env.get('APP_ORIGIN'); @@ -39,7 +39,9 @@ Deno.test('POST /admin/packages/:appId/deployment/install saves the Canvas bindi }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('lms', 'canvas'); @@ -80,7 +82,7 @@ Deno.test('GET /admin/packages/:appId/deployment renders an LMS tab strip with o Deno.env.set('APP_ORIGIN', 'http://localhost:8417'); try { - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => createInMemoryPackageReviewRepository({ packageVersions: [ @@ -230,7 +232,9 @@ Deno.test('POST /admin/packages/:appId/deployment/lti-profile saves an override }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('lms', 'moodle'); @@ -290,7 +294,9 @@ Deno.test('POST /admin/packages/:appId/deployment/lti-profile clears the selecte updatedAt: '2026-03-24T13:00:00Z', }, }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('lms', 'moodle'); diff --git a/src/app_admin_grade_smoke_moodle_test.ts b/src/app_admin_grade_smoke_moodle_test.ts index cb1b622..a1067c2 100644 --- a/src/app_admin_grade_smoke_moodle_test.ts +++ b/src/app_admin_grade_smoke_moodle_test.ts @@ -1,5 +1,4 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; import { restoreEnv, withFetchStub } from './admin/deployment_detail_test_helpers.ts'; import { getTestToolPrivateJwkEnvValue } from './test_helpers/lti.ts'; import { createTestLtiToolContext } from './test_helpers/lti_tool_context.ts'; @@ -9,6 +8,7 @@ import { createSmokeRouteRepository, createSuccessfulSmokeFetchHandler, } from './app_admin_grade_smoke_test_support.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; Deno.test('POST /admin/packages/:appId/deployment/verify-grade-smoke runs the Moodle grade-return check and records deployment-scoped evidence', async () => { const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); @@ -27,7 +27,7 @@ Deno.test('POST /admin/packages/:appId/deployment/verify-grade-smoke runs the Mo try { await withFetchStub(createSuccessfulSmokeFetchHandler(fixture, requestedUrls), async () => { - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, getLtiToolContext: () => Promise.resolve(ltiToolContext.context), }).request( @@ -85,7 +85,7 @@ Deno.test('POST /admin/packages/:appId/deployment/verify-grade-smoke records bou throw new Error(`Unexpected smoke request GET ${String(input)}`); }, async () => { - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, getLtiToolContext: () => Promise.resolve(ltiToolContext.context), }).request( @@ -141,7 +141,7 @@ Deno.test('deployment smoke verification renders the latest Moodle result back o try { await withFetchStub(createSuccessfulSmokeFetchHandler(fixture), async () => { - const postResponse = await createApp({ + const postResponse = await createAuthenticatedTestApp({ getRepository: () => repository, getLtiToolContext: () => Promise.resolve(ltiToolContext.context), }).request( @@ -157,7 +157,7 @@ Deno.test('deployment smoke verification renders the latest Moodle result back o assertEquals(postResponse.status, 303); - const getResponse = await createApp({ + const getResponse = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment?lms=moodle'); const body = await getResponse.text(); diff --git a/src/app_admin_grade_smoke_route.ts b/src/app_admin_grade_smoke_route.ts index 530f43c..bfa3664 100644 --- a/src/app_admin_grade_smoke_route.ts +++ b/src/app_admin_grade_smoke_route.ts @@ -9,6 +9,7 @@ import { combineNotices, createErrorNotice } from './app_notice_support.ts'; import { listCanvasEnvironments } from './lti/config.ts'; import { resolveLtiProfileForDeployment } from './lti/profile_resolution.ts'; import type { AppServices } from './app_services.ts'; +import { adminPageInput } from './admin_auth/page_input.ts'; import { recordGradeSmokeAuditEvent, runGradeSmokeVerification, @@ -102,7 +103,7 @@ export function registerAdminGradeSmokeRoute(app: Hono, services: AppServices): ); return context.html( - renderDeploymentDetailPage({ + renderDeploymentDetailPage(adminPageInput(context, { appId, appTitle: detail.appTitle, history: detail.history, @@ -120,7 +121,7 @@ export function registerAdminGradeSmokeRoute(app: Hono, services: AppServices): new Error(result.detail.error?.message ?? result.summary), ), ), - }), + })), statusForGradeSmokeFailureCode(result.detail.error?.code ?? null), ); } catch (error) { @@ -132,10 +133,10 @@ export function registerAdminGradeSmokeRoute(app: Hono, services: AppServices): if (detail.history.length === 0) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice('Connections page unavailable', error), - }), + })), statusForGradeSmokeError(error), ); } @@ -145,7 +146,7 @@ export function registerAdminGradeSmokeRoute(app: Hono, services: AppServices): : await opsRepository.getControlPlaneDeploymentDetail(deploymentRecordId); return context.html( - renderDeploymentDetailPage({ + renderDeploymentDetailPage(adminPageInput(context, { appId, appTitle: detail.appTitle, history: detail.history, @@ -160,7 +161,7 @@ export function registerAdminGradeSmokeRoute(app: Hono, services: AppServices): detail.canvasConfigUrl.notice, createErrorNotice('Grade return check failed', error), ), - }), + })), statusForGradeSmokeError(error), ); } diff --git a/src/app_admin_grade_smoke_sakai_test.ts b/src/app_admin_grade_smoke_sakai_test.ts index 72c58f9..a58c26e 100644 --- a/src/app_admin_grade_smoke_sakai_test.ts +++ b/src/app_admin_grade_smoke_sakai_test.ts @@ -1,5 +1,4 @@ import { assertEquals } from '@std/assert'; -import { createApp } from './app.ts'; import { restoreEnv, withFetchStub } from './admin/deployment_detail_test_helpers.ts'; import { getTestToolPrivateJwkEnvValue } from './test_helpers/lti.ts'; import { createTestLtiToolContext } from './test_helpers/lti_tool_context.ts'; @@ -9,6 +8,7 @@ import { createSmokeRouteRepository, createSuccessfulSmokeFetchHandler, } from './app_admin_grade_smoke_test_support.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; Deno.test('POST /admin/packages/:appId/deployment/verify-grade-smoke runs the blessed Sakai smoke path and records deployment-scoped evidence', async () => { const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); @@ -24,7 +24,7 @@ Deno.test('POST /admin/packages/:appId/deployment/verify-grade-smoke runs the bl try { await withFetchStub(createSuccessfulSmokeFetchHandler(fixture, requestedUrls), async () => { - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, getLtiToolContext: () => Promise.resolve(ltiToolContext.context), }).request( diff --git a/src/app_admin_inventory_routes.ts b/src/app_admin_inventory_routes.ts index 7c43785..f234b33 100644 --- a/src/app_admin_inventory_routes.ts +++ b/src/app_admin_inventory_routes.ts @@ -11,6 +11,7 @@ import { requireTrimmedFormValue } from './app_request_support.ts'; import { statusForError } from './app_status_support.ts'; import { loadPreviewCapabilityLog } from './app_deployment_support.ts'; import type { AppServices } from './app_services.ts'; +import { adminPageInput } from './admin_auth/page_input.ts'; import { createUploadedPackageSource, requireSupportedReferencePackageId, @@ -40,17 +41,17 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): return await renderPackagesPage(context, services); } catch (error) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice('Package inventory unavailable', error), - }), + })), statusForError(error), ); } }); app.get('/admin/packages/import', (context) => { - return context.html(renderPackageImportPage()); + return context.html(renderPackageImportPage(adminPageInput(context, {}))); }); app.post('/admin/packages/import', async (context) => { @@ -62,14 +63,14 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): if (!preflight.ok || !preflight.validatedPackage) { return context.html( - renderPackageImportPage({ + renderPackageImportPage(adminPageInput(context, { notice: { tone: 'error', title: 'Package import blocked', detail: 'Resolve the reviewed package findings below and try again.', }, diagnostics: preflight.diagnostics, - }), + })), 409, ); } @@ -98,9 +99,9 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): return context.redirect(packageOverviewPath(packageVersion.appId), 303); } catch (error) { return context.html( - renderPackageImportPage({ + renderPackageImportPage(adminPageInput(context, { notice: createErrorNotice('Package import blocked', error), - }), + })), statusForPackageImportError(error), ); } @@ -139,7 +140,7 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): }); app.get('/admin/packages/reference', (context) => { - return context.html(renderReferencePackagePage()); + return context.html(renderReferencePackagePage(adminPageInput(context, {}))); }); app.get('/admin/packages/:appId', async (context) => { @@ -151,14 +152,14 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): if (history.length === 0) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: await repository.listPackageVersions(), notice: { tone: 'error', title: 'App not found', detail: 'Lantern could not find that app.', }, - }), + })), 404, ); } @@ -166,30 +167,30 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): const deployments = await repository.listDeploymentsByApp(appId); return context.html( - renderPackageOverviewPage({ + renderPackageOverviewPage(adminPageInput(context, { appId, appTitle: history[0]?.title ?? appId, history, deployments, - }), + })), ); } catch (error) { try { const repository = services.getRepository(); return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: await repository.listPackageVersions(), notice: createErrorNotice('App details unavailable', error), - }), + })), statusForError(error), ); } catch { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice('App details unavailable', error), - }), + })), statusForError(error), ); } @@ -205,14 +206,14 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): if (history.length === 0) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: await repository.listPackageVersions(), notice: { tone: 'error', title: 'App not found', detail: 'Lantern could not find that app.', }, - }), + })), 404, ); } @@ -224,19 +225,19 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): }); return context.html( - renderPackageReportsPage({ + renderPackageReportsPage(adminPageInput(context, { appId, appTitle: history[0]?.title ?? appId, history, report, - }), + })), ); } catch (error) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice('Instructor reports unavailable', error), - }), + })), statusForError(error), ); } @@ -252,14 +253,14 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): if (!packageVersion) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: { tone: 'error', title: 'Version not found', detail: 'Lantern could not find that app version.', }, - }), + })), 404, ); } @@ -277,7 +278,7 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): }); return context.html( - renderPackageDetailPage({ + renderPackageDetailPage(adminPageInput(context, { packageVersion, history, generationActivityEvents: generationActivityEvents.filter( @@ -286,14 +287,14 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): reviewedPlacements, latestPreviewSession: runtimeLog.session, previewEvidence: runtimeLog.evidence, - }), + })), ); } catch (error) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice('Version details unavailable', error), - }), + })), statusForError(error), ); } @@ -309,14 +310,14 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): if (!packageVersion) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: { tone: 'error', title: 'Version not found', detail: 'Lantern could not find that app version.', }, - }), + })), 404, ); } @@ -326,7 +327,7 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): if (baseVersion === null) { return context.html( - renderPackageDetailPage({ + renderPackageDetailPage(adminPageInput(context, { packageVersion, history, notice: { @@ -334,7 +335,7 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): title: 'No earlier version to compare', detail: 'Lantern needs at least two saved versions before it can show changes.', }, - }), + })), 404, ); } @@ -346,17 +347,17 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): }); return context.html( - renderPackageDiffPage({ + renderPackageDiffPage(adminPageInput(context, { diff, history, - }), + })), ); } catch (error) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice('Version changes unavailable', error), - }), + })), statusForError(error), ); } @@ -372,14 +373,14 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): if (!packageVersion) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: { tone: 'error', title: 'Version not found', detail: 'Lantern could not find that app version.', }, - }), + })), 404, ); } @@ -387,18 +388,18 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): const history = await repository.listPackageVersionsByApp(packageVersion.appId); return context.html( - renderAppRevisionPage({ + renderAppRevisionPage(adminPageInput(context, { packageVersion, history, targetVersion: nextRevisionVersion(packageVersion.version, history), - }), + })), ); } catch (error) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice('Revision form unavailable', error), - }), + })), statusForError(error), ); } @@ -417,14 +418,14 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): if (!packageVersion) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: { tone: 'error', title: 'Version not found', detail: 'Lantern could not find that app version.', }, - }), + })), 404, ); } @@ -507,11 +508,11 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): return context.html( packageVersion === null - ? renderPackageIndexPage({ + ? renderPackageIndexPage(adminPageInput(context, { versions: await repository.listPackageVersions(), notice: createErrorNotice('Revision blocked', error), - }) - : renderAppRevisionPage({ + })) + : renderAppRevisionPage(adminPageInput(context, { packageVersion, history, promptText, @@ -519,7 +520,7 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): ? nextRevisionVersion(packageVersion.version, history) : targetVersion, notice: createErrorNotice('Revision blocked', error), - }), + })), statusForError(error), ); } diff --git a/src/app_admin_lms_bindings_test.ts b/src/app_admin_lms_bindings_test.ts index 2add6f1..91a9bff 100644 --- a/src/app_admin_lms_bindings_test.ts +++ b/src/app_admin_lms_bindings_test.ts @@ -1,5 +1,4 @@ import { assertEquals } from '@std/assert'; -import { createApp } from './app.ts'; import { buildDeploymentRecord, buildPackageVersionRecord, @@ -10,6 +9,7 @@ import { buildMoodleDeploymentBinding, buildSakaiDeploymentBinding, } from './test_helpers/lti.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; Deno.test('POST /admin/packages/:appId/deployment/install saves the Moodle binding without mutating Canvas or Sakai slots', async () => { const repository = createInMemoryPackageReviewRepository({ @@ -38,7 +38,9 @@ Deno.test('POST /admin/packages/:appId/deployment/install saves the Moodle bindi }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('lms', 'moodle'); @@ -111,7 +113,9 @@ Deno.test('POST /admin/packages/:appId/deployment/install saves the Sakai bindin }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('lms', 'sakai'); @@ -189,7 +193,9 @@ Deno.test('POST /admin/packages/:appId/deployment/pin pins the selected LMS slot }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('lms', 'moodle'); diff --git a/src/app_admin_lms_pin_validation_test.ts b/src/app_admin_lms_pin_validation_test.ts index 2046bbd..6014345 100644 --- a/src/app_admin_lms_pin_validation_test.ts +++ b/src/app_admin_lms_pin_validation_test.ts @@ -1,11 +1,11 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; import { buildDeploymentRecord, buildPackageVersionRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; import { buildMoodleDeploymentBinding } from './test_helpers/lti.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; Deno.test('POST /admin/packages/:appId/deployment/pin keeps the selected LMS tab open when the version is missing', async () => { const repository = createInMemoryPackageReviewRepository({ @@ -29,7 +29,9 @@ Deno.test('POST /admin/packages/:appId/deployment/pin keeps the selected LMS tab }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('lms', 'moodle'); diff --git a/src/app_admin_operations_routes.ts b/src/app_admin_operations_routes.ts index f7e686f..6e73c73 100644 --- a/src/app_admin_operations_routes.ts +++ b/src/app_admin_operations_routes.ts @@ -15,6 +15,7 @@ import { } from './app_request_support.ts'; import { statusForError, statusForVerificationError } from './app_status_support.ts'; import type { AppServices } from './app_services.ts'; +import { adminPageInput } from './admin_auth/page_input.ts'; export function registerAdminOperationsRoutes(app: Hono, services: AppServices): void { app.get('/admin/deployments', async (context) => { @@ -22,10 +23,10 @@ export function registerAdminOperationsRoutes(app: Hono, services: AppServices): return await renderDeploymentsResponse(context, services); } catch (error) { return context.html( - renderDeploymentsView({ + renderDeploymentsView(adminPageInput(context, { deployments: [], notice: createErrorNotice('Connections unavailable', error), - }), + })), statusForError(error), ); } @@ -38,13 +39,13 @@ export function registerAdminOperationsRoutes(app: Hono, services: AppServices): return await renderVerificationResponse(context, services, { section }); } catch (error) { return context.html( - renderVerificationView({ + renderVerificationView(adminPageInput(context, { deployments: [], latestBrokerVerification: null, ltiProfileSettings: null, section, notice: createErrorNotice('Verification unavailable', error), - }), + })), statusForError(error), ); } @@ -57,13 +58,13 @@ export function registerAdminOperationsRoutes(app: Hono, services: AppServices): return await renderVerificationResponse(context, services, { section }); } catch (error) { return context.html( - renderVerificationView({ + renderVerificationView(adminPageInput(context, { deployments: [], latestBrokerVerification: null, ltiProfileSettings: null, section, notice: createErrorNotice('Verification unavailable', error), - }), + })), statusForError(error), ); } @@ -76,13 +77,13 @@ export function registerAdminOperationsRoutes(app: Hono, services: AppServices): return await renderVerificationResponse(context, services, { section }); } catch (error) { return context.html( - renderVerificationView({ + renderVerificationView(adminPageInput(context, { deployments: [], latestBrokerVerification: null, ltiProfileSettings: null, section, notice: createErrorNotice('Verification unavailable', error), - }), + })), statusForError(error), ); } @@ -95,13 +96,13 @@ export function registerAdminOperationsRoutes(app: Hono, services: AppServices): return await renderVerificationResponse(context, services, { section }); } catch (error) { return context.html( - renderVerificationView({ + renderVerificationView(adminPageInput(context, { deployments: [], latestBrokerVerification: null, ltiProfileSettings: null, section, notice: createErrorNotice('Verification unavailable', error), - }), + })), statusForError(error), ); } diff --git a/src/app_admin_package_revision_test.ts b/src/app_admin_package_revision_test.ts index b9d9bfa..e782b6b 100644 --- a/src/app_admin_package_revision_test.ts +++ b/src/app_admin_package_revision_test.ts @@ -6,6 +6,7 @@ import { buildPackageVersionRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; +import { createAdminTestApp } from './test_helpers/admin_auth.ts'; Deno.test('revision authoring prompt uses the exact package plan and reviewed runtime contract', () => { const packageVersion = buildPackageVersionRecord({ @@ -159,10 +160,9 @@ Deno.test('POST /admin/packages/:appId/versions/:version/revise creates a revisi }); function createAdminApp(services: Parameters[0] = {}) { - return createApp({ + return createAdminTestApp({ env: createObjectEnvReader({ APP_ORIGIN: 'http://localhost', - LANTERN_OPERATOR_NAME: 'Signed in', }), ...services, }); diff --git a/src/app_admin_packages_test.ts b/src/app_admin_packages_test.ts index 926c3c1..700d1e7 100644 --- a/src/app_admin_packages_test.ts +++ b/src/app_admin_packages_test.ts @@ -15,6 +15,7 @@ import { } from './test_helpers/package_review.ts'; import { buildCanvasDeploymentBinding } from './test_helpers/lti.ts'; import type { PackageSnapshotStore } from './package_review/snapshot_store.ts'; +import { createAdminTestApp } from './test_helpers/admin_auth.ts'; Deno.test('GET /admin/packages renders the generic package import action when no versions exist', async () => { const response = await createAdminApp({ @@ -66,7 +67,8 @@ Deno.test('GET /admin/packages renders the app library when package data exists' assertStringIncludes(body, 'Import package'); assertStringIncludes(body, 'href="/admin/packages/import"'); assertStringIncludes(body, 'Import reference app'); - assertStringIncludes(body, 'Signed in'); + assertStringIncludes(body, 'Test Admin'); + assertStringIncludes(body, 'admin@example.test'); assertStringIncludes(body, 'href="/admin/deployments"'); assertStringIncludes(body, 'href="/admin/verification"'); assertStringIncludes(body, 'href="/admin/placements"'); @@ -712,10 +714,9 @@ Deno.test('POST /admin/packages/:appId/deployment/pin stores the exact approved }); function createAdminApp(services: Parameters[0] = {}) { - return createApp({ + return createAdminTestApp({ env: createObjectEnvReader({ APP_ORIGIN: 'http://localhost', - LANTERN_OPERATOR_NAME: 'Signed in', }), ...services, }); diff --git a/src/app_admin_placement_audit_test.ts b/src/app_admin_placement_audit_test.ts index 32073df..772aea4 100644 --- a/src/app_admin_placement_audit_test.ts +++ b/src/app_admin_placement_audit_test.ts @@ -1,5 +1,4 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; import { buildAuditEventRecord, buildPackageVersionRecord, @@ -8,6 +7,7 @@ import { buildReviewedPlacementRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; Deno.test('GET /admin/placements/:placementId renders selected content, reviewed version, Canvas context, and evidence timeline', async () => { const repository = createInMemoryPackageReviewRepository({ @@ -57,7 +57,7 @@ Deno.test('GET /admin/placements/:placementId renders selected content, reviewed ], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/placements/placement-audit-123'); @@ -76,7 +76,9 @@ Deno.test('GET /admin/placements/:placementId renders selected content, reviewed Deno.test('GET /admin/placements renders a lookup page and unknown placement ids still fail clearly', async () => { const repository = createInMemoryPackageReviewRepository(); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const missingIdResponse = await app.request('http://localhost/admin/placements'); assertEquals(missingIdResponse.status, 200); @@ -103,7 +105,7 @@ Deno.test('approved package review page includes a test launch link', async () = ], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => seededRepository, }).request('http://localhost/admin/packages/chapter-4-asteroids/versions/0.1.0'); diff --git a/src/app_admin_placement_routes.ts b/src/app_admin_placement_routes.ts index 7290dbb..4ac19c8 100644 --- a/src/app_admin_placement_routes.ts +++ b/src/app_admin_placement_routes.ts @@ -8,6 +8,7 @@ import { createErrorNotice } from './app_notice_support.ts'; import { normalizeOptionalString, requireTrimmedString } from './app_request_support.ts'; import { statusForPlacementAuditError } from './app_status_support.ts'; import type { AppServices } from './app_services.ts'; +import { adminPageInput } from './admin_auth/page_input.ts'; export function registerAdminPlacementRoutes(app: Hono, services: AppServices): void { app.get('/admin/placements', (context) => { @@ -19,9 +20,9 @@ export function registerAdminPlacementRoutes(app: Hono, services: AppServices): } return context.html( - renderPlacementAuditRequestPage({ + renderPlacementAuditRequestPage(adminPageInput(context, { notice: null, - }), + })), ); }); @@ -36,16 +37,16 @@ export function registerAdminPlacementRoutes(app: Hono, services: AppServices): const timeline = await loadPlacementAuditTimeline(repository, snapshot.placement); return context.html( - renderPlacementAuditPage({ + renderPlacementAuditPage(adminPageInput(context, { snapshot, timeline, - }), + })), ); } catch (error) { return context.html( - renderPlacementAuditRequestPage({ + renderPlacementAuditRequestPage(adminPageInput(context, { notice: createErrorNotice('Placement audit unavailable', error), - }), + })), statusForPlacementAuditError(error), ); } diff --git a/src/app_admin_preview_log_test.ts b/src/app_admin_preview_log_test.ts index 1d49859..fcf8515 100644 --- a/src/app_admin_preview_log_test.ts +++ b/src/app_admin_preview_log_test.ts @@ -8,6 +8,7 @@ import { buildPackageVersionRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; Deno.test('test-launch activity log shows durable launch, content-read, attempt, and finalize evidence after reload', async () => { await withRuntimeOriginEnv(async () => { @@ -37,7 +38,9 @@ Deno.test('test-launch activity log shows durable launch, content-read, attempt, }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('userRole', 'learner'); formData.set('courseId', 'course_demo'); @@ -178,7 +181,9 @@ Deno.test('GET /admin/packages/:appId/versions/:version/preview records a review }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const response = await app.request( 'http://localhost/admin/packages/chapter-4-asteroids/versions/0.3.0/preview', @@ -244,7 +249,9 @@ Deno.test('GET /admin/packages/:appId/versions/:version/preview records reviewer }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const response = await app.request( 'http://localhost/admin/packages/chapter-4-asteroids/versions/0.5.0/preview', @@ -272,7 +279,9 @@ Deno.test('test-launch runtime diagnostics show denied capabilities after reload }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const { launchResponse, runtimeSessionId, runtimeToken } = await launchAdminPreview({ app, version: '0.6.0', @@ -317,7 +326,9 @@ Deno.test('test-launch runtime diagnostics show integrity failures without leaki }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const { launchResponse, runtimeSessionId, runtimeToken } = await launchAdminPreview({ app, version: '0.7.0', @@ -410,7 +421,9 @@ Deno.test('test-launch runtime diagnostics exclude authoring preview session fai occurredAt: '2026-04-01T10:23:00Z', }); - const response = await createApp({ getRepository: () => repository }).request( + const response = await createAuthenticatedTestApp({ + getRepository: () => repository, + }).request( 'http://localhost/admin/packages/chapter-4-asteroids/versions/0.8.0/preview', ); const body = await response.text(); diff --git a/src/app_admin_preview_routes.ts b/src/app_admin_preview_routes.ts index fefe628..9478016 100644 --- a/src/app_admin_preview_routes.ts +++ b/src/app_admin_preview_routes.ts @@ -8,6 +8,7 @@ import { createErrorNotice } from './app_notice_support.ts'; import { formValueAsString } from './app_request_support.ts'; import { statusForError } from './app_status_support.ts'; import type { AppServices } from './app_services.ts'; +import { adminPageInput } from './admin_auth/page_input.ts'; import { readEnv } from './platform/env.ts'; import type { PreviewSessionRecord } from './package_review/types.ts'; import { buildRuntimeSessionUrl, requireConfiguredRuntimeOrigin } from './runtime_origin.ts'; @@ -28,14 +29,14 @@ export function registerAdminPreviewRoutes(app: Hono, services: AppServices): vo if (!packageVersion) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: { tone: 'error', title: 'Version not found', detail: 'Lantern could not find that app version.', }, - }), + })), 404, ); } @@ -68,24 +69,24 @@ export function registerAdminPreviewRoutes(app: Hono, services: AppServices): vo }); return context.html( - renderPreviewPage({ + renderPreviewPage(adminPageInput(context, { packageVersion, savedDefaults, latestSession: session, formValues: buildTestLaunchFormValuesFromSession(session ?? savedDefaults), previewEvidence: evidence, runtimeDiagnostics: diagnostics, - }), + })), ); } catch (error) { const packageVersion = await repository.getPackageVersionByAppVersion(appId, version); if (!packageVersion) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice('Test launch unavailable', error), - }), + })), statusForError(error), ); } @@ -93,11 +94,11 @@ export function registerAdminPreviewRoutes(app: Hono, services: AppServices): vo const history = await repository.listPackageVersionsByApp(appId); return context.html( - renderPackageDetailPage({ + renderPackageDetailPage(adminPageInput(context, { packageVersion, history, notice: createErrorNotice('Test launch unavailable', error), - }), + })), statusForError(error), ); } @@ -114,14 +115,14 @@ export function registerAdminPreviewRoutes(app: Hono, services: AppServices): vo if (!packageVersion) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: { tone: 'error', title: 'Version not found', detail: 'Lantern could not find that app version.', }, - }), + })), 404, ); } @@ -174,10 +175,10 @@ export function registerAdminPreviewRoutes(app: Hono, services: AppServices): vo if (!packageVersion) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice('Test launch blocked', error), - }), + })), statusForError(error), ); } @@ -200,7 +201,7 @@ export function registerAdminPreviewRoutes(app: Hono, services: AppServices): vo }); return context.html( - renderPreviewPage({ + renderPreviewPage(adminPageInput(context, { packageVersion, savedDefaults, latestSession: session, @@ -209,7 +210,7 @@ export function registerAdminPreviewRoutes(app: Hono, services: AppServices): vo previewEvidence: evidence, runtimeDiagnostics: diagnostics, notice: createErrorNotice('Test launch blocked', error), - }), + })), statusForError(error), ); } @@ -217,11 +218,11 @@ export function registerAdminPreviewRoutes(app: Hono, services: AppServices): vo const history = await repository.listPackageVersionsByApp(appId); return context.html( - renderPackageDetailPage({ + renderPackageDetailPage(adminPageInput(context, { packageVersion, history, notice: createErrorNotice('Test launch blocked', error), - }), + })), statusForError(error), ); } diff --git a/src/app_admin_preview_test.ts b/src/app_admin_preview_test.ts index bfff028..e93936e 100644 --- a/src/app_admin_preview_test.ts +++ b/src/app_admin_preview_test.ts @@ -1,5 +1,4 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; import { EXAMPLE_SNAPSHOT_ROOT, getReferenceAppSnapshotRoot, @@ -13,6 +12,7 @@ import { buildPreviewEvidenceRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; Deno.test('GET /admin/packages/:appId/versions/:version/preview renders test-launch defaults for approved reviewed versions', async () => { const repository = createInMemoryPackageReviewRepository({ @@ -38,7 +38,7 @@ Deno.test('GET /admin/packages/:appId/versions/:version/preview renders test-lau ], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/packages/chapter-4-asteroids/versions/0.1.0/preview'); @@ -79,7 +79,7 @@ Deno.test('GET /admin/packages/:appId/versions/:version/preview renders test-lau ], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/packages/chapter-4-asteroids/versions/0.2.0/preview'); @@ -120,7 +120,9 @@ Deno.test('POST /admin/packages/:appId/versions/:version/preview launches pendin }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('userRole', 'learner'); formData.set('courseId', 'course_demo'); @@ -181,7 +183,9 @@ Deno.test('POST /admin/packages/:appId/versions/:version/preview creates a runti }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('userRole', 'instructor'); formData.set('courseId', 'physics-101'); @@ -248,7 +252,9 @@ Deno.test('POST /admin/packages/:appId/versions/:version/preview writes durable }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('userRole', 'learner'); formData.set('courseId', 'course_demo'); @@ -316,7 +322,9 @@ Deno.test("POST /admin/packages/:appId/versions/:version/preview launches commit }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('userRole', 'learner'); formData.set('courseId', 'course_demo'); @@ -407,7 +415,7 @@ Deno.test('GET /admin/packages/:appId/versions/:version/preview keeps reviewer h ], }); - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, }).request('http://localhost/admin/packages/chapter-4-asteroids/versions/0.4.0/preview'); diff --git a/src/app_admin_retry_grade_publish_test.ts b/src/app_admin_retry_grade_publish_test.ts index 9b5af33..10009b3 100644 --- a/src/app_admin_retry_grade_publish_test.ts +++ b/src/app_admin_retry_grade_publish_test.ts @@ -1,5 +1,4 @@ import { assertEquals, assertObjectMatch, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; import { restoreEnv, withFetchStub } from './app_test_support.ts'; import { buildAttemptRecord, @@ -18,6 +17,7 @@ import { getTestToolPrivateJwkEnvValue, } from './test_helpers/lti.ts'; import { createTestLtiToolContext } from './test_helpers/lti_tool_context.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; Deno.test('POST /admin/packages/:appId/deployment/retry-grade-publish retries the failed grade publish through the SSR control plane', async () => { const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); @@ -120,7 +120,7 @@ Deno.test('POST /admin/packages/:appId/deployment/retry-grade-publish retries th return new Response(null, { status: 200 }); }, async () => { - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, getLtiToolContext: () => Promise.resolve(ltiToolContext.context), }).request( @@ -247,7 +247,7 @@ Deno.test('POST /admin/packages/:appId/deployment/retry-grade-publish records bo headers: { 'content-type': 'application/json' }, }), async () => { - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, getLtiToolContext: () => Promise.resolve(ltiToolContext.context), }).request( diff --git a/src/app_admin_runtime_evidence_test.ts b/src/app_admin_runtime_evidence_test.ts index bbf2afc..f411118 100644 --- a/src/app_admin_runtime_evidence_test.ts +++ b/src/app_admin_runtime_evidence_test.ts @@ -1,11 +1,11 @@ import { assertEquals } from '@std/assert'; -import { createApp } from './app.ts'; import { buildAttemptEvidenceArtifactRecord, buildAttemptRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; import type { EvidenceArtifactStore } from './runtime/evidence_artifact_store.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; Deno.test('GET /admin/packages/:appId/deployment/evidence/:artifactId returns the stored anonymous evidence artifact', async () => { const evidenceBytes = new TextEncoder().encode( @@ -41,7 +41,7 @@ Deno.test('GET /admin/packages/:appId/deployment/evidence/:artifactId returns th }, }; - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, evidenceArtifactStore, }).request( @@ -83,7 +83,7 @@ Deno.test('GET /admin/packages/:appId/deployment/evidence/:artifactId returns sc }, }; - const response = await createApp({ + const response = await createAuthenticatedTestApp({ getRepository: () => repository, evidenceArtifactStore, }).request( diff --git a/src/app_admin_support.ts b/src/app_admin_support.ts index c66d8f2..fa99ec7 100644 --- a/src/app_admin_support.ts +++ b/src/app_admin_support.ts @@ -21,6 +21,7 @@ import { loadDeploymentDetailStateSafe } from './app_deployment_support.ts'; import { normalizeOptionalString } from './app_request_support.ts'; import { statusForError } from './app_status_support.ts'; import type { AppServices } from './app_services.ts'; +import { adminPageInput } from './admin_auth/page_input.ts'; import { readEnv } from './platform/env.ts'; import { renderPackageDetailPage } from './admin/package_detail.ts'; import { renderPackageIndexPage } from './admin/package_index.ts'; @@ -127,10 +128,10 @@ export async function renderInventoryError( } return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions, notice: createErrorNotice(title, error), - }), + })), statusForError(error), ); } @@ -146,10 +147,10 @@ export async function renderPackagesPage( const versions = await services.getRepository().listPackageVersions(); return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions, notice: input.notice ?? null, - }), + })), input.status ?? 200, ); } @@ -165,10 +166,10 @@ export async function renderDeploymentsPage( const deployments = await services.getOpsRepository().listControlPlaneDeployments(); return context.html( - renderAdminDeploymentsPage({ + renderAdminDeploymentsPage(adminPageInput(context, { deployments, notice: input.notice ?? null, - }), + })), input.status ?? 200, ); } @@ -191,7 +192,7 @@ export async function renderVerificationPage( const ltiProfileSettings = await services.getRepository().getLanternLtiProfileSettings(); return context.html( - renderAdminVerificationPage({ + renderAdminVerificationPage(adminPageInput(context, { deployments, latestBrokerVerification, certificationWorkflowStatuses, @@ -199,7 +200,7 @@ export async function renderVerificationPage( ltiProfileSettings, notice: input.notice ?? null, section: input.section ?? 'checklist', - }), + })), input.status ?? 200, ); } @@ -217,10 +218,10 @@ export async function renderPackageDetailError( if (!packageVersion) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice(title, error), - }), + })), statusForError(error), ); } @@ -228,19 +229,19 @@ export async function renderPackageDetailError( const history = await repository.listPackageVersionsByApp(packageVersion.appId); return context.html( - renderPackageDetailPage({ + renderPackageDetailPage(adminPageInput(context, { packageVersion, history, notice: createErrorNotice(title, error), - }), + })), statusForError(error), ); } catch { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice(title, error), - }), + })), statusForError(error), ); } @@ -263,10 +264,10 @@ export async function renderDeploymentError( if (history.length === 0) { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice(title, error), - }), + })), statusForError(error), ); } @@ -284,7 +285,7 @@ export async function renderDeploymentError( const detailState = await loadDeploymentDetailStateSafe(repository, appId, appOrigin); return context.html( - renderDeploymentDetailPage({ + renderDeploymentDetailPage(adminPageInput(context, { appId, appTitle, history, @@ -300,15 +301,15 @@ export async function renderDeploymentError( notice: input.editorState === undefined || input.editorState === null ? combineNotices(canvasConfigUrl.notice, createErrorNotice(title, error)) : canvasConfigUrl.notice, - }), + })), statusForError(error), ); } catch { return context.html( - renderPackageIndexPage({ + renderPackageIndexPage(adminPageInput(context, { versions: [], notice: createErrorNotice(title, error), - }), + })), statusForError(error), ); } diff --git a/src/app_admin_verification_validation_test.ts b/src/app_admin_verification_validation_test.ts index 868ab00..596e9f2 100644 --- a/src/app_admin_verification_validation_test.ts +++ b/src/app_admin_verification_validation_test.ts @@ -1,11 +1,11 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; import { buildControlPlaneDeploymentInventoryRow, buildPackageVersionRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; import { buildCanvasDeploymentBinding, buildMoodleDeploymentBinding } from './test_helpers/lti.ts'; +import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; Deno.test('POST /admin/verification rejects internal verification rows that include an official certification state', async () => { const repository = createInMemoryPackageReviewRepository({ @@ -23,7 +23,9 @@ Deno.test('POST /admin/verification rejects internal verification rows that incl }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('source', 'manual'); @@ -70,7 +72,9 @@ Deno.test('POST /admin/verification rejects internal verification rows without a }), ], }); - const app = createApp({ getRepository: () => repository }); + const app = createAuthenticatedTestApp({ + getRepository: () => repository, + }); const formData = new FormData(); formData.set('source', 'manual'); diff --git a/src/app_core.ts b/src/app_core.ts index c86a163..b79b9bf 100644 --- a/src/app_core.ts +++ b/src/app_core.ts @@ -1,6 +1,10 @@ import { csrf } from '@hono/hono/csrf'; import { Hono } from '@hono/hono'; import { registerAdminAppWriterRoutes } from './app_admin_app_writer_routes.ts'; +import { + registerProtectedAdminAuthRoutes, + registerPublicAdminAuthRoutes, +} from './app_admin_auth_routes.ts'; import { registerAdminAuthoringRoutes } from './app_admin_authoring_routes.ts'; import { registerAdminAuthoringPreviewRoutes } from './app_admin_authoring_preview_routes.ts'; import { registerAdminDeploymentDetailRoutes } from './app_admin_deployment_detail_routes.ts'; @@ -15,18 +19,24 @@ import { registerDeepLinkingRoutes } from './app_deep_linking_routes.ts'; import { registerDeepLinkingSubmitRoutes } from './app_deep_linking_submit_routes.ts'; import { registerLaunchRoutes } from './app_launch_routes.ts'; import { registerRuntimeRoutes } from './app_runtime_routes.ts'; +import { createAdminAuthMiddleware } from './admin_auth/middleware.ts'; import type { AppServices } from './app_services.ts'; export function buildApp(services: AppServices): Hono { const app = new Hono(); - app.use('/admin/*', csrf()); - registerBasicRoutes(app, services); registerLaunchRoutes(app, services); registerDeepLinkingRoutes(app, services); registerDeepLinkingSubmitRoutes(app, services); registerRuntimeRoutes(app, services); + + registerPublicAdminAuthRoutes(app, services); + + app.use('/admin/*', createAdminAuthMiddleware(services)); + app.use('/admin/*', csrf()); + + registerProtectedAdminAuthRoutes(app, services); registerAdminAppWriterRoutes(app, services); registerAdminInventoryRoutes(app, services); registerAdminAuthoringRoutes(app, services); diff --git a/src/app_services.ts b/src/app_services.ts index 8911c2e..d6c1a36 100644 --- a/src/app_services.ts +++ b/src/app_services.ts @@ -1,4 +1,5 @@ import type { JSONWebKeySet } from 'jose'; +import { type AdminAuthService, createUnconfiguredAdminAuthService } from './admin_auth/service.ts'; import { type AppWriterAgentSessionCoordinator, createNoopAppWriterAgentSessionCoordinator, @@ -58,6 +59,7 @@ export interface AppServices { appGenerationRunScheduler: AppGenerationRunScheduler; appWriterAgentSessions: AppWriterAgentSessionCoordinator; browserAutograderDraftGenerator: BrowserAutograderDraftGenerator; + adminAuth: AdminAuthService; loadBrowserAutograderDraftReferenceExamples: () => Promise< BrowserAutograderDraftReferenceExample[] >; @@ -133,6 +135,10 @@ export function resolveServices(services: Partial): AppServices { createNoopAppWriterAgentSessionCoordinator(), browserAutograderDraftGenerator: services.browserAutograderDraftGenerator ?? createUnavailableBrowserAutograderDraftGenerator(), + adminAuth: services.adminAuth ?? + createUnconfiguredAdminAuthService( + 'Lantern admin authentication requires a Cloudflare D1 binding and BETTER_AUTH_SECRET.', + ), runtimeArtifactStore, runtimeDelivery: services.runtimeDelivery ?? createDirectRuntimeDelivery(runtimeArtifactStore), evidenceArtifactStore: services.evidenceArtifactStore ?? getDefaultEvidenceArtifactStore(), diff --git a/src/app_worker_services.ts b/src/app_worker_services.ts index e2b3271..42cf506 100644 --- a/src/app_worker_services.ts +++ b/src/app_worker_services.ts @@ -27,6 +27,7 @@ import { import type { BrowserAutograderDraftReferenceExample } from './authoring/browser_autograder_draft_generator.ts'; import { createUnavailableBrowserAutograderDraftGenerator } from './authoring/browser_autograder_draft_generator.ts'; import type { AppServices } from './app_services.ts'; +import { createBetterAdminAuth } from './admin_auth/service.ts'; import { type D1Database, isD1Database } from './db/d1.ts'; import type { OpsRepository } from './ops/repository.ts'; import { createD1OpsRepository } from './ops/repository_d1.ts'; @@ -121,6 +122,7 @@ export function resolveWorkerServices(bindings: WorkerBindings, env: EnvReader): appGenerationRunScheduler: resolveWorkerAppGenerationRunScheduler(bindings), appWriterAgentSessions: resolveWorkerAppWriterAgentSessions(bindings), browserAutograderDraftGenerator: createUnavailableBrowserAutograderDraftGenerator(), + adminAuth: resolveWorkerAdminAuth(bindings, env), runtimeArtifactStore: snapshotStore, runtimeDelivery: resolveWorkerRuntimeDelivery(bindings, snapshotStore), evidenceArtifactStore: resolveWorkerEvidenceArtifactStore(bindings), @@ -143,6 +145,14 @@ export function resolveWorkerServices(bindings: WorkerBindings, env: EnvReader): }; } +function resolveWorkerAdminAuth(bindings: WorkerBindings, env: EnvReader) { + if (isD1Database(bindings.DB)) { + return createBetterAdminAuth({ db: bindings.DB, env }); + } + + throw new Error(WORKER_REPOSITORY_MESSAGE); +} + function resolveWorkerLtiStorage(bindings: WorkerBindings) { if (isD1Database(bindings.DB)) { return createD1LtiStorage(bindings.DB); diff --git a/src/app_worker_services_test.ts b/src/app_worker_services_test.ts index 19a20e7..1f525ff 100644 --- a/src/app_worker_services_test.ts +++ b/src/app_worker_services_test.ts @@ -109,9 +109,7 @@ const QUICK_STUDY_SOURCE_FILES = { Deno.test('worker services import and reload the demo package from PACKAGE_ARTIFACTS', async () => { const bucket = createSeededArtifactBucket(DEMO_BUCKET_SOURCE_ROOT, DEMO_REFERENCE_SOURCE_FILES); - const env = createObjectEnvReader({ - LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), - }); + const env = createWorkerTestEnv(); const services = resolveWorkerServices(withRequiredDb({ PACKAGE_ARTIFACTS: bucket }), env); assertEquals(await services.loadReferencePackageSnapshot('chapter-4-asteroids'), null); @@ -162,9 +160,7 @@ Deno.test('worker services import and reload the demo package from PACKAGE_ARTIF Deno.test('worker services import and reload arbitrary reviewed package sources', async () => { const bucket = createInMemoryArtifactBucket({}); - const env = createObjectEnvReader({ - LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), - }); + const env = createWorkerTestEnv(); const services = resolveWorkerServices(withRequiredDb({ PACKAGE_ARTIFACTS: bucket }), env); const source = createMemoryPackageSource( Object.entries(QUICK_STUDY_SOURCE_FILES).map(([relativePath, bytes]) => ({ @@ -209,9 +205,7 @@ Deno.test('worker services import and reload arbitrary reviewed package sources' }); Deno.test('worker services expose Dynamic Worker runtime delivery only when LOADER is bound', async () => { - const env = createObjectEnvReader({ - LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), - }); + const env = createWorkerTestEnv(); const session = buildRuntimeSessionRecord({ snapshotRoot: 'var/packages/chapter-4-asteroids/0.1.0', entrypointPath: 'var/packages/chapter-4-asteroids/0.1.0/dist/index.html', @@ -260,9 +254,7 @@ Deno.test('worker services expose Dynamic Worker runtime delivery only when LOAD }); Deno.test('worker services route D1 persistence through ported repository slices', async () => { - const env = createObjectEnvReader({ - LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), - }); + const env = createWorkerTestEnv(); const services = resolveWorkerServices({ DB: createStubD1Database([]) }, env); assertEquals(await services.getRepository().listPackageVersions(), []); @@ -271,9 +263,7 @@ Deno.test('worker services route D1 persistence through ported repository slices Deno.test('worker services schedule app generation through APP_GENERATION_WORKFLOW when bound', async () => { const workflowCreateInputs: unknown[] = []; - const env = createObjectEnvReader({ - LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), - }); + const env = createWorkerTestEnv(); const services = resolveWorkerServices( withRequiredDb({ APP_GENERATION_WORKFLOW: { @@ -307,9 +297,7 @@ Deno.test('worker services schedule app generation through APP_GENERATION_WORKFL Deno.test('worker services route app writer Agent sessions through APP_WRITER_AGENT when bound', async () => { const observedRequests: Request[] = []; - const env = createObjectEnvReader({ - LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), - }); + const env = createWorkerTestEnv(); const services = resolveWorkerServices( withRequiredDb({ APP_WRITER_AGENT: { @@ -344,9 +332,7 @@ Deno.test('worker services route app writer Agent sessions through APP_WRITER_AG Deno.test('worker services route app writer workspace harness through APP_WRITER_AGENT when bound', async () => { const observedRequests: Request[] = []; - const env = createObjectEnvReader({ - LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), - }); + const env = createWorkerTestEnv(); const services = resolveWorkerServices( withRequiredDb({ APP_WRITER_AGENT: { @@ -429,9 +415,7 @@ Deno.test('worker services route app writer workspace harness through APP_WRITER Deno.test('worker services use a platform source compiler binding when configured', async () => { const compileRequests: Request[] = []; - const env = createObjectEnvReader({ - LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), - }); + const env = createWorkerTestEnv(); const services = resolveWorkerServices( withRequiredDb({ APP_WRITER_SOURCE_COMPILER: { @@ -479,9 +463,7 @@ Deno.test('worker services use a platform source compiler binding when configure Deno.test('worker services use a platform app previewer binding when configured', async () => { const previewRequests: Request[] = []; - const env = createObjectEnvReader({ - LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), - }); + const env = createWorkerTestEnv(); const services = resolveWorkerServices( withRequiredDb({ APP_WRITER_PREVIEWER: { @@ -535,9 +517,7 @@ Deno.test('worker services use a platform app previewer binding when configured' }); Deno.test('worker services fail clearly when app generation Workflow is not bound', async () => { - const env = createObjectEnvReader({ - LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), - }); + const env = createWorkerTestEnv(); const services = resolveWorkerServices(withRequiredDb({}), env); assertEquals(services.appPackageSourceCompiler.supportsTypeScriptAuthoring, true); @@ -573,17 +553,37 @@ Deno.test('worker services fail clearly when app generation Workflow is not boun }); Deno.test('worker services fail during composition when DB is not bound', () => { + const env = createWorkerTestEnv(); + + assertThrows( + () => resolveWorkerServices({}, env), + Error, + 'Cloudflare Workers persistence requires a D1 binding named DB.', + ); +}); + +Deno.test('worker services fail clearly when admin auth secret is missing', () => { const env = createObjectEnvReader({ + APP_ORIGIN: 'https://lantern.example', LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), }); assertThrows( - () => resolveWorkerServices({}, env), + () => resolveWorkerServices(withRequiredDb({}), env), Error, - 'Cloudflare Workers persistence requires a D1 binding named DB.', + 'BETTER_AUTH_SECRET is required to enable Lantern admin authentication.', ); }); +function createWorkerTestEnv() { + return createObjectEnvReader({ + APP_ORIGIN: 'https://lantern.example', + BETTER_AUTH_SECRET: 'test-better-auth-secret-test-better-auth-secret', + ADMIN_AUTH_ALLOW_SIGNUP: '1', + LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), + }); +} + function buildAppGenerationPlan(): AppGenerationPlan { return { appId: 'phonics-match', diff --git a/src/db/d1_migrations/0010_better_auth_tables.sql b/src/db/d1_migrations/0010_better_auth_tables.sql new file mode 100644 index 0000000..8bfb3a5 --- /dev/null +++ b/src/db/d1_migrations/0010_better_auth_tables.sql @@ -0,0 +1,51 @@ +CREATE TABLE IF NOT EXISTS auth_users ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + emailVerified INTEGER NOT NULL DEFAULT 0, + image TEXT, + createdAt DATE NOT NULL, + updatedAt DATE NOT NULL +); + +CREATE TABLE IF NOT EXISTS auth_sessions ( + id TEXT PRIMARY KEY NOT NULL, + expiresAt DATE NOT NULL, + token TEXT NOT NULL UNIQUE, + createdAt DATE NOT NULL, + updatedAt DATE NOT NULL, + ipAddress TEXT, + userAgent TEXT, + userId TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS auth_sessions_userId_idx ON auth_sessions(userId); + +CREATE TABLE IF NOT EXISTS auth_accounts ( + id TEXT PRIMARY KEY NOT NULL, + accountId TEXT NOT NULL, + providerId TEXT NOT NULL, + userId TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, + accessToken TEXT, + refreshToken TEXT, + idToken TEXT, + accessTokenExpiresAt DATE, + refreshTokenExpiresAt DATE, + scope TEXT, + password TEXT, + createdAt DATE NOT NULL, + updatedAt DATE NOT NULL +); + +CREATE INDEX IF NOT EXISTS auth_accounts_userId_idx ON auth_accounts(userId); + +CREATE TABLE IF NOT EXISTS auth_verifications ( + id TEXT PRIMARY KEY NOT NULL, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + expiresAt DATE NOT NULL, + createdAt DATE NOT NULL, + updatedAt DATE NOT NULL +); + +CREATE INDEX IF NOT EXISTS auth_verifications_identifier_idx ON auth_verifications(identifier); diff --git a/src/package_review/approval_test.ts b/src/package_review/approval_test.ts index 4a43360..09f5021 100644 --- a/src/package_review/approval_test.ts +++ b/src/package_review/approval_test.ts @@ -1,10 +1,12 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; +import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; import { renderPackageDetailPage } from '../admin/package_detail.ts'; import { buildPackageVersionRecord } from '../test_helpers/package_review.ts'; Deno.test('renderPackageDetailPage includes approve, reject, and structured accessibility controls for pending versions', () => { const pendingVersion = buildPackageVersionRecord(); const body = renderPackageDetailPage({ + identity: ADMIN_TEST_IDENTITY, packageVersion: pendingVersion, history: [pendingVersion], }); @@ -36,6 +38,7 @@ Deno.test('renderPackageDetailPage shows immutable decision history after review reviewedAt: '2026-03-23T18:05:00Z', }); const body = renderPackageDetailPage({ + identity: ADMIN_TEST_IDENTITY, packageVersion: rejectedVersion, history: [rejectedVersion], }); diff --git a/src/package_review/enablement_test.ts b/src/package_review/enablement_test.ts index 46d13cf..40a92ce 100644 --- a/src/package_review/enablement_test.ts +++ b/src/package_review/enablement_test.ts @@ -1,4 +1,5 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; +import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; import { buildCanvasDeploymentBinding } from '../test_helpers/lti.ts'; import { buildAccessibilityReview, @@ -25,6 +26,7 @@ Deno.test('renderDeploymentDetailPage shows semver history with strong status ba approvalStatus: 'pending', }); const body = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', history: [pending, approved], @@ -60,6 +62,7 @@ Deno.test('renderDeploymentDetailPage only offers approved versions in the picke approvalStatus: 'pending', }); const body = renderDeploymentDetailPage({ + identity: ADMIN_TEST_IDENTITY, appId: 'chapter-4-asteroids', appTitle: 'Chapter 4 Asteroids', history: [pending, approved], diff --git a/src/test_helpers/admin_auth.ts b/src/test_helpers/admin_auth.ts new file mode 100644 index 0000000..f91f80f --- /dev/null +++ b/src/test_helpers/admin_auth.ts @@ -0,0 +1,194 @@ +import type { + AdminAuthFormFailure, + AdminAuthFormResult, + AdminAuthService, + AdminAuthSession, +} from '../admin_auth/service.ts'; +import { + adminSessionCookieValue, + expiredAdminSessionCookieValue, + readAdminSessionCookie, +} from '../admin_auth/service.ts'; +import { createApp } from '../app.ts'; +import type { AppServices } from '../app_services.ts'; +import { createObjectEnvReader } from '../platform/env.ts'; + +export const ADMIN_TEST_SESSION_TOKEN = 'test-session'; + +const TEST_ADMIN_SESSION: AdminAuthSession = { + user: { + id: 'test-admin-user', + email: 'admin@example.test', + name: 'Test Admin', + }, +}; + +export function createAdminTestApp(services: Partial = {}) { + const { env: explicitEnv, ...restServices } = services; + + const app = createApp({ + adminAuth: restServices.adminAuth ?? createAuthenticatedAdminAuth(), + ...restServices, + ...(explicitEnv + ? { + env: createObjectEnvReader({ + ADMIN_AUTH_ALLOW_SIGNUP: '1', + APP_ORIGIN: explicitEnv.get('APP_ORIGIN') ?? 'http://localhost', + }), + } + : {}), + }); + + return withDefaultAdminSessionCookie(app); +} + +/** @deprecated Use createAdminTestApp instead. */ +export function createAuthenticatedTestApp(services: Partial = {}) { + return createAdminTestApp(services); +} + +export function createAuthenticatedAdminAuth(): AdminAuthService { + return createTestAdminAuth({ session: TEST_ADMIN_SESSION }); +} + +export function createUnauthenticatedAdminAuth(): AdminAuthService { + return createTestAdminAuth({ session: null }); +} + +export function createTestAdminAuth(input: { + session: AdminAuthSession | null; + duplicateSignupEmail?: string; + signOutFailure?: AdminAuthFormFailure; +}): AdminAuthService { + let activeSession = input.session; + + return { + handleAuthRequest() { + return Promise.resolve(new Response('Not found', { status: 404 })); + }, + getSession(request) { + if (!hasActiveSessionCookie(request)) { + return Promise.resolve(null); + } + + return Promise.resolve(activeSession); + }, + signIn(signInInput) { + if (signInInput.email === 'admin@example.test' && signInInput.password === 'password-123') { + activeSession = TEST_ADMIN_SESSION; + return Promise.resolve( + redirectResult(signInInput.returnTo, adminSessionCookieValue(ADMIN_TEST_SESSION_TOKEN)), + ); + } + + return Promise.resolve(authFailure('Invalid email or password.', 401)); + }, + signUp(signUpInput) { + if (signUpInput.email === input.duplicateSignupEmail) { + return Promise.resolve(authFailure('User already exists.', 409)); + } + + activeSession = { + user: { + id: 'signed-up-admin', + email: signUpInput.email, + name: signUpInput.name, + }, + }; + + return Promise.resolve( + redirectResult(signUpInput.returnTo, adminSessionCookieValue(ADMIN_TEST_SESSION_TOKEN)), + ); + }, + signOut(signOutInput) { + if (input.signOutFailure) { + return Promise.resolve(input.signOutFailure); + } + + if (!hasActiveSessionCookie(signOutInput.request)) { + return Promise.resolve(authFailure('Authentication required.', 401)); + } + + activeSession = null; + + return Promise.resolve( + redirectResult(signOutInput.returnTo, expiredAdminSessionCookieValue()), + ); + }, + }; +} + +export function cookieHeaderFromResponse(response: Response): string | null { + const cookies = response.headers.getSetCookie?.() ?? []; + + if (cookies.length > 0) { + return cookies.map((cookie) => cookie.split(';')[0] ?? cookie).join('; '); + } + + const cookie = response.headers.get('set-cookie'); + + return cookie ? cookie.split(';')[0] ?? cookie : null; +} + +function withDefaultAdminSessionCookie(app: ReturnType) { + const request = app.request.bind(app); + const fetch = app.fetch.bind(app); + + const withSessionCookie = (init?: RequestInit): RequestInit => { + const headers = new Headers(init?.headers); + + if (!headers.has('cookie') && !headers.has('Cookie')) { + headers.set('cookie', adminSessionCookieValue(ADMIN_TEST_SESSION_TOKEN)); + } + + return { ...init, headers }; + }; + + app.request = (input, init, environment) => { + return request(input, withSessionCookie(init), environment); + }; + + app.fetch = (input, environment, executionContext) => { + if (input instanceof Request) { + const headers = new Headers(input.headers); + + if (!headers.has('cookie') && !headers.has('Cookie')) { + headers.set('cookie', adminSessionCookieValue(ADMIN_TEST_SESSION_TOKEN)); + } + + const nextRequest = new Request(input, { headers }); + return fetch(nextRequest, environment, executionContext); + } + + return fetch(input, environment, executionContext); + }; + + return app; +} + +function hasActiveSessionCookie(request: Request): boolean { + const token = readAdminSessionCookie(request); + + return token === ADMIN_TEST_SESSION_TOKEN; +} + +function redirectResult(location: string, cookie: string): AdminAuthFormResult { + return { + success: true, + response: new Response(null, { + status: 303, + headers: { + location, + 'set-cookie': cookie, + }, + }), + }; +} + +function authFailure(message: string, status: 401 | 409): AdminAuthFormFailure { + return { + success: false, + message, + status, + }; +} diff --git a/tools/build_worker_bundle.ts b/tools/build_worker_bundle.ts index ecbb776..4ed1494 100644 --- a/tools/build_worker_bundle.ts +++ b/tools/build_worker_bundle.ts @@ -26,8 +26,22 @@ if (!result.success) { const rawBundle = await Deno.readTextFile(rawBundlePath); const workflowImport = 'import { WorkflowEntrypoint } from "cloudflare:workers";\n'; +const workerSafeBundle = neutralizeBundledDenoRuntimeDetection(rawBundle); -await Deno.writeTextFile(bundlePath, `${workflowImport}${rawBundle}`); +await Deno.writeTextFile(bundlePath, `${workflowImport}${workerSafeBundle}`); await Deno.remove(rawBundlePath); console.log(`Built Worker bundle at ${bundlePath.pathname}.`); + +function neutralizeBundledDenoRuntimeDetection(bundleText: string): string { + return bundleText + .replaceAll( + 'if (typeof Deno !== "undefined") return Deno.env.get(key) ?? fallback;', + 'if (false) return fallback;', + ) + .replaceAll('globalThis.Deno?.env.toObject() || ', '') + .replaceAll( + 'if (typeof Deno !== "undefined") return {\n name: "deno",\n version: Deno?.version?.deno ?? null\n };', + 'if (false) return null;', + ); +} diff --git a/tools/local_init.ts b/tools/local_init.ts index 2ebb14a..f52b2e2 100644 --- a/tools/local_init.ts +++ b/tools/local_init.ts @@ -1,5 +1,6 @@ import { appendMissingEnvAssignments, + createLocalBetterAuthSecret, createLocalLtiStateSecret, createLocalToolPrivateJwkString, DEFAULT_LOCAL_ENV_PATH, @@ -17,6 +18,7 @@ if (import.meta.main) { existingText, assignments: { ...assignments, + BETTER_AUTH_SECRET: createLocalBetterAuthSecret(), LTI_STATE_SECRET: createLocalLtiStateSecret(), LTI_TOOL_PRIVATE_JWK: generatedJwk, }, diff --git a/tools/local_support.ts b/tools/local_support.ts index 8e622cf..cb71637 100644 --- a/tools/local_support.ts +++ b/tools/local_support.ts @@ -72,6 +72,14 @@ export async function createLocalToolPrivateJwkString(): Promise { } export function createLocalLtiStateSecret(): string { + return createLocalSecret(); +} + +export function createLocalBetterAuthSecret(): string { + return createLocalSecret(); +} + +function createLocalSecret(): string { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); @@ -83,6 +91,7 @@ export function defaultLocalEnvAssignments(): Record { PORT: DEFAULT_LOCAL_PORT, APP_ORIGIN: DEFAULT_LOCAL_APP_ORIGIN, APP_RUNTIME_ORIGIN: DEFAULT_LOCAL_APP_ORIGIN, + ADMIN_AUTH_ALLOW_SIGNUP: '1', }; } diff --git a/tools/local_support_test.ts b/tools/local_support_test.ts index 0e0ede4..93c3a70 100644 --- a/tools/local_support_test.ts +++ b/tools/local_support_test.ts @@ -1,6 +1,7 @@ import { assert, assertEquals } from '@std/assert'; import { appendMissingEnvAssignments, + createLocalBetterAuthSecret, createLocalLtiStateSecret, createLocalToolPrivateJwkString, defaultLocalEnvAssignments, @@ -13,6 +14,7 @@ Deno.test('appendMissingEnvAssignments creates the default local env file', asyn existingText: null, assignments: { ...defaultLocalEnvAssignments(), + BETTER_AUTH_SECRET: createLocalBetterAuthSecret(), LTI_STATE_SECRET: createLocalLtiStateSecret(), LTI_TOOL_PRIVATE_JWK: generatedJwk, }, @@ -23,10 +25,13 @@ Deno.test('appendMissingEnvAssignments creates the default local env file', asyn 'PORT', 'APP_ORIGIN', 'APP_RUNTIME_ORIGIN', + 'ADMIN_AUTH_ALLOW_SIGNUP', + 'BETTER_AUTH_SECRET', 'LTI_STATE_SECRET', 'LTI_TOOL_PRIVATE_JWK', ]); assert(result.text.includes('APP_ORIGIN=http://localhost:8417')); + assert(result.text.includes('BETTER_AUTH_SECRET=')); assert(result.text.includes('LTI_STATE_SECRET=')); assert(result.text.includes('LTI_TOOL_PRIVATE_JWK="{')); }); @@ -36,6 +41,7 @@ Deno.test('appendMissingEnvAssignments preserves existing keys and appends only existingText: ['APP_ORIGIN=http://localhost:9000', ''].join('\n'), assignments: { ...defaultLocalEnvAssignments(), + BETTER_AUTH_SECRET: 'local-better-auth-secret-local-secret', LTI_STATE_SECRET: 'local-state-secret-local-state-secret', LTI_TOOL_PRIVATE_JWK: '{"kty":"RSA"}', }, @@ -45,6 +51,8 @@ Deno.test('appendMissingEnvAssignments preserves existing keys and appends only assertEquals(result.addedKeys, [ 'PORT', 'APP_RUNTIME_ORIGIN', + 'ADMIN_AUTH_ALLOW_SIGNUP', + 'BETTER_AUTH_SECRET', 'LTI_STATE_SECRET', 'LTI_TOOL_PRIVATE_JWK', ]); From c10bdb00155453605b1fd00ae33dee94cbc2be93 Mon Sep 17 00:00:00 2001 From: Sam Ottenhoff Date: Wed, 8 Jul 2026 16:00:13 -0400 Subject: [PATCH 04/12] Add Google admin sign-in --- README.md | 11 ++ src/admin_auth/config.ts | 89 ++++++++++++++++ src/admin_auth/page.ts | 46 +++++++- src/admin_auth/service.ts | 185 ++++++++++++++++++++++++--------- src/admin_auth/service_test.ts | 162 ++++++++++++++++++++++++++--- src/app_admin_auth_routes.ts | 54 +++++++++- src/app_admin_auth_test.ts | 40 +++++++ src/test_helpers/admin_auth.ts | 27 ++++- wrangler.jsonc | 1 + 9 files changed, 553 insertions(+), 62 deletions(-) create mode 100644 src/admin_auth/config.ts diff --git a/README.md b/README.md index d440ebe..d699c71 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,17 @@ To run the Worker locally: - keep the `DB` D1 binding present; it is the Worker persistence binding - apply D1 migrations from `src/db/d1_migrations` before using repository-backed Worker routes + +Google admin sign-in is optional. To enable it, create a Google OAuth web client +with these redirect URIs: + +- `https://lantern.appboundary.com/api/auth/callback/google` +- `http://localhost:8417/api/auth/callback/google` + +Then configure both `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` as Worker +secrets, or put both values in local `.dev.vars`. Setting only one value fails +startup clearly. + - if you want the demo import path to work on Workers, provision the curated reference sources into the same bucket first with `deno task reference:sync --bucket=` or diff --git a/src/admin_auth/config.ts b/src/admin_auth/config.ts new file mode 100644 index 0000000..1ac5783 --- /dev/null +++ b/src/admin_auth/config.ts @@ -0,0 +1,89 @@ +import type { EnvReader } from '../platform/env.ts'; +import { readEnv } from '../platform/env.ts'; + +const BETTER_AUTH_SECRET_ENV = 'BETTER_AUTH_SECRET'; +const APP_ORIGIN_ENV = 'APP_ORIGIN'; +const ADMIN_AUTH_ALLOW_SIGNUP_ENV = 'ADMIN_AUTH_ALLOW_SIGNUP'; +const GOOGLE_CLIENT_ID_ENV = 'GOOGLE_CLIENT_ID'; +const GOOGLE_CLIENT_SECRET_ENV = 'GOOGLE_CLIENT_SECRET'; + +export type AdminAuthSocialProviderId = 'google'; + +export interface AdminAuthSocialProvider { + readonly id: AdminAuthSocialProviderId; + readonly label: string; +} + +export interface AdminAuthSocialProviderConfig extends AdminAuthSocialProvider { + readonly clientId: string; + readonly clientSecret: string; +} + +export interface AdminAuthConfig { + readonly baseURL: string; + readonly secret: string; + readonly allowSignUp: boolean; + readonly socialProviders: ReadonlyArray; +} + +export function readAdminAuthConfig(env: EnvReader): AdminAuthConfig { + return { + baseURL: readRequiredEnv(env, APP_ORIGIN_ENV), + secret: readRequiredEnv(env, BETTER_AUTH_SECRET_ENV), + allowSignUp: readEnv(ADMIN_AUTH_ALLOW_SIGNUP_ENV, env) === '1', + socialProviders: readAdminAuthSocialProviderConfigs(env), + }; +} + +export function publicAdminAuthSocialProviders( + providers: ReadonlyArray, +): ReadonlyArray { + return providers.map((provider) => ({ + id: provider.id, + label: provider.label, + })); +} + +function readAdminAuthSocialProviderConfigs( + env: EnvReader, +): ReadonlyArray { + const google = readGoogleProviderConfig(env); + + return google === undefined ? [] : [google]; +} + +function readGoogleProviderConfig(env: EnvReader): AdminAuthSocialProviderConfig | undefined { + const clientId = readEnv(GOOGLE_CLIENT_ID_ENV, env)?.trim(); + const clientSecret = readEnv(GOOGLE_CLIENT_SECRET_ENV, env)?.trim(); + + if (!clientId && !clientSecret) { + return undefined; + } + + if (!clientId) { + throw new Error(`${GOOGLE_CLIENT_ID_ENV} is required when Google admin sign-in is enabled.`); + } + + if (!clientSecret) { + throw new Error( + `${GOOGLE_CLIENT_SECRET_ENV} is required when Google admin sign-in is enabled.`, + ); + } + + return { + id: 'google', + label: 'Google', + clientId, + clientSecret, + }; +} + +function readRequiredEnv(env: EnvReader, name: string): string { + const value = readEnv(name, env)?.trim(); + + if (!value) { + throw new Error(`${name} is required to enable Lantern admin authentication.`); + } + + return value; +} diff --git a/src/admin_auth/page.ts b/src/admin_auth/page.ts index f4a7f4f..4561cf3 100644 --- a/src/admin_auth/page.ts +++ b/src/admin_auth/page.ts @@ -1,5 +1,6 @@ import { escapeHtml } from '../admin/layout.ts'; import { ADMIN_LAYOUT_STYLES } from '../admin/layout_styles.ts'; +import type { AdminAuthSocialProvider } from './config.ts'; const AUTH_PAGE_STYLES = `${ADMIN_LAYOUT_STYLES} body { background: var(--color-bg); } @@ -32,6 +33,26 @@ const AUTH_PAGE_STYLES = `${ADMIN_LAYOUT_STYLES} display: grid; gap: 16px; } + .auth-stack { + display: grid; + gap: 18px; + } + .auth-divider { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 12px; + color: var(--color-muted); + font-size: 0.82rem; + font-weight: 650; + text-transform: uppercase; + } + .auth-divider::before, + .auth-divider::after { + content: ""; + height: 1px; + background: var(--color-border); + } .auth-field { display: grid; gap: 6px; @@ -74,6 +95,7 @@ const AUTH_PAGE_STYLES = `${ADMIN_LAYOUT_STYLES} export function renderAdminAuthPage(input: { mode: 'login' | 'signup'; returnTo: string; + socialProviders: ReadonlyArray; error?: string; email?: string; name?: string; @@ -92,6 +114,8 @@ export function renderAdminAuthPage(input: { 'Use a Lantern admin account to review packages, manage LMS deployments, and inspect runtime activity.', body: ` ${renderAuthError(input.error)} +
+ ${isSignup ? '' : renderSocialSignInForms(input.returnTo, input.socialProviders)}
${isSignup ? renderNameField(input.name) : ''} @@ -108,7 +132,8 @@ export function renderAdminAuthPage(input: { isSignup ? 'Create account' : 'Sign in' }
- `, + + `, }); } @@ -157,6 +182,25 @@ function renderAuthError(error: string | undefined): string { return error ? `` : ''; } +function renderSocialSignInForms( + returnTo: string, + providers: ReadonlyArray, +): string { + if (providers.length === 0) { + return ''; + } + + const forms = providers.map((provider) => + `
+ + +
` + ); + + return `${forms.join('')} + `; +} + function renderNameField(name: string | undefined): string { return `
diff --git a/src/admin_auth/service.ts b/src/admin_auth/service.ts index b47b288..768dcef 100644 --- a/src/admin_auth/service.ts +++ b/src/admin_auth/service.ts @@ -1,11 +1,15 @@ import { betterAuth, type BetterAuthOptions } from 'better-auth'; import type { D1Database } from '../db/d1.ts'; import type { EnvReader } from '../platform/env.ts'; -import { readEnv } from '../platform/env.ts'; +import { + type AdminAuthConfig, + type AdminAuthSocialProvider, + type AdminAuthSocialProviderConfig, + type AdminAuthSocialProviderId, + publicAdminAuthSocialProviders, + readAdminAuthConfig, +} from './config.ts'; -const BETTER_AUTH_SECRET_ENV = 'BETTER_AUTH_SECRET'; -const APP_ORIGIN_ENV = 'APP_ORIGIN'; -const ADMIN_AUTH_ALLOW_SIGNUP_ENV = 'ADMIN_AUTH_ALLOW_SIGNUP'; const ADMIN_SESSION_COOKIE = 'better-auth.session_token'; export interface AdminAuthUser { @@ -32,6 +36,7 @@ export interface AdminAuthFormFailure { export type AdminAuthFormOutcome = AdminAuthFormResult | AdminAuthFormFailure; export interface AdminAuthService { + socialProviders: ReadonlyArray; handleAuthRequest(request: Request): Promise; getSession(request: Request): Promise; signIn(input: { @@ -47,6 +52,11 @@ export interface AdminAuthService { password: string; returnTo: string; }): Promise; + startSocialSignIn(input: { + request: Request; + providerId: AdminAuthSocialProviderId; + returnTo: string; + }): Promise; signOut(input: { request: Request; returnTo: string }): Promise; } @@ -54,19 +64,17 @@ type BetterAuthRuntime = ReturnType; export function createBetterAdminAuthOptions(input: { db: D1Database; - baseURL: string; - secret: string; - allowSignUp: boolean; + config: AdminAuthConfig; }): BetterAuthOptions { - return { + const options: BetterAuthOptions = { appName: 'Lantern', database: input.db, - baseURL: input.baseURL, + baseURL: input.config.baseURL, basePath: '/api/auth', - secret: input.secret, + secret: input.config.secret, emailAndPassword: { enabled: true, - disableSignUp: !input.allowSignUp, + disableSignUp: !input.config.allowSignUp, }, user: { modelName: 'auth_users', @@ -81,24 +89,31 @@ export function createBetterAdminAuthOptions(input: { modelName: 'auth_verifications', }, }; + + const socialProviders = createBetterAuthSocialProviders(input.config.socialProviders); + + if (socialProviders !== undefined) { + options.socialProviders = socialProviders; + } + + return options; } export function createBetterAdminAuth(input: { db: D1Database; env: EnvReader; }): AdminAuthService { - const baseURL = readRequiredEnv(input.env, APP_ORIGIN_ENV); - const secret = readRequiredEnv(input.env, BETTER_AUTH_SECRET_ENV); - const allowSignUp = readEnv(ADMIN_AUTH_ALLOW_SIGNUP_ENV, input.env) === '1'; - const auth = betterAuth( - createBetterAdminAuthOptions({ db: input.db, baseURL, secret, allowSignUp }), - ); - - return createBetterAuthService(auth); + const config = readAdminAuthConfig(input.env); + const auth = betterAuth(createBetterAdminAuthOptions({ db: input.db, config })); + + return createBetterAuthService(auth, { + socialProviders: publicAdminAuthSocialProviders(config.socialProviders), + }); } export function createUnconfiguredAdminAuthService(message: string): AdminAuthService { return { + socialProviders: [], handleAuthRequest() { return Promise.resolve(new Response(message, { status: 503 })); }, @@ -111,14 +126,21 @@ export function createUnconfiguredAdminAuthService(message: string): AdminAuthSe signUp() { return Promise.resolve(unconfiguredAuthFailure(message)); }, + startSocialSignIn() { + return Promise.resolve(unconfiguredAuthFailure(message)); + }, signOut() { return Promise.resolve(unconfiguredAuthFailure(message)); }, }; } -function createBetterAuthService(auth: BetterAuthRuntime): AdminAuthService { +function createBetterAuthService( + auth: BetterAuthRuntime, + options: { readonly socialProviders: ReadonlyArray }, +): AdminAuthService { return { + socialProviders: options.socialProviders, handleAuthRequest(request) { return auth.handler(request); }, @@ -140,9 +162,9 @@ function createBetterAuthService(auth: BetterAuthRuntime): AdminAuthService { }; }, signIn(input) { - return runBetterAuthFormAction( - () => - auth.api.signInEmail({ + return runBetterAuthAction( + async () => { + const result = await auth.api.signInEmail({ body: { email: input.email, password: input.password, @@ -150,14 +172,19 @@ function createBetterAuthService(auth: BetterAuthRuntime): AdminAuthService { }, headers: input.request.headers, returnHeaders: true, - }), - input.returnTo, + }); + + return { + headers: result.headers, + location: input.returnTo, + }; + }, ); }, signUp(input) { - return runBetterAuthFormAction( - () => - auth.api.signUpEmail({ + return runBetterAuthAction( + async () => { + const result = await auth.api.signUpEmail({ body: { name: input.name, email: input.email, @@ -166,12 +193,47 @@ function createBetterAuthService(auth: BetterAuthRuntime): AdminAuthService { }, headers: input.request.headers, returnHeaders: true, - }), - input.returnTo, + }); + + return { + headers: result.headers, + location: input.returnTo, + }; + }, ); }, + startSocialSignIn(input) { + if (!hasSocialProvider(options.socialProviders, input.providerId)) { + return Promise.resolve({ + success: false, + status: 503, + message: 'That sign-in provider is not configured for this Lantern environment.', + }); + } + + return runBetterAuthAction(async () => { + const result = await auth.api.signInSocial({ + body: { + provider: input.providerId, + callbackURL: input.returnTo, + }, + headers: input.request.headers, + returnHeaders: true, + }); + const location = readRedirectLocation(result); + + if (!location) { + throw new Error('Better Auth did not return a Google authorization URL.'); + } + + return { + headers: result.headers, + location, + }; + }); + }, async signOut(input) { - return await runBetterAuthFormAction( + return await runBetterAuthAction( async () => { const result = await auth.api.signOut({ headers: input.request.headers, @@ -182,23 +244,24 @@ function createBetterAuthService(auth: BetterAuthRuntime): AdminAuthService { throw new Error('Better Auth did not invalidate the admin session.'); } - return result; + return { + headers: result.headers, + location: input.returnTo, + }; }, - input.returnTo, ); }, }; } -async function runBetterAuthFormAction( - action: () => Promise<{ headers: Headers }>, - returnTo: string, +async function runBetterAuthAction( + action: () => Promise<{ headers: Headers; location: string }>, ): Promise { try { const authResult = await action(); return { success: true, - response: redirectWithAuthHeaders(returnTo, authResult.headers), + response: redirectWithAuthHeaders(authResult.location, authResult.headers), }; } catch (error) { return parseBetterAuthFormFailure(error); @@ -268,6 +331,15 @@ function authErrorMessage(error: unknown): string { return 'Authentication failed.'; } +function readRedirectLocation(result: { + headers: Headers; + response: { readonly url?: string | undefined }; +}): string | null { + const location = result.headers.get('location') ?? result.response.url; + + return typeof location === 'string' && location.trim() !== '' ? location : null; +} + function readAuthErrorStatus(error: unknown): number | null { if ( typeof error === 'object' && @@ -302,16 +374,6 @@ function getSetCookies(headers: Headers): string[] { return cookie ? [cookie] : []; } -function readRequiredEnv(env: EnvReader, name: string): string { - const value = readEnv(name, env)?.trim(); - - if (!value) { - throw new Error(`${name} is required to enable Lantern admin authentication.`); - } - - return value; -} - function unconfiguredAuthFailure(message: string): AdminAuthFormFailure { return { success: false, @@ -346,3 +408,32 @@ export function adminSessionCookieValue(token: string): string { export function expiredAdminSessionCookieValue(): string { return `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly`; } + +function hasSocialProvider( + providers: ReadonlyArray, + providerId: AdminAuthSocialProviderId, +): boolean { + return providers.some((provider) => provider.id === providerId); +} + +function createBetterAuthSocialProviders( + providers: ReadonlyArray, +): BetterAuthOptions['socialProviders'] | undefined { + if (providers.length === 0) { + return undefined; + } + + const google = providers.find((provider) => provider.id === 'google'); + + if (google === undefined) { + return undefined; + } + + return { + google: { + clientId: google.clientId, + clientSecret: google.clientSecret, + prompt: 'select_account', + }, + }; +} diff --git a/src/admin_auth/service_test.ts b/src/admin_auth/service_test.ts index c974c9e..8c08c6a 100644 --- a/src/admin_auth/service_test.ts +++ b/src/admin_auth/service_test.ts @@ -1,6 +1,7 @@ -import { assertEquals, assertThrows } from '@std/assert'; +import { assertEquals, assertStringIncludes, assertThrows } from '@std/assert'; import { getAuthTables } from 'better-auth/db'; import { createBetterAdminAuth, createBetterAdminAuthOptions } from './service.ts'; +import type { AdminAuthConfig } from './config.ts'; import type { D1Database, D1PreparedStatement, D1Result } from '../db/d1.ts'; import type { EnvReader } from '../platform/env.ts'; @@ -23,14 +24,106 @@ Deno.test('createBetterAdminAuth fails clearly when BETTER_AUTH_SECRET is missin ); }); +Deno.test('createBetterAdminAuth fails clearly when Google client secret is missing', () => { + const env: EnvReader = { + get(name: string) { + const values: Record = { + APP_ORIGIN: 'https://lantern.example', + BETTER_AUTH_SECRET: 'test-better-auth-secret-test-better-auth-secret', + GOOGLE_CLIENT_ID: 'test-google-client', + }; + + return values[name]; + }, + }; + + assertThrows( + () => { + createBetterAdminAuth({ + db: createStubD1Database(), + env, + }); + }, + Error, + 'GOOGLE_CLIENT_SECRET is required', + ); +}); + +Deno.test('Better Auth admin service reports configured social providers', () => { + const withoutGoogle = createBetterAdminAuthOptions({ + db: createStubD1Database(), + config: createAdminAuthConfig({ socialProviders: [] }), + }); + const withGoogle = createBetterAdminAuthOptions({ + db: createStubD1Database(), + config: createAdminAuthConfig(), + }); + const googleEnv: EnvReader = { + get(name: string) { + const values: Record = { + APP_ORIGIN: 'https://lantern.example', + BETTER_AUTH_SECRET: 'test-better-auth-secret-test-better-auth-secret', + GOOGLE_CLIENT_ID: 'test-google-client', + GOOGLE_CLIENT_SECRET: 'test-google-secret', + }; + + return values[name]; + }, + }; + + assertEquals(withoutGoogle.socialProviders, undefined); + assertEquals(withGoogle.socialProviders?.google === undefined, false); + assertEquals( + createBetterAdminAuth({ + db: createStubD1Database(), + env: googleEnv, + }).socialProviders, + [{ id: 'google', label: 'Google' }], + ); +}); + +Deno.test('Better Auth Google social sign-in preserves authorization redirect and OAuth state cookie', async () => { + const auth = createBetterAdminAuth({ + db: createStubD1Database(), + env: createObjectEnvReader({ + APP_ORIGIN: 'https://lantern.example', + BETTER_AUTH_SECRET: 'test-better-auth-secret-test-better-auth-secret', + GOOGLE_CLIENT_ID: 'test-google-client', + GOOGLE_CLIENT_SECRET: 'test-google-secret', + }), + }); + + const result = await auth.startSocialSignIn({ + request: new Request('https://lantern.example/admin/login/google', { method: 'POST' }), + providerId: 'google', + returnTo: '/admin/packages', + }); + + assertEquals(result.success, true); + + if (!result.success) { + throw new Error(result.message); + } + + const location = result.response.headers.get('location') ?? ''; + const setCookie = result.response.headers.get('set-cookie') ?? ''; + + assertEquals(result.response.status, 303); + assertStringIncludes(location, 'https://accounts.google.com/o/oauth2/v2/auth?'); + assertStringIncludes(location, 'client_id=test-google-client'); + assertStringIncludes( + location, + 'redirect_uri=https%3A%2F%2Flantern.example%2Fapi%2Fauth%2Fcallback%2Fgoogle', + ); + assertStringIncludes(setCookie, 'better-auth.state='); +}); + Deno.test('Better Auth migration matches the configured auth schema tables and fields', async () => { const migration = await Deno.readTextFile('src/db/d1_migrations/0010_better_auth_tables.sql'); const tables = getAuthTables( createBetterAdminAuthOptions({ db: createStubD1Database(), - baseURL: 'https://lantern.example', - secret: 'test-better-auth-secret-test-better-auth-secret', - allowSignUp: true, + config: createAdminAuthConfig(), }), ); @@ -50,8 +143,8 @@ Deno.test('Better Auth migration matches the configured auth schema tables and f function createStubD1Database(): D1Database { return { - prepare() { - return createStubD1Statement(); + prepare(query: string) { + return createStubD1Statement(query); }, batch() { return Promise.resolve([]); @@ -62,19 +155,47 @@ function createStubD1Database(): D1Database { }; } -function createStubD1Statement(): D1PreparedStatement { +function createAdminAuthConfig( + overrides: Partial = {}, +): AdminAuthConfig { + return { + baseURL: 'https://lantern.example', + secret: 'test-better-auth-secret-test-better-auth-secret', + allowSignUp: true, + socialProviders: [{ + id: 'google', + label: 'Google', + clientId: 'test-google-client', + clientSecret: 'test-google-secret', + }], + ...overrides, + }; +} + +function createObjectEnvReader(values: Record): EnvReader { + return { + get(name: string) { + return values[name]; + }, + }; +} + +function createStubD1Statement(query: string): D1PreparedStatement { + let boundParams: unknown[] = []; + return { - bind() { + bind(...params: unknown[]) { + boundParams = params; return this; }, first() { return Promise.resolve(null); }, run() { - return Promise.resolve(createEmptyD1Result()); + return Promise.resolve(createD1Result(createReturningRows(query, boundParams))); }, all>() { - return Promise.resolve(createEmptyD1Result()); + return Promise.resolve(createD1Result(createReturningRows(query, boundParams) as T[])); }, raw() { return Promise.resolve([]); @@ -82,9 +203,26 @@ function createStubD1Statement(): D1PreparedStatement { }; } -function createEmptyD1Result(): D1Result { +function createReturningRows(query: string, params: unknown[]): Array> { + if (!query.includes('insert into "auth_verifications"')) { + return []; + } + + const [identifier, value, expiresAt, createdAt, updatedAt, id] = params; + + return [{ + id, + identifier, + value, + expiresAt, + createdAt, + updatedAt, + }]; +} + +function createD1Result(results: T[]): D1Result { return { - results: [], + results, success: true, meta: {}, }; diff --git a/src/app_admin_auth_routes.ts b/src/app_admin_auth_routes.ts index 05b348d..55c1d66 100644 --- a/src/app_admin_auth_routes.ts +++ b/src/app_admin_auth_routes.ts @@ -1,6 +1,7 @@ import { csrf } from '@hono/hono/csrf'; import type { Context, Hono } from '@hono/hono'; import type { AppServices } from './app_services.ts'; +import type { AdminAuthSocialProviderId } from './admin_auth/config.ts'; import { renderAdminAuthPage, renderAdminLogoutPage } from './admin_auth/page.ts'; type AuthMode = 'login' | 'signup'; @@ -14,6 +15,7 @@ export function registerPublicAdminAuthRoutes(app: Hono, services: AppServices): app.get('/admin/login', (context) => renderAuthPage(context, services, 'login')); app.post('/admin/login', csrf(), (context) => handleAuthForm(context, services, 'login')); + app.post('/admin/login/:providerId', csrf(), (context) => handleSocialSignIn(context, services)); app.get('/admin/signup', (context) => renderAuthPage(context, services, 'signup')); app.post('/admin/signup', csrf(), (context) => handleAuthForm(context, services, 'signup')); @@ -44,7 +46,51 @@ async function renderAuthPage(context: Context, services: AppServices, mode: Aut return context.redirect(returnTo, 303); } - return context.html(renderAdminAuthPage({ mode, returnTo })); + return context.html( + renderAdminAuthPage({ + mode, + returnTo, + socialProviders: services.adminAuth.socialProviders, + }), + ); +} + +async function handleSocialSignIn(context: Context, services: AppServices) { + const form = await context.req.raw.formData(); + const returnTo = normalizeReturnTo(readFormString(form, 'returnTo')); + const providerId = parseSocialProviderId(context.req.param('providerId')); + + if (providerId === null) { + return context.html( + renderAdminAuthPage({ + mode: 'login', + returnTo, + socialProviders: services.adminAuth.socialProviders, + error: 'That sign-in provider is not configured for this Lantern environment.', + }), + 404, + ); + } + + const result = await services.adminAuth.startSocialSignIn({ + request: context.req.raw, + providerId, + returnTo, + }); + + if (result.success) { + return result.response; + } + + return context.html( + renderAdminAuthPage({ + mode: 'login', + returnTo, + socialProviders: services.adminAuth.socialProviders, + error: result.message, + }), + result.status, + ); } async function handleAuthForm(context: Context, services: AppServices, mode: AuthMode) { @@ -58,6 +104,7 @@ async function handleAuthForm(context: Context, services: AppServices, mode: Aut renderAdminAuthPage({ mode, returnTo, + socialProviders: services.adminAuth.socialProviders, error: validationError, email: values.email, name: values.name, @@ -89,6 +136,7 @@ async function handleAuthForm(context: Context, services: AppServices, mode: Aut renderAdminAuthPage({ mode, returnTo, + socialProviders: services.adminAuth.socialProviders, error: result.message, email: values.email, name: values.name, @@ -129,6 +177,10 @@ function readFormString(form: FormData, name: string): string { return typeof value === 'string' ? value : ''; } +function parseSocialProviderId(value: string | undefined): AdminAuthSocialProviderId | null { + return value === 'google' ? value : null; +} + function normalizeReturnTo(value: string | undefined): string { if (!value || !value.startsWith('/admin/') || value.startsWith('//')) { return '/admin/packages'; diff --git a/src/app_admin_auth_test.ts b/src/app_admin_auth_test.ts index 363688a..10fa7b8 100644 --- a/src/app_admin_auth_test.ts +++ b/src/app_admin_auth_test.ts @@ -63,6 +63,46 @@ Deno.test('login and signup pages render without a session', async () => { assertStringIncludes(await signup.text(), 'Create your Lantern admin account'); }); +Deno.test('login page renders Google sign-in when configured', async () => { + const app = createApp({ + adminAuth: createTestAdminAuth({ + session: null, + socialProviders: [{ id: 'google', label: 'Google' }], + }), + getRepository: () => createInMemoryPackageReviewRepository(), + }); + + const login = await app.request('http://localhost/admin/login?returnTo=%2Fadmin%2Fpackages'); + + assertEquals(login.status, 200); + assertStringIncludes(await login.text(), 'Sign in with Google'); +}); + +Deno.test('Google sign-in redirects to Google authorization URL', async () => { + const app = createApp({ + adminAuth: createTestAdminAuth({ + session: null, + socialProviders: [{ id: 'google', label: 'Google' }], + }), + getRepository: () => createInMemoryPackageReviewRepository(), + }); + + const response = await app.request('http://localhost/admin/login/google', { + method: 'POST', + headers: { origin: 'http://localhost' }, + body: new URLSearchParams({ + returnTo: '/admin/packages', + }), + }); + + assertEquals(response.status, 303); + assertEquals( + response.headers.get('location'), + 'https://accounts.google.com/o/oauth2/v2/auth?client_id=test-google-client', + ); + assertStringIncludes(response.headers.get('set-cookie') ?? '', 'better-auth.state='); +}); + Deno.test('signup creates an admin session and redirects to packages', async () => { const auth = createUnauthenticatedAdminAuth(); const app = createApp({ diff --git a/src/test_helpers/admin_auth.ts b/src/test_helpers/admin_auth.ts index f91f80f..32baaa8 100644 --- a/src/test_helpers/admin_auth.ts +++ b/src/test_helpers/admin_auth.ts @@ -4,6 +4,7 @@ import type { AdminAuthService, AdminAuthSession, } from '../admin_auth/service.ts'; +import type { AdminAuthSocialProvider } from '../admin_auth/config.ts'; import { adminSessionCookieValue, expiredAdminSessionCookieValue, @@ -59,10 +60,14 @@ export function createTestAdminAuth(input: { session: AdminAuthSession | null; duplicateSignupEmail?: string; signOutFailure?: AdminAuthFormFailure; + socialProviders?: ReadonlyArray; + socialSignInFailure?: AdminAuthFormFailure; }): AdminAuthService { let activeSession = input.session; + const socialProviders = input.socialProviders ?? []; return { + socialProviders, handleAuthRequest() { return Promise.resolve(new Response('Not found', { status: 404 })); }, @@ -100,6 +105,26 @@ export function createTestAdminAuth(input: { redirectResult(signUpInput.returnTo, adminSessionCookieValue(ADMIN_TEST_SESSION_TOKEN)), ); }, + startSocialSignIn(signInInput) { + if (input.socialSignInFailure) { + return Promise.resolve(input.socialSignInFailure); + } + + const provider = socialProviders.find((item) => item.id === signInInput.providerId); + + if (!provider) { + return Promise.resolve( + authFailure('That sign-in provider is not configured for this Lantern environment.', 503), + ); + } + + return Promise.resolve( + redirectResult( + `https://accounts.google.com/o/oauth2/v2/auth?client_id=test-${provider.id}-client`, + 'better-auth.state=test-state; Path=/; HttpOnly', + ), + ); + }, signOut(signOutInput) { if (input.signOutFailure) { return Promise.resolve(input.signOutFailure); @@ -185,7 +210,7 @@ function redirectResult(location: string, cookie: string): AdminAuthFormResult { }; } -function authFailure(message: string, status: 401 | 409): AdminAuthFormFailure { +function authFailure(message: string, status: 401 | 409 | 503): AdminAuthFormFailure { return { success: false, message, diff --git a/wrangler.jsonc b/wrangler.jsonc index 6ac557f..5d41980 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -24,6 +24,7 @@ ], "vars": { "APP_ADMIN_TIME_ZONE": "America/New_York", + "APP_ORIGIN": "https://lantern.appboundary.com", "APP_RUNTIME_ORIGIN": "https://lantern.appboundary.com", "APP_WRITER_MODEL": "@cf/moonshotai/kimi-k2.6" }, From b1855292b1e4037a462ffbaaaa16d0e0cc89d39c Mon Sep 17 00:00:00 2001 From: Sam Ottenhoff Date: Wed, 8 Jul 2026 16:01:17 -0400 Subject: [PATCH 05/12] Enable production admin signup --- wrangler.jsonc | 1 + 1 file changed, 1 insertion(+) diff --git a/wrangler.jsonc b/wrangler.jsonc index 5d41980..44044b8 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -26,6 +26,7 @@ "APP_ADMIN_TIME_ZONE": "America/New_York", "APP_ORIGIN": "https://lantern.appboundary.com", "APP_RUNTIME_ORIGIN": "https://lantern.appboundary.com", + "ADMIN_AUTH_ALLOW_SIGNUP": "1", "APP_WRITER_MODEL": "@cf/moonshotai/kimi-k2.6" }, "ai": { From 201b897c05d29dcd7914d76a728c5e9df8fcf48b Mon Sep 17 00:00:00 2001 From: Sam Ottenhoff Date: Wed, 22 Jul 2026 12:27:22 -0400 Subject: [PATCH 06/12] feat: add governed LMS primitive kernel --- Dockerfile.python-grader | 5 + deno.json | 11 +- deno.lock | 345 +++++++ .../apps/chapter-4-asteroids/manifest.json | 7 +- examples/apps/photo-roster/dist/worker.js | 51 + examples/apps/photo-roster/manifest.json | 29 + .../apps/photo-roster/preview/fixtures.json | 10 + examples/apps/photo-roster/preview/tests.json | 9 + examples/apps/photo-roster/source/worker.ts | 154 +++ examples/apps/quick-study/manifest.json | 7 +- examples/apps/template/manifest.json | 7 +- .../apps/typescript-ladder-game/manifest.json | 7 +- examples/apps/web-checkup/manifest.json | 7 +- .../starters/simple-activity/manifest.json | 7 +- main.ts | 25 - schemas/app-manifest.schema.json | 171 +++- sdk/app-sdk.ts | 33 +- sdk/worker-app-sdk.ts | 88 ++ src/admin/app_writer_run_sections.ts | 2 +- src/admin/control_plane.ts | 147 --- src/admin/control_plane_sections.ts | 176 ---- src/admin/control_plane_support.ts | 347 ------- src/admin/control_plane_test.ts | 224 ----- .../control_plane_verification_sections.ts | 562 ----------- src/admin/deep_linking_picker.ts | 225 ----- src/admin/deep_linking_picker_styles.ts | 304 ------ src/admin/deep_linking_picker_test.ts | 133 --- src/admin/deployment_detail.ts | 278 ------ .../deployment_detail_history_section.ts | 81 -- src/admin/deployment_detail_install_test.ts | 184 ---- src/admin/deployment_detail_multilms_test.ts | 231 ----- ...deployment_detail_ops_evidence_sections.ts | 593 ------------ src/admin/deployment_detail_ops_labels.ts | 292 ------ .../deployment_detail_ops_render_test.ts | 568 ----------- src/admin/deployment_detail_ops_sections.ts | 198 ---- src/admin/deployment_detail_ops_support.ts | 577 ------------ .../deployment_detail_release_canvas_form.ts | 116 --- ...deployment_detail_release_field_support.ts | 200 ---- src/admin/deployment_detail_release_forms.ts | 227 ----- ...loyment_detail_release_non_canvas_forms.ts | 229 ----- .../deployment_detail_release_pin_section.ts | 153 --- .../deployment_detail_release_sections.ts | 99 -- .../deployment_detail_release_support.ts | 126 --- src/admin/deployment_detail_render_test.ts | 305 ------ src/admin/deployment_detail_roster_test.ts | 139 --- .../deployment_detail_smoke_render_test.ts | 197 ---- src/admin/deployment_detail_test_helpers.ts | 24 - src/admin/governance_page.ts | 466 ++++++++++ src/admin/layout_style_detail.ts | 2 - src/admin/layout_style_detail_deployment.ts | 96 -- src/admin/layout_support.ts | 59 +- src/admin/package_detail.ts | 88 +- src/admin/package_index.ts | 6 +- src/admin/package_navigation.ts | 12 - src/admin/package_overview.ts | 128 +-- src/admin/package_overview_test.ts | 23 +- src/admin/package_reports.ts | 516 ---------- src/admin/placement_audit_page.ts | 250 ----- src/admin/placement_audit_page_test.ts | 86 -- src/admin/preview_page.ts | 2 +- src/admin/preview_page_test.ts | 4 +- src/admin/python_lab_requests_page.ts | 57 ++ src/admin/verification_navigation.ts | 40 - src/admin_auth/page.ts | 2 +- src/admin_auth/service_test.ts | 2 +- src/app.ts | 14 - src/app_admin_app_writer_routes.ts | 2 +- src/app_admin_app_writer_test.ts | 20 +- src/app_admin_auth_test.ts | 10 +- src/app_admin_authoring_preview_test.ts | 1 - src/app_admin_certification_checklist_test.ts | 152 --- ...in_certification_official_evidence_test.ts | 133 --- src/app_admin_certification_recording_test.ts | 244 ----- src/app_admin_control_plane_test.ts | 433 --------- ...app_admin_deployment_detail_form_routes.ts | 267 ------ ...n_deployment_detail_registration_routes.ts | 311 ------- ...p_admin_deployment_detail_route_support.ts | 203 ---- src/app_admin_deployment_detail_routes.ts | 118 --- src/app_admin_deployment_editor_state.ts | 157 ---- ...min_deployment_moodle_registration_test.ts | 196 ---- src/app_admin_deployment_ops_routes.ts | 9 - src/app_admin_deployment_registration_test.ts | 313 ------- src/app_admin_deployment_retry_routes.ts | 182 ---- src/app_admin_deployment_roster_route.ts | 145 --- ...dmin_deployment_sakai_registration_test.ts | 220 ----- src/app_admin_deployment_test.ts | 323 ------- src/app_admin_governance_routes.ts | 291 ++++++ src/app_admin_grade_smoke_moodle_test.ts | 174 ---- src/app_admin_grade_smoke_route.ts | 169 ---- src/app_admin_grade_smoke_sakai_test.ts | 63 -- src/app_admin_grade_smoke_support.ts | 262 ------ src/app_admin_grade_smoke_test.ts | 90 -- src/app_admin_grade_smoke_test_support.ts | 236 ----- src/app_admin_grade_smoke_validation.ts | 88 -- src/app_admin_inventory_routes.ts | 100 +- src/app_admin_lms_bindings_test.ts | 228 ----- src/app_admin_lms_pin_validation_test.ts | 56 -- src/app_admin_operations_routes.ts | 149 --- src/app_admin_package_revision_test.ts | 2 +- src/app_admin_packages_test.ts | 880 ------------------ src/app_admin_placement_audit_test.ts | 118 --- src/app_admin_placement_routes.ts | 54 -- src/app_admin_preview_log_test.ts | 4 +- src/app_admin_preview_routes.ts | 6 +- src/app_admin_preview_test.ts | 10 +- src/app_admin_python_lab_routes.ts | 64 ++ src/app_admin_retry_grade_publish_test.ts | 283 ------ src/app_admin_runtime_evidence_test.ts | 8 +- src/app_admin_support.ts | 165 +--- src/app_admin_verification_validation_test.ts | 102 -- src/app_basic_routes.ts | 19 - src/app_core.ts | 34 +- ...app_deep_linking_authoring_preview_test.ts | 498 ---------- src/app_deep_linking_request_test.ts | 178 ---- src/app_deep_linking_routes.ts | 195 ---- src/app_deep_linking_session_test.ts | 166 ---- src/app_deep_linking_submit_routes.ts | 236 ----- src/app_deep_linking_submit_test.ts | 358 ------- src/app_deep_linking_views.ts | 287 ------ src/app_deployment_support.ts | 276 ------ src/app_dynamic_registration_views.ts | 165 ---- src/app_governed_runtime_routes.ts | 152 +++ src/app_governed_runtime_routes_test.ts | 92 ++ src/app_launch_routes.ts | 260 +++--- src/app_lms_kernel_routes.ts | 13 + src/app_lti_config_login_test.ts | 816 ---------------- src/app_lti_launch_authoring_test.ts | 112 --- src/app_lti_launch_edge_test.ts | 393 -------- src/app_lti_launch_multilms_test.ts | 224 ----- src/app_lti_launch_test.ts | 266 ------ src/app_lti_support.ts | 205 ++-- src/app_notice_support.ts | 18 - src/app_public_routes_test.ts | 2 +- src/app_python_lab_routes.ts | 353 +++++++ src/app_python_lab_routes_test.ts | 322 +++++++ src/app_request_broker_verification.ts | 155 --- src/app_request_support.ts | 14 - src/app_runtime_evidence_test.ts | 6 +- src/app_runtime_finalize_test.ts | 634 ------------- src/app_runtime_routes.ts | 119 ++- src/app_runtime_security_headers_test.ts | 5 +- src/app_runtime_support.ts | 12 +- src/app_runtime_test.ts | 8 +- src/app_services.ts | 133 ++- src/app_status_support.ts | 185 ---- src/app_test_support.ts | 29 +- src/app_worker_services.ts | 199 +++- src/app_worker_services_test.ts | 23 +- src/app_writer/background_execution.ts | 2 - src/app_writer/evaluation_runner_test.ts | 4 +- src/app_writer/revision_authoring_prompt.ts | 2 +- src/app_writer/service_audit.ts | 2 - src/app_writer/starter_workspace.ts | 8 +- src/audit/repository_test.ts | 50 - src/authoring/draft_snapshot.ts | 2 +- src/authoring/draft_snapshot_test.ts | 1 - src/authoring/local_app.ts | 9 +- src/authoring/local_app_test.ts | 4 +- src/authoring/local_dev.ts | 4 +- src/authoring/local_preview.ts | 6 +- src/db/d1.ts | 12 + src/db/d1_migrations/0001_initial_schema.sql | 231 +---- .../0002_app_generation_runs.sql | 44 - ...0_better_auth_tables.sql => 0002_auth.sql} | 0 .../0003_app_generation_workspaces.sql | 10 - .../0003_python_autograder_lab.sql | 76 ++ ...004_app_generation_initialization_plan.sql | 95 -- .../0004_governed_lms_primitive_kernel.sql | 337 +++++++ .../0005_runtime_session_preview_marker.sql | 4 - .../0006_attempt_event_normalization.sql | 54 -- src/db/d1_migrations/0007_lti_tool_tables.sql | 55 -- .../0008_runtime_session_lti_tool_session.sql | 1 - ..._deep_linking_session_lti_tool_session.sql | 2 - src/db/d1_migrations/README.md | 13 +- src/grading/attempt_scoring.ts | 4 + src/interop_audit.ts | 41 - src/lms_kernel/admin_connection.ts | 126 +++ src/lms_kernel/admin_context.ts | 26 + src/lms_kernel/admin_installation.ts | 388 ++++++++ src/lms_kernel/admin_institution.ts | 89 ++ src/lms_kernel/admin_middleware.ts | 44 + src/lms_kernel/admin_result.ts | 22 + src/lms_kernel/admin_service.ts | 122 +++ src/lms_kernel/admin_service_test.ts | 312 +++++++ src/lms_kernel/admin_support.ts | 89 ++ src/lms_kernel/capabilities.ts | 22 + src/lms_kernel/connector.ts | 172 ++++ src/lms_kernel/connector_test.ts | 54 ++ src/lms_kernel/cursor.ts | 113 +++ src/lms_kernel/governance_events.ts | 142 +++ src/lms_kernel/launch_service.ts | 222 +++++ src/lms_kernel/media.ts | 109 +++ src/lms_kernel/media_test.ts | 93 ++ src/lms_kernel/parsing.ts | 65 ++ src/lms_kernel/parsing_test.ts | 18 + src/lms_kernel/policy.ts | 85 ++ src/lms_kernel/policy_test.ts | 128 +++ src/lms_kernel/repository.ts | 235 +++++ src/lms_kernel/repository_d1.ts | 17 + src/lms_kernel/repository_d1_connections.ts | 177 ++++ src/lms_kernel/repository_d1_governance.ts | 114 +++ src/lms_kernel/repository_d1_installations.ts | 241 +++++ src/lms_kernel/repository_d1_institutions.ts | 185 ++++ .../repository_d1_integration_test.ts | 144 +++ src/lms_kernel/repository_d1_row.ts | 374 ++++++++ src/lms_kernel/repository_d1_runtime.ts | 240 +++++ .../repository_memory_test_support.ts | 349 +++++++ src/lms_kernel/service.ts | 431 +++++++++ src/lms_kernel/service_test.ts | 237 +++++ src/lms_kernel/types.ts | 155 +++ src/lti/canvas_platform.ts | 37 - src/lti/config.ts | 98 +- src/lti/config_test.ts | 152 --- src/lti/deep_linking.ts | 13 - src/lti/deep_linking_content_item.ts | 101 -- src/lti/deep_linking_response_tool.ts | 36 - src/lti/deep_linking_selection.ts | 112 --- src/lti/deep_linking_session.ts | 187 ---- src/lti/deep_linking_session_test.ts | 331 ------- src/lti/dynamic_registration_config.ts | 43 - src/lti/dynamic_registration_state.ts | 80 -- src/lti/dynamic_registration_support.ts | 96 -- src/lti/dynamic_registration_tool.ts | 143 --- src/lti/launch_audit.ts | 94 -- src/lti/launch_mapping.ts | 211 ----- src/lti/launch_rejection.ts | 79 -- src/lti/launch_runtime_session_test.ts | 158 ---- src/lti/launch_support_matrix.ts | 237 ----- src/lti/launch_target_resolution.ts | 186 ---- src/lti/login_policy.ts | 81 -- src/lti/platform_binding.ts | 46 - src/lti/profile.ts | 82 -- src/lti/profile_resolution.ts | 41 - src/lti/runtime_session.ts | 98 -- src/lti/target_link_uri.ts | 141 --- src/lti/tool_provider.ts | 57 +- src/lti/types.ts | 130 +-- src/ops/broker_verification_paths.ts | 56 -- src/ops/diagnostics.ts | 252 ----- src/ops/health.ts | 108 --- src/ops/health_support.ts | 237 ----- src/ops/repository.ts | 1 - src/ops/repository_d1.ts | 61 -- src/ops/repository_d1_broker.ts | 193 ---- src/ops/repository_d1_detail.ts | 364 -------- src/ops/repository_d1_mapping.ts | 592 ------------ src/ops/repository_d1_queries.ts | 324 ------- src/ops/repository_inventory_queries.ts | 195 ---- src/ops/repository_inventory_test.ts | 154 --- src/ops/repository_inventory_test_support.ts | 72 -- src/ops/repository_mapping.ts | 377 -------- src/ops/repository_mapping_support.ts | 249 ----- src/ops/repository_queries.ts | 356 ------- src/ops/repository_retry_mapping.ts | 101 -- src/ops/repository_types.ts | 227 ----- src/ops/retry.ts | 102 -- src/ops/service.ts | 4 - src/ops/service_health_test.ts | 76 -- src/ops/service_test.ts | 339 ------- src/ops/types.ts | 294 ------ src/package_review/enablement_test.ts | 85 -- src/package_review/intake.ts | 9 +- src/package_review/intake_test.ts | 2 +- src/package_review/manifest_contract.ts | 82 +- src/package_review/manifest_files.ts | 31 +- src/package_review/manifest_test.ts | 60 +- src/package_review/repository.ts | 123 +-- src/package_review/repository_activity_d1.ts | 247 +++++ .../repository_activity_d1_mapping.ts | 68 ++ .../repository_deep_linking_d1.ts | 515 ---------- .../repository_deep_linking_d1_mapping.ts | 193 ---- .../repository_deep_linking_d1_test.ts | 291 ------ .../repository_deployments_d1.ts | 601 ------------ .../repository_deployments_d1_mapping.ts | 126 --- .../repository_deployments_d1_test.ts | 230 ----- src/package_review/repository_grading_d1.ts | 539 ----------- .../repository_grading_d1_mapping.ts | 161 ---- .../repository_grading_d1_test.ts | 356 ------- .../repository_launch_state_d1.ts | 156 +--- .../repository_launch_state_d1_mapping.ts | 41 +- .../repository_mappers_attempts.ts | 78 +- .../repository_mappers_package.ts | 114 +-- .../repository_mappers_review.ts | 80 +- .../repository_mappers_sessions.ts | 125 +-- .../repository_mappers_sessions_test.ts | 4 +- .../repository_package_versions_d1.ts | 281 ++++-- .../repository_package_versions_d1_test.ts | 49 +- src/package_review/repository_preview_d1.ts | 8 +- .../repository_preview_d1_test.ts | 3 +- .../repository_resource_options.ts | 59 +- src/package_review/repository_row_types.ts | 146 +-- src/package_review/runtime_contract.ts | 36 +- src/package_review/summary.ts | 71 +- src/package_review/types.ts | 146 +-- src/pages/home.ts | 4 +- src/pages/home_test.ts | 2 +- src/preview/capability_log.ts | 33 + src/preview/service.ts | 37 +- src/preview/service_test.ts | 14 +- src/python_lab/access.ts | 51 + src/python_lab/artifact_store.ts | 157 ++++ src/python_lab/cloudflare_sandbox.ts | 66 ++ src/python_lab/contract.ts | 238 +++++ src/python_lab/contract_test.ts | 51 + src/python_lab/forms.ts | 90 ++ src/python_lab/generator.ts | 34 + src/python_lab/lab_service.ts | 175 ++++ src/python_lab/lab_service_test.ts | 181 ++++ src/python_lab/package_source.ts | 162 ++++ src/python_lab/package_source_test.ts | 41 + src/python_lab/repository.ts | 139 +++ src/python_lab/repository_d1.ts | 482 ++++++++++ src/python_lab/repository_d1_test.ts | 126 +++ src/python_lab/repository_memory.ts | 198 ++++ src/python_lab/retention.ts | 67 ++ src/python_lab/retention_test.ts | 126 +++ src/python_lab/runner.ts | 200 ++++ src/python_lab/runner_test.ts | 108 +++ src/python_lab/runtime_submission.ts | 324 +++++++ src/python_lab/runtime_submission_test.ts | 380 ++++++++ src/python_lab/service.ts | 270 ++++++ src/python_lab/service_test.ts | 220 +++++ src/python_lab/turnstile.ts | 44 + src/python_lab/views.ts | 97 ++ src/python_lab/workflow.ts | 46 + src/python_lab/workflow_runner.ts | 39 + src/reports/instructor_report.ts | 390 -------- src/runtime/artifact_store.ts | 1 + src/runtime/delivery.ts | 26 +- src/runtime/dynamic_worker_delivery.ts | 90 +- src/runtime/dynamic_worker_delivery_test.ts | 119 +++ src/runtime/gateway.ts | 88 +- src/runtime/gateway_attempt_events_test.ts | 6 +- src/runtime/gateway_broker_denials_test.ts | 1 - src/runtime/gateway_context.ts | 26 +- src/runtime/gateway_evidence_test.ts | 12 +- .../gateway_finalize_edge_cases_test.ts | 149 --- src/runtime/gateway_finalize_test.ts | 651 ------------- src/runtime/gateway_preview_test.ts | 26 +- src/runtime/gateway_publication.ts | 337 ------- src/runtime/gateway_publication_support.ts | 57 -- src/runtime/gateway_types.ts | 15 +- src/runtime/reviewed_worker_http.ts | 87 ++ src/runtime/route_commands.ts | 47 +- src/runtime/session.ts | 2 +- src/runtime/session_html.ts | 13 + src/test_helpers/admin_auth.ts | 2 +- src/test_helpers/app.ts | 12 + .../app_writer_generated_package.ts | 8 +- src/test_helpers/lti_session_builders.ts | 159 +--- src/test_helpers/lti_signing.ts | 45 +- src/test_helpers/package_review.ts | 1 - .../package_review_in_memory_attempts.ts | 119 --- ...e_review_in_memory_canvas_registrations.ts | 167 ---- .../package_review_in_memory_deployments.ts | 277 ------ .../package_review_in_memory_ops.ts | 288 ------ .../package_review_in_memory_ops_support.ts | 76 -- .../package_review_in_memory_placements.ts | 182 ---- .../package_review_in_memory_preview_test.ts | 13 +- .../package_review_in_memory_repository.ts | 6 - .../package_review_in_memory_sessions.ts | 183 +--- .../package_review_in_memory_shared.ts | 143 +-- .../package_review_in_memory_versions.ts | 140 ++- .../package_review_test_builder_base.ts | 111 +-- .../package_review_test_builder_ops.ts | 367 -------- ...ackage_review_test_builder_ops_launches.ts | 19 - .../package_review_test_builder_preview.ts | 69 +- src/worker.ts | 161 ++-- src/worker_handler.ts | 86 ++ src/worker_test.ts | 45 - tools/app_validate.ts | 4 +- tools/build_reviewed_worker_app.ts | 37 + tools/build_worker_bundle.ts | 3 +- wrangler.jsonc | 26 +- 374 files changed, 15071 insertions(+), 37322 deletions(-) create mode 100644 Dockerfile.python-grader create mode 100644 examples/apps/photo-roster/dist/worker.js create mode 100644 examples/apps/photo-roster/manifest.json create mode 100644 examples/apps/photo-roster/preview/fixtures.json create mode 100644 examples/apps/photo-roster/preview/tests.json create mode 100644 examples/apps/photo-roster/source/worker.ts delete mode 100644 main.ts create mode 100644 sdk/worker-app-sdk.ts delete mode 100644 src/admin/control_plane.ts delete mode 100644 src/admin/control_plane_sections.ts delete mode 100644 src/admin/control_plane_support.ts delete mode 100644 src/admin/control_plane_test.ts delete mode 100644 src/admin/control_plane_verification_sections.ts delete mode 100644 src/admin/deep_linking_picker.ts delete mode 100644 src/admin/deep_linking_picker_styles.ts delete mode 100644 src/admin/deep_linking_picker_test.ts delete mode 100644 src/admin/deployment_detail.ts delete mode 100644 src/admin/deployment_detail_history_section.ts delete mode 100644 src/admin/deployment_detail_install_test.ts delete mode 100644 src/admin/deployment_detail_multilms_test.ts delete mode 100644 src/admin/deployment_detail_ops_evidence_sections.ts delete mode 100644 src/admin/deployment_detail_ops_labels.ts delete mode 100644 src/admin/deployment_detail_ops_render_test.ts delete mode 100644 src/admin/deployment_detail_ops_sections.ts delete mode 100644 src/admin/deployment_detail_ops_support.ts delete mode 100644 src/admin/deployment_detail_release_canvas_form.ts delete mode 100644 src/admin/deployment_detail_release_field_support.ts delete mode 100644 src/admin/deployment_detail_release_forms.ts delete mode 100644 src/admin/deployment_detail_release_non_canvas_forms.ts delete mode 100644 src/admin/deployment_detail_release_pin_section.ts delete mode 100644 src/admin/deployment_detail_release_sections.ts delete mode 100644 src/admin/deployment_detail_release_support.ts delete mode 100644 src/admin/deployment_detail_render_test.ts delete mode 100644 src/admin/deployment_detail_roster_test.ts delete mode 100644 src/admin/deployment_detail_smoke_render_test.ts delete mode 100644 src/admin/deployment_detail_test_helpers.ts create mode 100644 src/admin/governance_page.ts delete mode 100644 src/admin/layout_style_detail_deployment.ts delete mode 100644 src/admin/package_reports.ts delete mode 100644 src/admin/placement_audit_page.ts delete mode 100644 src/admin/placement_audit_page_test.ts create mode 100644 src/admin/python_lab_requests_page.ts delete mode 100644 src/admin/verification_navigation.ts delete mode 100644 src/app.ts delete mode 100644 src/app_admin_certification_checklist_test.ts delete mode 100644 src/app_admin_certification_official_evidence_test.ts delete mode 100644 src/app_admin_certification_recording_test.ts delete mode 100644 src/app_admin_control_plane_test.ts delete mode 100644 src/app_admin_deployment_detail_form_routes.ts delete mode 100644 src/app_admin_deployment_detail_registration_routes.ts delete mode 100644 src/app_admin_deployment_detail_route_support.ts delete mode 100644 src/app_admin_deployment_detail_routes.ts delete mode 100644 src/app_admin_deployment_editor_state.ts delete mode 100644 src/app_admin_deployment_moodle_registration_test.ts delete mode 100644 src/app_admin_deployment_ops_routes.ts delete mode 100644 src/app_admin_deployment_registration_test.ts delete mode 100644 src/app_admin_deployment_retry_routes.ts delete mode 100644 src/app_admin_deployment_roster_route.ts delete mode 100644 src/app_admin_deployment_sakai_registration_test.ts delete mode 100644 src/app_admin_deployment_test.ts create mode 100644 src/app_admin_governance_routes.ts delete mode 100644 src/app_admin_grade_smoke_moodle_test.ts delete mode 100644 src/app_admin_grade_smoke_route.ts delete mode 100644 src/app_admin_grade_smoke_sakai_test.ts delete mode 100644 src/app_admin_grade_smoke_support.ts delete mode 100644 src/app_admin_grade_smoke_test.ts delete mode 100644 src/app_admin_grade_smoke_test_support.ts delete mode 100644 src/app_admin_grade_smoke_validation.ts delete mode 100644 src/app_admin_lms_bindings_test.ts delete mode 100644 src/app_admin_lms_pin_validation_test.ts delete mode 100644 src/app_admin_operations_routes.ts delete mode 100644 src/app_admin_packages_test.ts delete mode 100644 src/app_admin_placement_audit_test.ts delete mode 100644 src/app_admin_placement_routes.ts create mode 100644 src/app_admin_python_lab_routes.ts delete mode 100644 src/app_admin_retry_grade_publish_test.ts delete mode 100644 src/app_admin_verification_validation_test.ts delete mode 100644 src/app_deep_linking_authoring_preview_test.ts delete mode 100644 src/app_deep_linking_request_test.ts delete mode 100644 src/app_deep_linking_routes.ts delete mode 100644 src/app_deep_linking_session_test.ts delete mode 100644 src/app_deep_linking_submit_routes.ts delete mode 100644 src/app_deep_linking_submit_test.ts delete mode 100644 src/app_deep_linking_views.ts delete mode 100644 src/app_deployment_support.ts delete mode 100644 src/app_dynamic_registration_views.ts create mode 100644 src/app_governed_runtime_routes.ts create mode 100644 src/app_governed_runtime_routes_test.ts create mode 100644 src/app_lms_kernel_routes.ts delete mode 100644 src/app_lti_config_login_test.ts delete mode 100644 src/app_lti_launch_authoring_test.ts delete mode 100644 src/app_lti_launch_edge_test.ts delete mode 100644 src/app_lti_launch_multilms_test.ts delete mode 100644 src/app_lti_launch_test.ts create mode 100644 src/app_python_lab_routes.ts create mode 100644 src/app_python_lab_routes_test.ts delete mode 100644 src/app_request_broker_verification.ts delete mode 100644 src/app_runtime_finalize_test.ts delete mode 100644 src/audit/repository_test.ts delete mode 100644 src/db/d1_migrations/0002_app_generation_runs.sql rename src/db/d1_migrations/{0010_better_auth_tables.sql => 0002_auth.sql} (100%) delete mode 100644 src/db/d1_migrations/0003_app_generation_workspaces.sql create mode 100644 src/db/d1_migrations/0003_python_autograder_lab.sql delete mode 100644 src/db/d1_migrations/0004_app_generation_initialization_plan.sql create mode 100644 src/db/d1_migrations/0004_governed_lms_primitive_kernel.sql delete mode 100644 src/db/d1_migrations/0005_runtime_session_preview_marker.sql delete mode 100644 src/db/d1_migrations/0006_attempt_event_normalization.sql delete mode 100644 src/db/d1_migrations/0007_lti_tool_tables.sql delete mode 100644 src/db/d1_migrations/0008_runtime_session_lti_tool_session.sql delete mode 100644 src/db/d1_migrations/0009_deep_linking_session_lti_tool_session.sql delete mode 100644 src/interop_audit.ts create mode 100644 src/lms_kernel/admin_connection.ts create mode 100644 src/lms_kernel/admin_context.ts create mode 100644 src/lms_kernel/admin_installation.ts create mode 100644 src/lms_kernel/admin_institution.ts create mode 100644 src/lms_kernel/admin_middleware.ts create mode 100644 src/lms_kernel/admin_result.ts create mode 100644 src/lms_kernel/admin_service.ts create mode 100644 src/lms_kernel/admin_service_test.ts create mode 100644 src/lms_kernel/admin_support.ts create mode 100644 src/lms_kernel/capabilities.ts create mode 100644 src/lms_kernel/connector.ts create mode 100644 src/lms_kernel/connector_test.ts create mode 100644 src/lms_kernel/cursor.ts create mode 100644 src/lms_kernel/governance_events.ts create mode 100644 src/lms_kernel/launch_service.ts create mode 100644 src/lms_kernel/media.ts create mode 100644 src/lms_kernel/media_test.ts create mode 100644 src/lms_kernel/parsing.ts create mode 100644 src/lms_kernel/parsing_test.ts create mode 100644 src/lms_kernel/policy.ts create mode 100644 src/lms_kernel/policy_test.ts create mode 100644 src/lms_kernel/repository.ts create mode 100644 src/lms_kernel/repository_d1.ts create mode 100644 src/lms_kernel/repository_d1_connections.ts create mode 100644 src/lms_kernel/repository_d1_governance.ts create mode 100644 src/lms_kernel/repository_d1_installations.ts create mode 100644 src/lms_kernel/repository_d1_institutions.ts create mode 100644 src/lms_kernel/repository_d1_integration_test.ts create mode 100644 src/lms_kernel/repository_d1_row.ts create mode 100644 src/lms_kernel/repository_d1_runtime.ts create mode 100644 src/lms_kernel/repository_memory_test_support.ts create mode 100644 src/lms_kernel/service.ts create mode 100644 src/lms_kernel/service_test.ts create mode 100644 src/lms_kernel/types.ts delete mode 100644 src/lti/canvas_platform.ts delete mode 100644 src/lti/config_test.ts delete mode 100644 src/lti/deep_linking.ts delete mode 100644 src/lti/deep_linking_content_item.ts delete mode 100644 src/lti/deep_linking_response_tool.ts delete mode 100644 src/lti/deep_linking_selection.ts delete mode 100644 src/lti/deep_linking_session.ts delete mode 100644 src/lti/deep_linking_session_test.ts delete mode 100644 src/lti/dynamic_registration_config.ts delete mode 100644 src/lti/dynamic_registration_state.ts delete mode 100644 src/lti/dynamic_registration_support.ts delete mode 100644 src/lti/dynamic_registration_tool.ts delete mode 100644 src/lti/launch_audit.ts delete mode 100644 src/lti/launch_mapping.ts delete mode 100644 src/lti/launch_rejection.ts delete mode 100644 src/lti/launch_runtime_session_test.ts delete mode 100644 src/lti/launch_support_matrix.ts delete mode 100644 src/lti/launch_target_resolution.ts delete mode 100644 src/lti/login_policy.ts delete mode 100644 src/lti/platform_binding.ts delete mode 100644 src/lti/profile.ts delete mode 100644 src/lti/profile_resolution.ts delete mode 100644 src/lti/runtime_session.ts delete mode 100644 src/lti/target_link_uri.ts delete mode 100644 src/ops/broker_verification_paths.ts delete mode 100644 src/ops/diagnostics.ts delete mode 100644 src/ops/health.ts delete mode 100644 src/ops/health_support.ts delete mode 100644 src/ops/repository.ts delete mode 100644 src/ops/repository_d1.ts delete mode 100644 src/ops/repository_d1_broker.ts delete mode 100644 src/ops/repository_d1_detail.ts delete mode 100644 src/ops/repository_d1_mapping.ts delete mode 100644 src/ops/repository_d1_queries.ts delete mode 100644 src/ops/repository_inventory_queries.ts delete mode 100644 src/ops/repository_inventory_test.ts delete mode 100644 src/ops/repository_inventory_test_support.ts delete mode 100644 src/ops/repository_mapping.ts delete mode 100644 src/ops/repository_mapping_support.ts delete mode 100644 src/ops/repository_queries.ts delete mode 100644 src/ops/repository_retry_mapping.ts delete mode 100644 src/ops/repository_types.ts delete mode 100644 src/ops/retry.ts delete mode 100644 src/ops/service.ts delete mode 100644 src/ops/service_health_test.ts delete mode 100644 src/ops/service_test.ts delete mode 100644 src/ops/types.ts delete mode 100644 src/package_review/enablement_test.ts create mode 100644 src/package_review/repository_activity_d1.ts create mode 100644 src/package_review/repository_activity_d1_mapping.ts delete mode 100644 src/package_review/repository_deep_linking_d1.ts delete mode 100644 src/package_review/repository_deep_linking_d1_mapping.ts delete mode 100644 src/package_review/repository_deep_linking_d1_test.ts delete mode 100644 src/package_review/repository_deployments_d1.ts delete mode 100644 src/package_review/repository_deployments_d1_mapping.ts delete mode 100644 src/package_review/repository_deployments_d1_test.ts delete mode 100644 src/package_review/repository_grading_d1.ts delete mode 100644 src/package_review/repository_grading_d1_mapping.ts delete mode 100644 src/package_review/repository_grading_d1_test.ts create mode 100644 src/preview/capability_log.ts create mode 100644 src/python_lab/access.ts create mode 100644 src/python_lab/artifact_store.ts create mode 100644 src/python_lab/cloudflare_sandbox.ts create mode 100644 src/python_lab/contract.ts create mode 100644 src/python_lab/contract_test.ts create mode 100644 src/python_lab/forms.ts create mode 100644 src/python_lab/generator.ts create mode 100644 src/python_lab/lab_service.ts create mode 100644 src/python_lab/lab_service_test.ts create mode 100644 src/python_lab/package_source.ts create mode 100644 src/python_lab/package_source_test.ts create mode 100644 src/python_lab/repository.ts create mode 100644 src/python_lab/repository_d1.ts create mode 100644 src/python_lab/repository_d1_test.ts create mode 100644 src/python_lab/repository_memory.ts create mode 100644 src/python_lab/retention.ts create mode 100644 src/python_lab/retention_test.ts create mode 100644 src/python_lab/runner.ts create mode 100644 src/python_lab/runner_test.ts create mode 100644 src/python_lab/runtime_submission.ts create mode 100644 src/python_lab/runtime_submission_test.ts create mode 100644 src/python_lab/service.ts create mode 100644 src/python_lab/service_test.ts create mode 100644 src/python_lab/turnstile.ts create mode 100644 src/python_lab/views.ts create mode 100644 src/python_lab/workflow.ts create mode 100644 src/python_lab/workflow_runner.ts delete mode 100644 src/reports/instructor_report.ts delete mode 100644 src/runtime/gateway_finalize_edge_cases_test.ts delete mode 100644 src/runtime/gateway_finalize_test.ts delete mode 100644 src/runtime/gateway_publication.ts delete mode 100644 src/runtime/gateway_publication_support.ts create mode 100644 src/runtime/reviewed_worker_http.ts create mode 100644 src/test_helpers/app.ts delete mode 100644 src/test_helpers/package_review_in_memory_canvas_registrations.ts delete mode 100644 src/test_helpers/package_review_in_memory_deployments.ts delete mode 100644 src/test_helpers/package_review_in_memory_ops.ts delete mode 100644 src/test_helpers/package_review_in_memory_ops_support.ts delete mode 100644 src/test_helpers/package_review_in_memory_placements.ts delete mode 100644 src/test_helpers/package_review_test_builder_ops.ts delete mode 100644 src/test_helpers/package_review_test_builder_ops_launches.ts create mode 100644 src/worker_handler.ts delete mode 100644 src/worker_test.ts create mode 100644 tools/build_reviewed_worker_app.ts diff --git a/Dockerfile.python-grader b/Dockerfile.python-grader new file mode 100644 index 0000000..0fb3f98 --- /dev/null +++ b/Dockerfile.python-grader @@ -0,0 +1,5 @@ +FROM docker.io/cloudflare/sandbox:0.12.3-python + +# Keep the graded interpreter free of third-party packages. The fixed command +# also uses -I -S, so Python does not add user or site-package paths. +RUN rm -rf /usr/local/lib/python*/site-packages /usr/lib/python*/site-packages diff --git a/deno.json b/deno.json index c85710d..001f498 100644 --- a/deno.json +++ b/deno.json @@ -3,8 +3,6 @@ "private/**" ], "tasks": { - "dev": "deno run --unsafely-ignore-certificate-errors --watch --allow-read --allow-write --allow-env --allow-net main.ts", - "start": "deno run --allow-read --allow-write --allow-env --allow-net main.ts", "db:migrate": "npx wrangler@4.105.0 d1 migrations apply lantern --local", "db:migrate:remote": "npx wrangler@4.105.0 d1 migrations apply lantern --remote", "app:validate": "deno run --allow-read tools/app_validate.ts", @@ -12,6 +10,7 @@ "app:dev": "deno run --allow-read --allow-net --allow-env=WS_NO_BUFFER_UTIL,WS_NO_UTF_8_VALIDATE tools/app_dev.ts", "app:test-preview": "deno run --allow-read --allow-net --allow-env=WS_NO_BUFFER_UTIL,WS_NO_UTF_8_VALIDATE tools/app_test_preview.ts", "app:new": "deno run --allow-read --allow-write tools/app_new.ts", + "app:build-worker": "deno run --allow-read --allow-write --allow-run tools/build_reviewed_worker_app.ts", "reference:sync": "deno run --allow-read --allow-run tools/reference_sync.ts", "local:init": "deno run --allow-read --allow-write tools/local_init.ts", "local:migrate": "deno task db:migrate", @@ -25,9 +24,9 @@ "fmt": "deno fmt", "fmt:check": "deno fmt --check", "lint": "deno lint", - "check": "deno check main.ts src/**/*.ts sdk/**/*.ts tools/*.ts", - "test": "deno test --allow-read --allow-write --allow-env --allow-net", - "validate": "deno task fmt:check && deno task lint && deno task check && deno task check:worker && deno test --allow-read --allow-write --allow-env --allow-net" + "check": "deno check src/worker.ts src/**/*.ts sdk/**/*.ts tools/*.ts", + "test": "DENO_JOBS=1 deno test --allow-read --allow-write --allow-env --allow-net --allow-sys --allow-run", + "validate": "deno task fmt:check && deno task lint && deno task check && deno task check:worker && DENO_JOBS=1 deno test --allow-read --allow-write --allow-env --allow-net --allow-sys --allow-run" }, "fmt": { "exclude": [ @@ -51,6 +50,7 @@ "imports": { "@ajv2020": "npm:ajv@8.20.0/dist/2020.js", "@cloudflare/puppeteer": "npm:@cloudflare/puppeteer@1.1.0", + "@cloudflare/sandbox": "npm:@cloudflare/sandbox@0.12.3", "@happy-dom": "npm:happy-dom@20.10.6", "@hono/hono": "jsr:@hono/hono@^4.12.18", "@longsightgroup/lti-tool": "npm:@longsightgroup/lti-tool@0.1.4", @@ -60,6 +60,7 @@ "@std/assert": "jsr:@std/assert@^1.0.19", "@std/semver": "jsr:@std/semver@1.0.8", "jose": "npm:jose@6.2.3", + "miniflare": "npm:miniflare@4.20260708.1", "oauth4webapi": "npm:oauth4webapi@3.8.6", "typescript": "npm:typescript@6.0.3" }, diff --git a/deno.lock b/deno.lock index 81c6715..f3e1589 100644 --- a/deno.lock +++ b/deno.lock @@ -7,6 +7,7 @@ "jsr:@std/semver@1.0.8": "1.0.8", "npm:@cloudflare/puppeteer@1.1.0": "1.1.0", "npm:@cloudflare/puppeteer@latest": "1.1.0", + "npm:@cloudflare/sandbox@0.12.3": "0.12.3", "npm:@longsightgroup/lti-tool@0.1.4": "0.1.4_drizzle-orm@0.45.2__kysely@0.29.2_hono@4.12.27_kysely@0.29.2", "npm:ajv@8.20.0": "8.20.0", "npm:better-auth@1.6.23": "1.6.23_drizzle-orm@0.45.2__kysely@0.29.2", @@ -14,6 +15,8 @@ "npm:happy-dom@20.10.6": "20.10.6", "npm:hono@4.12.27": "4.12.27", "npm:jose@6.2.3": "6.2.3", + "npm:miniflare@*": "4.20260708.1", + "npm:miniflare@4.20260708.1": "4.20260708.1", "npm:oauth4webapi@3.8.6": "3.8.6", "npm:typescript@6.0.3": "6.0.3" }, @@ -109,6 +112,9 @@ "@better-fetch/fetch@1.3.1": { "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==" }, + "@cloudflare/containers@0.3.7": { + "integrity": "sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw==" + }, "@cloudflare/puppeteer@1.1.0": { "integrity": "sha512-lN10En49avRDQvz8Gpv/WiIoGvjjDiP6P+v2y9bA6rfPT5WHfnlg2O6MwjHc1POm8A9LXf1sMdgrsdW8xWapOw==", "dependencies": [ @@ -118,6 +124,220 @@ "ws" ] }, + "@cloudflare/sandbox@0.12.3": { + "integrity": "sha512-4yx+PtBCZBrS3eZteefwBfGOm+8MTZahLH8/fG/qsvqoNflzno9780FsM6HZf02gGuoDy+s9jQrXbg/h5gEvgw==", + "dependencies": [ + "@cloudflare/containers", + "aws4fetch", + "capnweb", + "hono" + ] + }, + "@cloudflare/workerd-darwin-64@1.20260708.1": { + "integrity": "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@cloudflare/workerd-darwin-arm64@1.20260708.1": { + "integrity": "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@cloudflare/workerd-linux-64@1.20260708.1": { + "integrity": "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@cloudflare/workerd-linux-arm64@1.20260708.1": { + "integrity": "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@cloudflare/workerd-windows-64@1.20260708.1": { + "integrity": "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@cspotcode/source-map-support@0.8.1": { + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dependencies": [ + "@jridgewell/trace-mapping" + ] + }, + "@emnapi/runtime@1.11.2": { + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dependencies": [ + "tslib" + ] + }, + "@img/colour@1.1.0": { + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==" + }, + "@img/sharp-darwin-arm64@0.34.5": { + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "optionalDependencies": [ + "@img/sharp-libvips-darwin-arm64" + ], + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@img/sharp-darwin-x64@0.34.5": { + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "optionalDependencies": [ + "@img/sharp-libvips-darwin-x64" + ], + "os": ["darwin"], + "cpu": ["x64"] + }, + "@img/sharp-libvips-darwin-arm64@1.2.4": { + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@img/sharp-libvips-darwin-x64@1.2.4": { + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@img/sharp-libvips-linux-arm64@1.2.4": { + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-libvips-linux-arm@1.2.4": { + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@img/sharp-libvips-linux-ppc64@1.2.4": { + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@img/sharp-libvips-linux-riscv64@1.2.4": { + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@img/sharp-libvips-linux-s390x@1.2.4": { + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@img/sharp-libvips-linux-x64@1.2.4": { + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-libvips-linuxmusl-arm64@1.2.4": { + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-libvips-linuxmusl-x64@1.2.4": { + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-linux-arm64@0.34.5": { + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-arm64" + ], + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-linux-arm@0.34.5": { + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-arm" + ], + "os": ["linux"], + "cpu": ["arm"] + }, + "@img/sharp-linux-ppc64@0.34.5": { + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-ppc64" + ], + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@img/sharp-linux-riscv64@0.34.5": { + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-riscv64" + ], + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@img/sharp-linux-s390x@0.34.5": { + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-s390x" + ], + "os": ["linux"], + "cpu": ["s390x"] + }, + "@img/sharp-linux-x64@0.34.5": { + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-x64" + ], + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-linuxmusl-arm64@0.34.5": { + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "optionalDependencies": [ + "@img/sharp-libvips-linuxmusl-arm64" + ], + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-linuxmusl-x64@0.34.5": { + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "optionalDependencies": [ + "@img/sharp-libvips-linuxmusl-x64" + ], + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-wasm32@0.34.5": { + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "dependencies": [ + "@emnapi/runtime" + ], + "cpu": ["wasm32"] + }, + "@img/sharp-win32-arm64@0.34.5": { + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@img/sharp-win32-ia32@0.34.5": { + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@img/sharp-win32-x64@0.34.5": { + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@jridgewell/resolve-uri@3.1.2": { + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" + }, + "@jridgewell/sourcemap-codec@1.5.5": { + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "@jridgewell/trace-mapping@0.3.9": { + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dependencies": [ + "@jridgewell/resolve-uri", + "@jridgewell/sourcemap-codec" + ] + }, "@longsightgroup/lti-tool@0.1.4_drizzle-orm@0.45.2__kysely@0.29.2_hono@4.12.27_kysely@0.29.2": { "integrity": "sha512-9A/CzLz3roa9ZVHH3PiZs+mj4H0uWDOeW6boY4XjpxQ01Fv17wiilTHxlEzYn2FLtY0GU5ibl8WtmO0WC+oIEQ==", "dependencies": [ @@ -140,6 +360,23 @@ "@opentelemetry/semantic-conventions@1.41.1": { "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==" }, + "@poppinss/colors@4.1.6": { + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dependencies": [ + "kleur" + ] + }, + "@poppinss/dumper@0.6.5": { + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dependencies": [ + "@poppinss/colors", + "@sindresorhus/is", + "supports-color" + ] + }, + "@poppinss/exception@1.2.3": { + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==" + }, "@puppeteer/browsers@2.2.4": { "integrity": "sha512-BdG2qiI1dn89OTUUsx2GZSpUzW+DRffR1wlMJyKxVHYrhnKoELSDxDd+2XImUkuWPEKk76H5FcM/gPFrEK1Tfw==", "dependencies": [ @@ -154,6 +391,12 @@ ], "bin": true }, + "@sindresorhus/is@7.2.0": { + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==" + }, + "@speed-highlight/core@1.2.17": { + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==" + }, "@standard-schema/spec@1.1.0": { "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" }, @@ -208,6 +451,9 @@ "tslib" ] }, + "aws4fetch@1.0.20": { + "integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==" + }, "b4a@1.8.1": { "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==" }, @@ -311,6 +557,9 @@ "ieee754" ] }, + "capnweb@0.8.0": { + "integrity": "sha512-BK/TuXUiyfLSKsmjojn70yN7oYG/JJzoURZ3tckjg5Zj2KcygPm0A5jyOlswK7SYB4f0Gh9tt+RZ132b80iLfA==" + }, "cliui@8.0.1": { "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dependencies": [ @@ -328,6 +577,9 @@ "color-name@1.1.4": { "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "cookie@1.1.1": { + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==" + }, "data-uri-to-buffer@6.0.2": { "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==" }, @@ -348,6 +600,9 @@ "esprima" ] }, + "detect-libc@2.1.2": { + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" + }, "devtools-protocol@0.0.1299070": { "integrity": "sha512-+qtL3eX50qsJ7c+qVyagqi7AWMoQCBGNfoyJZMwm/NSXVqLYbuitrWEEIzxfUmTNy7//Xe8yhMmQ+elj3uAqSg==" }, @@ -372,6 +627,9 @@ "entities@7.0.1": { "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==" }, + "error-stack-parser-es@1.0.5": { + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==" + }, "escalade@3.2.0": { "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" }, @@ -491,12 +749,27 @@ "json-schema-traverse@1.0.0": { "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, + "kleur@4.1.5": { + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==" + }, "kysely@0.29.2": { "integrity": "sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==" }, "lru-cache@7.18.3": { "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==" }, + "miniflare@4.20260708.1": { + "integrity": "sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA==", + "dependencies": [ + "@cspotcode/source-map-support", + "sharp", + "undici", + "workerd", + "ws", + "youch" + ], + "bin": true + }, "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, @@ -580,6 +853,41 @@ "set-cookie-parser@3.1.1": { "integrity": "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==" }, + "sharp@0.34.5": { + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dependencies": [ + "@img/colour", + "detect-libc", + "semver" + ], + "optionalDependencies": [ + "@img/sharp-darwin-arm64", + "@img/sharp-darwin-x64", + "@img/sharp-libvips-darwin-arm64", + "@img/sharp-libvips-darwin-x64", + "@img/sharp-libvips-linux-arm", + "@img/sharp-libvips-linux-arm64", + "@img/sharp-libvips-linux-ppc64", + "@img/sharp-libvips-linux-riscv64", + "@img/sharp-libvips-linux-s390x", + "@img/sharp-libvips-linux-x64", + "@img/sharp-libvips-linuxmusl-arm64", + "@img/sharp-libvips-linuxmusl-x64", + "@img/sharp-linux-arm", + "@img/sharp-linux-arm64", + "@img/sharp-linux-ppc64", + "@img/sharp-linux-riscv64", + "@img/sharp-linux-s390x", + "@img/sharp-linux-x64", + "@img/sharp-linuxmusl-arm64", + "@img/sharp-linuxmusl-x64", + "@img/sharp-wasm32", + "@img/sharp-win32-arm64", + "@img/sharp-win32-ia32", + "@img/sharp-win32-x64" + ], + "scripts": true + }, "smart-buffer@4.2.0": { "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==" }, @@ -623,6 +931,9 @@ "ansi-regex" ] }, + "supports-color@10.2.2": { + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==" + }, "tar-fs@3.1.2": { "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", "dependencies": [ @@ -675,9 +986,24 @@ "undici-types@7.19.2": { "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==" }, + "undici@7.28.0": { + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==" + }, "whatwg-mimetype@3.0.0": { "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==" }, + "workerd@1.20260708.1": { + "integrity": "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w==", + "optionalDependencies": [ + "@cloudflare/workerd-darwin-64", + "@cloudflare/workerd-darwin-arm64", + "@cloudflare/workerd-linux-64", + "@cloudflare/workerd-linux-arm64", + "@cloudflare/workerd-windows-64" + ], + "scripts": true, + "bin": true + }, "wrap-ansi@7.0.0": { "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dependencies": [ @@ -717,6 +1043,23 @@ "fd-slicer" ] }, + "youch-core@0.3.3": { + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dependencies": [ + "@poppinss/exception", + "error-stack-parser-es" + ] + }, + "youch@4.1.0-beta.10": { + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dependencies": [ + "@poppinss/colors", + "@poppinss/dumper", + "@speed-highlight/core", + "cookie", + "youch-core" + ] + }, "zod@4.4.3": { "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==" } @@ -731,6 +1074,7 @@ "jsr:@std/assert@^1.0.19", "jsr:@std/semver@1.0.8", "npm:@cloudflare/puppeteer@1.1.0", + "npm:@cloudflare/sandbox@0.12.3", "npm:@longsightgroup/lti-tool@0.1.4", "npm:ajv@8.20.0", "npm:better-auth@1.6.23", @@ -738,6 +1082,7 @@ "npm:happy-dom@20.10.6", "npm:hono@4.12.27", "npm:jose@6.2.3", + "npm:miniflare@4.20260708.1", "npm:oauth4webapi@3.8.6", "npm:typescript@6.0.3" ] diff --git a/examples/apps/chapter-4-asteroids/manifest.json b/examples/apps/chapter-4-asteroids/manifest.json index e95156b..6ef4d14 100644 --- a/examples/apps/chapter-4-asteroids/manifest.json +++ b/examples/apps/chapter-4-asteroids/manifest.json @@ -1,5 +1,5 @@ { - "schema_version": "1", + "schema_version": "2", "app_id": "chapter-4-asteroids", "version": "0.1.0", "title": "Chapter 4 Asteroids", @@ -8,7 +8,10 @@ "type": "user", "id": "instructor_123" }, - "entrypoint": "/dist/index.html", + "runtime": { + "kind": "browser", + "entrypoint": "/dist/index.html" + }, "roles": ["learner", "instructor"], "install_scope": "course", "capabilities": [ diff --git a/examples/apps/photo-roster/dist/worker.js b/examples/apps/photo-roster/dist/worker.js new file mode 100644 index 0000000..2e94f86 --- /dev/null +++ b/examples/apps/photo-roster/dist/worker.js @@ -0,0 +1,51 @@ +function s(e){let t=e.request.headers.get("x-lantern-runtime-session-id"),a=e.request.headers.get("x-lantern-runtime-session-token"),n=e.request.headers.get("x-lantern-trace-id");return{listRoster(r){return!t||!a?Promise.resolve({ok:!1,error:{code:"session_expired",traceId:crypto.randomUUID()}}):e.binding.listRoster({runtimeSessionId:t,runtimeSessionToken:a,...n===null?{}:{traceId:n},request:r})}}}var b={async fetch(e,t){let n=new URL(e.url).searchParams.get("cursor")??void 0,o=await s({request:e,binding:t.LANTERN_LMS}).listRoster({fields:["email","photo"],...n===void 0?{}:{cursor:n}});return o.ok?l(u(o.value.members,o.value.nextCursor),200):l(h(o.error.code,o.error.traceId),403)}};function u(e,t){let a=e.map(r=>{let o=r.photo===void 0?``:``,c=r.email===void 0?'Email not shared':`${i(r.email)}`;return`
  • + ${o} +
    +

    ${i(r.displayName)}

    +

    ${i(r.roles.map(g).join(", "))}

    +

    ${c}

    +
    +
  • `}).join(""),n=t===void 0?"":``;return p(` +
    +

    Current LMS course

    +

    Photo Roster

    +

    ${e.length} people on this page

    +
    +
      ${a}
    + + `)}function h(e,t){return p(` + + `)}function p(e){return` + + + + + Photo Roster + + +
    ${e}
    +`}function l(e,t){return new Response(e,{status:t,headers:{"content-type":"text/html; charset=UTF-8","cache-control":"private, no-store"}})}function m(e){return e.split(/\s+/).slice(0,2).map(t=>t.slice(0,1).toUpperCase()).join("")}function g(e){switch(e){case"administrator":return"Administrator";case"instructor":return"Instructor";case"learner":return"Learner";case"other":return"Other"}}function i(e){return e.replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}function d(e){return i(e)}export{b as default}; diff --git a/examples/apps/photo-roster/manifest.json b/examples/apps/photo-roster/manifest.json new file mode 100644 index 0000000..b687d5d --- /dev/null +++ b/examples/apps/photo-roster/manifest.json @@ -0,0 +1,29 @@ +{ + "schema_version": "2", + "app_id": "photo-roster", + "version": "0.1.0", + "title": "Photo Roster", + "description": "A governed course roster with names, roles, contact details, and protected profile photos.", + "owner": { + "type": "user", + "id": "lantern" + }, + "runtime": { + "kind": "worker", + "entrypoint": "/dist/worker.js" + }, + "roles": ["instructor"], + "install_scope": "course", + "capabilities": [ + "lms.roster.members.read", + "lms.roster.contact.read", + "lms.roster.profile_photo.read" + ], + "grading": { + "mode": "completion" + }, + "preview": { + "fixtures_file": "/preview/fixtures.json", + "tests_file": "/preview/tests.json" + } +} diff --git a/examples/apps/photo-roster/preview/fixtures.json b/examples/apps/photo-roster/preview/fixtures.json new file mode 100644 index 0000000..f6c3cae --- /dev/null +++ b/examples/apps/photo-roster/preview/fixtures.json @@ -0,0 +1,10 @@ +{ + "launch": { + "user_role": "instructor", + "course_id": "synthetic-course", + "assignment_id": null, + "activity_id": "photo-roster" + }, + "attempt_id": "photo-roster-preview", + "local_state": null +} diff --git a/examples/apps/photo-roster/preview/tests.json b/examples/apps/photo-roster/preview/tests.json new file mode 100644 index 0000000..a527a58 --- /dev/null +++ b/examples/apps/photo-roster/preview/tests.json @@ -0,0 +1,9 @@ +[ + { + "name": "renders the roster heading", + "assert": { + "selector": "h1", + "text": "Photo Roster" + } + } +] diff --git a/examples/apps/photo-roster/source/worker.ts b/examples/apps/photo-roster/source/worker.ts new file mode 100644 index 0000000..e7682f3 --- /dev/null +++ b/examples/apps/photo-roster/source/worker.ts @@ -0,0 +1,154 @@ +import { + createLanternLmsClient, + type LanternLmsCapabilityBinding, + type RosterMember, +} from '../../../../sdk/worker-app-sdk.ts'; + +interface PhotoRosterEnv { + readonly LANTERN_LMS: LanternLmsCapabilityBinding; +} + +export default { + async fetch(request: Request, env: PhotoRosterEnv): Promise { + const url = new URL(request.url); + const cursor = url.searchParams.get('cursor') ?? undefined; + const lms = createLanternLmsClient({ + request, + binding: env.LANTERN_LMS, + }); + const result = await lms.listRoster({ + fields: ['email', 'photo'], + ...(cursor === undefined ? {} : { cursor }), + }); + + if (!result.ok) { + return htmlResponse(renderFailure(result.error.code, result.error.traceId), 403); + } + + return htmlResponse(renderRoster(result.value.members, result.value.nextCursor), 200); + }, +}; + +function renderRoster(members: readonly RosterMember[], nextCursor: string | undefined): string { + const cards = members.map((member) => { + const photo = member.photo === undefined + ? `` + : ``; + const email = member.email === undefined + ? 'Email not shared' + : `${escapeHtml(member.email)}`; + return `
  • + ${photo} +
    +

    ${escapeHtml(member.displayName)}

    +

    ${escapeHtml(member.roles.map(roleLabel).join(', '))}

    +

    ${email}

    +
    +
  • `; + }).join(''); + const next = nextCursor === undefined + ? '' + : ``; + + return page(` +
    +

    Current LMS course

    +

    Photo Roster

    +

    ${members.length} people on this page

    +
    +
      ${cards}
    + + `); +} + +function renderFailure(code: string, traceId: string): string { + const message = code === 'permission_denied' + ? 'An institution administrator must approve this access, and the LMS launch must provide Names and Roles Provisioning.' + : code === 'session_expired' + ? 'This launch has expired. Open Photo Roster from the LMS again.' + : code === 'rate_limited' + ? 'The LMS is limiting roster requests. Try again shortly.' + : 'The LMS roster is unavailable right now.'; + return page(` + + `); +} + +function page(body: string): string { + return ` + + + + + Photo Roster + + +
    ${body}
    +`; +} + +function htmlResponse(body: string, status: number): Response { + return new Response(body, { + status, + headers: { + 'content-type': 'text/html; charset=UTF-8', + 'cache-control': 'private, no-store', + }, + }); +} + +function initials(name: string): string { + return name.split(/\s+/).slice(0, 2).map((part) => part.slice(0, 1).toUpperCase()).join(''); +} + +function roleLabel(role: RosterMember['roles'][number]): string { + switch (role) { + case 'administrator': + return 'Administrator'; + case 'instructor': + return 'Instructor'; + case 'learner': + return 'Learner'; + case 'other': + return 'Other'; + } +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function escapeAttribute(value: string): string { + return escapeHtml(value); +} diff --git a/examples/apps/quick-study/manifest.json b/examples/apps/quick-study/manifest.json index ca5136a..7aca8ab 100644 --- a/examples/apps/quick-study/manifest.json +++ b/examples/apps/quick-study/manifest.json @@ -1,5 +1,5 @@ { - "schema_version": "1", + "schema_version": "2", "app_id": "quick-study", "version": "0.1.0", "title": "Quick Study", @@ -8,7 +8,10 @@ "type": "user", "id": "instructor_123" }, - "entrypoint": "/dist/index.html", + "runtime": { + "kind": "browser", + "entrypoint": "/dist/index.html" + }, "roles": ["learner", "instructor"], "install_scope": "course", "capabilities": [ diff --git a/examples/apps/template/manifest.json b/examples/apps/template/manifest.json index 1a3f6d6..2bdbf82 100644 --- a/examples/apps/template/manifest.json +++ b/examples/apps/template/manifest.json @@ -1,5 +1,5 @@ { - "schema_version": "1", + "schema_version": "2", "app_id": "template-app", "version": "0.1.0", "title": "Template App", @@ -8,7 +8,10 @@ "type": "user", "id": "author_123" }, - "entrypoint": "/dist/index.html", + "runtime": { + "kind": "browser", + "entrypoint": "/dist/index.html" + }, "roles": ["learner", "instructor"], "install_scope": "course", "capabilities": [ diff --git a/examples/apps/typescript-ladder-game/manifest.json b/examples/apps/typescript-ladder-game/manifest.json index 6d2a73d..426f82f 100644 --- a/examples/apps/typescript-ladder-game/manifest.json +++ b/examples/apps/typescript-ladder-game/manifest.json @@ -1,5 +1,5 @@ { - "schema_version": "1", + "schema_version": "2", "app_id": "typescript-ladder-game", "version": "0.2.2", "title": "TypeScript Ladder Game", @@ -8,7 +8,10 @@ "type": "user", "id": "author_123" }, - "entrypoint": "/dist/index.html", + "runtime": { + "kind": "browser", + "entrypoint": "/dist/index.html" + }, "roles": ["learner", "instructor"], "install_scope": "assignment", "capabilities": [ diff --git a/examples/apps/web-checkup/manifest.json b/examples/apps/web-checkup/manifest.json index 01bc4ae..030ff22 100644 --- a/examples/apps/web-checkup/manifest.json +++ b/examples/apps/web-checkup/manifest.json @@ -1,5 +1,5 @@ { - "schema_version": "1", + "schema_version": "2", "app_id": "web-checkup", "version": "0.1.0", "title": "Web Checkup", @@ -8,7 +8,10 @@ "type": "user", "id": "author_123" }, - "entrypoint": "/dist/index.html", + "runtime": { + "kind": "browser", + "entrypoint": "/dist/index.html" + }, "roles": ["learner", "instructor"], "install_scope": "assignment", "capabilities": [ diff --git a/examples/starters/simple-activity/manifest.json b/examples/starters/simple-activity/manifest.json index 1dad4ca..4fd0d86 100644 --- a/examples/starters/simple-activity/manifest.json +++ b/examples/starters/simple-activity/manifest.json @@ -1,5 +1,5 @@ { - "schema_version": "1", + "schema_version": "2", "app_id": "simple-activity-starter", "version": "0.1.0", "title": "Simple Activity Starter", @@ -8,7 +8,10 @@ "type": "user", "id": "author_123" }, - "entrypoint": "/dist/index.html", + "runtime": { + "kind": "browser", + "entrypoint": "/dist/index.html" + }, "roles": ["learner", "instructor"], "install_scope": "course", "capabilities": [ diff --git a/main.ts b/main.ts deleted file mode 100644 index ce35ce9..0000000 --- a/main.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { app } from './src/app.ts'; - -const DEFAULT_PORT = 8417; - -function readPort(): number { - const rawPort = Deno.env.get('PORT'); - - if (rawPort === undefined || rawPort === '') { - return DEFAULT_PORT; - } - - const parsedPort = Number(rawPort); - - if (!Number.isInteger(parsedPort) || parsedPort <= 0 || parsedPort > 65535) { - throw new Error(`Invalid PORT value: ${rawPort}`); - } - - return parsedPort; -} - -const port = readPort(); - -console.log(`Lantern listening on http://localhost:${port}`); - -Deno.serve({ port }, app.fetch); diff --git a/schemas/app-manifest.schema.json b/schemas/app-manifest.schema.json index 5eedf6c..47ab05a 100644 --- a/schemas/app-manifest.schema.json +++ b/schemas/app-manifest.schema.json @@ -10,14 +10,14 @@ "version", "title", "owner", - "entrypoint", + "runtime", "capabilities", "roles", "grading" ], "properties": { "schema_version": { - "const": "1" + "const": "2" }, "app_id": { "type": "string", @@ -53,9 +53,37 @@ } } }, - "entrypoint": { - "type": "string", - "pattern": "^/dist/.+\\.html$" + "runtime": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "entrypoint"], + "properties": { + "kind": { + "const": "browser" + }, + "entrypoint": { + "type": "string", + "pattern": "^/dist/.+\\.html$" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "entrypoint"], + "properties": { + "kind": { + "const": "worker" + }, + "entrypoint": { + "type": "string", + "pattern": "^/dist/.+\\.js$" + } + } + } + ] }, "roles": { "type": "array", @@ -80,8 +108,12 @@ "submit_attempt_event", "submit_evidence_artifact", "finalize_attempt", + "run_code_submission", "read_local_state", - "write_local_state" + "write_local_state", + "lms.roster.members.read", + "lms.roster.contact.read", + "lms.roster.profile_photo.read" ] } }, @@ -91,7 +123,7 @@ "required": ["mode"], "properties": { "mode": { - "enum": ["declarative", "manual", "completion", "browser"] + "enum": ["declarative", "manual", "completion", "browser", "sandbox"] }, "rubric_file": { "type": "string", @@ -128,6 +160,24 @@ "then": { "required": ["max_score"] } + }, + { + "if": { + "properties": { + "mode": { + "const": "sandbox" + } + }, + "required": ["mode"] + }, + "then": { + "properties": { + "max_score": { + "const": 10 + } + }, + "required": ["max_score"] + } } ] }, @@ -169,10 +219,10 @@ "authoring": { "type": "object", "additionalProperties": false, - "required": ["kind", "grader_spec_files", "evidence_example_file"], + "required": ["kind"], "properties": { "kind": { - "const": "browser_autograder" + "enum": ["browser_autograder", "python_function"] }, "grader_spec_files": { "type": "array", @@ -195,6 +245,73 @@ } }, "allOf": [ + { + "if": { + "properties": { + "runtime": { + "type": "object", + "properties": { "kind": { "const": "worker" } }, + "required": ["kind"] + } + }, + "required": ["runtime"] + }, + "then": { + "properties": { + "roles": { + "type": "array", + "items": { "const": "instructor" } + }, + "install_scope": { "const": "course" }, + "capabilities": { + "type": "array", + "items": { + "enum": [ + "lms.roster.members.read", + "lms.roster.contact.read", + "lms.roster.profile_photo.read" + ] + } + }, + "grading": { + "type": "object", + "properties": { "mode": { "const": "completion" } }, + "not": { + "anyOf": [ + { "required": ["rubric_file"] }, + { "required": ["max_score"] } + ] + } + } + }, + "not": { + "anyOf": [ + { "required": ["browser"] }, + { "required": ["content_files"] }, + { "required": ["authoring"] } + ] + } + }, + "else": { + "properties": { + "capabilities": { + "type": "array", + "items": { + "enum": [ + "read_launch_context", + "read_activity_content", + "submit_attempt_event", + "submit_evidence_artifact", + "finalize_attempt", + "run_code_submission", + "read_local_state", + "write_local_state" + ] + } + } + } + } + }, { "if": { "properties": { @@ -218,6 +335,42 @@ } } }, + { + "if": { + "properties": { + "grading": { + "type": "object", + "properties": { + "mode": { + "const": "sandbox" + } + }, + "required": ["mode"] + } + }, + "required": ["grading"] + }, + "then": { + "required": ["authoring"], + "properties": { + "authoring": { + "type": "object", + "required": ["kind"], + "properties": { + "kind": { + "const": "python_function" + } + } + }, + "capabilities": { + "type": "array", + "contains": { + "const": "run_code_submission" + } + } + } + } + }, { "if": { "properties": { diff --git a/sdk/app-sdk.ts b/sdk/app-sdk.ts index e2a93bb..73c6cf9 100644 --- a/sdk/app-sdk.ts +++ b/sdk/app-sdk.ts @@ -1,15 +1,20 @@ export type UserRole = 'learner' | 'instructor'; export type SubmissionMode = 'standard' | 'anonymous_submission'; -export type Capability = +import type { RosterCapability } from './worker-app-sdk.ts'; + +export type BrowserCapability = | 'read_launch_context' | 'read_activity_content' | 'submit_attempt_event' | 'submit_evidence_artifact' | 'finalize_attempt' + | 'run_code_submission' | 'read_local_state' | 'write_local_state'; +export type Capability = BrowserCapability | RosterCapability; + export interface LaunchContext { userRole: UserRole; courseId: string; @@ -172,6 +177,28 @@ export interface GatewayEvidenceArtifactAcceptedResult extends GatewayMutationAc artifactId: string; } +export type CodeSubmissionPhase = 'practice' | 'final'; + +export interface CodeSubmissionPublicOutcome { + label: string; + status: 'passed' | 'failed'; + message: string | null; +} + +export interface GatewayCodeSubmissionAcceptedResult extends GatewayMutationAcceptedResult { + phase: CodeSubmissionPhase; + publicOutcomes: CodeSubmissionPublicOutcome[]; + hiddenSummary: { passed: number; total: number } | null; + scoreGiven: number | null; + scoreMaximum: number | null; + alreadyFinalized: boolean; + gradePublished: boolean; +} + +export type GatewayCodeSubmissionResult = + | GatewayCodeSubmissionAcceptedResult + | GatewayMutationDeniedResult; + export type GatewayEvidenceArtifactResult = | GatewayEvidenceArtifactAcceptedResult | GatewayMutationDeniedResult; @@ -185,6 +212,10 @@ export interface GatewayAppClient { submitEvidenceArtifact(input: EvidenceArtifactUpload): Promise; submitScoreProposal(input: ScoreProposal): Promise; runBrowserGrader(): Promise; + runCodeSubmission(input: { + source: string; + phase: CodeSubmissionPhase; + }): Promise; finalizeAttempt(input: { completionState: 'completed' | 'abandoned'; browserGraderResult?: BrowserGraderResult; diff --git a/sdk/worker-app-sdk.ts b/sdk/worker-app-sdk.ts new file mode 100644 index 0000000..01de3b2 --- /dev/null +++ b/sdk/worker-app-sdk.ts @@ -0,0 +1,88 @@ +export type RosterCapability = + | 'lms.roster.members.read' + | 'lms.roster.contact.read' + | 'lms.roster.profile_photo.read'; + +export type RosterRole = 'learner' | 'instructor' | 'administrator' | 'other'; + +export interface RosterListInput { + readonly fields: readonly ('email' | 'photo')[]; + readonly cursor?: string; +} + +export interface RosterMember { + readonly subjectId: string; + readonly displayName: string; + readonly roles: readonly RosterRole[]; + readonly status: 'active' | 'inactive' | 'unknown'; + readonly email?: string; + readonly photo?: { + readonly url: string; + readonly expiresAt: string; + }; +} + +export interface RosterPage { + readonly members: readonly RosterMember[]; + readonly nextCursor?: string; + readonly observedAt: string; +} + +export type PrimitiveErrorCode = + | 'invalid_request' + | 'permission_denied' + | 'session_expired' + | 'upstream_unavailable' + | 'rate_limited'; + +export type PrimitiveResult = + | { readonly ok: true; readonly value: T } + | { + readonly ok: false; + readonly error: { + readonly code: PrimitiveErrorCode; + readonly traceId: string; + }; + }; + +export interface LanternLmsBinding { + listRoster(input: RosterListInput): Promise>; +} + +export interface LanternLmsCapabilityBinding { + listRoster(input: { + readonly runtimeSessionId: string; + readonly runtimeSessionToken: string; + readonly traceId?: string; + readonly request: RosterListInput; + }): Promise>; +} + +export function createLanternLmsClient(input: { + readonly request: Request; + readonly binding: LanternLmsCapabilityBinding; +}): LanternLmsBinding { + const runtimeSessionId = input.request.headers.get('x-lantern-runtime-session-id'); + const runtimeSessionToken = input.request.headers.get('x-lantern-runtime-session-token'); + const traceId = input.request.headers.get('x-lantern-trace-id'); + + return { + listRoster(request) { + if (!runtimeSessionId || !runtimeSessionToken) { + return Promise.resolve({ + ok: false, + error: { + code: 'session_expired', + traceId: crypto.randomUUID(), + }, + }); + } + return input.binding.listRoster({ + runtimeSessionId, + runtimeSessionToken, + ...(traceId === null ? {} : { traceId }), + request, + }); + }, + }; +} diff --git a/src/admin/app_writer_run_sections.ts b/src/admin/app_writer_run_sections.ts index e62f7b8..a2c5d5b 100644 --- a/src/admin/app_writer_run_sections.ts +++ b/src/admin/app_writer_run_sections.ts @@ -210,7 +210,7 @@ export function renderGeneratedFilesSummary(input: { ].filter((path): path is string => path !== null); const fileRows: Array<{ label: string; path: string }> = [ { label: 'Manifest', path: input.packageVersion.artifact.manifestPath }, - { label: 'Entrypoint', path: input.packageVersion.entrypoint }, + { label: 'Entrypoint', path: input.packageVersion.runtime.entrypoint }, ...contentFiles.map((path) => ({ label: 'Content', path })), ...previewFiles.map((path) => ({ label: 'Preview', path })), { diff --git a/src/admin/control_plane.ts b/src/admin/control_plane.ts deleted file mode 100644 index 21c8503..0000000 --- a/src/admin/control_plane.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { - BrokerVerificationStatus, - CertificationWorkflowStatus, - ControlPlaneDeploymentInventoryRow, - LatestOfficialCertificationEvidence, -} from '../ops/types.ts'; -import type { LanternLtiProfileSettingsRecord } from '../package_review/types.ts'; -import { aggregatePilotUsage, resolveOfficialEvidenceDisplay } from './control_plane_support.ts'; -import { - renderDeploymentInventorySection, - renderInventorySummarySection, - renderPilotUsageSection, -} from './control_plane_sections.ts'; -import { - renderLtiProfileSettingsSection, - renderOfficialEvidenceSection, - renderVerificationChecklistSection, - renderVerificationSummarySection, - renderVerificationUpdateSection, -} from './control_plane_verification_sections.ts'; -import { type AdminIdentity, type AdminNotice, renderAdminLayout } from './layout.ts'; -import { - renderVerificationPageNav, - type VerificationPageSection, -} from './verification_navigation.ts'; - -export function renderDeploymentsPage(input: { - identity: AdminIdentity; - deployments: ControlPlaneDeploymentInventoryRow[]; - notice?: AdminNotice | null; -}): string { - const aggregateUsage = aggregatePilotUsage(input.deployments); - - return renderAdminLayout({ - identity: input.identity, - title: 'Lantern Admin Connections', - eyebrow: 'Connections', - heading: 'Connections', - intro: 'See which app setups are live and which ones need attention.', - activePath: '/admin/deployments', - notice: input.notice ?? null, - body: `${renderInventorySummarySection(input.deployments)} - ${renderPilotUsageSection(aggregateUsage)} - ${renderDeploymentInventorySection(input.deployments)}`, - }); -} - -export function renderVerificationPage(input: { - identity: AdminIdentity; - deployments: ControlPlaneDeploymentInventoryRow[]; - latestBrokerVerification: BrokerVerificationStatus | null; - certificationWorkflowStatuses?: CertificationWorkflowStatus[]; - latestOfficialCertificationEvidence?: LatestOfficialCertificationEvidence | null; - ltiProfileSettings?: LanternLtiProfileSettingsRecord | null; - notice?: AdminNotice | null; - section?: VerificationPageSection; -}): string { - const section = input.section ?? 'checklist'; - const officialEvidence = resolveOfficialEvidenceDisplay({ - latestOfficialCertificationEvidence: input.latestOfficialCertificationEvidence ?? null, - latestBrokerVerification: input.latestBrokerVerification, - }); - const page = resolveVerificationPageMeta(section); - - return renderAdminLayout({ - identity: input.identity, - title: `Lantern Admin ${page.heading}`, - eyebrow: 'Verification', - heading: page.heading, - intro: page.intro, - activePath: page.activePath, - pageNav: renderVerificationPageNav(section), - notice: input.notice ?? null, - body: renderVerificationBody({ - deployments: input.deployments, - certificationWorkflowStatuses: input.certificationWorkflowStatuses ?? [], - officialEvidence, - ltiProfileSettings: input.ltiProfileSettings ?? null, - section, - }), - }); -} - -function renderVerificationBody(input: { - deployments: ControlPlaneDeploymentInventoryRow[]; - certificationWorkflowStatuses: CertificationWorkflowStatus[]; - officialEvidence: ReturnType; - ltiProfileSettings: LanternLtiProfileSettingsRecord | null; - section: VerificationPageSection; -}): string { - switch (input.section) { - case 'official': - return renderOfficialEvidenceSection(input.officialEvidence); - case 'new': - return renderVerificationUpdateSection(input.deployments); - case 'profile': - return renderLtiProfileSettingsSection(input.ltiProfileSettings); - case 'checklist': - return `${ - renderVerificationSummarySection({ - certificationWorkflowStatuses: input.certificationWorkflowStatuses, - officialEvidence: input.officialEvidence, - ltiProfileSettings: input.ltiProfileSettings, - }) - } - ${ - renderVerificationChecklistSection({ - deployments: input.deployments, - certificationWorkflowStatuses: input.certificationWorkflowStatuses, - }) - }`; - } -} - -function resolveVerificationPageMeta(section: VerificationPageSection): { - heading: string; - intro: string; - activePath: string; -} { - switch (section) { - case 'official': - return { - heading: 'Official evidence', - intro: 'Track Product Directory claims without mixing them into the internal checklist.', - activePath: '/admin/verification/official', - }; - case 'new': - return { - heading: 'Add verification result', - intro: 'Record one internal or official result on a page built for data entry.', - activePath: '/admin/verification/new', - }; - case 'profile': - return { - heading: 'Lantern default profile', - intro: 'Set the Lantern-wide LTI baseline without sharing space with checklist work.', - activePath: '/admin/verification/lti-profile', - }; - case 'checklist': - return { - heading: 'Verification', - intro: - 'Review one calm checklist, then move into dedicated pages only when you need official evidence, data entry, or Lantern-wide defaults.', - activePath: '/admin/verification', - }; - } -} diff --git a/src/admin/control_plane_sections.ts b/src/admin/control_plane_sections.ts deleted file mode 100644 index d369b3c..0000000 --- a/src/admin/control_plane_sections.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { approvalStatusClass, approvalStatusLabel } from '../package_review/summary.ts'; -import { summarizePilotUsage } from '../ops/service.ts'; -import type { ControlPlaneDeploymentInventoryRow } from '../ops/types.ts'; -import { escapeHtml } from './layout.ts'; -import { - describeActivitySnapshot, - describeFollowUp, - describeHealthLabel, - describePrimaryAction, - healthStatusClass, -} from './control_plane_support.ts'; -import { formatLmsLabel } from './deployment_detail_ops_labels.ts'; - -export function renderInventorySummarySection( - deployments: ControlPlaneDeploymentInventoryRow[], -): string { - const deploymentsNeedingFollowUp = deployments.filter( - (deployment) => deployment.health.overallStatus !== 'healthy', - ).length; - - return `
    -
    - -

    At a glance

    -
    -
    - Connections - ${escapeHtml(String(deployments.length))} -
    -
    - Healthy now - ${ - escapeHtml( - String( - deployments.filter((deployment) => deployment.health.overallStatus === 'healthy') - .length, - ), - ) - } -
    -
    - Need follow-up - ${escapeHtml(String(deploymentsNeedingFollowUp))} -
    -
    -
    -
    `; -} - -export function renderPilotUsageSection( - aggregateUsage: ControlPlaneDeploymentInventoryRow['pilotUsage'], -): string { - return `
    -
    - -

    Recent usage

    -
    - ${ - summarizePilotUsage(aggregateUsage) - .map( - (fact) => - `
    - ${escapeHtml(fact.label)} - ${escapeHtml(fact.value)} -
    `, - ) - .join('') - } -
    -
    -
    `; -} - -export function renderDeploymentInventorySection( - deployments: ControlPlaneDeploymentInventoryRow[], -): string { - return `
    -
    - -

    All connections

    - ${ - deployments.length === 0 - ? `
    -

    No connections recorded yet

    -

    Lantern has package history, but no reviewed tool has been connected to an LMS yet.

    -
    ` - : `
    - ${deployments.map(renderDeploymentRow).join('')} -
    ` - } -
    -
    `; -} - -function renderDeploymentRow(deployment: ControlPlaneDeploymentInventoryRow): string { - const activityQuery = deployment.binding?.lms - ? `?lms=${encodeURIComponent(deployment.binding.lms)}&view=activity` - : '?view=activity'; - const settingsHref = `/admin/packages/${encodeURIComponent(deployment.appId)}/deployment`; - const activityHref = `/admin/packages/${ - encodeURIComponent( - deployment.appId, - ) - }/deployment${activityQuery}#activity-details`; - const primaryAction = describePrimaryAction(deployment); - const actionMarkup = primaryAction.hrefType === 'activity' - ? `Open settings - ${ - escapeHtml( - primaryAction.label, - ) - }` - : primaryAction.label === 'Open settings' - ? `View launches and problems - ${ - escapeHtml( - primaryAction.label, - ) - }` - : `${ - escapeHtml( - primaryAction.label, - ) - }`; - const healthClass = healthStatusClass(deployment.health.overallStatus); - const approvalMarkup = deployment.approvalStatus === null - ? `Not reviewed` - : `${escapeHtml(approvalStatusLabel(deployment.approvalStatus))}`; - - return `
    -
    -
    -

    - ${escapeHtml(deployment.deploymentLabel)} - ${ - escapeHtml( - describeHealthLabel(deployment.health.overallStatus), - ) - } - ${approvalMarkup} -

    -

    ${escapeHtml(deployment.health.summary)}

    -
    -
    - ${actionMarkup} -
    -
    -
    - LMS ${ - escapeHtml( - deployment.binding === null ? 'Not connected yet' : formatLmsLabel(deployment.binding.lms), - ) - } - Recent people ${ - escapeHtml( - String(deployment.pilotUsage.recentActiveUsers), - ) - } - Live version ${ - escapeHtml( - deployment.enabledPackageVersion ?? 'Not pinned yet', - ) - } - Latest launch ${ - escapeHtml( - describeActivitySnapshot(deployment.lastLaunchStatus, deployment.lastLaunchAt), - ) - } - Next step ${escapeHtml(describeFollowUp(deployment))} -
    -
    `; -} diff --git a/src/admin/control_plane_support.ts b/src/admin/control_plane_support.ts deleted file mode 100644 index 4e4ef2b..0000000 --- a/src/admin/control_plane_support.ts +++ /dev/null @@ -1,347 +0,0 @@ -import type { - BrokerVerificationStatus, - CertificationWorkflowKey, - ControlPlaneDeploymentInventoryRow, - LatestOfficialCertificationEvidence, - OfficialCertificationState, -} from '../ops/types.ts'; -export { - describeSupportedPath, - resolveSupportedPathForDeployment, -} from '../ops/broker_verification_paths.ts'; -import { formatDateTime } from './layout.ts'; - -export interface VerificationOfficialEvidenceDisplay { - state: OfficialCertificationState; - checkedAt: string; - summary: string; - directoryUrl: string | null; - workflowLabel: string | null; -} - -export function aggregatePilotUsage(deployments: ControlPlaneDeploymentInventoryRow[]) { - return deployments.reduce( - (summary, deployment) => ({ - deploymentRecordId: 0, - totalLaunches: summary.totalLaunches + deployment.pilotUsage.totalLaunches, - attemptsStarted: summary.attemptsStarted + deployment.pilotUsage.attemptsStarted, - attemptsCompleted: summary.attemptsCompleted + deployment.pilotUsage.attemptsCompleted, - gradePublishesSucceeded: summary.gradePublishesSucceeded + - deployment.pilotUsage.gradePublishesSucceeded, - gradePublishesFailed: summary.gradePublishesFailed + - deployment.pilotUsage.gradePublishesFailed, - recentActiveUsers: summary.recentActiveUsers + deployment.pilotUsage.recentActiveUsers, - lastLaunchAt: pickLatestTimestamp(summary.lastLaunchAt, deployment.pilotUsage.lastLaunchAt), - measuredAt: pickLatestTimestamp(summary.measuredAt, deployment.pilotUsage.measuredAt) ?? - new Date().toISOString(), - }), - { - deploymentRecordId: 0, - totalLaunches: 0, - attemptsStarted: 0, - attemptsCompleted: 0, - gradePublishesSucceeded: 0, - gradePublishesFailed: 0, - recentActiveUsers: 0, - lastLaunchAt: null, - measuredAt: new Date().toISOString(), - }, - ); -} - -export function resolveOfficialBrokerVerification( - latestBrokerVerification: BrokerVerificationStatus | null, -): BrokerVerificationStatus | null { - if (latestBrokerVerification === null) { - return null; - } - - return { - supportedPath: latestBrokerVerification.supportedPath, - internal: null, - official: latestBrokerVerification.official, - }; -} - -export function resolveOfficialEvidenceDisplay(input: { - latestOfficialCertificationEvidence: LatestOfficialCertificationEvidence | null; - latestBrokerVerification: BrokerVerificationStatus | null; -}): VerificationOfficialEvidenceDisplay | null { - if (input.latestOfficialCertificationEvidence !== null) { - return { - state: input.latestOfficialCertificationEvidence.state, - checkedAt: input.latestOfficialCertificationEvidence.checkedAt, - summary: input.latestOfficialCertificationEvidence.summary, - directoryUrl: input.latestOfficialCertificationEvidence.directoryUrl, - workflowLabel: describeCertificationWorkflowLabel( - input.latestOfficialCertificationEvidence.workflowKey, - ), - }; - } - - const official = input.latestBrokerVerification?.official ?? null; - - if (official === null || official.checkedAt === null || official.directoryUrl === null) { - return null; - } - - return { - state: official.state, - checkedAt: official.checkedAt, - summary: official.state === 'notCertified' - ? 'Latest recorded 1EdTech evidence does not show a certification listing.' - : 'Latest recorded 1EdTech evidence shows the listed certification state.', - directoryUrl: official.directoryUrl, - workflowLabel: null, - }; -} - -export function describeBrokerRunStatus( - status: 'passed' | 'failed' | 'pending' | 'notRun', -): string { - switch (status) { - case 'passed': - return 'Passed'; - case 'failed': - return 'Failed'; - case 'pending': - return 'Pending'; - case 'notRun': - return 'Not run'; - } -} - -export function describeOfficialCertificationState(state: OfficialCertificationState): string { - switch (state) { - case 'notCertified': - return 'Not certified'; - case 'ltiAdvantageCertified': - return 'LTI Advantage Certified'; - case 'ltiAdvantageComplete': - return 'LTI Advantage Complete'; - } -} - -export function describeCertificationWorkflowLabel(workflowKey: CertificationWorkflowKey): string { - switch (workflowKey) { - case 'core': - return 'LTI Core'; - case 'deepLinking': - return 'Deep Linking'; - case 'nrps': - return 'NRPS'; - case 'ags': - return 'AGS'; - } -} - -export function describeCertificationWorkflowShortLabel( - workflowKey: CertificationWorkflowKey, -): string { - switch (workflowKey) { - case 'core': - return 'Core'; - case 'deepLinking': - return 'Deep Linking'; - case 'nrps': - return 'NRPS'; - case 'ags': - return 'AGS'; - } -} - -export function buildDeploymentActivityHref( - deployment: ControlPlaneDeploymentInventoryRow, -): string { - const activityQuery = deployment.binding?.lms - ? `?lms=${encodeURIComponent(deployment.binding.lms)}&view=activity` - : '?view=activity'; - - return `/admin/packages/${ - encodeURIComponent( - deployment.appId, - ) - }/deployment${activityQuery}#activity-details`; -} - -export function describeEnablementState(deployment: ControlPlaneDeploymentInventoryRow): string { - if (deployment.enabledPackageVersionId !== null && deployment.binding !== null) { - return 'Launch-ready'; - } - - if (deployment.enabledPackageVersionId !== null) { - return 'Version pinned, binding missing'; - } - - if (deployment.binding !== null) { - return 'Binding saved, version missing'; - } - - return 'Needs configuration'; -} - -export function describeActivitySnapshot( - status: ControlPlaneDeploymentInventoryRow['lastLaunchStatus'], - occurredAt: string | null, -): string { - if (status === null || occurredAt === null) { - return 'Not recorded yet'; - } - - return `${describeActivityStatus(status)} at ${formatDateTime(occurredAt)}`; -} - -export function describeGradePublicationSnapshot( - status: ControlPlaneDeploymentInventoryRow['lastGradePublishStatus'], - occurredAt: string | null, -): string { - if (status === null || occurredAt === null) { - return 'Not recorded yet'; - } - - return `${describeGradePublicationStatus(status)} at ${formatDateTime(occurredAt)}`; -} - -export function describeFollowUp(deployment: ControlPlaneDeploymentInventoryRow): string { - if (deployment.binding === null) { - return 'Connect LMS'; - } - - if (deployment.enabledPackageVersionId === null) { - return 'Choose live version'; - } - - if (deployment.lastGradePublishStatus === 'failed') { - return 'Retry grade return'; - } - - if (deployment.lastLaunchStatus === 'failed') { - return 'Review launch problem'; - } - - if (deployment.lastLaunchStatus === null) { - return 'Run a test launch'; - } - - if (deployment.health.overallStatus === 'healthy') { - return 'Nothing needed'; - } - - if (deployment.health.overallStatus === 'failed') { - return 'Open settings'; - } - - if (deployment.health.overallStatus === 'attention') { - return 'Open settings'; - } - - return 'Open settings'; -} - -export function describePrimaryAction(deployment: ControlPlaneDeploymentInventoryRow): { - hrefType: 'settings' | 'activity'; - label: string; -} { - if (deployment.binding === null) { - return { - hrefType: 'settings', - label: 'Connect LMS', - }; - } - - if (deployment.enabledPackageVersionId === null) { - return { - hrefType: 'settings', - label: 'Choose live version', - }; - } - - if (deployment.lastGradePublishStatus === 'failed') { - return { - hrefType: 'activity', - label: 'Review grade problem', - }; - } - - if (deployment.lastLaunchStatus === 'failed') { - return { - hrefType: 'activity', - label: 'Review launch problem', - }; - } - - return { - hrefType: 'settings', - label: 'Open settings', - }; -} - -export function healthStatusClass( - status: ControlPlaneDeploymentInventoryRow['health']['overallStatus'], -): string { - switch (status) { - case 'healthy': - return 'status-badge status-approved'; - case 'attention': - return 'status-badge status-pending'; - case 'failed': - return 'status-badge status-rejected'; - case 'unknown': - return 'status-badge status-pending'; - } -} - -export function describeHealthLabel( - status: ControlPlaneDeploymentInventoryRow['health']['overallStatus'], -): string { - switch (status) { - case 'healthy': - return 'Healthy'; - case 'attention': - return 'Needs attention'; - case 'failed': - return 'Blocked'; - case 'unknown': - return 'Not recorded'; - } -} - -function pickLatestTimestamp(left: string | null, right: string | null): string | null { - if (left === null) { - return right; - } - - if (right === null) { - return left; - } - - return left.localeCompare(right) >= 0 ? left : right; -} - -function describeActivityStatus( - status: NonNullable, -): string { - switch (status) { - case 'succeeded': - return 'Succeeded'; - case 'failed': - return 'Failed'; - case 'pending': - return 'Pending'; - case 'notRun': - return 'Not run'; - } -} - -function describeGradePublicationStatus( - status: NonNullable, -): string { - switch (status) { - case 'published': - return 'Published'; - case 'failed': - return 'Failed'; - case 'pending': - return 'Pending'; - } -} diff --git a/src/admin/control_plane_test.ts b/src/admin/control_plane_test.ts deleted file mode 100644 index 4b56d1e..0000000 --- a/src/admin/control_plane_test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; -import { - buildBrokerVerificationStatus, - buildCertificationWorkflowStatus, - buildControlPlaneDeploymentInventoryRow, - buildLatestOfficialCertificationEvidence, - buildOfficialBrokerCertificationStatus, -} from '../test_helpers/package_review.ts'; -import { - buildCanvasDeploymentBinding, - buildMoodleDeploymentBinding, - buildSakaiDeploymentBinding, -} from '../test_helpers/lti.ts'; -import { renderDeploymentsPage, renderVerificationPage } from './control_plane.ts'; - -Deno.test('renderDeploymentsPage keeps deployment operations separate from verification entry', () => { - const html = renderDeploymentsPage({ - identity: ADMIN_TEST_IDENTITY, - deployments: [ - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 1, - deploymentSlug: 'chapter-4-asteroids-pilot', - deploymentLabel: 'Chapter 4 Asteroids Pilot Deployment', - lastGradePublishStatus: 'failed', - }), - ], - }); - - assertStringIncludes(html, 'Connections'); - assertStringIncludes(html, 'Chapter 4 Asteroids Pilot Deployment'); - assertStringIncludes(html, 'Pilot usage'); - assertStringIncludes(html, 'Recent active users'); - assertStringIncludes(html, 'Next step'); - assertStringIncludes(html, 'Retry grade return'); - assertStringIncludes(html, 'Review grade problem'); - assertStringIncludes(html, 'Open settings'); - assertStringIncludes(html, 'view=activity#activity-details'); - assertEquals(html.includes('Broker verification'), false); - assertEquals(html.includes('Save check result'), false); -}); - -Deno.test('renderDeploymentsPage shows one clear setup action when an LMS is not connected yet', () => { - const deployment = buildControlPlaneDeploymentInventoryRow({ - deploymentId: 2, - deploymentSlug: 'chapter-4-asteroids-moodle', - deploymentLabel: 'Chapter 4 Asteroids Moodle Deployment', - lastLaunchAt: null, - lastLaunchStatus: null, - lastGradePublishAt: null, - lastGradePublishStatus: null, - }); - deployment.binding = null; - deployment.enabledPackageVersionId = null; - deployment.enabledPackageVersion = null; - - const html = renderDeploymentsPage({ identity: ADMIN_TEST_IDENTITY, deployments: [deployment] }); - - assertStringIncludes(html, 'Next step'); - assertStringIncludes(html, 'Connect LMS'); - assertEquals(html.includes('View launches and problems'), false); -}); - -Deno.test('renderVerificationPage shows deployment-scoped verification facts while keeping official evidence separate', () => { - const html = renderVerificationPage({ - identity: ADMIN_TEST_IDENTITY, - deployments: [ - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 1, - deploymentSlug: 'chapter-4-asteroids-pilot', - deploymentLabel: 'Chapter 4 Asteroids Pilot Deployment', - binding: buildCanvasDeploymentBinding(), - brokerVerification: buildBrokerVerificationStatus({ - internal: { - source: 'manual', - status: 'passed', - checkedAt: '2026-03-24T12:50:00Z', - summary: 'Canvas deployment verification passed.', - evidenceUrl: 'https://example.test/internal-proof', - }, - }), - }), - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 2, - deploymentSlug: 'chapter-4-asteroids-moodle', - deploymentLabel: 'Chapter 4 Asteroids Moodle Deployment', - binding: buildMoodleDeploymentBinding(), - brokerVerification: buildBrokerVerificationStatus({ - supportedPath: 'lti13LaunchAgsScore', - internal: { - source: 'ci', - status: 'failed', - checkedAt: '2026-03-24T12:40:00Z', - summary: 'Moodle deployment verification failed.', - evidenceUrl: 'https://example.test/moodle-proof', - }, - }), - }), - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 3, - deploymentSlug: 'chapter-4-asteroids-sakai', - deploymentLabel: 'Chapter 4 Asteroids Sakai Deployment', - binding: buildSakaiDeploymentBinding(), - brokerVerification: buildBrokerVerificationStatus({ - supportedPath: 'lti13LaunchAgsScore', - internal: null, - }), - }), - ], - latestBrokerVerification: buildBrokerVerificationStatus({ - supportedPath: 'lti13LaunchAgsScore', - internal: null, - official: buildOfficialBrokerCertificationStatus({ - state: 'notCertified', - checkedAt: '2026-03-24T12:55:00Z', - directoryUrl: 'https://example.test/official-directory', - }), - }), - certificationWorkflowStatuses: [ - buildCertificationWorkflowStatus({ - workflowKey: 'core', - latestInternal: { - deploymentRecordId: 1, - deploymentLabel: 'Chapter 4 Asteroids Pilot Deployment', - status: 'passed', - checkedAt: '2026-03-24T12:50:00Z', - summary: 'Canvas deployment verification passed.', - evidenceUrl: 'https://example.test/internal-proof', - }, - }), - buildCertificationWorkflowStatus({ - workflowKey: 'deepLinking', - latestInternal: { - deploymentRecordId: 2, - deploymentLabel: 'Chapter 4 Asteroids Moodle Deployment', - status: 'failed', - checkedAt: '2026-03-24T12:40:00Z', - summary: 'Moodle deployment verification failed.', - evidenceUrl: 'https://example.test/moodle-proof', - }, - }), - buildCertificationWorkflowStatus({ - workflowKey: 'nrps', - latestInternal: null, - }), - buildCertificationWorkflowStatus({ - workflowKey: 'ags', - latestInternal: null, - }), - ], - latestOfficialCertificationEvidence: buildLatestOfficialCertificationEvidence({ - workflowKey: 'core', - state: 'notCertified', - checkedAt: '2026-03-24T12:55:00Z', - summary: 'Latest recorded 1EdTech evidence does not show a certification listing.', - directoryUrl: 'https://example.test/official-directory', - }), - ltiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-24T13:00:00Z', - }, - }); - - assertStringIncludes(html, 'Verification overview'); - assertStringIncludes(html, 'Keep each verification job on its own page'); - assertStringIncludes(html, 'Certification checklist'); - assertStringIncludes(html, 'Saved internal workflows'); - assertStringIncludes(html, 'Official evidence'); - assertStringIncludes(html, 'Lantern default'); - assertStringIncludes(html, 'LTI Core'); - assertStringIncludes(html, 'Deep Linking'); - assertStringIncludes(html, 'NRPS'); - assertStringIncludes(html, 'AGS'); - assertStringIncludes(html, 'Chapter 4 Asteroids Pilot Deployment'); - assertStringIncludes(html, 'Chapter 4 Asteroids Moodle Deployment'); - assertStringIncludes(html, 'Canvas deployment verification passed.'); - assertStringIncludes(html, 'Moodle deployment verification failed.'); - assertStringIncludes(html, 'A passed Core row does not cover Deep Linking, NRPS, or AGS.'); - assertStringIncludes(html, 'No internal evidence has been recorded for NRPS yet.'); - assertStringIncludes(html, 'Not certified'); - assertStringIncludes(html, 'https://example.test/internal-proof'); - assertStringIncludes(html, 'https://example.test/moodle-proof'); - assertStringIncludes(html, 'href="/admin/verification/new"'); - assertStringIncludes(html, 'href="/admin/verification/official"'); - assertStringIncludes(html, 'href="/admin/verification/lti-profile"'); - assertStringIncludes(html, 'Certification'); - assertEquals(html.includes('Deployment inventory'), false); - assertEquals(html.includes('Supported Canvas path'), false); - assertEquals(html.includes('Official 1EdTech listing'), false); - assertEquals(html.includes('action="/admin/verification"'), false); - assertEquals(html.includes('action="/admin/verification/lti-profile"'), false); -}); - -Deno.test('renderVerificationPage renders official evidence on its own dedicated page', () => { - const html = renderVerificationPage({ - identity: ADMIN_TEST_IDENTITY, - deployments: [buildControlPlaneDeploymentInventoryRow({ deploymentId: 1 })], - latestBrokerVerification: buildBrokerVerificationStatus({ - supportedPath: 'lti13LaunchAgsScore', - internal: null, - official: buildOfficialBrokerCertificationStatus({ - state: 'ltiAdvantageCertified', - checkedAt: '2026-03-24T12:55:00Z', - directoryUrl: 'https://example.test/official-directory', - }), - }), - certificationWorkflowStatuses: [], - latestOfficialCertificationEvidence: buildLatestOfficialCertificationEvidence({ - workflowKey: 'core', - state: 'ltiAdvantageCertified', - checkedAt: '2026-03-24T12:55:00Z', - summary: '1EdTech lists Lantern as LTI Advantage Certified for Core.', - directoryUrl: 'https://example.test/official-directory', - }), - ltiProfileSettings: null, - section: 'official', - }); - - assertStringIncludes(html, 'Official 1EdTech listing'); - assertStringIncludes(html, 'Claim boundary'); - assertStringIncludes(html, 'Open directory entry'); - assertEquals(html.includes('Certification checklist'), false); - assertEquals(html.includes('action="/admin/verification"'), false); -}); diff --git a/src/admin/control_plane_verification_sections.ts b/src/admin/control_plane_verification_sections.ts deleted file mode 100644 index 632e1e5..0000000 --- a/src/admin/control_plane_verification_sections.ts +++ /dev/null @@ -1,562 +0,0 @@ -import type { - CertificationWorkflowKey, - CertificationWorkflowStatus, - ControlPlaneDeploymentInventoryRow, -} from '../ops/types.ts'; -import { - DEFAULT_LTI_PROFILE_ID, - getLtiProfileDefinition, - LTI_PROFILE_DEFINITIONS, -} from '../lti/profile.ts'; -import { - BROKER_VERIFICATION_SUPPORTED_PATHS, - describeSupportedPath, -} from '../ops/broker_verification_paths.ts'; -import type { LanternLtiProfileSettingsRecord } from '../package_review/types.ts'; -import { escapeHtml, formatDateTime } from './layout.ts'; -import { - buildDeploymentActivityHref, - describeBrokerRunStatus, - describeCertificationWorkflowShortLabel, - describeOfficialCertificationState, - type VerificationOfficialEvidenceDisplay, -} from './control_plane_support.ts'; - -interface CertificationChecklistItem { - workflowKey: CertificationWorkflowKey; - label: string; -} - -const CERTIFICATION_CHECKLIST: readonly CertificationChecklistItem[] = [ - { workflowKey: 'core', label: 'LTI Core' }, - { workflowKey: 'deepLinking', label: 'Deep Linking' }, - { workflowKey: 'nrps', label: 'NRPS' }, - { workflowKey: 'ags', label: 'AGS' }, -] as const; - -export function renderVerificationSummarySection(input: { - certificationWorkflowStatuses: CertificationWorkflowStatus[]; - officialEvidence: VerificationOfficialEvidenceDisplay | null; - ltiProfileSettings: LanternLtiProfileSettingsRecord | null; -}): string { - const currentProfile = getLtiProfileDefinition( - input.ltiProfileSettings?.defaultLtiProfile ?? DEFAULT_LTI_PROFILE_ID, - ); - const recordedWorkflows = input.certificationWorkflowStatuses.filter( - (status) => status.latestInternal !== null, - ).length; - - return `
    -
    -
    -
    - -

    Keep each verification job on its own page

    -

    The checklist keeps only internal workflow results. Official Product Directory evidence and Lantern-wide defaults now live on dedicated pages.

    -
    - -
    -
    -
    - Saved internal workflows - ${recordedWorkflows} of ${CERTIFICATION_CHECKLIST.length} recorded -

    Each workflow stores only its latest internal result.

    -
    -
    - Official directory - ${ - escapeHtml( - input.officialEvidence === null - ? 'No official claim recorded' - : describeOfficialCertificationState(input.officialEvidence.state), - ) - } -

    ${ - escapeHtml( - input.officialEvidence?.summary ?? - 'No 1EdTech Product Directory evidence is recorded yet.', - ) - }

    -
    -
    - Lantern default - ${escapeHtml(currentProfile.label)} -

    ${escapeHtml(currentProfile.summary)}

    -
    -
    -
    -
    `; -} - -export function renderVerificationChecklistSection(input: { - deployments: ControlPlaneDeploymentInventoryRow[]; - certificationWorkflowStatuses: CertificationWorkflowStatus[]; -}): string { - const workflowStatuses = new Map( - input.certificationWorkflowStatuses.map((status) => [status.workflowKey, status] as const), - ); - - return `
    -
    -
    -
    - -

    Certification checklist

    -

    Review one workflow at a time. A passed result never spills into a different workflow.

    -
    - -
    -
    - ${ - CERTIFICATION_CHECKLIST.map((item) => - renderCertificationChecklistRow({ - item, - status: workflowStatuses.get(item.workflowKey) ?? null, - deployments: input.deployments, - }) - ).join('') - } -
    -
    -
    `; -} - -export function renderOfficialEvidenceSection( - officialEvidence: VerificationOfficialEvidenceDisplay | null, -): string { - const latestCheckedAt = officialEvidence === null - ? 'Not recorded yet' - : formatDateTime(officialEvidence.checkedAt); - - return `
    -
    -
    -
    - -

    Official 1EdTech listing

    -

    Only the 1EdTech Product Directory supports an official certification claim. Internal checklist rows stay separate.

    -
    -
    - Add result - ${ - officialEvidence?.directoryUrl - ? `Open directory entry` - : '' - } -
    -
    -
    -
    - Product Directory status - ${ - escapeHtml( - officialEvidence === null - ? 'No official claim recorded' - : describeOfficialCertificationState(officialEvidence.state), - ) - } -

    Internal checks can support readiness work, but they never become an official claim.

    -
    -
    - Covers workflow - ${ - escapeHtml( - officialEvidence?.workflowLabel ?? 'No workflow recorded', - ) - } -

    Save one Product Directory result against the workflow it actually covers.

    -
    -
    - Latest official evidence - ${escapeHtml(latestCheckedAt)} -

    ${ - escapeHtml( - officialEvidence?.summary ?? - 'Lantern has no recorded 1EdTech Product Directory evidence yet.', - ) - }

    -
    -
    -
    -

    Claim boundary

    -

    A passed Core, Deep Linking, NRPS, or AGS row does not claim official certification on its own. Only the dated Product Directory entry on this page can do that.

    -
    -
    -
    `; -} - -export function renderVerificationUpdateSection( - deployments: ControlPlaneDeploymentInventoryRow[], -): string { - return `
    -
    -
    -
    - -

    Record one verification result

    -

    Save one workflow result at a time. Internal checks need a specific deployment. Leave deployment blank only for Product Directory evidence.

    -
    - -
    -
    -
    - Internal result - CI or manual workflow evidence -

    Choose one deployment and one workflow. Lantern stores the latest internal result for that workflow only.

    -
    -
    - Official result - 1EdTech Product Directory evidence -

    Use the directory URL and official certification state. Leave deployment blank for this case.

    -
    -
    - ${renderBrokerVerificationForm(deployments)} -
    -
    `; -} - -export function renderLtiProfileSettingsSection( - settings: LanternLtiProfileSettingsRecord | null, -): string { - const currentProfile = getLtiProfileDefinition( - settings?.defaultLtiProfile ?? DEFAULT_LTI_PROFILE_ID, - ); - const savedAt = settings?.updatedAt === undefined || settings?.updatedAt === null - ? 'Not recorded yet' - : formatDateTime(settings.updatedAt); - - return `
    -
    -
    -
    - -

    Lantern default profile

    -

    Choose the baseline LTI behavior Lantern should enforce unless one deployment saves its own explicit override.

    -
    - -
    -
    -
    - Current Lantern default - ${escapeHtml(currentProfile.label)} -

    ${escapeHtml(currentProfile.summary)}

    -
    -
    - Deployment overrides - Allowed -

    An individual LMS setup can inherit this default or save one explicit profile on the app settings page.

    -
    -
    - Last saved - ${escapeHtml(savedAt)} -

    This Lantern-wide setting applies before any deployment-specific override.

    -
    -
    - ${renderLtiProfileSettingsForm(currentProfile.id)} -
    -
    `; -} - -function renderBrokerVerificationForm(deployments: ControlPlaneDeploymentInventoryRow[]): string { - return `
    -
    -
    - - -
    -
    - - -

    Leave this blank only for an official 1EdTech listing.

    -
    -
    - - -
    -
    - - -

    Save each result against exactly one workflow. A passed AGS row does not also cover NRPS, Core, or Deep Linking.

    -
    -
    - - -
    -
    - - -

    Enter the evidence timestamp in ISO-8601 format.

    -
    -
    - - -

    Link to CI logs, operator notes, or the official 1EdTech directory entry.

    -
    -
    - - -

    Keep the wording factual. Lantern should not claim official certification from internal checks alone.

    -
    -
    - Official listing details -
    -
    - - -

    Use this only when the 1EdTech directory shows a certified state.

    -
    -
    -
    -
    -
    - -
    -
    `; -} - -function renderLtiProfileSettingsForm(currentProfileId: string): string { - return `
    -
    - LTI profile -
    - ${ - LTI_PROFILE_DEFINITIONS.map( - (profile) => - ``, - ).join('') - } -
    -

    This is the saved Lantern-wide default. Deployment pages can still choose “Use Lantern default” or save one explicit override.

    -
    -
    - -
    -
    `; -} - -function renderCertificationChecklistRow(input: { - item: CertificationChecklistItem; - status: CertificationWorkflowStatus | null; - deployments: ControlPlaneDeploymentInventoryRow[]; -}): string { - const internal = input.status?.latestInternal ?? null; - const guidanceDeployment = resolveGuidanceDeployment( - input.deployments, - internal?.deploymentRecordId ?? null, - ); - const guidance = resolveWorkflowGuidance(input.item.workflowKey, guidanceDeployment); - const statusLabel = internal === null ? 'Not recorded' : describeBrokerRunStatus(internal.status); - const checkedAt = internal === null - ? 'Checked at Not recorded yet' - : `Checked ${formatDateTime(internal.checkedAt)}`; - const summary = internal?.summary ?? - `No internal evidence has been recorded for ${ - describeCertificationWorkflowShortLabel( - input.item.workflowKey, - ) - } yet.`; - const evidenceSetup = internal?.deploymentLabel ?? 'No internal evidence recorded'; - - return `
    -
    -

    - ${escapeHtml(input.item.label)} - ${escapeHtml(statusLabel)} -

    -

    ${escapeHtml(checkedAt)}

    -
    -

    ${escapeHtml(summary)}

    -

    ${escapeHtml(describeWorkflowBoundaryNote(input.item.workflowKey))}

    -
    - Latest internal evidence: ${escapeHtml(evidenceSetup)} - Run guidance: ${escapeHtml(guidance.copy)} -
    -
    - ${ - internal?.evidenceUrl - ? `Open internal evidence` - : '' - } - ${ - guidance.href - ? `${ - escapeHtml( - guidance.label, - ) - }` - : '' - } -
    -
    `; -} - -function resolveGuidanceDeployment( - deployments: ControlPlaneDeploymentInventoryRow[], - deploymentRecordId: number | null, -): ControlPlaneDeploymentInventoryRow | null { - if (deploymentRecordId !== null) { - const matchingDeployment = deployments.find( - (deployment) => deployment.deploymentId === deploymentRecordId, - ); - - if (matchingDeployment) { - return matchingDeployment; - } - } - - return deployments.find((deployment) => deployment.binding !== null) ?? deployments[0] ?? null; -} - -function resolveWorkflowGuidance( - workflowKey: CertificationWorkflowKey, - deployment: ControlPlaneDeploymentInventoryRow | null, -): { - copy: string; - href: string | null; - label: string; -} { - switch (workflowKey) { - case 'core': - return { - copy: - 'Run the LTI Core workflow in the official 1EdTech suite. Save the latest result here after the suite finishes.', - href: 'https://www.imsglobal.org/spec/lti/v1p3/cert/', - label: 'Open Core suite guidance', - }; - case 'deepLinking': - return { - copy: - 'Run the Deep Linking workflow in the official 1EdTech suite. Save the latest result here after the suite finishes.', - href: 'https://www.imsglobal.org/spec/lti/v1p3/cert/', - label: 'Open Deep Linking suite guidance', - }; - case 'nrps': - return deployment === null - ? { - copy: - 'Save one app setup, then open its deployment activity page to run or review the roster check.', - href: null, - label: 'Open deployment activity', - } - : { - copy: - "Open the deployment activity page to run or review the saved roster check from Lantern's existing SSR surface.", - href: buildDeploymentActivityHref(deployment), - label: 'Open deployment activity', - }; - case 'ags': - return deployment === null - ? { - copy: - 'Save one app setup, then open its deployment activity page to run or review the AGS smoke check.', - href: null, - label: 'Open deployment activity', - } - : { - copy: - "Open the deployment activity page to run or review the saved AGS smoke check from Lantern's existing SSR surface.", - href: buildDeploymentActivityHref(deployment), - label: 'Open deployment activity', - }; - } -} - -function describeWorkflowBoundaryNote(workflowKey: CertificationWorkflowKey): string { - switch (workflowKey) { - case 'core': - return 'A passed Core row does not cover Deep Linking, NRPS, or AGS.'; - case 'deepLinking': - return 'A passed Deep Linking row does not cover Core, NRPS, or AGS.'; - case 'nrps': - return 'A passed NRPS row does not cover Core, Deep Linking, or AGS.'; - case 'ags': - return 'A passed AGS row does not cover Core, Deep Linking, or NRPS.'; - } -} diff --git a/src/admin/deep_linking_picker.ts b/src/admin/deep_linking_picker.ts deleted file mode 100644 index e78aacf..0000000 --- a/src/admin/deep_linking_picker.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { buildDeepLinkingSelectionValue } from '../lti/deep_linking.ts'; -import type { DeepLinkingSessionRecord } from '../lti/types.ts'; -import type { - DeepLinkingResourceOption, - DeepLinkingResourceSelection, -} from '../package_review/types.ts'; -import { DEEP_LINKING_PICKER_STYLES } from './deep_linking_picker_styles.ts'; -import { type AdminNotice, escapeHtml, formatDateTime } from './layout.ts'; - -export function renderDeepLinkingPickerPage(input: { - session?: Pick< - DeepLinkingSessionRecord, - 'appId' | 'contextTitle' | 'deploymentSlug' | 'expiresAt' | 'placement' - >; - sessionId?: string; - token?: string; - resources: DeepLinkingResourceOption[]; - selection: DeepLinkingResourceSelection | null; - notice?: AdminNotice | null; -}): string { - const session = input.session ?? { - appId: 'app', - contextTitle: null, - deploymentSlug: 'deployment', - expiresAt: null, - placement: 'assignment_selection', - }; - const scopeLabel = session.placement === 'resource_selection' ? 'course' : 'assignment'; - const scopeResourceLabel = session.placement === 'resource_selection' - ? 'course resource' - : 'assignment resource'; - const placementLabel = session.placement === 'resource_selection' - ? 'course placement' - : 'assignment placement'; - const saveAction = input.sessionId - ? `/lti/deep-linking/sessions/${encodeURIComponent(input.sessionId)}` - : '#'; - const submitAction = input.sessionId - ? `/lti/deep-linking/sessions/${encodeURIComponent(input.sessionId)}/submit` - : '#'; - const previewAction = input.sessionId - ? `/lti/deep-linking/sessions/${encodeURIComponent(input.sessionId)}/preview` - : '#'; - const canSave = input.resources.length > 0; - const canReturn = input.selection !== null && input.sessionId !== undefined && - input.token !== undefined; - const canPreview = canReturn; - const returnStateCopy = input.selection === null - ? `Save one reviewed ${scopeResourceLabel} before returning it to the LMS.` - : input.sessionId === undefined || input.token === undefined - ? 'Return to LMS is unavailable until Lantern can verify this session.' - : `Ready to return this reviewed ${scopeResourceLabel} to the LMS.`; - const previewStateCopy = - `Open this reviewed ${scopeResourceLabel} in Lantern preview without consuming the LMS return.`; - - return ` - - - - - Lantern Deep Linking Picker - - - -
    -
    -
    -
    -

    Lantern Deep Linking

    -

    Select one reviewed resource for ${placementLabel}.

    -

    - Lantern keeps the reviewed package version and content path explicit here. - Save one reviewed selection first, then return that saved choice to the LMS from a separate verified action. -

    -
    -
    -
    - Course context - ${escapeHtml(session.contextTitle ?? 'LMS context')} -
    -
    - Bound app - ${escapeHtml(session.appId)} -
    -
    - Deployment - ${escapeHtml(session.deploymentSlug)} -
    -
    - Session expiry - ${ - escapeHtml( - formatDateTime(session.expiresAt ?? null), - ) - } -
    -
    - ${renderNotice(input.notice ?? null)} -
    -
    -
    - - ${ - input.selection === null - ? `
    - No reviewed resource selected yet. -

    Choose one approved ${scopeLabel} resource below. Lantern will store the reviewed version and canonical content path explicitly.

    -
    ` - : `
    - ${escapeHtml(input.selection.packageTitle)} ${ - escapeHtml( - input.selection.packageVersion, - ) - } -

    ${escapeHtml(input.selection.contentTitle ?? input.selection.contentPath)}

    -

    ${escapeHtml(input.selection.contentPath)}

    -
    ` - } -
    -
    - -
    - ${ - input.token === undefined - ? '' - : `` - } - ${ - input.resources.length === 0 - ? `
    - No approved ${scopeLabel}-scope reviewed resources are available for this app yet. -
    ` - : `
    - ${ - input.resources - .map((resource) => renderResourceCard(resource, input.selection)) - .join('') - } -
    ` - } -
    - -
    -
    - ${ - canPreview - ? `
    - ${ - input.token === undefined - ? '' - : `` - } -
    - - ${escapeHtml(previewStateCopy)} -
    -
    ` - : '' - } -
    - ${ - input.token === undefined - ? '' - : `` - } -
    - - ${escapeHtml(returnStateCopy)} -
    -
    -
    -
    -
    -
    - -`; -} - -function renderNotice(notice: AdminNotice | null): string { - if (notice === null) { - return ''; - } - - return `
    -

    ${escapeHtml(notice.title)}

    -

    ${escapeHtml(notice.detail)}

    -
    `; -} - -function renderResourceCard( - resource: DeepLinkingResourceOption, - selection: DeepLinkingResourceSelection | null, -): string { - const checked = selection !== null && - selection.packageVersionId === resource.packageVersionId && - selection.contentPath === resource.contentPath; - - return ``; -} diff --git a/src/admin/deep_linking_picker_styles.ts b/src/admin/deep_linking_picker_styles.ts deleted file mode 100644 index c206e94..0000000 --- a/src/admin/deep_linking_picker_styles.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { PICO_CSS } from '../styles/pico_css.ts'; - -export const DEEP_LINKING_PICKER_STYLES = `${PICO_CSS} - :root { - color-scheme: light; - --font: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; - --pico-font-family: var(--font); - --pico-primary: #14532d; - --pico-primary-hover: #166534; - --pico-primary-background: #14532d; - --pico-primary-hover-background: #166534; - --pico-primary-border: #14532d; - --pico-primary-hover-border: #166534; - --bg: linear-gradient(180deg, #f6f8fb 0%, #eef2f7 100%); - --surface: #ffffff; - --surface-soft: #f8fafc; - --ink: #0f172a; - --muted: #475569; - --line: #d9e2ec; - --accent: #14532d; - --accent-soft: #e8f5eb; - --accent-strong: #166534; - --warn: #9a3412; - --warn-soft: #fff3ec; - --error: #b42318; - --error-soft: #fef3f2; - --shadow: 0 18px 40px rgba(15, 23, 42, 0.08); - --radius: 18px; - --radius-sm: 12px; - } - * { - box-sizing: border-box; - } - html, body { - margin: 0; - min-height: 100%; - } - body { - font: 14px/1.55 var(--font); - color: var(--ink); - background: var(--bg); - padding: 24px; - } - main { - max-width: 920px; - margin: 0 auto; - } - .shell { - background: rgba(255, 255, 255, 0.72); - border: 1px solid rgba(217, 226, 236, 0.9); - border-radius: 28px; - padding: 20px; - box-shadow: var(--shadow); - backdrop-filter: blur(18px); - } - .hero { - display: grid; - gap: 16px; - padding: 22px; - border-radius: 22px; - background: - radial-gradient(circle at top right, rgba(20, 83, 45, 0.12), transparent 36%), - linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.94)); - border: 1px solid rgba(217, 226, 236, 0.85); - } - .eyebrow { - margin: 0; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--accent-strong); - } - h1 { - margin: 0; - font-size: clamp(1.8rem, 4vw, 2.6rem); - line-height: 1.05; - letter-spacing: -0.03em; - } - .hero-copy { - margin: 0; - max-width: 62ch; - color: var(--muted); - } - .facts { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; - } - - .fact { - padding: 14px; - border-radius: 16px; - background: var(--surface); - border: 1px solid var(--line); - } - - .fact-label { - display: block; - margin-bottom: 6px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--muted); - } - - .fact-value { - font-size: 15px; - font-weight: 600; - } - - .notice { - margin-top: 16px; - padding: 14px 16px; - border-radius: 16px; - border: 1px solid var(--line); - } - - .notice.error { - background: var(--error-soft); - border-color: rgba(180, 35, 24, 0.18); - } - - .notice.success { - background: var(--accent-soft); - border-color: rgba(20, 83, 45, 0.16); - } - - .notice.note { - background: var(--warn-soft); - border-color: rgba(154, 52, 18, 0.16); - } - - .notice h2 { - margin: 0 0 6px; - font-size: 1rem; - } - - .notice p { - margin: 0; - color: var(--muted); - } - - .layout { - display: grid; - gap: 18px; - margin-top: 18px; - } - - .selection-panel, - .resource-panel { - background: var(--surface); - border: 1px solid var(--line); - border-radius: 22px; - padding: 18px; - } - - .section-label { - margin: 0 0 8px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--muted); - } - - .selection-summary { - padding: 14px; - border-radius: 16px; - background: var(--surface-soft); - border: 1px solid var(--line); - } - - .selection-summary strong { - display: block; - margin-bottom: 4px; - font-size: 0.95rem; - } - - .selection-summary p { - margin: 0; - color: var(--muted); - } - - .resource-list { - display: grid; - gap: 12px; - } - - .resource-card { - display: grid; - grid-template-columns: auto 1fr; - gap: 14px; - padding: 16px; - border-radius: 18px; - border: 1px solid var(--line); - background: var(--surface); - cursor: pointer; - } - - .resource-card.selected { - border-color: rgba(20, 83, 45, 0.34); - background: linear-gradient(180deg, #ffffff 0%, #f4fbf6 100%); - box-shadow: inset 0 0 0 1px rgba(20, 83, 45, 0.06); - } - - .resource-card input { - margin-top: 4px; - } - - .resource-card h2 { - margin: 0 0 4px; - font-size: 1.05rem; - } - - .resource-kicker, - .resource-meta, - .resource-path { - margin: 0; - } - - .resource-kicker { - color: var(--accent-strong); - font-size: 12px; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; - } - - .resource-path { - margin-top: 8px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12px; - color: var(--muted); - } - - .resource-meta { - color: var(--muted); - } - - .actions { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin-top: 16px; - } - - button, - .phase-note { - border-radius: 999px; - padding: 12px 18px; - font: inherit; - } - - button { - border: none; - cursor: pointer; - } - - .button-primary { - background: var(--accent); - color: white; - font-weight: 600; - } - - .button-primary:disabled { - cursor: not-allowed; - opacity: 0.55; - } - - .phase-note { - display: inline-flex; - align-items: center; - background: var(--surface-soft); - border: 1px solid var(--line); - color: var(--muted); - } - - .empty { - padding: 18px; - border-radius: 18px; - background: var(--surface-soft); - border: 1px dashed var(--line); - color: var(--muted); - } - - @media (max-width: 720px) { - body { - padding: 12px; - } - - .shell { - padding: 12px; - border-radius: 20px; - } - - .hero, - .selection-panel, - .resource-panel { - padding: 16px; - } - } -`; diff --git a/src/admin/deep_linking_picker_test.ts b/src/admin/deep_linking_picker_test.ts deleted file mode 100644 index 625c960..0000000 --- a/src/admin/deep_linking_picker_test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { renderDeepLinkingPickerPage } from './deep_linking_picker.ts'; -import { - buildDeepLinkingResourceOption, - buildDeepLinkingResourceSelection, -} from '../test_helpers/package_review.ts'; - -Deno.test('deep linking picker fixtures expose approved assignment resources with canonical content paths', () => { - const option = buildDeepLinkingResourceOption(); - - assertEquals(option.installScope, 'assignment'); - assertEquals(option.approvalStatus, 'approved'); - assertEquals(option.contentPath, '/content/activity.json'); - assertEquals(option.activityId, '/content/activity.json'); -}); - -Deno.test('deep linking picker adjusts copy for resource_selection course placement', () => { - const html = renderDeepLinkingPickerPage({ - sessionId: 'deep-linking-session-123', - token: 'deep-linking-token-123', - session: { - appId: 'chapter-4-asteroids', - deploymentSlug: 'chapter-4-asteroids-pilot', - contextTitle: 'Physics 101', - expiresAt: '2026-03-24T16:30:00Z', - placement: 'resource_selection', - }, - resources: [ - buildDeepLinkingResourceOption({ - installScope: 'course', - }), - ], - selection: null, - notice: null, - }); - - assertStringIncludes(html, 'Select one reviewed resource for course placement.'); - assertStringIncludes(html, 'Choose one approved course resource below.'); - assertStringIncludes(html, 'Save one reviewed course resource before returning it to the LMS.'); - assertEquals( - html.includes('No approved course-scope reviewed resources are available for this app yet.'), - false, - ); -}); - -Deno.test('deep linking picker separates save from LMS return and only enables return from one saved selection', () => { - const readyHtml = renderDeepLinkingPickerPage({ - sessionId: 'deep-linking-session-123', - token: 'deep-linking-token-123', - session: { - appId: 'chapter-4-asteroids', - deploymentSlug: 'chapter-4-asteroids-pilot', - contextTitle: 'Physics 101', - expiresAt: '2026-03-24T16:30:00Z', - placement: 'assignment_selection', - }, - resources: [ - buildDeepLinkingResourceOption(), - buildDeepLinkingResourceOption({ - packageVersionId: 2, - packageVersion: '0.2.0', - contentPath: '/content/bonus.json', - activityId: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - ], - selection: buildDeepLinkingResourceSelection({ - packageVersionId: 2, - packageVersion: '0.2.0', - contentPath: '/content/bonus.json', - activityId: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - notice: null, - }); - const blockedHtml = renderDeepLinkingPickerPage({ - sessionId: 'deep-linking-session-123', - token: 'deep-linking-token-123', - session: { - appId: 'chapter-4-asteroids', - deploymentSlug: 'chapter-4-asteroids-pilot', - contextTitle: 'Physics 101', - expiresAt: '2026-03-24T16:30:00Z', - placement: 'assignment_selection', - }, - resources: [ - buildDeepLinkingResourceOption(), - buildDeepLinkingResourceOption({ - packageVersionId: 2, - packageVersion: '0.2.0', - contentPath: '/content/bonus.json', - activityId: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - ], - selection: null, - notice: null, - }); - - assertStringIncludes(readyHtml, 'Chapter 4 Asteroids'); - assertStringIncludes(readyHtml, '0.2.0'); - assertStringIncludes(readyHtml, '/content/bonus.json'); - assertStringIncludes(readyHtml, 'Bonus Activity'); - assertStringIncludes(readyHtml, 'Save reviewed selection'); - assertStringIncludes(readyHtml, '/lti/deep-linking/sessions/deep-linking-session-123/submit'); - assertStringIncludes(readyHtml, 'Ready to return this reviewed assignment resource to the LMS.'); - assertStringIncludes(readyHtml, 'Return to LMS'); - assertStringIncludes(readyHtml, 'deep-linking-token-123'); - assertStringIncludes( - blockedHtml, - 'Save one reviewed assignment resource before returning it to the LMS.', - ); - assertStringIncludes(blockedHtml, 'disabled>Return to LMS'); -}); - -Deno.test('deep linking picker blocks LMS return when Lantern cannot verify the session token', () => { - const html = renderDeepLinkingPickerPage({ - sessionId: 'deep-linking-session-123', - session: { - appId: 'chapter-4-asteroids', - deploymentSlug: 'chapter-4-asteroids-pilot', - contextTitle: 'Physics 101', - expiresAt: '2026-03-24T16:30:00Z', - placement: 'assignment_selection', - }, - resources: [buildDeepLinkingResourceOption()], - selection: buildDeepLinkingResourceSelection(), - notice: null, - }); - - assertStringIncludes(html, 'Return to LMS is unavailable until Lantern can verify this session.'); - assertStringIncludes(html, 'disabled>Return to LMS'); -}); diff --git a/src/admin/deployment_detail.ts b/src/admin/deployment_detail.ts deleted file mode 100644 index 23fb14d..0000000 --- a/src/admin/deployment_detail.ts +++ /dev/null @@ -1,278 +0,0 @@ -import type { CanvasEnvironmentOption } from '../lti/config.ts'; -import { DEFAULT_LTI_PROFILE_ID } from '../lti/profile.ts'; -import type { LmsType } from '../lti/types.ts'; -import type { ControlPlaneDeploymentDetailSnapshot } from '../ops/types.ts'; -import type { - DeploymentRecord, - LanternLtiProfileSettingsRecord, - PackageVersionRecord, -} from '../package_review/types.ts'; -import { renderManagedDeploymentSections } from './deployment_detail_release_sections.ts'; -import { renderVersionHistorySection } from './deployment_detail_history_section.ts'; -import { renderOperationalEvidenceSection } from './deployment_detail_ops_sections.ts'; -import { type AdminIdentity, type AdminNotice, renderAdminLayout } from './layout.ts'; -import { renderPackagePageNav } from './package_navigation.ts'; - -export interface DeploymentNrpsVerificationSummary { - status: 'succeeded' | 'failed'; - checkedAt: string; - contextId: string | null; - memberCount: number | null; -} - -export interface ManagedDeploymentSlot { - lms: LmsType; - deployment: DeploymentRecord; - persisted: boolean; -} - -export type DeploymentEditorField = - | 'canvasEnvironment' - | 'issuer' - | 'clientId' - | 'deploymentId' - | 'authorizationEndpoint' - | 'accessTokenUrl' - | 'jwksUrl' - | 'packageVersionId'; - -export interface DeploymentEditorState { - lms: LmsType; - focusSection: 'install' | 'pin'; - notice: AdminNotice; - fieldErrors: Partial>; - installValues: Partial>; - pinPackageVersionId: string | null; -} - -export function buildDefaultDeploymentSeed( - appId: string, - appTitle: string, -): { - slug: string; - label: string; -} { - return buildManagedDeploymentSeed(appId, appTitle, 'canvas'); -} - -export function buildManagedDeploymentSeed( - appId: string, - appTitle: string, - lms: LmsType, -): { - slug: string; - label: string; -} { - switch (lms) { - case 'canvas': - return { - slug: `${appId}-pilot`, - label: `${appTitle} Pilot Deployment`, - }; - case 'moodle': - return { - slug: `${appId}-moodle`, - label: `${appTitle} Moodle Deployment`, - }; - case 'sakai': - return { - slug: `${appId}-sakai`, - label: `${appTitle} Sakai Deployment`, - }; - } -} - -export function buildManagedDeploymentSlots(input: { - appId: string; - appTitle: string; - deployments: DeploymentRecord[]; -}): ManagedDeploymentSlot[] { - const orderedLms: LmsType[] = ['canvas', 'moodle', 'sakai']; - - return orderedLms.map((lms) => { - const seed = buildManagedDeploymentSeed(input.appId, input.appTitle, lms); - const existing = input.deployments.find( - (candidate) => - candidate.lmsType === lms || candidate.binding?.lms === lms || candidate.slug === seed.slug, - ); - - if (existing) { - return { - lms, - deployment: existing, - persisted: true, - }; - } - - return { - lms, - persisted: false, - deployment: { - id: 0, - slug: seed.slug, - label: seed.label, - appId: input.appId, - enabledPackageVersionId: null, - enabledPackageVersion: null, - lmsType: lms, - ltiProfileOverride: null, - binding: null, - updatedAt: new Date().toISOString(), - }, - }; - }); -} - -export function getManagedDeploymentSlot( - slots: ManagedDeploymentSlot[], - lms: LmsType, -): ManagedDeploymentSlot { - const slot = slots.find((candidate) => candidate.lms === lms); - - if (!slot) { - throw new Error(`Managed deployment slot ${lms} is required.`); - } - - return slot; -} - -export function resolveSelectedManagedDeploymentLms( - slots: ManagedDeploymentSlot[], - selectedLms: LmsType | null, -): LmsType { - if (selectedLms !== null && slots.some((slot) => slot.lms === selectedLms)) { - return selectedLms; - } - - return slots.find((slot) => slot.persisted)?.lms ?? 'canvas'; -} - -export function getSelectedManagedDeploymentSlot( - slots: ManagedDeploymentSlot[], - selectedLms: LmsType | null, -): ManagedDeploymentSlot { - return getManagedDeploymentSlot(slots, resolveSelectedManagedDeploymentLms(slots, selectedLms)); -} - -export function getPrimaryManagedDeployment( - slots: ManagedDeploymentSlot[], -): DeploymentRecord | null { - const canvasSlot = slots.find((slot) => slot.lms === 'canvas' && slot.persisted); - if (canvasSlot) { - return canvasSlot.deployment; - } - - return slots.find((slot) => slot.persisted)?.deployment ?? null; -} - -export function getPersistedManagedDeployment( - slots: ManagedDeploymentSlot[], - lms: LmsType, -): DeploymentRecord | null { - const slot = getManagedDeploymentSlot(slots, lms); - return slot.persisted ? slot.deployment : null; -} - -export function buildEmptyDeploymentRecord(appId: string, appTitle: string): DeploymentRecord { - return { - id: 0, - ...buildDefaultDeploymentSeed(appId, appTitle), - appId, - enabledPackageVersionId: null, - enabledPackageVersion: null, - lmsType: 'canvas', - ltiProfileOverride: null, - binding: null, - updatedAt: new Date().toISOString(), - }; -} - -export function renderDeploymentDetailPage(input: { - identity: AdminIdentity; - appId: string; - appTitle: string; - history: PackageVersionRecord[]; - deployments: DeploymentRecord[]; - selectedLms?: LmsType | null; - openOperationalEvidence?: boolean; - editorState?: DeploymentEditorState | null; - nrpsVerification?: DeploymentNrpsVerificationSummary | null; - lanternLtiProfileSettings?: LanternLtiProfileSettingsRecord | null; - controlPlaneDetail?: ControlPlaneDeploymentDetailSnapshot | null; - canvasConfigUrl?: string | null; - canvasDynamicRegistrationUrl?: string | null; - moodleDynamicRegistrationUrl?: string | null; - sakaiDynamicRegistrationUrl?: string | null; - supportedCanvasEnvironments?: CanvasEnvironmentOption[]; - notice?: AdminNotice | null; -}): string { - const slots = buildManagedDeploymentSlots({ - appId: input.appId, - appTitle: input.appTitle, - deployments: input.deployments, - }); - const approvedVersions = input.history.filter((version) => version.approvalStatus === 'approved'); - const selectedSlot = getSelectedManagedDeploymentSlot(slots, input.selectedLms ?? null); - const lanternLtiProfileSettings = input.lanternLtiProfileSettings ?? { - defaultLtiProfile: DEFAULT_LTI_PROFILE_ID, - updatedAt: '', - }; - const primaryDeployment = getPrimaryManagedDeployment(slots) ?? - buildEmptyDeploymentRecord(input.appId, input.appTitle); - const canvasConfigUrl = input.canvasConfigUrl ?? null; - const canvasDynamicRegistrationUrl = input.canvasDynamicRegistrationUrl ?? null; - const moodleDynamicRegistrationUrl = input.moodleDynamicRegistrationUrl ?? null; - const sakaiDynamicRegistrationUrl = input.sakaiDynamicRegistrationUrl ?? null; - const nrpsVerification = input.nrpsVerification ?? null; - const controlPlaneDetail = input.controlPlaneDetail ?? null; - const supportedCanvasEnvironments = input.supportedCanvasEnvironments ?? []; - - return renderAdminLayout({ - identity: input.identity, - title: `${input.appTitle} App settings`, - eyebrow: 'Settings', - heading: input.appTitle, - intro: 'Connect this app to one LMS and choose what version people should open.', - activePath: '/admin/packages', - breadcrumbs: [ - { label: 'Apps', href: '/admin/packages' }, - { - label: input.appTitle, - href: `/admin/packages/${input.appId}`, - }, - { label: 'Settings' }, - ], - notice: input.notice ?? null, - pageNav: renderPackagePageNav({ - appId: input.appId, - history: input.history, - currentSection: 'settings', - }), - body: `${ - renderManagedDeploymentSections({ - appId: input.appId, - slots, - selectedLms: input.selectedLms ?? null, - editorState: input.editorState ?? null, - nrpsVerification, - lanternLtiProfileSettings, - canvasConfigUrl, - canvasDynamicRegistrationUrl, - moodleDynamicRegistrationUrl, - sakaiDynamicRegistrationUrl, - supportedCanvasEnvironments, - approvedVersions, - history: input.history, - }) - } - ${renderVersionHistorySection(input.history, primaryDeployment)} - ${ - renderOperationalEvidenceSection( - input.appId, - selectedSlot, - controlPlaneDetail, - input.openOperationalEvidence ?? false, - ) - }`, - }); -} diff --git a/src/admin/deployment_detail_history_section.ts b/src/admin/deployment_detail_history_section.ts deleted file mode 100644 index d934ff3..0000000 --- a/src/admin/deployment_detail_history_section.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - approvalStatusClass, - approvalStatusLabel, - summarizeAccessibilityReview, -} from '../package_review/summary.ts'; -import type { DeploymentRecord, PackageVersionRecord } from '../package_review/types.ts'; -import { escapeHtml, formatDateTime } from './layout.ts'; - -export function renderVersionHistorySection( - history: PackageVersionRecord[], - activeDeployment: Pick, -): string { - return `
    -
    - -

    Past versions and review notes

    -
    - - - - - - - - - - - - - ${history.map((version) => renderHistoryRow(activeDeployment, version)).join('')} - -
    VersionStatusFor learnersImportedAccessibilityReview notes
    -
    -
    -
    `; -} - -function renderHistoryRow( - deployment: Pick, - version: PackageVersionRecord, -): string { - const isPinned = deployment.enabledPackageVersionId === version.id; - const accessibility = summarizeAccessibilityReview(version); - - return ` - -
    - Version ${escapeHtml(version.version)} -
    - - - ${ - escapeHtml( - approvalStatusLabel(version.approvalStatus), - ) - } - - ${ - isPinned ? 'Live now' : 'Past version' - } - ${escapeHtml(formatDateTime(version.importedAt))} - -
    - ${escapeHtml(accessibility.label)} - ${escapeHtml(accessibility.detail)} - ${ - accessibility.exceptionNote === null - ? '' - : `${escapeHtml(accessibility.exceptionNote)}` - } -
    - - -
    ${ - escapeHtml( - version.reviewNotes ?? 'No review notes recorded.', - ) - }
    - - `; -} diff --git a/src/admin/deployment_detail_install_test.ts b/src/admin/deployment_detail_install_test.ts deleted file mode 100644 index d6a147a..0000000 --- a/src/admin/deployment_detail_install_test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { - buildDeploymentRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from '../test_helpers/package_review.ts'; -import { buildDeploymentBinding } from '../test_helpers/lti.ts'; -import { restoreEnv } from './deployment_detail_test_helpers.ts'; -import { createAuthenticatedTestApp } from '../test_helpers/admin_auth.ts'; - -Deno.test('POST /admin/packages/:appId/deployment/install saves one exact Canvas binding', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - }), - ], - }); - const previousOrigin = Deno.env.get('APP_ORIGIN'); - const formData = new FormData(); - - Deno.env.set('APP_ORIGIN', 'http://localhost:8417'); - formData.set('lms', 'canvas'); - formData.set('canvasEnvironment', 'production'); - formData.set('clientId', '10000000000001'); - formData.set('deploymentId', 'deployment-123'); - - try { - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment/install', { - method: 'POST', - headers: { - Origin: 'http://localhost', - }, - body: formData, - }); - - assertEquals(response.status, 303); - assertEquals( - response.headers.get('location'), - '/admin/packages/chapter-4-asteroids/deployment?lms=canvas#slot-panel', - ); - - const deployment = await repository.getDeploymentBySlug('chapter-4-asteroids-pilot'); - assertEquals(deployment?.binding?.clientId, '10000000000001'); - assertEquals(deployment?.binding?.deploymentId, 'deployment-123'); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - } -}); - -Deno.test('duplicate issuer, client_id, and deployment_id bindings are rejected clearly', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - const formData = new FormData(); - - Deno.env.set('APP_ORIGIN', 'http://localhost:8417'); - formData.set('lms', 'canvas'); - formData.set('canvasEnvironment', 'production'); - formData.set('clientId', '10000000000001'); - formData.set('deploymentId', 'deployment-123'); - - try { - const response = await createAuthenticatedTestApp({ - getRepository: () => - createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - slug: 'second-app-pilot', - label: 'Second App Pilot Deployment', - appId: 'second-app', - binding: buildDeploymentBinding(), - }), - ], - }), - }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment/install', { - method: 'POST', - headers: { - Origin: 'http://localhost', - }, - body: formData, - }); - - assertEquals(response.status, 409); - assertStringIncludes(await response.text(), 'already belongs to another deployment'); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - } -}); - -Deno.test('POST /admin/packages/:appId/deployment/install requires the exact Moodle binding fields', async () => { - const formData = new FormData(); - - formData.set('lms', 'moodle'); - formData.set('issuer', 'https://moodle.example'); - formData.set('clientId', 'moodle-client-123'); - formData.set('deploymentId', 'moodle-deployment-123'); - formData.set('accessTokenUrl', 'https://moodle.example/mod/lti/token.php'); - formData.set('jwksUrl', 'https://moodle.example/mod/lti/certs.php'); - - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - }); - - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment/install', { - method: 'POST', - headers: { - Origin: 'http://localhost', - }, - body: formData, - }); - - assertEquals(response.status, 409); - const body = await response.text(); - - assertStringIncludes(body, 'Moodle install blocked'); - assertStringIncludes(body, 'Set up Moodle'); - assertStringIncludes(body, 'Moodle Authorization endpoint is required.'); - assertStringIncludes(body, 'value="https://moodle.example"'); - assertStringIncludes(body, 'value="moodle-client-123"'); - assertStringIncludes(body, 'value="moodle-deployment-123"'); - assertStringIncludes(body, 'name="authorizationEndpoint"'); - assertStringIncludes(body, 'aria-invalid="true"'); - assertStringIncludes(body, 'Advanced Moodle settings'); - assertEquals(await repository.getDeploymentBySlug('chapter-4-asteroids-moodle'), null); -}); - -Deno.test('POST /admin/packages/:appId/deployment/install requires the exact Sakai binding fields', async () => { - const formData = new FormData(); - - formData.set('lms', 'sakai'); - formData.set('issuer', 'https://sakai.example'); - formData.set('clientId', 'sakai-client-123'); - formData.set('deploymentId', 'sakai-deployment-123'); - formData.set('accessTokenUrl', 'https://sakai.example/imsblis/lti13/token/3'); - formData.set('jwksUrl', 'https://sakai.example/imsblis/lti13/keyset'); - - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - }); - - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment/install', { - method: 'POST', - headers: { - Origin: 'http://localhost', - }, - body: formData, - }); - - assertEquals(response.status, 409); - assertStringIncludes(await response.text(), 'Sakai Authorization endpoint is required.'); - assertEquals(await repository.getDeploymentBySlug('chapter-4-asteroids-sakai'), null); -}); diff --git a/src/admin/deployment_detail_multilms_test.ts b/src/admin/deployment_detail_multilms_test.ts deleted file mode 100644 index 72064f8..0000000 --- a/src/admin/deployment_detail_multilms_test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { assertFalse, assertStringIncludes } from '@std/assert'; -import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; -import { resolveCanvasIssuer } from '../lti/config.ts'; -import { - buildBrokerVerificationStatus, - buildControlPlaneDeploymentDetailSnapshot, - buildControlPlaneDeploymentInventoryRow, - buildControlPlaneDiagnosticItem, - buildDeploymentActivitySnapshot, - buildDeploymentRecord, - buildPackageVersionRecord, -} from '../test_helpers/package_review.ts'; -import { - buildCanvasDeploymentBinding, - buildMoodleDeploymentBinding, - buildSakaiDeploymentBinding, -} from '../test_helpers/lti.ts'; -import { renderDeploymentDetailPage } from './deployment_detail.ts'; - -Deno.test('deployment page keeps separate Canvas, Moodle, and Sakai slots for one app', () => { - const html = renderDeploymentDetailPage({ - identity: ADMIN_TEST_IDENTITY, - appId: 'chapter-4-asteroids', - appTitle: 'Chapter 4 Asteroids', - history: [ - buildPackageVersionRecord({ - id: 1, - version: '0.1.0', - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - buildPackageVersionRecord({ - id: 2, - version: '0.2.0', - approvalStatus: 'approved', - reviewedAt: '2026-03-24T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 3, - slug: 'chapter-4-asteroids-pilot', - label: 'Chapter 4 Asteroids Pilot Deployment', - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildCanvasDeploymentBinding(), - }), - buildDeploymentRecord({ - id: 4, - slug: 'chapter-4-asteroids-moodle', - label: 'Chapter 4 Asteroids Moodle Deployment', - enabledPackageVersionId: null, - enabledPackageVersion: null, - binding: buildMoodleDeploymentBinding(), - }), - buildDeploymentRecord({ - id: 5, - slug: 'chapter-4-asteroids-sakai', - label: 'Chapter 4 Asteroids Sakai Deployment', - enabledPackageVersionId: 2, - enabledPackageVersion: '0.2.0', - binding: buildSakaiDeploymentBinding(), - }), - ], - canvasConfigUrl: 'http://localhost:8417/lti/canvas/config.json', - supportedCanvasEnvironments: [ - { - id: 'production', - label: 'Production Canvas', - issuer: resolveCanvasIssuer('production'), - }, - ], - }); - - assertStringIncludes(html, 'deployment-tab-label">Canvas'); - assertStringIncludes(html, 'deployment-tab-label">Moodle'); - assertStringIncludes(html, 'deployment-tab-label">Sakai'); - assertStringIncludes(html, 'Pinned to version 0.1.0.'); - assertStringIncludes( - html, - 'href="/admin/packages/chapter-4-asteroids/deployment?lms=moodle#slot-panel"', - ); - assertStringIncludes( - html, - 'href="/admin/packages/chapter-4-asteroids/deployment?lms=sakai#slot-panel"', - ); - assertStringIncludes(html, 'name="lms" value="canvas"'); -}); - -Deno.test('deployment page keeps one deployment route while rendering setup, launch, failure, and verification history for each LMS slot', () => { - const cases = [ - { - lms: 'canvas' as const, - binding: buildCanvasDeploymentBinding(), - deploymentId: 3, - slug: 'chapter-4-asteroids-pilot', - label: 'Chapter 4 Asteroids Pilot Deployment', - setupHeading: 'Set up Canvas', - saveButtonLabel: 'Save Canvas settings', - launchSummary: 'Latest Canvas launch reached the governed runtime handoff.', - diagnosticSummary: 'Canvas launch failed on the saved deployment path.', - verificationSummary: 'Latest internal proof passed for the saved Canvas deployment.', - supportedPath: 'lti13LaunchAgsNrps' as const, - }, - { - lms: 'moodle' as const, - binding: buildMoodleDeploymentBinding(), - deploymentId: 4, - slug: 'chapter-4-asteroids-moodle', - label: 'Chapter 4 Asteroids Moodle Deployment', - setupHeading: 'Set up Moodle', - saveButtonLabel: 'Save Moodle settings', - launchSummary: 'Latest Moodle launch reached the governed runtime handoff.', - diagnosticSummary: 'Moodle launch failed on the saved deployment path.', - verificationSummary: 'Latest internal proof passed for the saved Moodle deployment.', - supportedPath: 'lti13LaunchAgsScore' as const, - }, - { - lms: 'sakai' as const, - binding: buildSakaiDeploymentBinding(), - deploymentId: 5, - slug: 'chapter-4-asteroids-sakai', - label: 'Chapter 4 Asteroids Sakai Deployment', - setupHeading: 'Set up Sakai', - saveButtonLabel: 'Save Sakai settings', - launchSummary: 'Latest Sakai launch reached the governed runtime handoff.', - diagnosticSummary: 'Sakai launch failed on the saved deployment path.', - verificationSummary: 'Latest internal proof passed for the saved Sakai deployment.', - supportedPath: 'lti13LaunchAgsScore' as const, - }, - ]; - - for (const testCase of cases) { - const html = renderDeploymentDetailPage({ - identity: ADMIN_TEST_IDENTITY, - appId: 'chapter-4-asteroids', - appTitle: 'Chapter 4 Asteroids', - selectedLms: testCase.lms, - history: [ - buildPackageVersionRecord({ - id: 1, - version: '0.1.0', - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: testCase.deploymentId, - slug: testCase.slug, - label: testCase.label, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - lmsType: testCase.lms, - binding: testCase.binding, - }), - ], - controlPlaneDetail: buildControlPlaneDeploymentDetailSnapshot({ - inventory: buildControlPlaneDeploymentInventoryRow({ - deploymentId: testCase.deploymentId, - deploymentSlug: testCase.slug, - deploymentLabel: testCase.label, - binding: testCase.binding, - installEvidence: buildDeploymentActivitySnapshot({ - summary: `Saved the ${ - testCase.setupHeading.replace( - 'Set up ', - '', - ) - } deployment binding.`, - }), - brokerVerification: buildBrokerVerificationStatus({ - supportedPath: testCase.supportedPath, - internal: { - source: 'manual', - status: 'passed', - checkedAt: '2026-03-24T12:50:00Z', - summary: testCase.verificationSummary, - evidenceUrl: 'https://example.test/verification/internal-proof', - }, - }), - }), - brokerVerification: buildBrokerVerificationStatus({ - supportedPath: testCase.supportedPath, - internal: { - source: 'manual', - status: 'passed', - checkedAt: '2026-03-24T12:50:00Z', - summary: testCase.verificationSummary, - evidenceUrl: 'https://example.test/verification/internal-proof', - }, - }), - latestInstallEvidence: buildDeploymentActivitySnapshot({ - summary: `Saved the ${testCase.setupHeading.replace('Set up ', '')} deployment binding.`, - }), - latestLaunch: buildDeploymentActivitySnapshot({ - summary: testCase.launchSummary, - }), - diagnostics: [ - buildControlPlaneDiagnosticItem({ - kind: 'launch', - eventType: 'launch.rejected', - status: 'failed', - summary: testCase.diagnosticSummary, - operatorSummary: testCase.diagnosticSummary, - }), - ], - }), - canvasConfigUrl: 'http://localhost:8417/lti/canvas/config.json', - supportedCanvasEnvironments: [ - { - id: 'production', - label: 'Production Canvas', - issuer: resolveCanvasIssuer('production'), - }, - ], - }); - - assertStringIncludes( - html, - `href="/admin/packages/chapter-4-asteroids/deployment?lms=${testCase.lms}#slot-panel" aria-current="page"`, - ); - assertStringIncludes(html, testCase.setupHeading); - assertStringIncludes(html, testCase.saveButtonLabel); - assertStringIncludes(html, 'Setup history'); - assertStringIncludes(html, testCase.launchSummary); - assertStringIncludes(html, testCase.diagnosticSummary); - assertStringIncludes(html, testCase.verificationSummary); - assertFalse(html.includes(`/admin/packages/chapter-4-asteroids/${testCase.lms}/deployment`)); - } -}); diff --git a/src/admin/deployment_detail_ops_evidence_sections.ts b/src/admin/deployment_detail_ops_evidence_sections.ts deleted file mode 100644 index 4bb80c1..0000000 --- a/src/admin/deployment_detail_ops_evidence_sections.ts +++ /dev/null @@ -1,593 +0,0 @@ -import type { ManagedDeploymentSlot } from './deployment_detail.ts'; -import type { - ControlPlaneAnonymousEvidenceArtifact, - ControlPlaneDeploymentDetailSnapshot, - ControlPlaneRuntimeEvidenceSnapshot, -} from '../ops/types.ts'; -import { escapeHtml } from './layout.ts'; -import { - describeActivityLtiProfile, - describeRuntimeRoute, - formatActivityTimestamp, - formatByteSize, - formatOptionalTimestamp, - formatRuntimeTimestamp, - readBooleanDetail, - readNestedStringDetail, - readStringDetail, - renderActivityFact, - renderDiagnosticRow, -} from './deployment_detail_ops_support.ts'; -import { - describeBrokerVerificationStatus as describeBrokerVerificationStatusLabel, - describeEvidenceArtifactKind, - describeRuntimeBoundary, - describeRuntimeDeliveryState, - describeRuntimeDeliverySubstrate, - describeRuntimeOutcome, - describeRuntimeSandboxModel, - describeSmokeCapability, - describeSmokeCapabilitySummary, - describeSmokePublication, - describeSmokePublicationSummary, - describeSmokeStatus, - formatLmsLabel, -} from './deployment_detail_ops_labels.ts'; - -export function renderDiagnosticsSection( - appId: string, - detail: ControlPlaneDeploymentDetailSnapshot | null, -): string { - const diagnostics = detail?.diagnostics ?? []; - const retryAttemptId = detail?.retryableGradePublication?.attemptId ?? null; - - return `
    -
    - -

    Problems to review

    - ${ - diagnostics.length === 0 - ? `
    -

    No problems recorded

    -

    Lantern has not recorded a failed launch, reviewed runtime event, roster read, or grade write for this setup.

    -
    ` - : `
    - ${ - diagnostics - .map((item) => renderDiagnosticRow(item, appId, retryAttemptId)) - .join('') - } -
    ` - } -
    -
    `; -} - -export function renderRuntimeSection(detail: ControlPlaneDeploymentDetailSnapshot | null): string { - const latestRuntimeSession = detail?.latestRuntimeSession ?? null; - const latestRuntimeOutcome = detail?.latestRuntimeOutcome ?? null; - const runtimeSnapshot = latestRuntimeOutcome ?? latestRuntimeSession; - const packageVersion = runtimeSnapshot?.packageVersion ?? null; - const artifactDigest = runtimeSnapshot?.artifactDigest ?? null; - const runtimeContractSignature = runtimeSnapshot?.runtimeContractSignature ?? null; - const sandboxModel = latestRuntimeSession?.sandboxModel ?? latestRuntimeOutcome?.sandboxModel ?? - null; - const boundary = latestRuntimeSession?.boundary ?? latestRuntimeOutcome?.boundary ?? null; - const deliverySubstrate = latestRuntimeOutcome?.deliverySubstrate ?? - latestRuntimeSession?.deliverySubstrate ?? null; - const deliveryState = latestRuntimeOutcome?.deliveryState ?? - latestRuntimeSession?.deliveryState ?? null; - const deliveryWorkerId = latestRuntimeOutcome?.deliveryWorkerId ?? - latestRuntimeSession?.deliveryWorkerId ?? null; - const attemptId = latestRuntimeOutcome?.attemptId ?? latestRuntimeSession?.attemptId ?? null; - - return `
    -
    - -

    Runtime session

    -

    Lantern records which reviewed package was launched, how Lantern delivered it, and how the latest governed runtime session ended for this setup.

    -
    - ${ - renderRuntimeFact( - 'Runtime session', - latestRuntimeSession?.sessionId ?? 'Not recorded yet', - latestRuntimeSession?.summary ?? - 'Lantern has not recorded a reviewed runtime session for this setup yet.', - ) - } - ${ - renderRuntimeFact( - 'Attempt binding', - attemptId ?? 'Not recorded yet', - attemptId === null - ? 'Lantern has not tied the latest reviewed runtime record to an attempt yet.' - : `Lantern tied the latest reviewed runtime record to attempt ${attemptId}.`, - ) - } - ${ - renderRuntimeFact( - 'Reviewed package', - packageVersion === null ? 'Not recorded yet' : `Package ${packageVersion}`, - packageVersion === null - ? 'Lantern has not recorded which approved reviewed package version launched for this setup yet.' - : `Lantern launched the approved reviewed package version ${packageVersion} for this setup.`, - ) - } - ${ - renderRuntimeFact( - 'Artifact digest', - artifactDigest ?? 'Not recorded yet', - artifactDigest === null - ? 'Lantern has not recorded the reviewed artifact digest for this runtime yet.' - : 'Lantern records the immutable reviewed artifact digest that backed this runtime session.', - ) - } - ${ - renderRuntimeFact( - 'Runtime contract', - runtimeContractSignature ?? 'Not recorded yet', - runtimeContractSignature === null - ? 'Lantern has not recorded the reviewed runtime contract signature for this runtime yet.' - : 'Lantern records the reviewed runtime contract signature used to bind delivery and audit to the approved package.', - ) - } - ${ - renderActivityFact( - 'Started at', - formatRuntimeTimestamp(latestRuntimeSession), - latestRuntimeSession === null - ? 'Lantern has not recorded when the latest reviewed runtime session started for this setup yet.' - : 'Lantern records when the latest reviewed runtime session crossed into the governed runtime boundary.', - ) - } - ${ - renderActivityFact( - 'Sandbox model', - describeRuntimeSandboxModel(sandboxModel), - sandboxModel === null - ? 'Lantern has not recorded the enforced sandbox model for this setup yet.' - : `Lantern enforced the ${ - describeRuntimeSandboxModel( - sandboxModel, - ) - } for the latest reviewed runtime session.`, - ) - } - ${ - renderActivityFact( - 'Runtime boundary', - describeRuntimeBoundary(boundary), - boundary === null - ? 'Lantern has not recorded the enforced runtime boundary for this setup yet.' - : `Lantern kept reviewed app traffic inside the ${ - describeRuntimeBoundary( - boundary, - ) - } boundary.`, - ) - } - ${ - renderActivityFact( - 'Delivery substrate', - describeRuntimeDeliverySubstrate(deliverySubstrate), - deliverySubstrate === null - ? 'Lantern has not recorded which reviewed-runtime delivery path served this setup yet.' - : `Lantern served the latest reviewed runtime through ${ - describeRuntimeDeliverySubstrate( - deliverySubstrate, - ) - }.`, - ) - } - ${ - renderActivityFact( - 'Delivery state', - describeRuntimeDeliveryState(deliveryState), - deliveryState === null - ? 'Lantern has not normalized the latest reviewed runtime state for this setup yet.' - : describeRuntimeDeliveryStateSummary(runtimeSnapshot), - ) - } - ${ - renderRuntimeFact( - 'Delivery worker', - deliveryWorkerId ?? 'Not recorded yet', - deliveryWorkerId === null - ? 'Lantern did not record a Dynamic Worker identity for this runtime delivery path.' - : 'Lantern records the deterministic Dynamic Worker id used for the immutable reviewed runtime envelope.', - ) - } - ${ - renderRuntimeFact( - 'Latest outcome', - describeRuntimeOutcome(latestRuntimeOutcome?.eventType), - latestRuntimeOutcome?.summary ?? - 'Lantern has not recorded a reviewed runtime outcome for this setup yet.', - ) - } -
    - ${renderRuntimeTroubleshootingCallout(runtimeSnapshot)} - ${renderRuntimeOutcomeCallout(runtimeSnapshot)} -
    -
    `; -} - -export function renderAnonymousEvidenceSection( - detail: ControlPlaneDeploymentDetailSnapshot | null, -): string { - const anonymousEvidence = detail?.latestAnonymousEvidence ?? []; - const runtimeOutcome = detail?.latestRuntimeOutcome ?? null; - - return `
    -
    - -

    Anonymous submission evidence

    - ${ - anonymousEvidence.length === 0 - ? `
    -

    No anonymous evidence recorded

    -

    Lantern has not recorded a stored anonymous submission artifact for this deployment yet.

    -
    ` - : `

    ${escapeHtml(describeAnonymousEvidenceOutcome(runtimeOutcome))}

    -
    - ${ - anonymousEvidence - .map((artifact) => renderAnonymousEvidenceArtifact(artifact)) - .join('') - } -
    ` - } -
    -
    `; -} - -export function renderBrokerVerificationSection( - detail: ControlPlaneDeploymentDetailSnapshot | null, -): string { - const internalVerification = detail?.brokerVerification?.internal ?? null; - - return `
    -
    - -

    Setup history

    -

    If you need past setup records or test logs for this app setup, open Verification. Most admins can ignore this unless they are troubleshooting.

    -
    - ${ - renderActivityFact( - 'Latest saved result', - describeBrokerVerificationStatusLabel(internalVerification?.status ?? null), - internalVerification?.summary ?? - 'No setup record has been saved for this app setup yet.', - ) - } -
    -
    - Open Verification - ${ - internalVerification?.evidenceUrl - ? `Open log` - : '' - } -
    -
    -
    `; -} - -export function renderAgsSmokeSection( - appId: string, - slot: ManagedDeploymentSlot, - detail: ControlPlaneDeploymentDetailSnapshot | null, -): string { - const latestAgsSmoke = detail?.latestAgsSmoke ?? null; - const lineItemUrl = readStringDetail(latestAgsSmoke?.detail, 'lineItemUrl'); - const errorCode = readNestedStringDetail(latestAgsSmoke?.detail, 'error', 'code'); - const errorText = readNestedStringDetail(latestAgsSmoke?.detail, 'error', 'message'); - const canRunSmoke = slot.persisted && - (slot.lms === 'moodle' || slot.lms === 'sakai') && - slot.deployment.binding?.lms === slot.lms; - const runCopy = slot.lms === 'canvas' - ? 'This test is available for Moodle and Sakai only.' - : canRunSmoke - ? `Run a grade return test for this ${formatLmsLabel(slot.lms)} setup.` - : `Save the exact ${formatLmsLabel(slot.lms)} setup before running a grade return test.`; - const runAction = slot.lms === 'canvas' - ? '' - : `
    - - -
    - -
    -
    `; - const lineItemFact = lineItemUrl === null ? '' : `
    - Test line item - ${escapeHtml(lineItemUrl)} -

    Lantern uses a separate test line item so this check does not touch learner grades.

    -
    `; - const failureCallout = errorText === null ? '' : `
    -

    Latest check failure

    -

    ${escapeHtml(errorText)}

    - ${errorCode === null ? '' : `

    Code ${escapeHtml(errorCode)}

    `} -
    `; - - return `
    -
    - -

    Latest grade return check

    -

    ${escapeHtml(runCopy)}

    -
    - ${ - renderActivityFact( - 'Status', - describeSmokeStatus(latestAgsSmoke), - latestAgsSmoke?.summary ?? - 'No grade return check has been recorded for this setup yet.', - ) - } - ${ - renderActivityFact( - 'Checked at', - formatActivityTimestamp(latestAgsSmoke), - latestAgsSmoke === null - ? 'Lantern has not recorded a grade return check for this setup yet.' - : `Lantern keeps this result scoped to the viewed ${formatLmsLabel(slot.lms)} setup.`, - ) - } - ${ - renderActivityFact( - 'Grade return access', - describeSmokeCapability(latestAgsSmoke, readBooleanDetail), - describeSmokeCapabilitySummary(latestAgsSmoke, readBooleanDetail), - ) - } - ${ - renderActivityFact( - 'Test write', - describeSmokePublication(latestAgsSmoke, readStringDetail), - describeSmokePublicationSummary(latestAgsSmoke, readStringDetail), - ) - } - ${ - renderActivityFact( - 'LTI profile', - describeActivityLtiProfile(latestAgsSmoke?.detail) ?? 'Not recorded yet', - latestAgsSmoke === null - ? 'Lantern has not recorded a grade return check for this setup yet.' - : 'Lantern records which saved LTI profile was enforced for this check.', - ) - } -
    - ${lineItemFact} - ${failureCallout} - ${runAction} -
    -
    `; -} - -function renderRuntimeTroubleshootingCallout( - runtimeSnapshot: ControlPlaneRuntimeEvidenceSnapshot | null, -): string { - if (runtimeSnapshot === null) { - return ''; - } - - const summary = describeRuntimeTroubleshootingSummary(runtimeSnapshot); - - if (summary === null) { - return ''; - } - - return `
    -

    ${escapeHtml(describeRuntimeDeliveryState(runtimeSnapshot.deliveryState))}

    -

    ${escapeHtml(summary)}

    -
    `; -} - -function renderRuntimeOutcomeCallout( - runtimeSnapshot: ControlPlaneRuntimeEvidenceSnapshot | null, -): string { - if (runtimeSnapshot === null) { - return ''; - } - - const runtimeFacts = [ - describeRuntimeRoute(runtimeSnapshot.route), - runtimeSnapshot.capability === null ? null : `Capability ${runtimeSnapshot.capability}`, - runtimeSnapshot.code === null ? null : `Code ${runtimeSnapshot.code}`, - ].filter((value): value is string => value !== null); - - if (runtimeFacts.length === 0) { - return ''; - } - - return `

    ${escapeHtml(runtimeFacts.join(' · '))}

    `; -} - -function describeRuntimeDeliveryStateSummary( - runtimeSnapshot: ControlPlaneRuntimeEvidenceSnapshot | null, -): string { - if (runtimeSnapshot === null) { - return 'Lantern has not normalized the latest reviewed runtime state for this setup yet.'; - } - - switch (runtimeSnapshot.deliveryState) { - case 'started': - return 'Lantern launched the reviewed package and has not recorded a later runtime outcome for this setup yet.'; - case 'exited': - return 'Lantern recorded a governed runtime exit for the latest reviewed session.'; - case 'deliveryFailed': - return 'Lantern recorded a reviewed-runtime delivery failure before app bytes could be served.'; - case 'assetMissing': - return 'Lantern recorded that a reviewed runtime file was missing from the approved package snapshot.'; - case 'integrityFailed': - return 'Lantern blocked the reviewed runtime before app code could continue because an integrity check failed.'; - case 'timedOut': - return 'Lantern recorded that the reviewed runtime session timed out before the app could continue.'; - case 'denied': - return 'Lantern denied the reviewed runtime session before app code could continue.'; - case 'capabilityDenied': - return 'Lantern denied a reviewed app capability at the governed runtime boundary.'; - case null: - return 'Lantern has not normalized the latest reviewed runtime state for this setup yet.'; - } -} - -function describeRuntimeTroubleshootingSummary( - runtimeSnapshot: ControlPlaneRuntimeEvidenceSnapshot, -): string | null { - switch (runtimeSnapshot.deliveryState) { - case 'deliveryFailed': - return 'Dynamic Worker delivery failed before Lantern could serve the immutable reviewed runtime bytes for the latest session.'; - case 'assetMissing': - return 'Lantern could not find one of the reviewed runtime files inside the approved package snapshot.'; - case 'integrityFailed': - return 'Lantern stopped the reviewed runtime before app code could continue because a governed integrity check failed.'; - case 'timedOut': - return 'Lantern recorded that the reviewed runtime session timed out before the learner could continue.'; - case 'denied': - return 'Lantern denied the reviewed runtime session before the reviewed app could continue.'; - case 'capabilityDenied': - return 'Lantern denied a reviewed app capability at the governed runtime boundary.'; - case 'started': - case 'exited': - case null: - return null; - } -} - -function describeAnonymousEvidenceOutcome( - latestRuntimeOutcome: ControlPlaneRuntimeEvidenceSnapshot | null, -): string { - if (latestRuntimeOutcome === null) { - return 'Lantern has not recorded an anonymous finalize outcome for this deployment yet.'; - } - - const detail = latestRuntimeOutcome.detail; - const submissionMode = readStringDetail(detail, 'submissionMode'); - const scoreGiven = readFiniteDetailNumber(detail, 'scoreGiven'); - const scoreMaximum = readFiniteDetailNumber(detail, 'scoreMaximum'); - const specCount = readBrowserGraderSpecCount(detail); - - if ( - submissionMode === 'anonymous_submission' && - scoreGiven !== null && - scoreMaximum !== null && - specCount !== null - ) { - return `Latest browser-grader outcome: ${scoreGiven} / ${scoreMaximum} across ${specCount} reviewed specs.`; - } - - if (submissionMode === 'anonymous_submission' && scoreGiven !== null && scoreMaximum !== null) { - return `Latest finalize outcome: ${scoreGiven} / ${scoreMaximum} recorded through anonymous submission.`; - } - - return latestRuntimeOutcome.summary; -} - -function describeAnonymousEvidenceArtifactMetadata(artifact: { - contentType: string | null; - byteSize: number | null; - sha256: string | null; - createdAt: string | null; -}): string { - const facts = [ - artifact.contentType === null - ? 'Content type not recorded yet' - : `Content type ${artifact.contentType}`, - `Size ${formatByteSize(artifact.byteSize)}`, - artifact.sha256 === null ? 'SHA-256 not recorded yet' : `SHA-256 ${artifact.sha256}`, - `Recorded ${formatOptionalTimestamp(artifact.createdAt)}`, - ]; - - return facts.join(' · '); -} - -function renderAnonymousEvidenceArtifact(artifact: ControlPlaneAnonymousEvidenceArtifact): string { - const isScreenshot = artifact.kind === 'screenshot_png' || artifact.contentType === 'image/png'; - - if (!isScreenshot) { - return `
    -

    ${escapeHtml(artifact.fileName)}

    -

    ${ - escapeHtml( - `${describeEvidenceArtifactKind(artifact.kind)} stored for ${artifact.artifactId}.`, - ) - }

    -

    ${ - escapeHtml( - describeAnonymousEvidenceArtifactMetadata(artifact), - ) - }

    -

    Open stored artifact

    -
    `; - } - - return `
    -

    Supplemental screenshot evidence

    -

    ${escapeHtml(artifact.fileName)}

    -

    Supplemental screenshot evidence stored for ${ - escapeHtml( - artifact.artifactId, - ) - }. Helpful for review, but not exhaustive proof of learner behavior.

    -

    ${escapeHtml(describeAnonymousEvidenceArtifactMetadata(artifact))}

    - ${
-    escapeHtml(
-      `Supplemental screenshot evidence ${artifact.fileName}`,
-    )
-  } -

    Open stored artifact

    -
    `; -} - -function readFiniteDetailNumber(detail: Record, key: string): number | null { - const value = detail[key]; - - return typeof value === 'number' && Number.isFinite(value) ? value : null; -} - -function readBrowserGraderSpecCount(detail: Record): number | null { - const browserGraderResult = detail.browserGraderResult; - - if ( - !browserGraderResult || - typeof browserGraderResult !== 'object' || - Array.isArray(browserGraderResult) - ) { - return null; - } - - const specResults = (browserGraderResult as Record).specResults; - - return Array.isArray(specResults) ? specResults.length : null; -} - -function renderRuntimeFact(label: string, value: string, summary: string): string { - return `
    - ${escapeHtml(label)} - ${escapeHtml(value)} -

    ${escapeTextContent(summary)}

    -
    `; -} - -function escapeTextContent(value: string): string { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"'); -} diff --git a/src/admin/deployment_detail_ops_labels.ts b/src/admin/deployment_detail_ops_labels.ts deleted file mode 100644 index 759e614..0000000 --- a/src/admin/deployment_detail_ops_labels.ts +++ /dev/null @@ -1,292 +0,0 @@ -import type { - ControlPlaneDiagnosticItem, - ControlPlaneHealthStatus, - ControlPlaneRuntimeEvidenceSnapshot, - DeploymentActivitySnapshot, - DeploymentGradePublicationSnapshot, - InternalBrokerVerificationStatus, -} from '../ops/types.ts'; - -export function describeSmokeStatus( - snapshot: DeploymentActivitySnapshot | null | undefined, -): string { - if (snapshot === null || snapshot === undefined) { - return 'Not run yet'; - } - - switch (snapshot.status) { - case 'succeeded': - return 'Succeeded'; - case 'failed': - return 'Failed'; - case 'pending': - return 'Pending'; - case 'notRun': - return 'Not run yet'; - } -} - -export function describeSmokeCapability( - snapshot: DeploymentActivitySnapshot | null | undefined, - readBooleanDetail: ( - detail: Record | null | undefined, - key: string, - ) => boolean | null, -): string { - const agsCapable = readBooleanDetail(snapshot?.detail, 'agsCapable'); - - if (agsCapable === true) { - return 'Available'; - } - - if (agsCapable === false) { - return 'Missing'; - } - - return 'Not checked yet'; -} - -export function describeSmokeCapabilitySummary( - snapshot: DeploymentActivitySnapshot | null | undefined, - readBooleanDetail: ( - detail: Record | null | undefined, - key: string, - ) => boolean | null, -): string { - const agsCapable = readBooleanDetail(snapshot?.detail, 'agsCapable'); - - if (agsCapable === true) { - return 'Launch-scoped grade return access was available for this saved setup.'; - } - - if (agsCapable === false) { - return 'This saved setup did not expose the grade return access Lantern needs for this check.'; - } - - return 'This check has not inspected grade return access for this setup yet.'; -} - -export function describeSmokePublication( - snapshot: DeploymentActivitySnapshot | null | undefined, - readStringDetail: ( - detail: Record | null | undefined, - key: string, - ) => string | null, -): string { - const publicationStatus = readStringDetail(snapshot?.detail, 'publicationStatus'); - - switch (publicationStatus) { - case 'succeeded': - return 'Succeeded'; - case 'failed': - return 'Failed'; - case 'not_attempted': - return 'Not attempted'; - default: - return 'Not checked yet'; - } -} - -export function describeSmokePublicationSummary( - snapshot: DeploymentActivitySnapshot | null | undefined, - readStringDetail: ( - detail: Record | null | undefined, - key: string, - ) => string | null, -): string { - const publicationStatus = readStringDetail(snapshot?.detail, 'publicationStatus'); - - switch (publicationStatus) { - case 'succeeded': - return 'Lantern completed a test grade write for this setup.'; - case 'failed': - return 'Lantern reached the test grade write step, but the write failed.'; - case 'not_attempted': - return 'Lantern stopped before the test grade write step.'; - default: - return 'No test grade write result has been recorded for this setup yet.'; - } -} - -export function describeGradePublication(snapshot: DeploymentGradePublicationSnapshot): string { - switch (snapshot.status) { - case 'published': - return 'Latest grade publish succeeded.'; - case 'pending': - return 'Latest grade publish is still pending.'; - case 'failed': - return 'Latest grade publish failed and may need retry.'; - } -} - -export function describeBrokerVerificationStatus( - status: InternalBrokerVerificationStatus['status'] | null, -): string { - switch (status) { - case 'passed': - return 'Passed'; - case 'failed': - return 'Failed'; - case 'pending': - return 'Pending'; - case 'notRun': - case null: - return 'Not recorded yet'; - } -} - -export function describeOverallStatus(status: ControlPlaneHealthStatus | null): string { - switch (status) { - case 'healthy': - return 'Healthy'; - case 'attention': - return 'Needs follow-up'; - case 'failed': - return 'Failed'; - case 'unknown': - case null: - return 'Not recorded yet'; - } -} - -export function formatLmsLabel(lms: 'canvas' | 'moodle' | 'sakai'): string { - switch (lms) { - case 'canvas': - return 'Canvas'; - case 'moodle': - return 'Moodle'; - case 'sakai': - return 'Sakai'; - } -} - -export function describeDiagnosticKind(kind: ControlPlaneDiagnosticItem['kind']): string { - switch (kind) { - case 'launch': - return 'Launch'; - case 'deepLinking': - return 'Deep Linking'; - case 'nrps': - return 'Roster read'; - case 'brokerVerification': - return 'Compatibility result'; - case 'runtime': - return 'Runtime'; - case 'reviewer': - return 'Reviewer action'; - case 'gradePublication': - return 'Grade write'; - } -} - -export function describeRuntimeSandboxModel( - sandboxModel: ControlPlaneRuntimeEvidenceSnapshot['sandboxModel'], -): string { - switch (sandboxModel) { - case 'contained_browser_runtime': - return 'Contained browser runtime'; - case null: - return 'Not recorded yet'; - } -} - -export function describeRuntimeBoundary( - boundary: ControlPlaneRuntimeEvidenceSnapshot['boundary'], -): string { - switch (boundary) { - case 'app_runtime_origin': - return 'App runtime origin'; - case null: - return 'Not recorded yet'; - } -} - -export function describeRuntimeDeliverySubstrate( - substrate: ControlPlaneRuntimeEvidenceSnapshot['deliverySubstrate'], -): string { - switch (substrate) { - case 'direct': - return 'Direct package read'; - case 'dynamic_worker': - return 'Dynamic Worker'; - case null: - return 'Not recorded yet'; - } -} - -export function describeRuntimeDeliveryState( - state: ControlPlaneRuntimeEvidenceSnapshot['deliveryState'], -): string { - switch (state) { - case 'started': - return 'Started'; - case 'exited': - return 'Exited'; - case 'deliveryFailed': - return 'Delivery failed'; - case 'assetMissing': - return 'Reviewed file missing'; - case 'integrityFailed': - return 'Integrity failure'; - case 'timedOut': - return 'Timed out'; - case 'denied': - return 'Denied'; - case 'capabilityDenied': - return 'Capability denied'; - case null: - return 'Not recorded yet'; - } -} - -export function describeRuntimeOutcome(eventType: string | null | undefined): string { - switch (eventType) { - case 'runtime.session.started': - return 'Started'; - case 'runtime.session.exited': - return 'Exited'; - case 'runtime.session.timeout': - return 'Timed out'; - case 'runtime.session.integrity_failed': - return 'Integrity failure'; - case 'runtime.session.denied': - return 'Denied'; - case 'runtime.capability.denied': - return 'Capability denied'; - case 'runtime.score_proposal.accepted': - return 'Score proposal accepted'; - default: - return 'Not recorded yet'; - } -} - -export function describeEvidenceArtifactKind(kind: string): string { - switch (kind) { - case 'structured_json': - return 'Structured JSON'; - case 'screenshot_png': - return 'Screenshot PNG'; - default: - return kind; - } -} - -export function describeDiagnosticStatus(item: ControlPlaneDiagnosticItem): string { - if (item.retryable) { - return 'Retry available'; - } - - return item.status === 'failed' ? 'Failed' : 'Recorded'; -} - -export function describeProblemSummary(problemCount: number): string { - if (problemCount === 0) { - return 'None right now'; - } - - if (problemCount === 1) { - return '1 to review'; - } - - return `${problemCount} to review`; -} diff --git a/src/admin/deployment_detail_ops_render_test.ts b/src/admin/deployment_detail_ops_render_test.ts deleted file mode 100644 index 9f46cb1..0000000 --- a/src/admin/deployment_detail_ops_render_test.ts +++ /dev/null @@ -1,568 +0,0 @@ -import { assertFalse, assertStringIncludes } from '@std/assert'; -import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; -import { resolveCanvasIssuer } from '../lti/config.ts'; -import { - buildBrokerVerificationStatus, - buildControlPlaneAnonymousEvidenceArtifact, - buildControlPlaneDeploymentDetailSnapshot, - buildControlPlaneDeploymentInventoryRow, - buildControlPlaneDiagnosticItem, - buildControlPlaneRuntimeEvidenceSnapshot, - buildDeploymentActivitySnapshot, - buildDeploymentGradePublicationSnapshot, - buildDeploymentRecentLaunch, - buildDeploymentRecord, - buildPackageVersionRecord, - buildPilotUsageMetrics, - buildRetryableGradePublicationLookup, -} from '../test_helpers/package_review.ts'; -import { buildDeploymentBinding, buildMoodleDeploymentBinding } from '../test_helpers/lti.ts'; -import { renderDeploymentDetailPage } from './deployment_detail.ts'; - -Deno.test('deployment page shows status panels and pilot usage without dropping the binding and version controls', () => { - const html = renderDeploymentDetailPage({ - identity: ADMIN_TEST_IDENTITY, - appId: 'chapter-4-asteroids', - appTitle: 'Chapter 4 Asteroids', - history: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - controlPlaneDetail: buildControlPlaneDeploymentDetailSnapshot({ - recentLaunches: [ - buildDeploymentRecentLaunch({ - userId: 'https://sakai.example/user/ead80b74-e0e4-42f5-a602-489adae928b1', - userDisplayName: 'Ada Lovelace', - userEmail: 'ada@example.com', - userLogin: 'adal', - ltiProfileId: 'certification', - ltiProfileSource: 'lanternDefault', - }), - ], - latestLaunch: buildDeploymentActivitySnapshot({ - occurredAt: '2026-03-24T12:30:00Z', - summary: 'Latest launch reached the governed runtime handoff.', - detail: { - code: 'ok', - ltiProfileId: 'certification', - ltiProfileSource: 'lanternDefault', - }, - }), - latestCompatibilityPath: buildDeploymentActivitySnapshot({ - occurredAt: '2026-03-24T12:31:00Z', - summary: 'Lantern tolerated bounded target_link_uri drift during launch validation.', - detail: { - scope: 'launch', - path: 'target_link_uri_drift', - ltiProfileId: 'governedCompatibility', - ltiProfileSource: 'deploymentOverride', - }, - }), - latestNrpsRead: buildDeploymentActivitySnapshot({ - occurredAt: '2026-03-24T12:33:00Z', - summary: 'Latest roster verification succeeded.', - detail: { - code: 'ok', - ltiProfileId: 'governedCompatibility', - ltiProfileSource: 'deploymentOverride', - }, - }), - latestGradePublish: buildDeploymentGradePublicationSnapshot({ - updatedAt: '2026-03-24T12:35:00Z', - status: 'failed', - }), - pilotUsage: buildPilotUsageMetrics({ - totalLaunches: 6, - attemptsCompleted: 5, - gradePublishesSucceeded: 4, - gradePublishesFailed: 1, - recentActiveUsers: 3, - }), - }), - canvasConfigUrl: 'http://localhost:8417/lti/canvas/config.json', - supportedCanvasEnvironments: [ - { - id: 'production', - label: 'Production Canvas', - issuer: resolveCanvasIssuer('production'), - }, - ], - }); - - assertStringIncludes(html, 'Current status'); - assertStringIncludes(html, 'Last launch'); - assertStringIncludes(html, 'Last compatibility path'); - assertStringIncludes(html, 'Last grade write'); - assertStringIncludes(html, 'Last NRPS read'); - assertStringIncludes(html, 'Pilot usage'); - assertStringIncludes(html, 'Launches recorded'); - assertStringIncludes(html, 'Attempts completed'); - assertStringIncludes(html, 'Grade publishes'); - assertStringIncludes(html, 'Recent active users'); - assertStringIncludes(html, 'Recent launches'); - assertStringIncludes(html, 'recent-launches-table'); - assertStringIncludes(html, 'Opened by'); - assertStringIncludes(html, 'Ada Lovelace'); - assertStringIncludes(html, 'ada@example.com'); - assertStringIncludes(html, 'Profile Certification from Lantern default'); - assertStringIncludes(html, 'Course or site course-42'); - assertStringIncludes(html, 'Placement resource-link-123'); - assertStringIncludes( - html, - 'Latest roster verification succeeded. Profile Governed interoperability override.', - ); - assertStringIncludes( - html, - 'Lantern last used launch target drift tolerance on the saved deployment path. Profile Governed interoperability override.', - ); - assertStringIncludes(html, 'deployment-tab-label">Canvas'); - assertStringIncludes(html, 'Save version for learners'); - assertStringIncludes(html, 'chip-status-healthy'); - assertStringIncludes(html, 'chip-status-failed'); - assertStringIncludes(html, 'table-row-status-failed'); - assertFalse(html.includes('See who opened this')); - assertFalse(html.includes('target_link_uri_drift')); -}); - -Deno.test('deployment page shows diagnostics and an explicit retry action for retryable AGS failures', () => { - const html = renderDeploymentDetailPage({ - identity: ADMIN_TEST_IDENTITY, - appId: 'chapter-4-asteroids', - appTitle: 'Chapter 4 Asteroids', - history: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - controlPlaneDetail: buildControlPlaneDeploymentDetailSnapshot({ - diagnostics: [ - buildControlPlaneDiagnosticItem({ - id: 1, - kind: 'launch', - eventType: 'launch.rejected', - summary: 'Rejected launch before runtime handoff.', - operatorSummary: - 'Launch failed before Lantern could hand the learner into the governed runtime.', - boundaryDenialCategory: 'specInvalid', - detail: { - category: 'specInvalid', - }, - }), - buildControlPlaneDiagnosticItem({ - id: 2, - kind: 'deepLinking', - eventType: 'deep_linking.request.rejected', - status: 'failed', - code: 'target_link_uri_drift_not_allowed', - summary: 'Rejected a Canvas Deep Linking request before picker handoff.', - operatorSummary: - 'Deep Linking request matched the saved Canvas deployment, but the active LTI profile denied it before picker handoff.', - boundaryDenialCategory: 'policyDenied', - detail: { - category: 'policyDenied', - ltiProfileId: 'certification', - ltiProfileSource: 'lanternDefault', - }, - }), - buildControlPlaneDiagnosticItem({ - id: 3, - kind: 'nrps', - eventType: 'deployment.nrps_verified', - status: 'failed', - code: 'token_request_failed', - summary: 'Canvas roster verification failed.', - operatorSummary: 'Roster verification failed for the saved deployment path.', - detail: { - ltiProfileId: 'certification', - ltiProfileSource: 'lanternDefault', - }, - }), - buildControlPlaneDiagnosticItem({ - id: 4, - kind: 'gradePublication', - eventType: 'grade_publish.failed', - status: 'failed', - attemptId: 'attempt-123', - code: 'token_request_failed', - summary: 'Canvas AGS score publish failed.', - operatorSummary: 'Grade publish failed and can be retried from the control plane.', - retryable: true, - detail: { - request: { - method: 'POST', - path: '/runtime/sessions/runtime-session-123/finalize', - host: 'runtime.appboundary.com', - queryKeys: [], - formKeys: [], - bodyKeys: ['completionState'], - contentType: 'application/json', - contentLength: 31, - userAgent: 'Canvas-Test/1.0', - clientIpMasked: '203.0.113.x', - forwardedHost: 'runtime.appboundary.com', - forwardedProto: 'https', - cfRay: 'abc123-IAD', - }, - }, - }), - ], - retryableGradePublication: buildRetryableGradePublicationLookup({ - attemptId: 'attempt-123', - }), - }), - canvasConfigUrl: 'http://localhost:8417/lti/canvas/config.json', - supportedCanvasEnvironments: [ - { - id: 'production', - label: 'Production Canvas', - issuer: resolveCanvasIssuer('production'), - }, - ], - }); - - assertStringIncludes( - html, - 'Launch failed before Lantern could hand the learner into the governed runtime.', - ); - assertStringIncludes( - html, - 'Deep Linking request matched the saved Canvas deployment, but the active LTI profile denied it before picker handoff.', - ); - assertStringIncludes(html, 'Roster verification failed for the saved deployment path.'); - assertStringIncludes(html, 'Spec-invalid request'); - assertStringIncludes(html, 'Policy denial'); - assertStringIncludes(html, 'Deep Linking'); - assertStringIncludes(html, 'Profile Certification from Lantern default'); - assertStringIncludes(html, 'Grade publish failed and can be retried from the control plane.'); - assertStringIncludes( - html, - 'Request POST /runtime/sessions/runtime-session-123/finalize · Host runtime.appboundary.com · Body completionState · application/json · 31 bytes · UA Canvas-Test/1.0 · IP 203.0.113.x · CF-Ray abc123-IAD', - ); - assertStringIncludes(html, 'retry-grade-publish'); - assertStringIncludes(html, 'Retry grade publish'); - assertStringIncludes(html, 'chip-status-attention'); - assertStringIncludes(html, 'details id="activity-details" open'); - assertFalse(html.includes('specInvalid')); - assertFalse(html.includes('policyDenied')); - assertFalse(html.includes('target_link_uri_drift_not_allowed')); -}); - -Deno.test('deployment page keeps setup history as an optional verification link-out', () => { - const html = renderDeploymentDetailPage({ - identity: ADMIN_TEST_IDENTITY, - appId: 'chapter-4-asteroids', - appTitle: 'Chapter 4 Asteroids', - selectedLms: 'moodle', - history: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 4, - slug: 'chapter-4-asteroids-moodle', - label: 'Chapter 4 Asteroids Moodle Deployment', - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - lmsType: 'moodle', - binding: buildMoodleDeploymentBinding(), - }), - ], - controlPlaneDetail: buildControlPlaneDeploymentDetailSnapshot({ - inventory: buildControlPlaneDeploymentInventoryRow({ - deploymentId: 4, - deploymentSlug: 'chapter-4-asteroids-moodle', - deploymentLabel: 'Chapter 4 Asteroids Moodle Deployment', - binding: buildMoodleDeploymentBinding(), - brokerVerification: buildBrokerVerificationStatus({ - supportedPath: 'lti13LaunchAgsScore', - internal: { - source: 'ci', - status: 'passed', - checkedAt: '2026-03-24T12:50:00Z', - summary: 'Latest internal proof passed for the saved Moodle deployment.', - evidenceUrl: 'https://example.test/verification/moodle-ci-pass', - }, - }), - }), - brokerVerification: buildBrokerVerificationStatus({ - supportedPath: 'lti13LaunchAgsScore', - internal: { - source: 'ci', - status: 'passed', - checkedAt: '2026-03-24T12:50:00Z', - summary: 'Latest internal proof passed for the saved Moodle deployment.', - evidenceUrl: 'https://example.test/verification/moodle-ci-pass', - }, - }), - }), - canvasConfigUrl: 'http://localhost:8417/lti/canvas/config.json', - supportedCanvasEnvironments: [ - { - id: 'production', - label: 'Production Canvas', - issuer: resolveCanvasIssuer('production'), - }, - ], - }); - - assertStringIncludes(html, 'Setup history'); - assertStringIncludes( - html, - 'If you need past setup records or test logs for this app setup, open Verification.', - ); - assertStringIncludes(html, 'Latest internal proof passed for the saved Moodle deployment.'); - assertStringIncludes(html, 'Latest saved result'); - assertStringIncludes(html, 'Open Verification'); - assertStringIncludes(html, 'Open log'); - assertFalse(html.includes('Official certification')); - assertFalse(html.includes('Supported Canvas path')); -}); - -Deno.test('deployment page shows reviewed runtime boundary facts without exposing raw runtime tokens', () => { - const html = renderDeploymentDetailPage({ - identity: ADMIN_TEST_IDENTITY, - appId: 'chapter-4-asteroids', - appTitle: 'Chapter 4 Asteroids', - history: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - controlPlaneDetail: { - ...buildControlPlaneDeploymentDetailSnapshot(), - latestRuntimeSession: buildControlPlaneRuntimeEvidenceSnapshot({ - eventType: 'runtime.session.started', - occurredAt: '2026-03-24T12:36:00Z', - summary: - "Started the reviewed runtime session inside Lantern's contained browser boundary.", - attemptId: 'attempt-123', - sessionId: 'runtime-session-123', - packageVersion: '0.1.0', - artifactDigest: 'sha256:chapter-4-asteroids-0.1.0', - runtimeContractSignature: 'test-reviewed-runtime-contract-signature', - deliverySubstrate: 'dynamic_worker', - deliveryWorkerId: 'reviewed-runtime:v1:test-reviewed-runtime-contract-signature', - deliveryState: 'started', - route: 'session', - detail: {}, - }), - latestRuntimeOutcome: buildControlPlaneRuntimeEvidenceSnapshot({ - eventType: 'runtime.session.exited', - occurredAt: '2026-03-24T12:37:00Z', - summary: "Exited the reviewed runtime through Lantern's finalize boundary.", - attemptId: 'attempt-123', - sessionId: 'runtime-session-123', - packageVersion: '0.1.0', - artifactDigest: 'sha256:chapter-4-asteroids-0.1.0', - runtimeContractSignature: 'test-reviewed-runtime-contract-signature', - deliverySubstrate: 'dynamic_worker', - deliveryWorkerId: 'reviewed-runtime:v1:test-reviewed-runtime-contract-signature', - deliveryState: 'exited', - route: 'finalize', - detail: { - completionState: 'completed', - scoreGiven: 8, - scoreMaximum: 10, - gradePublished: false, - }, - }), - } as ReturnType, - canvasConfigUrl: 'http://localhost:8417/lti/canvas/config.json', - supportedCanvasEnvironments: [ - { - id: 'production', - label: 'Production Canvas', - issuer: resolveCanvasIssuer('production'), - }, - ], - }); - - assertStringIncludes(html, 'Reviewed runtime'); - assertStringIncludes(html, 'Runtime session'); - assertStringIncludes(html, 'runtime-session-123'); - assertStringIncludes(html, 'Attempt binding'); - assertStringIncludes(html, 'Reviewed package'); - assertStringIncludes(html, 'Package 0.1.0'); - assertStringIncludes(html, 'Artifact digest'); - assertStringIncludes(html, 'sha256:chapter-4-asteroids-0.1.0'); - assertStringIncludes(html, 'Runtime contract'); - assertStringIncludes(html, 'test-reviewed-runtime-contract-signature'); - assertStringIncludes(html, 'Contained browser runtime'); - assertStringIncludes(html, 'App runtime origin'); - assertStringIncludes(html, 'Delivery substrate'); - assertStringIncludes(html, 'Dynamic Worker'); - assertStringIncludes(html, 'Delivery state'); - assertStringIncludes(html, 'Delivery worker'); - assertStringIncludes(html, 'reviewed-runtime:v1:test-reviewed-runtime-contract-signature'); - assertStringIncludes(html, 'Latest outcome'); - assertStringIncludes(html, "Exited the reviewed runtime through Lantern's finalize boundary."); - assertFalse(html.includes('contained_browser_runtime')); - assertFalse(html.includes('app_runtime_origin')); -}); - -Deno.test('deployment page renders anonymous submission evidence links beside the latest normalized browser outcome', () => { - const html = renderDeploymentDetailPage({ - identity: ADMIN_TEST_IDENTITY, - appId: 'chapter-4-asteroids', - appTitle: 'Chapter 4 Asteroids', - history: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - controlPlaneDetail: buildControlPlaneDeploymentDetailSnapshot({ - latestRuntimeOutcome: buildControlPlaneRuntimeEvidenceSnapshot({ - eventType: 'runtime.session.exited', - summary: "Exited the reviewed runtime through Lantern's finalize boundary.", - detail: { - submissionMode: 'anonymous_submission', - scoreGiven: 100, - scoreMaximum: 100, - browserGraderResult: { - specResults: [ - { - source: '/grading/specs/checks.spec.js', - result: 'passed', - failures: [], - }, - ], - }, - }, - }), - latestAnonymousEvidence: [ - buildControlPlaneAnonymousEvidenceArtifact({ - artifactId: 'artifact-001', - fileName: 'submission.json', - artifactUrl: '/admin/packages/chapter-4-asteroids/deployment/evidence/artifact-001', - }), - buildControlPlaneAnonymousEvidenceArtifact({ - artifactId: 'artifact-002', - kind: 'screenshot_png', - fileName: 'submission.png', - contentType: 'image/png', - byteSize: 2048, - sha256: 'sha256:artifact-002', - artifactUrl: '/admin/packages/chapter-4-asteroids/deployment/evidence/artifact-002', - }), - ], - }), - canvasConfigUrl: 'http://localhost:8417/lti/canvas/config.json', - supportedCanvasEnvironments: [ - { - id: 'production', - label: 'Production Canvas', - issuer: resolveCanvasIssuer('production'), - }, - ], - }); - - assertStringIncludes(html, 'Anonymous submission evidence'); - assertStringIncludes(html, 'Latest browser-grader outcome: 100 / 100 across 1 reviewed specs.'); - assertStringIncludes(html, 'submission.json'); - assertStringIncludes(html, 'submission.png'); - assertStringIncludes(html, 'Content type application/json'); - assertStringIncludes(html, 'Size 128 bytes'); - assertStringIncludes(html, 'SHA-256 sha256:artifact-001'); - assertStringIncludes(html, 'Recorded Mar 24, 2026'); - assertStringIncludes(html, 'Supplemental screenshot evidence'); - assertStringIncludes(html, 'not exhaustive proof of learner behavior'); - assertStringIncludes(html, ' { - const html = renderDeploymentDetailPage({ - identity: ADMIN_TEST_IDENTITY, - appId: 'chapter-4-asteroids', - appTitle: 'Chapter 4 Asteroids', - history: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - controlPlaneDetail: buildControlPlaneDeploymentDetailSnapshot({ - latestRuntimeOutcome: buildControlPlaneRuntimeEvidenceSnapshot({ - eventType: 'runtime.session.integrity_failed', - summary: 'Reviewed runtime integrity checks blocked this session.', - deliverySubstrate: 'dynamic_worker', - deliveryState: 'deliveryFailed', - code: 'runtime_delivery_failed', - route: 'session', - }), - }), - canvasConfigUrl: 'http://localhost:8417/lti/canvas/config.json', - supportedCanvasEnvironments: [ - { - id: 'production', - label: 'Production Canvas', - issuer: resolveCanvasIssuer('production'), - }, - ], - }); - - assertStringIncludes(html, 'Delivery failed'); - assertStringIncludes( - html, - 'Dynamic Worker delivery failed before Lantern could serve the immutable reviewed runtime bytes for the latest session.', - ); - assertStringIncludes(html, 'Code runtime_delivery_failed'); -}); diff --git a/src/admin/deployment_detail_ops_sections.ts b/src/admin/deployment_detail_ops_sections.ts deleted file mode 100644 index 9c79362..0000000 --- a/src/admin/deployment_detail_ops_sections.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { summarizePilotUsage } from '../ops/service.ts'; -import type { ManagedDeploymentSlot } from './deployment_detail.ts'; -import type { ControlPlaneDeploymentDetailSnapshot } from '../ops/types.ts'; -import { escapeHtml } from './layout.ts'; -import { - renderAgsSmokeSection, - renderAnonymousEvidenceSection, - renderBrokerVerificationSection, - renderDiagnosticsSection, - renderRuntimeSection, -} from './deployment_detail_ops_evidence_sections.ts'; -import { - describeActivityLtiProfile, - describeCompatibilityPathSummary, - formatActivityTimestamp, - formatGradePublicationTimestamp, - renderActivityFact, - renderDimensionRow, - renderRecentLaunchTableRow, -} from './deployment_detail_ops_support.ts'; -import { describeGradePublication, describeOverallStatus } from './deployment_detail_ops_labels.ts'; - -export function renderControlPlaneStatusSection( - detail: ControlPlaneDeploymentDetailSnapshot | null, -): string { - const health = detail?.inventory.health ?? null; - - return `
    -
    -
    - -

    Current status

    -
    - ${ - renderActivityFact( - 'Overall status', - describeOverallStatus(health?.overallStatus ?? null), - health?.summary ?? 'No control-plane status has been recorded yet.', - ) - } - ${ - renderActivityFact( - 'Last launch', - formatActivityTimestamp(detail?.latestLaunch), - appendLtiProfileSummary( - detail?.latestLaunch?.summary ?? 'No launch has been recorded yet.', - detail?.latestLaunch?.detail, - ), - ) - } - ${ - renderActivityFact( - 'Last compatibility path', - formatActivityTimestamp(detail?.latestCompatibilityPath), - appendLtiProfileSummary( - describeCompatibilityPathSummary(detail?.latestCompatibilityPath), - detail?.latestCompatibilityPath?.detail, - ), - ) - } - ${ - renderActivityFact( - 'Last grade write', - formatGradePublicationTimestamp(detail?.latestGradePublish), - detail?.latestGradePublish - ? describeGradePublication(detail.latestGradePublish) - : 'No grade publish has been recorded yet.', - ) - } - ${ - renderActivityFact( - 'Last NRPS read', - formatActivityTimestamp(detail?.latestNrpsRead), - appendLtiProfileSummary( - detail?.latestNrpsRead?.summary ?? 'Roster verification has not run yet.', - detail?.latestNrpsRead?.detail, - ), - ) - } -
    -
    -
    - -

    Status by area

    -
    - ${renderDimensionRow('Review', health?.dimensions.review ?? null)} - ${renderDimensionRow('Enablement', health?.dimensions.enablement ?? null)} - ${renderDimensionRow('Launch', health?.dimensions.launch ?? null)} - ${renderDimensionRow('AGS publish', health?.dimensions.gradePublication ?? null)} - ${renderDimensionRow('NRPS', health?.dimensions.nrps ?? null)} -
    -
    -
    -
    `; -} - -function appendLtiProfileSummary( - summary: string, - detail: Record | null | undefined, -): string { - const profile = describeActivityLtiProfile(detail); - - return profile === null ? summary : `${summary} ${profile}.`; -} - -export function renderPilotUsageSection( - detail: ControlPlaneDeploymentDetailSnapshot | null, -): string { - const pilotUsageFacts = detail === null - ? [ - { label: 'Launches recorded', value: '0' }, - { label: 'Attempts completed', value: '0' }, - { label: 'Grade publishes', value: '0 passed / 0 failed' }, - { label: 'Recent active users', value: '0' }, - ] - : summarizePilotUsage(detail.pilotUsage); - - return `
    -
    - -

    Recent usage

    -
    - ${ - pilotUsageFacts - .map( - (fact) => - `
    - ${escapeHtml(fact.label)} - ${escapeHtml(fact.value)} -
    `, - ) - .join('') - } -
    -
    -
    `; -} - -export function renderOperationalEvidenceSection( - appId: string, - slot: ManagedDeploymentSlot, - detail: ControlPlaneDeploymentDetailSnapshot | null, - openDetails = false, -): string { - const diagnostics = detail?.diagnostics ?? []; - const recentLaunches = detail?.recentLaunches ?? []; - const visibleLaunches = recentLaunches.slice(0, 3); - const showDetails = openDetails || diagnostics.length > 0; - - return `
    -
    - - ${ - visibleLaunches.length === 0 - ? `
    -

    No launches recorded yet

    -

    Lantern has not recorded a successful launch for this LMS setup yet.

    -
    ` - : `
    - - - - - - - - - - - ${visibleLaunches.map((item) => renderRecentLaunchTableRow(item)).join('')} - -
    Opened byOpenedContextDetails
    -
    ` - } - ${ - recentLaunches.length > visibleLaunches.length - ? `

    Showing the ${ - escapeHtml( - String(visibleLaunches.length), - ) - } most recent launches.

    ` - : '' - } -
    - Open checks and troubleshooting -
    - ${renderControlPlaneStatusSection(detail)} - ${renderRuntimeSection(detail)} - ${renderAnonymousEvidenceSection(detail)} - ${renderAgsSmokeSection(appId, slot, detail)} - ${renderBrokerVerificationSection(detail)} - ${renderPilotUsageSection(detail)} - ${renderDiagnosticsSection(appId, detail)} -
    -
    -
    -
    `; -} diff --git a/src/admin/deployment_detail_ops_support.ts b/src/admin/deployment_detail_ops_support.ts deleted file mode 100644 index d2debfa..0000000 --- a/src/admin/deployment_detail_ops_support.ts +++ /dev/null @@ -1,577 +0,0 @@ -import { describeResolvedLtiProfile } from '../lti/profile_resolution.ts'; -import { isLtiProfileId } from '../lti/profile.ts'; -import type { - ControlPlaneDiagnosticItem, - ControlPlaneHealthDimension, - ControlPlaneHealthStatus, - ControlPlaneRuntimeEvidenceSnapshot, - DeploymentActivitySnapshot, - DeploymentGradePublicationSnapshot, - DeploymentRecentLaunch, -} from '../ops/types.ts'; -import { escapeHtml, formatDateTime } from './layout.ts'; -import { - describeDiagnosticKind, - describeDiagnosticStatus, - describeRuntimeBoundary, - describeRuntimeSandboxModel, -} from './deployment_detail_ops_labels.ts'; - -export function renderActivityFact(label: string, value: string, summary: string): string { - return `
    - ${escapeHtml(label)} - ${escapeHtml(value)} -

    ${escapeHtml(summary)}

    -
    `; -} - -export function renderDimensionRow( - label: string, - dimension: ControlPlaneHealthDimension | null, -): string { - const tone = dimension?.status ?? 'unknown'; - const status = dimension === null ? 'Unknown' : describeDimensionStatus(dimension.status); - const summary = dimension?.summary ?? - 'No control-plane evidence has been recorded for this dimension yet.'; - const checkedAt = dimension?.checkedAt === null || dimension?.checkedAt === undefined - ? 'Not recorded yet' - : formatDateTime(dimension.checkedAt); - - return `
    -
    -

    - ${escapeHtml(label)} - ${ - escapeHtml( - status, - ) - } -

    -

    ${escapeHtml(checkedAt)}

    -
    -

    ${escapeHtml(summary)}

    -
    `; -} - -export function renderDiagnosticRow( - item: ControlPlaneDiagnosticItem, - appId: string, - retryAttemptId: string | null, -): string { - const tone = describeDiagnosticTone(item); - const request = describeDiagnosticRequest(item.detail); - const details = [ - describeBoundaryDenialCategory(item.boundaryDenialCategory), - describeRuntimeFact( - item.kind === 'runtime' ? readRuntimeSandboxModel(item.detail) : null, - describeRuntimeSandboxModel, - ), - describeRuntimeFact( - item.kind === 'runtime' ? readRuntimeBoundary(item.detail) : null, - describeRuntimeBoundary, - ), - describeActivityLtiProfile(item.detail), - item.boundaryDenialCategory === null && item.code !== null ? `Code ${item.code}` : null, - item.kind === 'runtime' ? describeRuntimeRoute(readStringDetail(item.detail, 'route')) : null, - item.attemptId === null ? null : `Attempt ${item.attemptId}`, - ].filter((value): value is string => value !== null); - const retryAction = item.retryable && retryAttemptId !== null - ? `
    - -
    - -
    -
    ` - : ''; - - return `
    -
    -

    - ${escapeHtml(describeDiagnosticKind(item.kind))} - ${ - escapeHtml( - describeDiagnosticStatus(item), - ) - } -

    -

    ${escapeHtml(formatDateTime(item.occurredAt))}

    -
    -

    ${escapeHtml(item.operatorSummary)}

    - ${details.length === 0 ? '' : `

    ${escapeHtml(details.join(' · '))}

    `} - ${request === null ? '' : `

    ${escapeHtml(request)}

    `} - ${retryAction} -
    `; -} - -export function renderRecentLaunchTableRow(item: DeploymentRecentLaunch): string { - const identity = resolveLaunchIdentity(item); - const launchContext = [ - item.contextId === null ? null : `Course or site ${item.contextId}`, - item.resourceLinkId === null ? null : `Placement ${item.resourceLinkId}`, - ].filter((value): value is string => value !== null); - const launchDetails = [ - identity.secondary, - formatResolvedLtiProfile({ - id: item.ltiProfileId, - source: item.ltiProfileSource, - }), - item.attemptId === null ? null : `Attempt ${item.attemptId}`, - ].filter((value): value is string => value !== null); - const summary = launchContext.length === 0 ? item.summary : launchContext.join(' · '); - - return ` - -
    - ${escapeHtml(identity.primary ?? 'Unknown person')} - ${ - identity.secondary === null - ? '' - : `${escapeHtml(identity.secondary)}` - } -
    - - ${escapeHtml(formatDateTime(item.occurredAt))} - ${escapeHtml(summary)} - -
    - Opened - ${ - launchDetails.length === 0 - ? '' - : `${escapeHtml(launchDetails.join(' · '))}` - } -
    - - `; -} - -function resolveLaunchIdentity(item: DeploymentRecentLaunch): { - primary: string | null; - secondary: string | null; -} { - if (item.userDisplayName !== null) { - return { - primary: item.userDisplayName, - secondary: item.userEmail ?? item.userLogin, - }; - } - - if (item.userEmail !== null) { - return { - primary: item.userEmail, - secondary: item.userLogin, - }; - } - - if (item.userLogin !== null) { - return { - primary: item.userLogin, - secondary: null, - }; - } - - return { - primary: item.userId === null ? null : normalizeOpaqueSubject(item.userId), - secondary: null, - }; -} - -function describeDiagnosticRequest(detail: Record): string | null { - const request = readRequestEnvelope(detail); - - if (request === null) { - return null; - } - - const facts = [ - `${request.method} ${request.path}`, - request.host === null ? null : `Host ${request.host}`, - request.queryKeys.length === 0 ? null : `Query ${request.queryKeys.join(', ')}`, - request.formKeys.length === 0 ? null : `Form ${request.formKeys.join(', ')}`, - request.bodyKeys.length === 0 ? null : `Body ${request.bodyKeys.join(', ')}`, - request.contentType === null ? null : request.contentType, - request.contentLength === null ? null : `${request.contentLength} bytes`, - request.userAgent === null ? null : `UA ${request.userAgent}`, - request.clientIpMasked === null ? null : `IP ${request.clientIpMasked}`, - request.cfRay === null ? null : `CF-Ray ${request.cfRay}`, - ].filter((value): value is string => value !== null); - - if (facts.length === 0) { - return null; - } - - return `Request ${facts.join(' · ')}`; -} - -function readRequestEnvelope(detail: Record): { - method: string; - path: string; - host: string | null; - queryKeys: string[]; - formKeys: string[]; - bodyKeys: string[]; - contentType: string | null; - contentLength: number | null; - userAgent: string | null; - clientIpMasked: string | null; - cfRay: string | null; -} | null { - const value = detail.request; - - if (!isRecord(value)) { - return null; - } - - const method = readStringDetail(value, 'method'); - const path = readStringDetail(value, 'path'); - - if (method === null || path === null) { - return null; - } - - return { - method, - path, - host: readStringDetail(value, 'host'), - queryKeys: readStringArrayDetail(value, 'queryKeys'), - formKeys: readStringArrayDetail(value, 'formKeys'), - bodyKeys: readStringArrayDetail(value, 'bodyKeys'), - contentType: readStringDetail(value, 'contentType'), - contentLength: readNumberDetail(value, 'contentLength'), - userAgent: readStringDetail(value, 'userAgent'), - clientIpMasked: readStringDetail(value, 'clientIpMasked'), - cfRay: readStringDetail(value, 'cfRay'), - }; -} - -function normalizeOpaqueSubject(value: string): string { - try { - const url = new URL(value); - const pathSegments = url.pathname.split('/').filter((segment) => segment.length > 0); - const lastSegment = pathSegments.at(-1); - - return lastSegment === undefined ? value : lastSegment; - } catch { - return value; - } -} - -export function formatActivityTimestamp( - snapshot: DeploymentActivitySnapshot | null | undefined, -): string { - if (snapshot === null || snapshot === undefined) { - return 'Not recorded yet'; - } - - return formatDateTime(snapshot.occurredAt); -} - -export function formatGradePublicationTimestamp( - snapshot: DeploymentGradePublicationSnapshot | null | undefined, -): string { - if (snapshot === null || snapshot === undefined) { - return 'Not recorded yet'; - } - - return formatDateTime(snapshot.publishedAt ?? snapshot.updatedAt); -} - -export function formatBrokerVerificationTimestamp( - verification: - | { - checkedAt: string; - } - | null - | undefined, -): string { - if (verification === null || verification === undefined) { - return 'Not recorded yet'; - } - - return formatDateTime(verification.checkedAt); -} - -export function formatRuntimeTimestamp( - snapshot: ControlPlaneRuntimeEvidenceSnapshot | null | undefined, -): string { - if (snapshot === null || snapshot === undefined) { - return 'Not recorded yet'; - } - - return formatDateTime(snapshot.occurredAt); -} - -export function formatOptionalTimestamp(value: string | null | undefined): string { - if (value === null || value === undefined) { - return 'Not recorded yet'; - } - - return formatDateTime(value); -} - -export function formatByteSize(byteSize: number | null | undefined): string { - if (byteSize === null || byteSize === undefined) { - return 'Not recorded yet'; - } - - if (byteSize < 1024) { - return `${byteSize} bytes`; - } - - const kibibytes = byteSize / 1024; - return `${kibibytes.toFixed(kibibytes >= 10 ? 0 : 1)} KiB`; -} - -export function describeActivityLtiProfile( - detail: Record | null | undefined, -): string | null { - if (detail === null || detail === undefined) { - return null; - } - - return formatResolvedLtiProfile({ - id: readLtiProfileId(detail), - source: readLtiProfileSource(detail), - }); -} - -export function describeBoundaryDenialCategory( - category: ControlPlaneDiagnosticItem['boundaryDenialCategory'], -): string | null { - switch (category) { - case 'specInvalid': - return 'Spec-invalid request'; - case 'policyDenied': - return 'Policy denial'; - case null: - return null; - } -} - -export function describeRuntimeRoute(route: string | null): string | null { - switch (route) { - case 'session': - return 'Route Session bootstrap'; - case 'content': - return 'Route Reviewed content'; - case 'finalize': - return 'Route Finalize'; - case 'local-state.read': - return 'Route Local state read'; - case 'local-state.write': - return 'Route Local state write'; - case null: - return null; - default: - return `Route ${route}`; - } -} - -export function describeCompatibilityPathSummary( - snapshot: DeploymentActivitySnapshot | null | undefined, -): string { - if (snapshot === null || snapshot === undefined) { - return 'Lantern has not recorded a governed compatibility path for this setup yet.'; - } - - const scope = readStringDetail(snapshot.detail, 'scope'); - const path = readStringDetail(snapshot.detail, 'path'); - - switch (`${scope}:${path}`) { - case 'login:opaque_login_hint_decode': - return 'Lantern last decoded an opaque login hint on the saved deployment path.'; - case 'login:opaque_lti_message_hint_decode': - return 'Lantern last decoded an opaque LTI message hint on the saved deployment path.'; - case 'login:platform_default_launch_target': - return 'Lantern last filled the default launch target on the saved deployment path.'; - case 'launch:jwks_refetch': - return 'Lantern last retried launch JWKS lookup on the saved deployment path.'; - case 'launch:target_link_uri_drift': - return 'Lantern last used launch target drift tolerance on the saved deployment path.'; - case 'deep_linking:jti_nonce_bridge': - return 'Lantern last bridged the Deep Linking nonce from jti on the saved deployment path.'; - case 'deep_linking:jwks_refetch': - return 'Lantern last retried Deep Linking JWKS lookup on the saved deployment path.'; - case 'deep_linking:target_link_uri_drift': - return 'Lantern last used Deep Linking target drift tolerance on the saved deployment path.'; - default: - return snapshot.summary; - } -} - -export function readStringDetail( - detail: Record | null | undefined, - key: string, -): string | null { - if (detail === null || detail === undefined) { - return null; - } - - const value = detail[key]; - - return typeof value === 'string' && value.trim() !== '' ? value.trim() : null; -} - -export function readBooleanDetail( - detail: Record | null | undefined, - key: string, -): boolean | null { - if (detail === null || detail === undefined) { - return null; - } - - const value = detail[key]; - - return typeof value === 'boolean' ? value : null; -} - -function readNumberDetail( - detail: Record | null | undefined, - key: string, -): number | null { - if (detail === null || detail === undefined) { - return null; - } - - const value = detail[key]; - - return typeof value === 'number' && Number.isFinite(value) ? value : null; -} - -function readStringArrayDetail( - detail: Record | null | undefined, - key: string, -): string[] { - if (detail === null || detail === undefined) { - return []; - } - - const value = detail[key]; - - if (!Array.isArray(value)) { - return []; - } - - return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim() !== ''); -} - -export function readNestedStringDetail( - detail: Record | null | undefined, - key: string, - nestedKey: string, -): string | null { - if (detail === null || detail === undefined) { - return null; - } - - const value = detail[key]; - - if (typeof value !== 'object' || value === null) { - return null; - } - - const nestedValue = (value as Record)[nestedKey]; - - return typeof nestedValue === 'string' && nestedValue.trim() !== '' ? nestedValue.trim() : null; -} - -function formatResolvedLtiProfile(input: { - id: string | null; - source: string | null; -}): string | null { - if ( - input.id === null || - !isLtiProfileId(input.id) || - (input.source !== 'lanternDefault' && input.source !== 'deploymentOverride') - ) { - return null; - } - - return `Profile ${ - describeResolvedLtiProfile({ - id: input.id, - source: input.source, - }) - }`; -} - -function readLtiProfileId(detail: Record): string | null { - const value = detail['ltiProfileId']; - - return typeof value === 'string' ? value : null; -} - -function readLtiProfileSource(detail: Record): string | null { - const value = detail['ltiProfileSource']; - - return typeof value === 'string' ? value : null; -} - -function readRuntimeSandboxModel( - detail: Record | null | undefined, -): ControlPlaneRuntimeEvidenceSnapshot['sandboxModel'] { - return readStringDetail(detail, 'sandboxModel') === 'contained_browser_runtime' - ? 'contained_browser_runtime' - : null; -} - -function readRuntimeBoundary( - detail: Record | null | undefined, -): ControlPlaneRuntimeEvidenceSnapshot['boundary'] { - return readStringDetail(detail, 'boundary') === 'app_runtime_origin' - ? 'app_runtime_origin' - : null; -} - -function describeDimensionStatus(status: ControlPlaneHealthStatus): string { - switch (status) { - case 'healthy': - return 'Healthy'; - case 'attention': - return 'Needs follow-up'; - case 'failed': - return 'Failed'; - case 'unknown': - return 'Unknown'; - } -} - -function describeDiagnosticTone( - item: ControlPlaneDiagnosticItem, -): 'healthy' | 'attention' | 'failed' | 'unknown' { - if (item.retryable) { - return 'attention'; - } - - if (item.status === 'failed') { - return 'failed'; - } - - return 'unknown'; -} - -function describeRuntimeFact(value: T | null, describe: (value: T) => string): string | null { - return value === null ? null : describe(value); -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -export function describeProblemFactSummary(problemCount: number, retryableCount: number): string { - if (problemCount === 0) { - return 'No problems are recorded for this LMS setup right now.'; - } - - if (retryableCount === 0) { - return 'Open the details below to review the latest failures and warnings.'; - } - - return `${retryableCount} retry action${ - retryableCount === 1 ? '' : 's' - } still need operator follow-up.`; -} diff --git a/src/admin/deployment_detail_release_canvas_form.ts b/src/admin/deployment_detail_release_canvas_form.ts deleted file mode 100644 index 5ee1528..0000000 --- a/src/admin/deployment_detail_release_canvas_form.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { CanvasEnvironmentOption } from '../lti/config.ts'; -import type { PackageVersionRecord } from '../package_review/types.ts'; -import { escapeHtml } from './layout.ts'; -import type { DeploymentEditorState, ManagedDeploymentSlot } from './deployment_detail.ts'; -import { - renderCanvasEnvironmentField, - renderSavedBindingSummary, - renderTextField, -} from './deployment_detail_release_field_support.ts'; -import { getCanvasBinding } from './deployment_detail_release_support.ts'; - -export function renderCanvasInstallForm(input: { - appId: string; - slot: ManagedDeploymentSlot; - editorState: DeploymentEditorState | null; - canvasConfigUrl: string | null; - canvasDynamicRegistrationUrl: string | null; - supportedCanvasEnvironments: CanvasEnvironmentOption[]; - history: PackageVersionRecord[]; -}): string { - const binding = getCanvasBinding(input.slot.deployment.binding); - const configDisabled = input.canvasConfigUrl === null; - const advancedOpen = input.editorState?.focusSection === 'install'; - - return `
    - -

    Use Dynamic Registration first. Open Advanced Canvas settings only if you need to enter values by hand.

    -
    - -

    In Canvas, open Install a New App, choose LTI 1.3, set Install Method to Dynamic Registration, and paste this URL.

    -
    - Dynamic Registration URL - ${ - escapeHtml( - input.canvasDynamicRegistrationUrl ?? - 'APP_ORIGIN is required before Lantern can publish the Canvas registration URL.', - ) - } -
    - ${ - input.canvasDynamicRegistrationUrl === null - ? `
    -

    Dynamic Registration URL unavailable

    -

    Set APP_ORIGIN before you attempt Canvas setup. Lantern will not guess public callback URLs from the local request.

    -
    ` - : '' - } -
    -
    - Advanced Canvas settings -
    -

    Use this only if you need the Client ID, Deployment ID, or Configuration URL by hand.

    - ${renderSavedBindingSummary(input.slot)} -
    - Configuration URL - ${ - escapeHtml( - input.canvasConfigUrl ?? - 'APP_ORIGIN is required before Lantern can publish the config URL.', - ) - } -
    - ${ - input.canvasConfigUrl === null - ? `
    -

    Configuration URL unavailable

    -

    Set APP_ORIGIN before you attempt the Canvas install flow. Lantern will not guess public launch URLs from the local request.

    -
    ` - : '' - } -
    - - ${ - renderCanvasEnvironmentField({ - editorState: input.editorState, - binding, - supportedCanvasEnvironments: input.supportedCanvasEnvironments, - disabled: configDisabled, - }) - } - ${ - renderTextField({ - editorState: input.editorState, - field: 'clientId', - label: 'Client ID', - id: 'canvas-client-id', - name: 'clientId', - placeholder: '10000000000001', - value: binding?.clientId ?? null, - }) - } - ${ - renderTextField({ - editorState: input.editorState, - field: 'deploymentId', - label: 'Deployment ID', - id: 'canvas-deployment-id', - name: 'deploymentId', - placeholder: 'deployment-123', - value: binding?.deploymentId ?? null, - }) - } -
    - -
    -
    -
    -
    -
    `; -} diff --git a/src/admin/deployment_detail_release_field_support.ts b/src/admin/deployment_detail_release_field_support.ts deleted file mode 100644 index ccbfd9c..0000000 --- a/src/admin/deployment_detail_release_field_support.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { CanvasEnvironmentOption } from '../lti/config.ts'; -import type { DeploymentBinding } from '../lti/types.ts'; -import { escapeHtml } from './layout.ts'; -import type { - DeploymentEditorField, - DeploymentEditorState, - ManagedDeploymentSlot, -} from './deployment_detail.ts'; -import type { AdminNotice } from './layout.ts'; -import { - describeBindingValue, - getCanvasBinding, - getMoodleBinding, - getSakaiBinding, - hasPendingCanvasRegistration, - hasSavedBinding, -} from './deployment_detail_release_support.ts'; - -export function renderInlineNotice(notice: AdminNotice | null): string { - if (notice === null) { - return ''; - } - - return `
    -

    ${escapeHtml(notice.title)}

    -

    ${escapeHtml(notice.detail)}

    - ${ - (notice.items?.length ?? 0) > 0 - ? `
      ${(notice.items ?? []).map((item) => `
    • ${escapeHtml(item)}
    • `).join('')}
    ` - : '' - } -
    `; -} - -export function renderSavedBindingSummary(slot: ManagedDeploymentSlot): string { - if (hasPendingCanvasRegistration(slot)) { - return `
    - Pending connection -

    Canvas created the registration, but Lantern still needs one real Canvas launch to capture the exact deployment_id.

    -
    `; - } - - if (!hasSavedBinding(slot)) { - return ''; - } - - switch (slot.lms) { - case 'canvas': { - const binding = getCanvasBinding(slot.deployment.binding); - - return `
    - Saved Canvas values -

    Environment ${ - escapeHtml( - describeBindingValue(binding?.canvasEnvironment), - ) - } · Issuer ${escapeHtml(describeBindingValue(binding?.issuer))} · Client ${ - escapeHtml( - describeBindingValue(binding?.clientId), - ) - } · Deployment ${escapeHtml(describeBindingValue(binding?.deploymentId))}

    -
    `; - } - case 'moodle': { - const binding = getMoodleBinding(slot.deployment.binding); - - return `
    - Saved Moodle values -

    Platform ${ - escapeHtml( - describeBindingValue(binding?.issuer), - ) - } · Client ${ - escapeHtml( - describeBindingValue(binding?.clientId), - ) - } · Deployment ${escapeHtml(describeBindingValue(binding?.deploymentId))}

    -
    `; - } - case 'sakai': { - const binding = getSakaiBinding(slot.deployment.binding); - - return `
    - Saved Sakai values -

    Platform ${ - escapeHtml( - describeBindingValue(binding?.issuer), - ) - } · Client ${ - escapeHtml( - describeBindingValue(binding?.clientId), - ) - } · Deployment ${escapeHtml(describeBindingValue(binding?.deploymentId))}

    -
    `; - } - } -} - -export function renderFieldError( - editorState: DeploymentEditorState | null, - field: DeploymentEditorField, -): string { - const message = editorState?.fieldErrors[field]; - - if (!message) { - return ''; - } - - return `

    ${escapeHtml(message)}

    `; -} - -export function renderFieldAriaInvalid( - editorState: DeploymentEditorState | null, - field: DeploymentEditorField, -): string { - return editorState?.fieldErrors[field] ? 'aria-invalid="true"' : ''; -} - -export function resolveInstallValue( - editorState: DeploymentEditorState | null, - field: DeploymentEditorField, - fallback: string | null, -): string { - const draftValue = editorState?.installValues[field]; - - if (typeof draftValue === 'string') { - return draftValue; - } - - return fallback ?? ''; -} - -export function resolvePinnedVersionId( - editorState: DeploymentEditorState | null, - slot: ManagedDeploymentSlot, -): string | null { - return ( - editorState?.pinPackageVersionId ?? - (slot.deployment.enabledPackageVersionId === null - ? null - : String(slot.deployment.enabledPackageVersionId)) - ); -} - -export function renderTextField(input: { - editorState: DeploymentEditorState | null; - field: DeploymentEditorField; - label: string; - id: string; - name: string; - placeholder: string; - value: string | null; -}): string { - return `
    - - - ${renderFieldError(input.editorState, input.field)} -
    `; -} - -export function renderCanvasEnvironmentField(input: { - editorState: DeploymentEditorState | null; - binding: Extract | null; - supportedCanvasEnvironments: CanvasEnvironmentOption[]; - disabled: boolean; -}): string { - return `
    - - -

    Lantern stores the matching issuer value behind the scenes.

    - ${renderFieldError(input.editorState, 'canvasEnvironment')} -
    `; -} diff --git a/src/admin/deployment_detail_release_forms.ts b/src/admin/deployment_detail_release_forms.ts deleted file mode 100644 index 105c1a3..0000000 --- a/src/admin/deployment_detail_release_forms.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { getLtiProfileDefinition, LTI_PROFILE_DEFINITIONS } from '../lti/profile.ts'; -import type { CanvasEnvironmentOption } from '../lti/config.ts'; -import type { - LanternLtiProfileSettingsRecord, - PackageVersionRecord, -} from '../package_review/types.ts'; -import { escapeHtml } from './layout.ts'; -import type { - DeploymentEditorState, - DeploymentNrpsVerificationSummary, - ManagedDeploymentSlot, -} from './deployment_detail.ts'; -import { - describeBindingStatusChipClass, - describeBindingStatusHeading, - describeManagedSlotIntro, - formatLmsLabel, -} from './deployment_detail_release_support.ts'; -import { renderInlineNotice } from './deployment_detail_release_field_support.ts'; -import { renderCanvasInstallForm } from './deployment_detail_release_canvas_form.ts'; -import { - renderMoodleInstallForm, - renderSakaiInstallForm, -} from './deployment_detail_release_non_canvas_forms.ts'; -import { - renderCanvasRosterVerification, - renderVersionPinForm, -} from './deployment_detail_release_pin_section.ts'; - -export function renderSelectedSlotPanel(input: { - appId: string; - slot: ManagedDeploymentSlot; - editorState: DeploymentEditorState | null; - nrpsVerification: DeploymentNrpsVerificationSummary | null; - lanternLtiProfileSettings: LanternLtiProfileSettingsRecord; - canvasConfigUrl: string | null; - canvasDynamicRegistrationUrl: string | null; - moodleDynamicRegistrationUrl: string | null; - sakaiDynamicRegistrationUrl: string | null; - supportedCanvasEnvironments: CanvasEnvironmentOption[]; - approvedVersions: PackageVersionRecord[]; - history: PackageVersionRecord[]; -}): string { - const lmsLabel = formatLmsLabel(input.slot.lms); - const bindingStatusHeading = describeBindingStatusHeading(input.slot); - const bindingStatusChipClass = describeBindingStatusChipClass(input.slot); - - return `
    -
    -
    - -

    Set up ${escapeHtml(lmsLabel)}

    -

    ${ - escapeHtml( - describeManagedSlotIntro(input.slot.lms), - ) - }

    -
    - ${ - escapeHtml( - bindingStatusHeading, - ) - } -
    -
    -
    - Setup status - ${escapeHtml(bindingStatusHeading)} -
    -
    - Live version - ${ - escapeHtml( - input.slot.deployment.enabledPackageVersionId === null - ? 'Not chosen yet' - : (input.slot.deployment.enabledPackageVersion ?? 'Reviewed version'), - ) - } -
    - ${renderLtiProfileFacts(input.slot, input.lanternLtiProfileSettings)} -
    - ${renderInlineNotice(input.editorState?.notice ?? null)} -
    - ${renderInstallForm(input)} - ${ - input.slot.lms === 'canvas' - ? renderCanvasRosterVerification(input.appId, input.slot, input.nrpsVerification) - : '' - } - ${renderVersionPinForm(input.appId, input.slot, input.editorState, input.approvedVersions)} - ${renderLtiProfileForm(input.appId, input.slot, input.lanternLtiProfileSettings)} -
    -
    `; -} - -function renderInstallForm(input: { - appId: string; - slot: ManagedDeploymentSlot; - editorState: DeploymentEditorState | null; - nrpsVerification: DeploymentNrpsVerificationSummary | null; - lanternLtiProfileSettings: LanternLtiProfileSettingsRecord; - canvasConfigUrl: string | null; - canvasDynamicRegistrationUrl: string | null; - moodleDynamicRegistrationUrl: string | null; - sakaiDynamicRegistrationUrl: string | null; - supportedCanvasEnvironments: CanvasEnvironmentOption[]; - approvedVersions: PackageVersionRecord[]; - history: PackageVersionRecord[]; -}): string { - switch (input.slot.lms) { - case 'canvas': - return renderCanvasInstallForm(input); - case 'moodle': - return renderMoodleInstallForm(input); - case 'sakai': - return renderSakaiInstallForm(input); - } -} - -function renderLtiProfileFacts( - slot: ManagedDeploymentSlot, - lanternLtiProfileSettings: LanternLtiProfileSettingsRecord, -): string { - const defaultProfile = getLtiProfileDefinition(lanternLtiProfileSettings.defaultLtiProfile); - const overrideProfile = slot.deployment.ltiProfileOverride === null - ? null - : getLtiProfileDefinition(slot.deployment.ltiProfileOverride); - const effectiveProfile = overrideProfile ?? defaultProfile; - - return ` -
    - LTI behavior - ${escapeHtml(effectiveProfile.label)} -

    ${ - escapeHtml( - overrideProfile === null - ? `Uses Lantern default. ${defaultProfile.summary}` - : `Saved only for this LMS setup. ${overrideProfile.summary}`, - ) - }

    -
    `; -} - -function renderLtiProfileForm( - appId: string, - slot: ManagedDeploymentSlot, - lanternLtiProfileSettings: LanternLtiProfileSettingsRecord, -): string { - const defaultProfile = getLtiProfileDefinition(lanternLtiProfileSettings.defaultLtiProfile); - const selectedValue = slot.deployment.ltiProfileOverride; - const overrideProfile = selectedValue === null ? null : getLtiProfileDefinition(selectedValue); - const disabled = slot.persisted ? '' : 'disabled'; - const currentMode = overrideProfile === null - ? `Using Lantern default (${defaultProfile.label})` - : `Saved override (${overrideProfile.label})`; - const detailsOpen = selectedValue === null ? '' : 'open'; - const currentSummary = overrideProfile?.summary ?? defaultProfile.summary; - - return `
    - Advanced LTI behavior override -
    -

    Most LMS setups should inherit the Lantern default. Save an override only when this setup needs a different enforcement profile.

    -
    - Current mode - ${escapeHtml(currentMode)} -

    ${escapeHtml(currentSummary)}

    -
    -
    - -
    - Saved override -
    - - ${ - LTI_PROFILE_DEFINITIONS.map( - (profile) => - ``, - ).join('') - } -
    -

    ${ - escapeHtml( - slot.persisted - ? 'Saving here only changes this LMS setup.' - : 'Save the app settings first. Lantern only stores an override after this LMS slot exists.', - ) - }

    -
    -
    - -
    -
    -
    -
    `; -} diff --git a/src/admin/deployment_detail_release_non_canvas_forms.ts b/src/admin/deployment_detail_release_non_canvas_forms.ts deleted file mode 100644 index 830cea2..0000000 --- a/src/admin/deployment_detail_release_non_canvas_forms.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { escapeHtml } from './layout.ts'; -import type { DeploymentEditorState, ManagedDeploymentSlot } from './deployment_detail.ts'; -import { - renderSavedBindingSummary, - renderTextField, -} from './deployment_detail_release_field_support.ts'; -import { getMoodleBinding, getSakaiBinding } from './deployment_detail_release_support.ts'; - -export function renderMoodleInstallForm(input: { - appId: string; - slot: ManagedDeploymentSlot; - editorState: DeploymentEditorState | null; - moodleDynamicRegistrationUrl: string | null; -}): string { - const binding = getMoodleBinding(input.slot.deployment.binding); - const advancedOpen = input.editorState?.focusSection === 'install'; - - return `
    - -

    Use Moodle's setup link first. Open Advanced Moodle settings only if you need to enter values by hand.

    -
    - -

    Paste this URL into Moodle's Add LTI Advantage screen. Moodle sends the saved values back here automatically.

    -
    - Tool URL - ${ - escapeHtml( - input.moodleDynamicRegistrationUrl ?? - 'APP_ORIGIN is required before Lantern can publish the Moodle registration URL.', - ) - } -
    -
    -
    - Advanced Moodle settings -
    -

    Enter exact values only if you need to bypass automatic setup.

    - ${renderSavedBindingSummary(input.slot)} -
    - - ${ - renderTextField({ - editorState: input.editorState, - field: 'issuer', - label: 'Platform ID', - id: 'moodle-issuer', - name: 'issuer', - placeholder: 'https://moodle.example', - value: binding?.issuer ?? null, - }) - } - ${ - renderTextField({ - editorState: input.editorState, - field: 'clientId', - label: 'Client ID', - id: 'moodle-client-id', - name: 'clientId', - placeholder: 'moodle-client-123', - value: binding?.clientId ?? null, - }) - } - ${ - renderTextField({ - editorState: input.editorState, - field: 'deploymentId', - label: 'Deployment ID', - id: 'moodle-deployment-id', - name: 'deploymentId', - placeholder: 'moodle-deployment-123', - value: binding?.deploymentId ?? null, - }) - } - ${ - renderTextField({ - editorState: input.editorState, - field: 'authorizationEndpoint', - label: 'Authorization endpoint', - id: 'moodle-authorization-endpoint', - name: 'authorizationEndpoint', - placeholder: 'https://moodle.example/mod/lti/auth.php', - value: binding?.authorizationEndpoint ?? null, - }) - } - ${ - renderTextField({ - editorState: input.editorState, - field: 'accessTokenUrl', - label: 'Access token URL', - id: 'moodle-access-token-url', - name: 'accessTokenUrl', - placeholder: 'https://moodle.example/mod/lti/token.php', - value: binding?.accessTokenUrl ?? null, - }) - } - ${ - renderTextField({ - editorState: input.editorState, - field: 'jwksUrl', - label: 'Public keyset URL', - id: 'moodle-jwks-url', - name: 'jwksUrl', - placeholder: 'https://moodle.example/mod/lti/certs.php', - value: binding?.jwksUrl ?? null, - }) - } -
    - -
    -
    -
    -
    -
    `; -} - -export function renderSakaiInstallForm(input: { - appId: string; - slot: ManagedDeploymentSlot; - editorState: DeploymentEditorState | null; - sakaiDynamicRegistrationUrl: string | null; -}): string { - const binding = getSakaiBinding(input.slot.deployment.binding); - const advancedOpen = input.editorState?.focusSection === 'install'; - - return `
    - -

    Use Sakai's setup link first. Open Advanced Sakai settings only if you need to enter values by hand.

    -
    - -

    Paste this URL into Sakai's LTI Dynamic Registration flow. Sakai opens it and sends the setup values back automatically.

    -
    - Dynamic Registration URL - ${ - escapeHtml( - input.sakaiDynamicRegistrationUrl ?? - 'APP_ORIGIN is required before Lantern can publish the Sakai registration URL.', - ) - } -
    -
    -
    - Advanced Sakai settings -
    -

    Only use these settings if you need to enter Sakai values by hand.

    - ${renderSavedBindingSummary(input.slot)} -
    - - ${ - renderTextField({ - editorState: input.editorState, - field: 'issuer', - label: 'Platform ID', - id: 'sakai-issuer', - name: 'issuer', - placeholder: 'https://sakai.example', - value: binding?.issuer ?? null, - }) - } - ${ - renderTextField({ - editorState: input.editorState, - field: 'clientId', - label: 'Client ID', - id: 'sakai-client-id', - name: 'clientId', - placeholder: 'sakai-client-123', - value: binding?.clientId ?? null, - }) - } - ${ - renderTextField({ - editorState: input.editorState, - field: 'deploymentId', - label: 'Deployment ID', - id: 'sakai-deployment-id', - name: 'deploymentId', - placeholder: 'sakai-deployment-123', - value: binding?.deploymentId ?? null, - }) - } - ${ - renderTextField({ - editorState: input.editorState, - field: 'authorizationEndpoint', - label: 'Authorization endpoint', - id: 'sakai-authorization-endpoint', - name: 'authorizationEndpoint', - placeholder: 'https://sakai.example/imsoidc/lti13/oidc_auth', - value: binding?.authorizationEndpoint ?? null, - }) - } - ${ - renderTextField({ - editorState: input.editorState, - field: 'accessTokenUrl', - label: 'Access token URL', - id: 'sakai-access-token-url', - name: 'accessTokenUrl', - placeholder: 'https://sakai.example/imsblis/lti13/token/3', - value: binding?.accessTokenUrl ?? null, - }) - } - ${ - renderTextField({ - editorState: input.editorState, - field: 'jwksUrl', - label: 'Public keyset URL', - id: 'sakai-jwks-url', - name: 'jwksUrl', - placeholder: 'https://sakai.example/imsblis/lti13/keyset', - value: binding?.jwksUrl ?? null, - }) - } -
    - -
    -
    -
    -
    -
    `; -} diff --git a/src/admin/deployment_detail_release_pin_section.ts b/src/admin/deployment_detail_release_pin_section.ts deleted file mode 100644 index 1ee593f..0000000 --- a/src/admin/deployment_detail_release_pin_section.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { summarizeAccessibilityReview } from '../package_review/summary.ts'; -import type { PackageVersionRecord } from '../package_review/types.ts'; -import { escapeHtml, formatDateTime } from './layout.ts'; -import type { - DeploymentEditorState, - DeploymentNrpsVerificationSummary, - ManagedDeploymentSlot, -} from './deployment_detail.ts'; -import { - getCanvasBinding, - hasPendingCanvasRegistration, - hasSavedBinding, -} from './deployment_detail_release_support.ts'; -import { - renderFieldAriaInvalid, - renderFieldError, - resolvePinnedVersionId, -} from './deployment_detail_release_field_support.ts'; - -export function renderCanvasRosterVerification( - appId: string, - slot: ManagedDeploymentSlot, - nrpsVerification: DeploymentNrpsVerificationSummary | null, -): string { - const rosterStatus = nrpsVerification === null - ? 'Not run yet' - : nrpsVerification.status === 'succeeded' - ? 'Succeeded' - : 'Failed'; - const rosterSummary = nrpsVerification === null - ? 'Run this after the Canvas slot has launched once.' - : `Last check ${formatDateTime(nrpsVerification.checkedAt)} · Context ${ - nrpsVerification.contextId ?? 'not recorded' - } · Members ${ - nrpsVerification.memberCount === null ? 'not recorded' : nrpsVerification.memberCount - }`; - - return `
    - -
    - Roster access - ${escapeHtml(rosterStatus)} -

    ${escapeHtml(rosterSummary)}

    -
    -
    -
    - -
    -
    -
    `; -} - -export function renderVersionPinForm( - appId: string, - slot: ManagedDeploymentSlot, - editorState: DeploymentEditorState | null, - approvedVersions: PackageVersionRecord[], -): string { - const bindingSaved = hasSavedBinding(slot) || hasPendingCanvasRegistration(slot); - const pinEnabled = bindingSaved && approvedVersions.length > 0; - const selectedVersionId = resolvePinnedVersionId(editorState, slot); - const accessibilityVersion = resolveAccessibilityVersion(approvedVersions, selectedVersionId); - const accessibilitySummary = accessibilityVersion === null - ? null - : summarizeAccessibilityReview(accessibilityVersion); - const pinHint = bindingSaved - ? approvedVersions.length === 0 - ? 'Approve a version before you choose what learners should open.' - : hasPendingCanvasRegistration(slot) - ? 'Choose what learners should open now. Lantern will finish the exact Canvas setup on the first launch.' - : 'Choose which approved version learners should open.' - : 'Save the app settings first. Lantern only lets you choose what learners open after this LMS slot is identified.'; - - return `
    - -

    ${escapeHtml(pinHint)}

    -
    - -
    - - -

    Pending and rejected versions stay visible in history, but they cannot become active pins.

    - ${renderFieldError(editorState, 'packageVersionId')} -
    - ${ - accessibilitySummary === null ? '' : `
    - Accessibility - ${escapeHtml(accessibilitySummary.label)} -

    ${escapeHtml(accessibilitySummary.detail)}

    - ${ - accessibilitySummary.exceptionNote === null - ? '' - : `

    ${escapeHtml(accessibilitySummary.exceptionNote)}

    ` - } -
    ` - } -
    - -
    -
    -
    `; -} - -function resolveAccessibilityVersion( - approvedVersions: PackageVersionRecord[], - selectedVersionId: string | null, -): PackageVersionRecord | null { - if (selectedVersionId !== null) { - const selectedVersion = approvedVersions.find( - (version) => String(version.id) === selectedVersionId, - ); - - if (selectedVersion) { - return selectedVersion; - } - } - - return approvedVersions[0] ?? null; -} diff --git a/src/admin/deployment_detail_release_sections.ts b/src/admin/deployment_detail_release_sections.ts deleted file mode 100644 index a9addc5..0000000 --- a/src/admin/deployment_detail_release_sections.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describeDeploymentPin } from '../package_review/summary.ts'; -import type { CanvasEnvironmentOption } from '../lti/config.ts'; -import type { - LanternLtiProfileSettingsRecord, - PackageVersionRecord, -} from '../package_review/types.ts'; -import { escapeHtml } from './layout.ts'; -import { - getSelectedManagedDeploymentSlot, - resolveSelectedManagedDeploymentLms, -} from './deployment_detail.ts'; -import type { - DeploymentEditorState, - DeploymentNrpsVerificationSummary, - ManagedDeploymentSlot, -} from './deployment_detail.ts'; -import { - describeBindingStatusHeading, - formatLmsLabel, -} from './deployment_detail_release_support.ts'; -import { renderSelectedSlotPanel } from './deployment_detail_release_forms.ts'; - -export function renderManagedDeploymentSections(input: { - appId: string; - slots: ManagedDeploymentSlot[]; - selectedLms: ManagedDeploymentSlot['lms'] | null; - editorState: DeploymentEditorState | null; - nrpsVerification: DeploymentNrpsVerificationSummary | null; - lanternLtiProfileSettings: LanternLtiProfileSettingsRecord; - canvasConfigUrl: string | null; - canvasDynamicRegistrationUrl: string | null; - moodleDynamicRegistrationUrl: string | null; - sakaiDynamicRegistrationUrl: string | null; - supportedCanvasEnvironments: CanvasEnvironmentOption[]; - approvedVersions: PackageVersionRecord[]; - history: PackageVersionRecord[]; -}): string { - const selectedLms = resolveSelectedManagedDeploymentLms(input.slots, input.selectedLms); - const selectedSlot = getSelectedManagedDeploymentSlot(input.slots, input.selectedLms); - - return `
    -
    - -

    Choose your LMS.

    - - ${ - renderSelectedSlotPanel({ - appId: input.appId, - slot: selectedSlot, - editorState: input.editorState?.lms === selectedSlot.lms ? input.editorState : null, - nrpsVerification: input.nrpsVerification, - lanternLtiProfileSettings: input.lanternLtiProfileSettings, - canvasConfigUrl: input.canvasConfigUrl, - canvasDynamicRegistrationUrl: input.canvasDynamicRegistrationUrl, - moodleDynamicRegistrationUrl: input.moodleDynamicRegistrationUrl, - sakaiDynamicRegistrationUrl: input.sakaiDynamicRegistrationUrl, - supportedCanvasEnvironments: input.supportedCanvasEnvironments, - approvedVersions: input.approvedVersions, - history: input.history, - }) - } -
    -
    `; -} - -function renderLmsTab(input: { - appId: string; - slot: ManagedDeploymentSlot; - selectedLms: ManagedDeploymentSlot['lms']; -}): string { - const lmsLabel = formatLmsLabel(input.slot.lms); - const editorHref = `/admin/packages/${ - encodeURIComponent( - input.appId, - ) - }/deployment?lms=${encodeURIComponent(input.slot.lms)}#slot-panel`; - const bindingStatusHeading = describeBindingStatusHeading(input.slot); - const pinStatus = describeDeploymentPin(input.slot.persisted ? input.slot.deployment : null); - - return ` - ${escapeHtml(lmsLabel)} - ${escapeHtml(bindingStatusHeading)} - ${escapeHtml(pinStatus)} - `; -} diff --git a/src/admin/deployment_detail_release_support.ts b/src/admin/deployment_detail_release_support.ts deleted file mode 100644 index 314b103..0000000 --- a/src/admin/deployment_detail_release_support.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { CanvasDeploymentBinding, DeploymentBinding } from '../lti/types.ts'; -import type { ManagedDeploymentSlot } from './deployment_detail.ts'; - -export function describeSavedBindingChip(slot: ManagedDeploymentSlot): string { - if (hasSavedBinding(slot)) { - return 'Connected'; - } - - if (hasPendingCanvasRegistration(slot)) { - return 'Waiting for first launch'; - } - - return 'Not connected'; -} - -export function hasSavedBinding(slot: ManagedDeploymentSlot): boolean { - return slot.deployment.binding?.lms === slot.lms; -} - -export function describeBindingStatusHeading(slot: ManagedDeploymentSlot): string { - switch (slot.lms) { - case 'canvas': - if (hasPendingCanvasRegistration(slot)) { - return 'Waiting for first Canvas launch'; - } - - if (getCanvasBinding(slot.deployment.binding) === null) { - return 'Not connected'; - } - - return slot.deployment.enabledPackageVersionId === null - ? 'Connected, choose a live version' - : 'Connected and live'; - case 'moodle': - return getMoodleBinding(slot.deployment.binding) === null - ? 'Not connected' - : slot.deployment.enabledPackageVersionId === null - ? 'Connected, choose a live version' - : 'Connected and live'; - case 'sakai': - return getSakaiBinding(slot.deployment.binding) === null - ? 'Not connected' - : slot.deployment.enabledPackageVersionId === null - ? 'Connected, choose a live version' - : 'Connected and live'; - } -} - -export function describeBindingStatusChipClass(slot: ManagedDeploymentSlot): string { - switch (describeBindingStatusHeading(slot)) { - case 'Connected and live': - return 'chip-status chip-status-healthy'; - case 'Connected, choose a live version': - case 'Waiting for first Canvas launch': - return 'chip-status chip-status-attention'; - case 'Not connected': - return 'chip-status chip-status-unknown'; - } - - return 'chip-status chip-status-unknown'; -} - -export function describeEditorCopy(lms: ManagedDeploymentSlot['lms']): string { - switch (lms) { - case 'canvas': - return 'Start in Canvas Settings > Apps, choose the live version next, and open Advanced Canvas settings only if you need them.'; - case 'moodle': - return 'Start with Moodle setup, then choose the live version for learners.'; - case 'sakai': - return 'Start with Sakai setup, then choose the live version for learners.'; - } -} - -export function describeManagedSlotIntro(lms: ManagedDeploymentSlot['lms']): string { - switch (lms) { - case 'canvas': - return 'Use Dynamic Registration first. Open Advanced Canvas settings only if you need to enter values by hand.'; - case 'moodle': - return 'Use the setup link first. Open Advanced Moodle settings only if you need to enter values by hand.'; - case 'sakai': - return 'Use the setup link first. Open Advanced Sakai settings only if you need to enter values by hand.'; - } -} - -export function formatLmsLabel(lms: ManagedDeploymentSlot['lms']): string { - switch (lms) { - case 'canvas': - return 'Canvas'; - case 'moodle': - return 'Moodle'; - case 'sakai': - return 'Sakai'; - } -} - -export function getCanvasBinding( - binding: DeploymentBinding | null, -): CanvasDeploymentBinding | null { - return binding?.lms === 'canvas' ? binding : null; -} - -export function hasPendingCanvasRegistration(slot: ManagedDeploymentSlot): boolean { - return ( - slot.lms === 'canvas' && slot.persisted && getCanvasBinding(slot.deployment.binding) === null - ); -} - -export function getMoodleBinding( - binding: DeploymentBinding | null, -): Extract | null { - return binding?.lms === 'moodle' ? binding : null; -} - -export function getSakaiBinding( - binding: DeploymentBinding | null, -): Extract | null { - return binding?.lms === 'sakai' ? binding : null; -} - -export function describeBindingValue(value: string | null | undefined): string { - if (!value) { - return 'Not saved yet'; - } - - return value; -} diff --git a/src/admin/deployment_detail_render_test.ts b/src/admin/deployment_detail_render_test.ts deleted file mode 100644 index 06c2d43..0000000 --- a/src/admin/deployment_detail_render_test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { assertFalse, assertStringIncludes } from '@std/assert'; -import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; -import { resolveCanvasIssuer } from '../lti/config.ts'; -import { - buildAccessibilityReview, - buildDeploymentRecord, - buildPackageVersionRecord, -} from '../test_helpers/package_review.ts'; -import { - buildCanvasDeploymentBinding, - buildDeploymentBinding, - buildMoodleDeploymentBinding, - buildSakaiDeploymentBinding, -} from '../test_helpers/lti.ts'; -import { renderDeploymentDetailPage } from './deployment_detail.ts'; - -Deno.test('deployment page keeps shared copy neutral while scoping Canvas, Moodle, and Sakai setup into tabs', () => { - const binding = buildCanvasDeploymentBinding(); - const html = renderDeploymentDetailPage({ - identity: ADMIN_TEST_IDENTITY, - appId: 'chapter-4-asteroids', - appTitle: 'Chapter 4 Asteroids', - history: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding, - }), - ], - canvasConfigUrl: 'http://localhost:8417/lti/canvas/config.json', - canvasDynamicRegistrationUrl: - 'http://localhost:8417/admin/packages/chapter-4-asteroids/deployment/register/canvas', - supportedCanvasEnvironments: [ - { - id: 'production', - label: 'Production Canvas', - issuer: resolveCanvasIssuer('production'), - }, - ], - }); - - assertStringIncludes(html, 'App settings'); - assertStringIncludes( - html, - 'Connect this app to one LMS and choose what version people should open.', - ); - assertStringIncludes(html, 'deployment-tab-strip'); - assertStringIncludes(html, 'Past versions and review notes'); - assertStringIncludes(html, 'version-history-table'); - assertStringIncludes(html, 'Set up Canvas'); - assertStringIncludes(html, 'Dynamic Registration'); - assertStringIncludes( - html, - 'http://localhost:8417/admin/packages/chapter-4-asteroids/deployment/register/canvas', - ); - assertStringIncludes(html, 'deployment-tab-label">Moodle'); - assertStringIncludes(html, 'deployment-tab-label">Sakai'); - assertFalse( - html.includes( - 'Pin the reviewed version, then wire this deployment into Canvas through one supported LTI 1.3 path.', - ), - ); - assertStringIncludes(html, 'Advanced Canvas settings'); - assertStringIncludes(html, 'Configuration URL'); - assertStringIncludes(html, 'Canvas environment'); - assertStringIncludes(html, ' + +
    + `, + }); +} + +export function renderInviteAcceptancePage(input: { + readonly identity: AdminIdentity; + readonly token: string; +}): string { + return renderAdminLayout({ + identity: input.identity, + title: 'Accept institution invitation · Lantern', + eyebrow: 'Institution invitation', + heading: 'Join this institution?', + intro: + 'Accepting consumes this one-time invitation and adds your account with the role chosen by the institution.', + activePath: '/admin/governance', + notice: null, + body: + `
    +
    +
    `, + }); +} + +export function renderGovernancePage(input: { + readonly identity: AdminIdentity; + readonly publicOrigin: string; + readonly institution: InstitutionRecord; + readonly membership: InstitutionMembershipRecord; + readonly connections: readonly LmsConnectionRecord[]; + readonly hasSyntheticConnection: boolean; + readonly contexts: readonly LmsContextRecord[]; + readonly installations: readonly AppInstallationRecord[]; + readonly packages: readonly PackageVersionRecord[]; + readonly grants: ReadonlyMap; + readonly events: readonly GovernanceEventRecord[]; + readonly invite?: { readonly token: string; readonly expiresAt: string }; + readonly notice?: { readonly tone: 'error' | 'success'; readonly detail: string }; +}): string { + const packageById = new Map(input.packages.map((item) => [item.id, item])); + const canGovern = input.membership.role === 'owner' || input.membership.role === 'admin'; + const body = [ + renderInstitutionSummary(input.institution, input.membership), + input.invite === undefined ? '' : renderInvite(input.invite), + renderConnections(input.connections, canGovern, input.publicOrigin), + renderInstallations({ + installations: input.installations, + packages: input.packages, + packageById, + contexts: input.contexts, + grants: input.grants, + canGovern, + hasSyntheticConnection: input.hasSyntheticConnection, + publicOrigin: input.publicOrigin, + }), + canGovern ? renderInviteForm() : '', + renderEvents(input.events, input.connections, input.installations), + ].join(''); + return renderAdminLayout({ + identity: input.identity, + title: `${input.institution.name} governance · Lantern`, + eyebrow: 'Governed LMS', + heading: input.institution.name, + intro: + 'Institution-owned LMS connections, reviewed app installations, explicit capability grants, and append-only decisions.', + activePath: '/admin/governance', + notice: input.notice === undefined ? null : { + tone: input.notice.tone, + title: input.notice.tone === 'success' ? 'Saved' : 'Action blocked', + detail: input.notice.detail, + }, + body, + }); +} + +function renderInstitutionSummary( + institution: InstitutionRecord, + membership: InstitutionMembershipRecord, +): string { + return `
    + Institution ${escapeHtml(institution.slug)} + Your role ${escapeHtml(membership.role)} + Status ${escapeHtml(institution.status)} +
    `; +} + +function renderConnections( + connections: readonly LmsConnectionRecord[], + canGovern: boolean, + publicOrigin: string, +): string { + const rows = connections.length === 0 + ? '

    No LMS connections yet.

    ' + : connections.map((connection) => + `
    +

    ${escapeHtml(connection.label)}

    +

    ${escapeHtml(connection.platformType)} · ${ + escapeHtml(connection.issuer) + }

    + ${renderPlatformDiagnostic(connection)} +
    ${escapeHtml(connection.status)}
    +
    + Client ${escapeHtml(connection.clientId)} + Deployment ${escapeHtml(connection.deploymentId)} + Validated ${ + escapeHtml(formatDateTime(connection.validatedAt)) + } +
    + ${ + canGovern && connection.status !== 'disabled' + ? `
    ` + : '' + }
    ` + ).join(''); + return `
    +

    Connections

    +

    Canvas, Moodle, Brightspace, and Sakai remain pending until a signed launch validates the registration. Synthetic activates immediately.

    +
    +
    OIDC login URL
    ${escapeHtml(`${publicOrigin}/lti/login`)}
    +
    Redirect and launch URL
    ${ + escapeHtml(`${publicOrigin}/lti/launch`) + }
    +
    JWKS URL
    ${escapeHtml(`${publicOrigin}/lti/jwks.json`)}
    +
    Required NRPS scope
    https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly
    +
    +
    ${rows}
    + ${canGovern ? renderConnectionForm() : ''} +
    `; +} + +function renderPlatformDiagnostic(connection: LmsConnectionRecord): string { + switch (connection.platformType) { + case 'canvas': + return '

    Canvas: enable the NRPS readonly scope on the Developer Key. Canvas privacy settings control which names, emails, and photos are returned; no REST token is used.

    '; + case 'moodle': + return '

    Moodle: enable Names and Role Provisioning on the external tool. Lantern validates the service claim from a signed course launch and never derives the endpoint from the Moodle URL.

    '; + case 'brightspace': + return '

    Brightspace: enable NRPS on both the registration and deployment. If the roster is incomplete, verify “Classlist including users not known to this deployment.”

    '; + case 'sakai': + return '

    Sakai: enable the Names and Roles service and readonly membership scope for this registration.

    '; + case 'synthetic': + return '

    Synthetic: deterministic paginated classlist using the same policy, cursor, media, and audit path.

    '; + } +} + +function renderConnectionForm(): string { + return `
    Add LMS connection +
    + + +

    For a real LMS, enter the exact values issued by its LTI 1.3 registration.

    + + + + + + +
    +
    +
    `; +} + +function renderInstallations(input: { + readonly installations: readonly AppInstallationRecord[]; + readonly packages: readonly PackageVersionRecord[]; + readonly packageById: ReadonlyMap; + readonly contexts: readonly LmsContextRecord[]; + readonly grants: ReadonlyMap; + readonly canGovern: boolean; + readonly hasSyntheticConnection: boolean; + readonly publicOrigin: string; +}): string { + const rows = input.installations.length === 0 + ? '

    No governed apps installed yet.

    ' + : input.installations.map((installation) => { + const packageVersion = input.packageById.get(installation.packageVersionId); + const grants = input.grants.get(installation.id) ?? []; + return `
    +

    ${escapeHtml(packageVersion?.title ?? installation.appId)}

    +

    Exact reviewed version ${ + escapeHtml(packageVersion?.version ?? String(installation.packageVersionId)) + }

    + ${ + packageVersion === undefined + ? '' + : `

    Reviewed ${escapeHtml(formatDateTime(packageVersion.reviewedAt))}${ + packageVersion.reviewNotes === null + ? '' + : ` · ${escapeHtml(packageVersion.reviewNotes)}` + }

    ` + } +
    ${escapeHtml(installation.status)}
    + ${ + renderCapabilityApprovals( + installation, + packageVersion, + grants, + input.contexts, + input.canGovern, + ) + } + ${ + input.canGovern && installation.status !== 'active' + ? `
    ` + : '' + } + ${ + input.canGovern && input.hasSyntheticConnection && installation.status === 'active' + ? `
    ` + : '' + } + ${ + input.canGovern && installation.status !== 'disabled' + ? `
    ` + : '' + } + ${ + installation.status === 'active' + ? `

    LMS target link URI
    ${ + escapeHtml( + `${input.publicOrigin}/lti/launch?installation_id=${ + encodeURIComponent(installation.id) + }`, + ) + }

    ` + : '' + } +
    `; + }).join(''); + const options = input.packages.filter((item) => + item.approvalStatus === 'approved' && item.runtime.kind === 'worker' + ).map((item) => + `` + ).join(''); + return `
    +

    App installations

    +

    Changing a package version creates a new approval boundary. Grants never carry forward.

    +
    ${rows}
    + ${ + input.canGovern && options !== '' + ? `
    + +
    +
    ` + : '' + } +
    `; +} + +function renderCapabilityApprovals( + installation: AppInstallationRecord, + packageVersion: PackageVersionRecord | undefined, + grants: readonly CapabilityGrantRecord[], + contexts: readonly LmsContextRecord[], + canGovern: boolean, +): string { + if (packageVersion === undefined) { + return '

    Reviewed package record is unavailable.

    '; + } + return `
    ${ + packageVersion.capabilities.map((capability) => { + const matching = grants.filter((grant) => grant.capability === capability); + const active = matching.filter((grant) => grant.revokedAt === null); + const grantRows = matching.map((grant) => + `
  • ${ + escapeHtml( + grant.scope.kind === 'institution' + ? 'All institution contexts' + : grant.scope.lmsContextId, + ) + } + · approved by ${escapeHtml(grant.approvedByUserId)} at ${ + escapeHtml(formatDateTime(grant.approvedAt)) + } + · ${ + grant.revokedAt === null + ? 'active' + : `revoked ${escapeHtml(formatDateTime(grant.revokedAt))}` + } + ${ + canGovern && grant.revokedAt === null + ? `
    ` + : '' + }
  • ` + ).join(''); + return `

    ${escapeHtml(capability)} · ${ + escapeHtml(capabilityFieldLabel(capability)) + } · ${active.length > 0 ? 'approved' : 'approval required'}

    + ${grantRows === '' ? '' : `
      ${grantRows}
    `} + ${ + canGovern && active.length === 0 + ? renderGrantForm(installation.id, capability, contexts) + : '' + } +
    `; + }).join('') + }
    `; +} + +function capabilityFieldLabel(capability: string): string { + switch (capability) { + case 'lms.roster.members.read': + return 'names, canonical roles, and status'; + case 'lms.roster.contact.read': + return 'email addresses'; + case 'lms.roster.profile_photo.read': + return 'proxied profile photos'; + default: + return 'reviewed app capability'; + } +} + +function renderGrantForm( + installationId: string, + capability: string, + contexts: readonly LmsContextRecord[], +): string { + const contextOptions = contexts.map((context) => + `` + ).join(''); + return `
    + + + +
    `; +} + +function renderInviteForm(): string { + return `
    +

    Invite administrator or auditor

    +

    Lantern creates a one-time link. It does not send email.

    +
    + + +
    +
    `; +} + +function renderInvite(invite: { readonly token: string; readonly expiresAt: string }): string { + const path = `/admin/invites/${encodeURIComponent(invite.token)}`; + return `
    + +

    ${escapeHtml(path)}

    +

    Expires ${ + escapeHtml(formatDateTime(invite.expiresAt)) + }. Copy this link now; Lantern stores only its hash.

    +
    `; +} + +function renderEvents( + events: readonly GovernanceEventRecord[], + connections: readonly LmsConnectionRecord[], + installations: readonly AppInstallationRecord[], +): string { + const rows = events.length === 0 + ? '

    No governance events recorded yet.

    ' + : events.map((event) => + `

    ${escapeHtml(event.eventType)}

    +

    ${escapeHtml(event.decision ?? 'execution')} · ${ + escapeHtml(event.outcome) + }${event.capability === null ? '' : ` · ${escapeHtml(event.capability)}`}

    +
    Trace ${escapeHtml(event.traceId)}${ + escapeHtml(formatDateTime(event.occurredAt)) + } + ${ + event.appInstallationId === null + ? '' + : `Installation` + } + ${ + event.capabilityGrantId === null + ? '' + : `Grant` + }
    ` + ).join(''); + const connectionOptions = connections.map((connection) => + `` + ).join(''); + const installationOptions = installations.map((installation) => + `` + ).join(''); + return `
    +

    Recent governance events

    +

    Roster payloads, names, emails, provider IDs, photo URLs, tokens, cursors, and media handles are excluded.

    +
    +
    Filter audit events + + + + + + + + + +
    +
    +
    ${rows}
    +
    `; +} diff --git a/src/admin/layout_style_detail.ts b/src/admin/layout_style_detail.ts index 3b08793..b092726 100644 --- a/src/admin/layout_style_detail.ts +++ b/src/admin/layout_style_detail.ts @@ -1,4 +1,3 @@ -import { ADMIN_LAYOUT_STYLE_DETAIL_DEPLOYMENT } from './layout_style_detail_deployment.ts'; import { ADMIN_LAYOUT_STYLE_DETAIL_TABLES } from './layout_style_detail_tables.ts'; export const ADMIN_LAYOUT_STYLE_DETAIL = ` @@ -273,7 +272,6 @@ export const ADMIN_LAYOUT_STYLE_DETAIL = ` background: var(--surface); } - ${ADMIN_LAYOUT_STYLE_DETAIL_DEPLOYMENT} .inline-code { display: inline-block; diff --git a/src/admin/layout_style_detail_deployment.ts b/src/admin/layout_style_detail_deployment.ts deleted file mode 100644 index 2e9c609..0000000 --- a/src/admin/layout_style_detail_deployment.ts +++ /dev/null @@ -1,96 +0,0 @@ -export const ADMIN_LAYOUT_STYLE_DETAIL_DEPLOYMENT = ` - .deployment-tab-strip { - display: flex; - flex-wrap: wrap; - gap: 10px; - padding: 8px; - border-radius: var(--radius); - border: 1px solid var(--line); - background: color-mix(in srgb, var(--bg) 86%, white); - } - - .deployment-tab { - display: grid; - gap: 6px; - flex: 1 1 180px; - min-width: 0; - padding: 14px 16px; - border-radius: calc(var(--radius) - 4px); - border: 1px solid transparent; - background: transparent; - text-decoration: none; - transition: border-color 120ms, background 120ms, box-shadow 120ms; - } - - .deployment-tab:hover { - background: var(--surface); - border-color: var(--line-light); - } - - .deployment-tab.active { - background: var(--surface); - border-color: color-mix(in srgb, var(--accent) 20%, var(--line)); - box-shadow: 0 10px 28px rgba(10, 37, 64, 0.06); - } - - .deployment-tab-label { - font-size: 15px; - font-weight: 600; - color: var(--ink); - } - - .deployment-tab-note { - font-size: 12.5px; - color: var(--secondary); - } - - .deployment-tab-panel { - padding: 18px; - border-radius: var(--radius); - border: 1px solid var(--line); - background: var(--surface); - box-shadow: 0 10px 28px rgba(10, 37, 64, 0.04); - } - - .deployment-summary-grid { - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - } - - .deployment-primary-path { - gap: 6px; - padding: 14px 16px; - border-radius: var(--radius-sm); - border: 1px solid var(--line-light); - background: color-mix(in srgb, var(--bg) 78%, white); - } - - .advanced-details { - border-radius: var(--radius-sm); - } - - .advanced-details summary { - display: flex; - align-items: center; - justify-content: space-between; - } - - .advanced-details summary::after { - content: "Show"; - font-size: 12px; - font-weight: 500; - color: var(--muted); - } - - .advanced-details[open] summary::after { - content: "Hide"; - } - - .advanced-details-body { - padding: 0 16px 16px; - } - - .deployment-tab-body { - display: grid; - gap: 18px; - } -`; diff --git a/src/admin/layout_support.ts b/src/admin/layout_support.ts index c36f9ea..c02eca9 100644 --- a/src/admin/layout_support.ts +++ b/src/admin/layout_support.ts @@ -74,6 +74,13 @@ function readAdminTimeZone(): string { export function defaultNav(activePath?: string): NavItem[] { return [ + { + label: 'Governance', + href: '/admin/governance', + icon: + ``, + active: activePath?.startsWith('/admin/governance') ?? false, + }, { label: 'Apps', href: '/admin/packages', @@ -89,26 +96,11 @@ export function defaultNav(activePath?: string): NavItem[] { active: activePath?.startsWith('/admin/app-writer') ?? false, }, { - label: 'Connections', - href: '/admin/deployments', + label: 'Lab Requests', + href: '/admin/python-lab-requests', icon: - ``, - active: activePath?.startsWith('/admin/deployments') ?? false, - }, - { - label: 'Verification', - href: '/admin/verification', - icon: - ``, - active: activePath?.startsWith('/admin/verification') ?? false, - children: buildVerificationNav(activePath), - }, - { - label: 'Placements', - href: '/admin/placements', - icon: - ``, - active: activePath?.startsWith('/admin/placements') ?? false, + ``, + active: activePath?.startsWith('/admin/python-lab-requests') ?? false, }, ]; } @@ -234,32 +226,3 @@ function normalizeIdentityValue(value: string | undefined): string | null { return normalized.length > 0 ? normalized : null; } - -function buildVerificationNav(activePath?: string): NavChildItem[] { - if (!activePath?.startsWith('/admin/verification')) { - return []; - } - - return [ - { - label: 'Checklist', - href: '/admin/verification', - active: activePath === '/admin/verification', - }, - { - label: 'Official evidence', - href: '/admin/verification/official', - active: activePath.startsWith('/admin/verification/official'), - }, - { - label: 'Add result', - href: '/admin/verification/new', - active: activePath.startsWith('/admin/verification/new'), - }, - { - label: 'Lantern default', - href: '/admin/verification/lti-profile', - active: activePath.startsWith('/admin/verification/lti-profile'), - }, - ]; -} diff --git a/src/admin/package_detail.ts b/src/admin/package_detail.ts index e29e1e6..5e7da83 100644 --- a/src/admin/package_detail.ts +++ b/src/admin/package_detail.ts @@ -10,12 +10,10 @@ import { summarizeValidation, } from '../package_review/summary.ts'; import type { - AuditEventRecord, GradingSettings, PackageVersionRecord, PreviewEvidenceRecord, PreviewSessionRecord, - ReviewedPlacementRecord, } from '../package_review/types.ts'; import { type AdminIdentity, @@ -43,8 +41,6 @@ export function renderPackageDetailPage(input: { identity: AdminIdentity; packageVersion: PackageVersionRecord; history: PackageVersionRecord[]; - generationActivityEvents?: AuditEventRecord[]; - reviewedPlacements?: ReviewedPlacementRecord[]; latestPreviewSession?: PreviewSessionRecord | null; previewEvidence?: PreviewEvidenceRecord[]; notice?: AdminNotice | null; @@ -154,11 +150,7 @@ export function renderPackageDetailPage(input: {
    Next step - Open app settings + Install reviewed app
    @@ -249,8 +241,6 @@ export function renderPackageDetailPage(input: { previewEvidence, }) } - ${renderGenerationActivitySection(input.generationActivityEvents ?? [])} - ${renderReviewedPlacementsSection(input.reviewedPlacements ?? [])}
    @@ -435,13 +425,22 @@ function renderCapabilityCard(capability: CapabilitySummary): string { } function summarizeGradeReviewItem(grading: GradingSettings): string | null { - if (grading.mode !== 'browser' && grading.mode !== 'declarative') { + if ( + grading.mode !== 'browser' && grading.mode !== 'declarative' && + grading.mode !== 'sandbox' + ) { return null; } - const gradingLabel = grading.mode === 'browser' ? 'Browser grading' : 'Reviewed scoring rules'; + const gradingLabel = grading.mode === 'browser' + ? 'Browser grading' + : grading.mode === 'sandbox' + ? 'Python sandbox grading' + : 'Reviewed scoring rules'; const gradingDetail = grading.mode === 'browser' ? 'Lantern runs reviewed browser-grader checks and owns any grade publication.' + : grading.mode === 'sandbox' + ? 'Lantern runs learner Python in an isolated Sandbox and owns the resulting grade publication.' : 'Lantern scores the attempt with reviewed scoring rules and owns any grade publication.'; const maxScore = grading.maxScore === null ? 'Maximum score not recorded.' @@ -531,69 +530,6 @@ function renderRuntimeLogItem(record: PreviewEvidenceRecord): string { `; } -function renderReviewedPlacementsSection(placements: ReviewedPlacementRecord[]): string { - if (placements.length === 0) { - return ''; - } - - return `
    -
    - -
    - ${ - placements - .map( - (placement) => - `
    -

    ${escapeHtml(placement.deploymentSlug)} · ${ - escapeHtml( - placement.placementId, - ) - }

    -

    ${ - escapeHtml( - placement.contentTitle ?? placement.contentPath, - ) - }

    -

    Context ${ - escapeHtml( - placement.contextTitle ?? placement.contextId ?? 'Not recorded', - ) - }; resource link ${escapeHtml(placement.resourceLinkId ?? 'not bound yet')}.

    -
    `, - ) - .join('') - } -
    -
    -
    `; -} - -function renderGenerationActivitySection(events: AuditEventRecord[]): string { - if (events.length === 0) { - return ''; - } - - return `
    -
    - -
    - ${ - events - .map( - (event) => - `
    -

    ${escapeHtml(event.summary)}

    -

    ${escapeHtml(formatDateTime(event.occurredAt))}

    -
    `, - ) - .join('') - } -
    -
    -
    `; -} - function formatInstallScope(scope: PackageVersionRecord['installScope']): string { return scope === 'assignment' ? 'Assignment placement' : 'Course placement'; } diff --git a/src/admin/package_index.ts b/src/admin/package_index.ts index 8ecf1a5..89c4802 100644 --- a/src/admin/package_index.ts +++ b/src/admin/package_index.ts @@ -132,11 +132,7 @@ function renderPackageEntry(entry: PackageLibraryEntry): string {
    diff --git a/src/admin/package_navigation.ts b/src/admin/package_navigation.ts index 07488fa..8aa8a38 100644 --- a/src/admin/package_navigation.ts +++ b/src/admin/package_navigation.ts @@ -6,8 +6,6 @@ export type PackagePageSection = | 'overview' | 'version' | 'diff' - | 'settings' - | 'reports' | 'preview' | 'authoring'; @@ -50,16 +48,6 @@ export function renderPackagePageNav(input: { }/diff`, current: input.currentSection === 'diff', }), - renderNavLink({ - label: 'Settings', - href: `/admin/packages/${encodeURIComponent(input.appId)}/deployment`, - current: input.currentSection === 'settings', - }), - renderNavLink({ - label: 'Reports', - href: `/admin/packages/${encodeURIComponent(input.appId)}/reports`, - current: input.currentSection === 'reports', - }), actionVersion === null ? '' : renderNavLink({ label: 'Test launch', href: `/admin/packages/${encodeURIComponent(input.appId)}/versions/${ diff --git a/src/admin/package_overview.ts b/src/admin/package_overview.ts index 8600ded..b67a5e5 100644 --- a/src/admin/package_overview.ts +++ b/src/admin/package_overview.ts @@ -4,7 +4,7 @@ import { approvalStatusLabel, summarizeAccessibilityReview, } from '../package_review/summary.ts'; -import type { DeploymentRecord, PackageVersionRecord } from '../package_review/types.ts'; +import type { PackageVersionRecord } from '../package_review/types.ts'; import { type AdminIdentity, type AdminNotice, @@ -19,23 +19,17 @@ export function renderPackageOverviewPage(input: { appId: string; appTitle: string; history: PackageVersionRecord[]; - deployments: DeploymentRecord[]; notice?: AdminNotice | null; }): string { const latestVersion = input.history[0] ?? null; const latestApprovedVersion = input.history.find((version) => version.approvalStatus === 'approved') ?? null; - const connectedDeployments = input.deployments.filter( - (deployment) => deployment.binding !== null, - ); - const liveDeploymentCounts = buildLiveDeploymentCounts(input.deployments); - return renderAdminLayout({ identity: input.identity, title: `${input.appTitle} App`, eyebrow: 'App', heading: input.appTitle, - intro: 'Versions, governed launch tools, and LMS settings for this app.', + intro: 'Reviewed versions for installation through the governed app catalog.', activePath: '/admin/packages', breadcrumbs: [ { label: 'Apps', href: '/admin/packages' }, @@ -69,16 +63,7 @@ export function renderPackageOverviewPage(input: { ) }/versions/${escapeHtml(latestVersion.version)}">Open latest version` } - Open settings - Reports + Install app ${ latestApprovedVersion === null ? '' @@ -111,10 +96,6 @@ export function renderPackageOverviewPage(input: { ) }
    -
    - LMS connections - ${escapeHtml(String(connectedDeployments.length))} -
    Updated ${ @@ -132,7 +113,7 @@ export function renderPackageOverviewPage(input: {

    Reviewed versions

    -

    Each version shows whether it is the current reviewed baseline and whether it is live in LMS now.

    +

    Each immutable version is reviewed independently before an institution can install it.

    @@ -143,38 +124,12 @@ export function renderPackageOverviewPage(input: { version, isLatestVersion: latestVersion?.id === version.id, isLatestApproved: latestApprovedVersion?.id === version.id, - liveDeploymentCount: liveDeploymentCounts.get(version.id) ?? 0, }) ) .join('') }
    -
    -
    -
    -
    -
    - -

    LMS setup

    -

    Keep live LMS bindings in one place so version review stays separate from rollout decisions.

    -
    - -
    - ${ - input.deployments.length === 0 - ? '

    No LMS connection has been saved for this app yet.

    ' - : `
    - ${input.deployments.map((deployment) => renderDeploymentRow(deployment)).join('')} -
    ` - } -
    `, }); } @@ -183,14 +138,12 @@ function renderOverviewVersionRow(input: { version: PackageVersionRecord; isLatestVersion: boolean; isLatestApproved: boolean; - liveDeploymentCount: number; }): string { - const { version, isLatestVersion, isLatestApproved, liveDeploymentCount } = input; + const { version, isLatestVersion, isLatestApproved } = input; const accessibility = summarizeAccessibilityReview(version); const stateChips = renderVersionStateChips({ isLatestVersion, isLatestApproved, - liveDeploymentCount, }); return `
    @@ -259,7 +212,6 @@ function renderOverviewVersionRow(input: { function renderVersionStateChips(input: { isLatestVersion: boolean; isLatestApproved: boolean; - liveDeploymentCount: number; }): string { const chips: string[] = []; @@ -271,75 +223,7 @@ function renderVersionStateChips(input: { ); } - chips.push( - input.liveDeploymentCount > 0 - ? `${ - escapeHtml( - formatLiveRolloutChipLabel(input.liveDeploymentCount), - ) - }` - : 'Not live in LMS', - ); + chips.push('Install through governance'); return `
    ${chips.join('')}
    `; } - -function buildLiveDeploymentCounts(deployments: DeploymentRecord[]): Map { - const counts = new Map(); - - for (const deployment of deployments) { - if (deployment.binding === null || deployment.enabledPackageVersionId === null) { - continue; - } - - counts.set( - deployment.enabledPackageVersionId, - (counts.get(deployment.enabledPackageVersionId) ?? 0) + 1, - ); - } - - return counts; -} - -function formatLiveRolloutChipLabel(liveDeploymentCount: number): string { - return `Live now in ${liveDeploymentCount} LMS setup${liveDeploymentCount === 1 ? '' : 's'}`; -} - -function renderDeploymentRow(deployment: DeploymentRecord): string { - return `
    -
    -
    -

    ${escapeHtml(formatLmsLabel(deployment.lmsType))}

    -

    ${ - escapeHtml( - deployment.binding === null - ? 'No binding saved yet.' - : 'Binding saved and ready for governed launch.', - ) - }

    -
    -
    -
    - Live version ${ - escapeHtml( - deployment.enabledPackageVersion ?? 'Not pinned', - ) - } - Updated ${escapeHtml(formatDateTime(deployment.updatedAt))} - Deployment ${escapeHtml(deployment.label)} -
    -
    `; -} - -function formatLmsLabel(lms: DeploymentRecord['lmsType']): string { - switch (lms) { - case 'canvas': - return 'Canvas'; - case 'moodle': - return 'Moodle'; - case 'sakai': - return 'Sakai'; - default: - return lms; - } -} diff --git a/src/admin/package_overview_test.ts b/src/admin/package_overview_test.ts index 9e0b528..7547d7f 100644 --- a/src/admin/package_overview_test.ts +++ b/src/admin/package_overview_test.ts @@ -1,13 +1,9 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; import { renderPackageOverviewPage } from './package_overview.ts'; -import { - buildDeploymentRecord, - buildPackageVersionRecord, -} from '../test_helpers/package_review.ts'; -import { buildCanvasDeploymentBinding } from '../test_helpers/lti.ts'; +import { buildPackageVersionRecord } from '../test_helpers/package_review.ts'; -Deno.test('renderPackageOverviewPage keeps latest approved distinct from live rollout', () => { +Deno.test('renderPackageOverviewPage directs reviewed versions through governance', () => { const body = renderPackageOverviewPage({ identity: ADMIN_TEST_IDENTITY, appId: 'typescript-ladder-game', @@ -30,24 +26,15 @@ Deno.test('renderPackageOverviewPage keeps latest approved distinct from live ro importedAt: '2026-04-09T21:27:00Z', }), ], - deployments: [ - buildDeploymentRecord({ - appId: 'typescript-ladder-game', - enabledPackageVersionId: 2, - enabledPackageVersion: '0.2.0', - binding: buildCanvasDeploymentBinding(), - }), - ], }); assertStringIncludes( body, - 'Each version shows whether it is the current reviewed baseline and whether it is live in LMS now.', + 'Each immutable version is reviewed independently before an institution can install it.', ); assertStringIncludes(body, 'Current reviewed baseline'); assertStringIncludes(body, 'version-row-current'); - assertStringIncludes(body, 'Live now in 1 LMS setup'); - assertStringIncludes(body, 'Not live in LMS'); + assertStringIncludes(body, 'Install through governance'); assertStringIncludes(body, 'version-row-actions'); - assertEquals(body.includes('Live rollout'), false); + assertEquals(body.includes('/deployment'), false); }); diff --git a/src/admin/package_reports.ts b/src/admin/package_reports.ts deleted file mode 100644 index af9fe35..0000000 --- a/src/admin/package_reports.ts +++ /dev/null @@ -1,516 +0,0 @@ -import type { PackageVersionRecord } from '../package_review/types.ts'; -import type { - InstructorReport, - InstructorReportDailyActivity, - InstructorReportEventMix, - InstructorReportItemActivity, - InstructorReportScoreBucket, - InstructorReportStudent, - InstructorReportStudentSignal, -} from '../reports/instructor_report.ts'; -import { - type AdminIdentity, - type AdminNotice, - escapeHtml, - formatDate, - formatDateTime, - renderAdminLayout, -} from './layout.ts'; -import { renderPackagePageNav } from './package_navigation.ts'; - -export function renderPackageReportsPage(input: { - identity: AdminIdentity; - appId: string; - appTitle: string; - history: PackageVersionRecord[]; - report: InstructorReport; - notice?: AdminNotice | null; -}): string { - return renderAdminLayout({ - identity: input.identity, - title: `${input.appTitle} Reports`, - eyebrow: 'Instructor reports', - heading: input.appTitle, - intro: 'Learner progress from Lantern-managed attempts and attempt events.', - activePath: '/admin/packages', - breadcrumbs: [ - { label: 'Apps', href: '/admin/packages' }, - { label: input.appTitle, href: `/admin/packages/${input.appId}` }, - { label: 'Reports' }, - ], - notice: input.notice ?? null, - pageNav: renderPackagePageNav({ - appId: input.appId, - history: input.history, - currentSection: 'reports', - }), - body: input.report.summary.attemptCount === 0 - ? renderEmptyReport() - : `${renderReportSummary(input.report)} - ${renderClassHealthSection(input.report)} - ${renderDailyActivitySection(input.report.dailyActivity)} - ${renderReportInsightsSection(input.report)} - ${renderStudentSection(input.report.students)} - ${renderRecentAttemptsSection(input.report)}`, - }); -} - -function renderEmptyReport(): string { - return `
    -
    -
    - -

    Reports appear after students launch the approved app.

    -

    Lantern excludes admin preview launches here. The report uses only learner attempts, GatewayApp attempt events, local progress writes, finalization, and grade publication records from the governed runtime.

    -
    -
    -
    `; -} - -function renderReportSummary(report: InstructorReport): string { - const summary = report.summary; - - return `
    -
    -
    -
    - -

    Reportable progress

    -

    Counts come from learner launches and GatewayApp events. Preview launches stay out of this report.

    -
    -

    Updated ${escapeHtml(formatDateTime(report.generatedAt))}

    -
    -
    - ${renderFact('Learners', String(summary.learnerCount))} - ${renderFact('Attempts', String(summary.attemptCount))} - ${renderFact('Completed', String(summary.completedAttemptCount))} - ${renderFact('In progress', String(summary.inProgressAttemptCount))} - ${renderFact('Answer events', String(summary.answerEventCount))} - ${renderFact('Progress events', String(summary.progressEventCount))} - ${renderFact('Completion events', String(summary.completeEventCount))} - ${renderFact('Completion rate', formatPercent(summary.completionRatePercent))} - ${renderFact('Average score', formatPercent(summary.averageScorePercent))} - ${renderFact('Latest activity', formatDateTime(summary.latestActivityAt))} -
    -
    -
    `; -} - -function renderClassHealthSection(report: InstructorReport): string { - const followUpCount = report.students.filter( - (student) => student.signal === 'needs_follow_up', - ).length; - const topItem = report.itemActivity[0] ?? null; - - return `
    -
    -
    -
    - -

    Instructor takeaways

    -

    These signals summarize learner progress, completion, and the activity evidence generated by this app.

    -
    -
    -
    -
    - ${renderCompletionMeter(report.summary.completionRatePercent)} -
    -

    ${escapeHtml(formatPercent(report.summary.completionRatePercent))} complete

    -

    ${ - escapeHtml( - `${report.summary.completedAttemptCount} of ${report.summary.attemptCount} learner attempts are complete.`, - ) - }

    -
    -
    -
      -
    • ${escapeHtml(String(followUpCount))} ${ - escapeHtml( - pluralize(followUpCount, 'learner'), - ) - } may need follow-up.
    • -
    • ${ - escapeHtml( - String(report.summary.scoredAttemptCount), - ) - } ${ - escapeHtml( - pluralize(report.summary.scoredAttemptCount, 'attempt'), - ) - } produced a reportable score.
    • -
    • ${ - escapeHtml( - String(report.summary.totalEventCount), - ) - } governed runtime ${ - escapeHtml( - pluralize(report.summary.totalEventCount, 'event'), - ) - } recorded across answers, progress, and completions.
    • -
    • ${renderTopItemTakeaway(topItem)}
    • -
    -
    -
    -
    `; -} - -function renderReportInsightsSection(report: InstructorReport): string { - return `
    -
    -
    -
    - -

    What students did

    -
    -
    -
    -
    -

    Score distribution

    - ${renderScoreDistribution(report.scoreDistribution)} -
    -
    -

    Event mix

    - ${renderEventMix(report.eventMix)} -
    -
    -

    Most practiced items

    - ${renderItemActivity(report.itemActivity)} -
    -
    -
    -
    `; -} - -function renderDailyActivitySection(dailyActivity: InstructorReportDailyActivity[]): string { - return `
    -
    -
    -
    - -

    Attempts by day

    -
    -
    - ${renderAttemptBarChart(dailyActivity)} -
    -
    `; -} - -function renderStudentSection(students: InstructorReportStudent[]): string { - return `
    -
    -
    -
    - -

    Student progress

    -
    -
    -
    - - - - - - - - - - - - - - ${students.map(renderStudentRow).join('')} - -
    StudentSignalAttemptsCompletedEventsLatest scoreLast activity
    -
    -
    -
    `; -} - -function renderRecentAttemptsSection(report: InstructorReport): string { - return `
    -
    - -
    - ${ - report.recentAttempts - .map( - (attempt) => - `
    -

    - ${escapeHtml(displayAttemptUser(attempt.attempt))} - ${escapeHtml(formatAttemptStatus(attempt.attempt.status))} -

    -

    ${escapeHtml(attempt.attempt.packageVersion)} · ${ - escapeHtml( - attempt.attempt.deploymentSlug, - ) - } · ${escapeHtml(String(attempt.eventCount))} events · ${ - escapeHtml( - String(attempt.answerEventCount), - ) - } answers${ - attempt.scorePercent === null - ? '' - : ` · ${escapeHtml(formatPercent(attempt.scorePercent))}` - }

    -

    Started ${ - escapeHtml( - formatDateTime(attempt.attempt.startedAt), - ) - }; latest event ${escapeHtml(formatDateTime(attempt.latestEventAt))}

    -
    `, - ) - .join('') - } -
    -
    -
    `; -} - -function renderScoreDistribution(buckets: InstructorReportScoreBucket[]): string { - return renderMeterRows( - buckets.map((bucket) => ({ - label: bucket.label, - value: bucket.count, - valueLabel: pluralize(bucket.count, 'attempt'), - })), - ); -} - -function renderEventMix(eventMix: InstructorReportEventMix[]): string { - return renderMeterRows( - eventMix.map((event) => ({ - label: event.label, - value: event.count, - valueLabel: pluralize(event.count, 'event'), - })), - ); -} - -function renderItemActivity(itemActivity: InstructorReportItemActivity[]): string { - if (itemActivity.length === 0) { - return `

    No answer events have been recorded yet.

    `; - } - - return renderMeterRows( - itemActivity.map((item) => ({ - label: item.questionId, - value: item.answerEventCount, - valueLabel: pluralize(item.answerEventCount, 'answer'), - detail: `latest ${formatDateTime(item.latestAnsweredAt)}`, - })), - ); -} - -function renderMeterRows( - rows: Array<{ label: string; value: number; valueLabel: string; detail?: string }>, -): string { - const maxValue = Math.max(1, ...rows.map((row) => row.value)); - - return `
    - ${ - rows - .map((row) => { - const width = Math.round((row.value / maxValue) * 100); - - return `
    -
    - ${escapeHtml(row.label)} - ${escapeHtml(String(row.value))} ${ - escapeHtml( - row.valueLabel, - ) - }${row.detail ? ` · ${escapeHtml(row.detail)}` : ''} -
    - -
    `; - }) - .join('') - } -
    `; -} - -function renderAttemptBarChart(dailyActivity: InstructorReportDailyActivity[]): string { - const maxAttempts = Math.max(1, ...dailyActivity.map((day) => day.attemptCount)); - - return ``; -} - -function renderStudentRow(student: InstructorReportStudent): string { - return ` - - ${escapeHtml(student.displayName)} - ${ - escapeHtml( - student.email ?? student.login ?? student.userId, - ) - } - - ${renderStudentSignal(student.signal)} - ${escapeHtml(String(student.attemptCount))} - ${renderCompletionCell(student)} - - ${escapeHtml(String(student.totalEventCount))} - ${ - escapeHtml( - `${student.answerEventCount} answers, ${student.progressEventCount} progress, ${student.completeEventCount} complete`, - ) - } - - ${renderScoreCell(student.latestScorePercent)} - ${escapeHtml(formatDateTime(student.latestActivityAt))} - `; -} - -function renderCompletionMeter(value: number): string { - const percent = clampPercent(value); - - return ``; -} - -function renderCompletionCell(student: InstructorReportStudent): string { - return `
    - ${escapeHtml(String(student.completedAttemptCount))}/${ - escapeHtml( - String(student.attemptCount), - ) - } - -
    `; -} - -function renderScoreCell(score: number | null): string { - if (score === null) { - return 'Not scored'; - } - - return `
    - ${escapeHtml(formatPercent(score))} - -
    `; -} - -function renderStudentSignal(signal: InstructorReportStudentSignal): string { - return `${ - escapeHtml( - formatStudentSignal(signal), - ) - }`; -} - -function renderTopItemTakeaway(item: InstructorReportItemActivity | null): string { - if (item === null) { - return 'No practiced item has enough answer evidence yet.'; - } - - return `${ - escapeHtml( - item.questionId, - ) - } is the most practiced item with ${ - escapeHtml( - String(item.answerEventCount), - ) - } ${escapeHtml(pluralize(item.answerEventCount, 'answer'))}.`; -} - -function renderFact(label: string, value: string): string { - return `
    - ${escapeHtml(label)} - ${escapeHtml(value)} -
    `; -} - -function displayAttemptUser(attempt: { - userDisplayName: string | null; - userLogin: string | null; - userEmail: string | null; - userId: string; -}): string { - return attempt.userDisplayName ?? attempt.userLogin ?? attempt.userEmail ?? attempt.userId; -} - -function formatAttemptStatus(status: string): string { - return status - .split('_') - .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) - .join(' '); -} - -function formatStudentSignal(signal: InstructorReportStudentSignal): string { - switch (signal) { - case 'on_track': - return 'On track'; - case 'in_progress': - return 'In progress'; - case 'needs_follow_up': - return 'Needs follow-up'; - } -} - -function formatPercent(value: number | null): string { - if (value === null) { - return 'Not scored'; - } - - return `${Math.round(value)}%`; -} - -function clampPercent(value: number): number { - return Math.max(0, Math.min(100, Math.round(value))); -} - -function pluralize(count: number, singular: string): string { - return count === 1 ? singular : `${singular}s`; -} diff --git a/src/admin/placement_audit_page.ts b/src/admin/placement_audit_page.ts deleted file mode 100644 index c5f08ac..0000000 --- a/src/admin/placement_audit_page.ts +++ /dev/null @@ -1,250 +0,0 @@ -import type { AuditEventRecord, PlacementAuditSnapshot } from '../package_review/types.ts'; -import { type AdminIdentity, escapeHtml, formatDateTime, renderAdminLayout } from './layout.ts'; - -export function renderPlacementAuditPage(input: { - identity: AdminIdentity; - snapshot: PlacementAuditSnapshot; - timeline: AuditEventRecord[]; - notice?: { - tone: 'error' | 'note' | 'success'; - title: string; - detail: string; - items?: string[]; - } | null; -}): string { - const { placement } = input.snapshot; - const status = describePlacementStatus(input.snapshot.status); - const previewLink = - `/admin/packages/${placement.appId}/versions/${placement.packageVersion}/preview`; - const packageLink = `/admin/packages/${placement.appId}/versions/${placement.packageVersion}`; - const deploymentLink = `/admin/packages/${placement.appId}/deployment`; - - return renderAdminLayout({ - identity: input.identity, - title: `Placement ${placement.placementId}`, - eyebrow: 'Placement audit', - heading: 'Placement audit', - intro: - 'Inspect the reviewed placement record and one durable evidence timeline from Lantern-owned logs.', - activePath: '/admin/placements', - breadcrumbs: [ - { label: 'Apps', href: '/admin/packages' }, - { label: 'Placements', href: '/admin/placements' }, - { label: placement.placementId }, - ], - notice: input.notice ?? null, - body: `
    -
    -
    - -

    ${escapeHtml(placement.placementId)}

    -

    Selected content ${ - escapeHtml( - placement.contentTitle ?? placement.contentPath, - ) - } from ${escapeHtml(placement.contentPath)}.

    -
    -
    - Current status - ${escapeHtml(status.label)} -

    ${escapeHtml(status.detail)}

    -
    -
    - Reviewed package - ${escapeHtml(placement.packageTitle)} -

    Version ${escapeHtml(placement.packageVersion)}

    -
    -
    - Canvas context - ${ - escapeHtml( - placement.contextTitle ?? 'Not recorded yet', - ) - } -

    ${escapeHtml(placement.contextId ?? 'Not recorded yet')}

    -
    -
    - Resource link - ${ - escapeHtml( - placement.resourceLinkId ?? 'Not recorded yet', - ) - } -
    -
    -
    - -
    -
    -
    -
    - -

    Durable placement and reviewer evidence

    -
    - ${renderTimelineRows(input.snapshot, input.timeline, previewLink)} -
    -
    -
    `, - }); -} - -export function renderPlacementAuditRequestPage(input: { - identity: AdminIdentity; - notice?: { - tone: 'error' | 'note' | 'success'; - title: string; - detail: string; - items?: string[]; - } | null; -}): string { - return renderAdminLayout({ - identity: input.identity, - title: 'Placements', - eyebrow: 'Placements', - heading: 'Placements', - intro: - 'Open one reviewed placement when you need the exact content choice, package version, Canvas context, and durable reviewer evidence.', - activePath: '/admin/placements', - breadcrumbs: [ - { label: 'Apps', href: '/admin/packages' }, - { - label: 'Placements', - }, - ], - notice: input.notice ?? null, - body: `
    -
    -
    - -

    Open one reviewed placement.

    -

    Paste a placement id from version details, app settings, or an audit event to inspect the record behind it.

    -

    Lantern keeps placement audits on their own page so package review and deployment tasks stay simpler.

    -
    -
    -
    - - -

    Use the durable placement id, not the Canvas resource link id.

    -
    -
    - -
    -
    -
    -
    -
    -
    - -

    No placement is open.

    -

    Choose a placement id to inspect one reviewed selection without mixing this audit trail into the package library.

    -
    -
    `, - }); -} - -function renderTimelineRows( - snapshot: PlacementAuditSnapshot, - timeline: AuditEventRecord[], - previewLink: string, -): string { - const rows = [...timeline] - .sort((left, right) => { - const occurred = right.occurredAt.localeCompare(left.occurredAt); - if (occurred !== 0) { - return occurred; - } - return right.id - left.id; - }) - .map( - (event) => - `
    -

    ${escapeHtml(event.eventType)}

    -

    ${escapeHtml(event.summary)}

    -

    ${escapeHtml(formatDateTime(event.occurredAt))}

    -
    `, - ); - - if (snapshot.latestPreviewSessionId !== null) { - rows.unshift(`
    -

    test_launch.activity

    -

    Test session ${ - escapeHtml( - snapshot.latestPreviewSessionId, - ) - } recorded ${escapeHtml(String(snapshot.previewEvidenceCount))} events.

    -

    ${escapeHtml(formatDateTime(snapshot.latestPreviewOccurredAt))}

    - Open test activity -
    `); - } - - if (rows.length > 0) { - return rows.join(''); - } - - return `
    -

    No timeline evidence recorded yet

    -

    Lantern has the placement record, but no linked deep-linking, test-launch, or reviewer activity has been recorded for this placement yet.

    -
    `; -} - -function describePlacementStatus(status: PlacementAuditSnapshot['status']): { - label: string; - detail: string; -} { - switch (status) { - case 'awaiting_canvas_binding': - return { - label: 'Awaiting Canvas binding', - detail: 'The reviewed placement exists but Canvas has not yet bound a resource_link.id.', - }; - case 'bound_no_preview': - return { - label: 'Bound with no test-launch activity', - detail: - 'Canvas binding exists and Lantern has no test-launch activity for this reviewed package version yet.', - }; - case 'bound_with_preview': - return { - label: 'Bound with test-launch activity', - detail: - 'Canvas binding and test-launch activity are both recorded for this reviewed placement.', - }; - case 'reviewed': - return { - label: 'Reviewed with operator activity', - detail: 'Reviewer evidence has been recorded against this reviewed placement.', - }; - } -} diff --git a/src/admin/placement_audit_page_test.ts b/src/admin/placement_audit_page_test.ts deleted file mode 100644 index 67ecf83..0000000 --- a/src/admin/placement_audit_page_test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { assertStringIncludes } from '@std/assert'; -import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; -import { renderPlacementAuditPage } from './placement_audit_page.ts'; -import { - buildAuditEventRecord, - buildReviewedPlacementRecord, -} from '../test_helpers/package_review.ts'; - -Deno.test('renderPlacementAuditPage shows selected content, reviewed package identity, Canvas context, and placement status', () => { - const html = renderPlacementAuditPage({ - identity: ADMIN_TEST_IDENTITY, - snapshot: { - placement: buildReviewedPlacementRecord({ - placementId: 'placement-audit-123', - appId: 'chapter-4-asteroids', - packageVersion: '0.8.0', - packageTitle: 'Chapter 4 Asteroids', - contentPath: '/content/bonus.json', - contentTitle: 'Bonus Activity', - contextId: 'course-42', - contextTitle: 'Physics 101', - resourceLinkId: 'resource-link-123', - }), - status: 'bound_with_preview', - latestPreviewSessionId: 'preview-session-123', - latestPreviewOccurredAt: '2026-03-25T12:00:00Z', - previewEvidenceCount: 3, - evidenceSummary: { - deepLinkingRequestCount: 1, - placementEventCount: 2, - reviewerEventCount: 1, - latestOccurredAt: '2026-03-25T12:05:00Z', - }, - }, - timeline: [], - }); - - assertStringIncludes(html, 'Placement audit'); - assertStringIncludes(html, 'placement-audit-123'); - assertStringIncludes(html, 'Chapter 4 Asteroids'); - assertStringIncludes(html, 'Version 0.8.0'); - assertStringIncludes(html, '/content/bonus.json'); - assertStringIncludes(html, 'Physics 101'); - assertStringIncludes(html, 'Bound with test-launch activity'); - assertStringIncludes(html, 'href="/admin/packages/chapter-4-asteroids/versions/0.8.0"'); - assertStringIncludes(html, 'href="/admin/packages/chapter-4-asteroids/deployment"'); -}); - -Deno.test('renderPlacementAuditPage includes deep-linking, test-launch, and reviewer timeline entries when evidence exists', () => { - const html = renderPlacementAuditPage({ - identity: ADMIN_TEST_IDENTITY, - snapshot: { - placement: buildReviewedPlacementRecord({ - placementId: 'placement-audit-123', - packageVersion: '0.8.0', - }), - status: 'bound_no_preview', - latestPreviewSessionId: 'preview-session-123', - latestPreviewOccurredAt: '2026-03-25T12:00:00Z', - previewEvidenceCount: 2, - evidenceSummary: { - deepLinkingRequestCount: 1, - placementEventCount: 2, - reviewerEventCount: 1, - latestOccurredAt: '2026-03-25T12:05:00Z', - }, - }, - timeline: [ - buildAuditEventRecord({ - id: 41, - eventType: 'deep_linking.placement.created', - summary: 'Created reviewed placement from Deep Linking selection.', - }), - buildAuditEventRecord({ - id: 42, - eventType: 'reviewer.preview_viewed', - summary: 'Reviewer opened the test launch page.', - }), - ], - }); - - assertStringIncludes(html, 'Evidence timeline'); - assertStringIncludes(html, 'deep_linking.placement.created'); - assertStringIncludes(html, 'reviewer.preview_viewed'); - assertStringIncludes(html, 'Open test activity'); -}); diff --git a/src/admin/preview_page.ts b/src/admin/preview_page.ts index c290c4e..36590df 100644 --- a/src/admin/preview_page.ts +++ b/src/admin/preview_page.ts @@ -351,7 +351,7 @@ function resolvePreviewEvidenceArtifactUrl( const artifactId = readPreviewEvidenceString(detail, 'artifactId'); - return artifactId === null ? null : `/admin/packages/${appId}/deployment/evidence/${artifactId}`; + return artifactId === null ? null : `/admin/packages/${appId}/evidence/${artifactId}`; } function readPreviewEvidenceFileName(detail: Record): string | null { diff --git a/src/admin/preview_page_test.ts b/src/admin/preview_page_test.ts index 16f0114..2c8d109 100644 --- a/src/admin/preview_page_test.ts +++ b/src/admin/preview_page_test.ts @@ -220,7 +220,7 @@ Deno.test('renderPreviewPage shows durable test activity evidence in capability assertStringIncludes(body, 'preview.finalize'); assertStringIncludes( body, - '/admin/packages/chapter-4-asteroids/deployment/evidence/artifact-001', + '/admin/packages/chapter-4-asteroids/evidence/artifact-001', ); assertStringIncludes(body, 'finalize_attempt'); assertStringIncludes(body, 'Finished the test attempt with simulated scoring and no LMS writes.'); @@ -283,7 +283,7 @@ Deno.test('renderPreviewPage renders screenshot evidence as supplemental on the assertStringIncludes(body, '

    No access requests

    Canvas access requests from the public Python Lab will appear here.

    `; +} + +function renderRequests(requests: readonly PythonLabAccessRequestRecord[]): string { + return `
    ${ + requests.map(renderRequest).join('') + }
    InstructorInstitutionRequestedStatus
    `; +} + +function renderRequest(request: PythonLabAccessRequestRecord): string { + return `
    ${ + escapeHtml(request.name) + }${escapeHtml(request.workEmail)}
    ${ + escapeHtml(request.institution) + }${ + escapeHtml(formatDateTime(request.createdAt)) + }
    `; +} + +function renderStatusOptions(current: PythonLabAccessRequestRecord['status']): string { + return (['new', 'contacted', 'closed'] as const).map((status) => + `` + ).join(''); +} diff --git a/src/admin/verification_navigation.ts b/src/admin/verification_navigation.ts deleted file mode 100644 index e185e68..0000000 --- a/src/admin/verification_navigation.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { escapeHtml } from './layout.ts'; - -export type VerificationPageSection = 'checklist' | 'official' | 'new' | 'profile'; - -export function renderVerificationPageNav(currentSection: VerificationPageSection): string { - const links = [ - renderNavLink({ - label: 'Checklist', - href: '/admin/verification', - current: currentSection === 'checklist', - }), - renderNavLink({ - label: 'Official evidence', - href: '/admin/verification/official', - current: currentSection === 'official', - }), - renderNavLink({ - label: 'Add result', - href: '/admin/verification/new', - current: currentSection === 'new', - }), - renderNavLink({ - label: 'Lantern default', - href: '/admin/verification/lti-profile', - current: currentSection === 'profile', - }), - ].join(''); - - return ``; -} - -function renderNavLink(input: { label: string; href: string; current: boolean }): string { - return `${ - escapeHtml( - input.label, - ) - }`; -} diff --git a/src/admin_auth/page.ts b/src/admin_auth/page.ts index 4561cf3..d8cf9fb 100644 --- a/src/admin_auth/page.ts +++ b/src/admin_auth/page.ts @@ -111,7 +111,7 @@ export function renderAdminAuthPage(input: { return renderAdminAuthShell({ title, intro: - 'Use a Lantern admin account to review packages, manage LMS deployments, and inspect runtime activity.', + 'Use a Lantern admin account to review packages, govern LMS connections, and inspect runtime activity.', body: ` ${renderAuthError(input.error)}
    diff --git a/src/admin_auth/service_test.ts b/src/admin_auth/service_test.ts index 8c08c6a..9fa646c 100644 --- a/src/admin_auth/service_test.ts +++ b/src/admin_auth/service_test.ts @@ -119,7 +119,7 @@ Deno.test('Better Auth Google social sign-in preserves authorization redirect an }); Deno.test('Better Auth migration matches the configured auth schema tables and fields', async () => { - const migration = await Deno.readTextFile('src/db/d1_migrations/0010_better_auth_tables.sql'); + const migration = await Deno.readTextFile('src/db/d1_migrations/0002_auth.sql'); const tables = getAuthTables( createBetterAdminAuthOptions({ db: createStubD1Database(), diff --git a/src/app.ts b/src/app.ts deleted file mode 100644 index 854de95..0000000 --- a/src/app.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Hono } from '@hono/hono'; -import { buildApp } from './app_core.ts'; -import { type AppServices, resolveServices } from './app_services.ts'; -import { installDenoEnvReader } from './platform/deno_env.ts'; - -export type { AppServices } from './app_services.ts'; - -installDenoEnvReader(); - -export const app = createApp(); - -export function createApp(services: Partial = {}): Hono { - return buildApp(resolveServices(services)); -} diff --git a/src/app_admin_app_writer_routes.ts b/src/app_admin_app_writer_routes.ts index c946b5c..e4c1288 100644 --- a/src/app_admin_app_writer_routes.ts +++ b/src/app_admin_app_writer_routes.ts @@ -14,7 +14,7 @@ import { renderAppWriterPlanPreviewPage, } from './admin/app_writer_page.ts'; import { createErrorNotice } from './app_notice_support.ts'; -import { loadPreviewCapabilityLog } from './app_deployment_support.ts'; +import { loadPreviewCapabilityLog } from './preview/capability_log.ts'; import { formValueAsString, requireTrimmedFormValue } from './app_request_support.ts'; import { statusForError } from './app_status_support.ts'; import type { AppServices } from './app_services.ts'; diff --git a/src/app_admin_app_writer_test.ts b/src/app_admin_app_writer_test.ts index acdfce0..9b7e3fd 100644 --- a/src/app_admin_app_writer_test.ts +++ b/src/app_admin_app_writer_test.ts @@ -14,7 +14,6 @@ import { buildImportedPackageVersion, buildPackageVersionRecord, buildPreviewEvidenceRecord, - buildReviewedPlacementRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; @@ -204,30 +203,13 @@ Deno.test('POST /admin/app-writer runs generation and redirects to the run detai assertStringIncludes(detailBody, 'Preview summary'); assertStringIncludes(detailBody, 'Generation preview completed: Passed 1/1 preview assertions.'); - await repository.createReviewedPlacement( - buildReviewedPlacementRecord({ - placementId: 'generated-placement-1', - deploymentSlug: 'phonics-match-canvas', - appId: 'phonics-match', - packageVersionId: 1, - packageVersion: '0.1.0', - packageTitle: 'Phonics Match', - contextTitle: 'Reading 101', - resourceLinkId: 'resource-link-generated', - }), - ); - const packageResponse = await app.request( 'https://lantern.example/admin/packages/phonics-match/versions/0.1.0', ); const packageBody = await packageResponse.text(); assertEquals(packageResponse.status, 200); - assertStringIncludes(packageBody, 'Generated package activity'); - assertStringIncludes(packageBody, 'Saved generated package as a pending package version.'); - assertStringIncludes(packageBody, 'LMS placements using this version'); - assertStringIncludes(packageBody, 'generated-placement-1'); - assertStringIncludes(packageBody, 'resource-link-generated'); + assertStringIncludes(packageBody, 'Install reviewed app'); }); Deno.test('POST /admin/app-writer redirects even without a Worker execution context', async () => { diff --git a/src/app_admin_auth_test.ts b/src/app_admin_auth_test.ts index 10fa7b8..2a972cc 100644 --- a/src/app_admin_auth_test.ts +++ b/src/app_admin_auth_test.ts @@ -1,5 +1,5 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; +import { createTestApp as createApp } from './test_helpers/app.ts'; import { ADMIN_TEST_SESSION_TOKEN, cookieHeaderFromResponse, @@ -167,19 +167,19 @@ Deno.test('login creates an admin session and redirects to requested admin path' body: new URLSearchParams({ email: 'admin@example.test', password: 'password-123', - returnTo: '/admin/deployments', + returnTo: '/admin/packages', }), }); const sessionCookie = cookieHeaderFromResponse(login); - const deployments = await app.request('http://localhost/admin/deployments', { + const packages = await app.request('http://localhost/admin/packages', { headers: { cookie: sessionCookie ?? '', }, }); assertEquals(login.status, 303); - assertEquals(login.headers.get('location'), '/admin/deployments'); - assertEquals(deployments.status, 200); + assertEquals(login.headers.get('location'), '/admin/packages'); + assertEquals(packages.status, 200); }); Deno.test('logout invalidates the admin session', async () => { diff --git a/src/app_admin_authoring_preview_test.ts b/src/app_admin_authoring_preview_test.ts index 1131b6f..4b7be0a 100644 --- a/src/app_admin_authoring_preview_test.ts +++ b/src/app_admin_authoring_preview_test.ts @@ -116,7 +116,6 @@ Deno.test('GET /authoring renders the latest draft preview state and evidence li packageTitle: 'Template App', origin: 'adminAuthoringDraft', contentPath: '/content/activity.json', - deepLinkingSessionId: null, snapshotRoot: `var/authoring-drafts/${DRAFT_ID}/snapshots/20260408T170000000Z`, entrypointPath: `var/authoring-drafts/${DRAFT_ID}/snapshots/20260408T170000000Z/dist/index.html`, diff --git a/src/app_admin_certification_checklist_test.ts b/src/app_admin_certification_checklist_test.ts deleted file mode 100644 index 030e293..0000000 --- a/src/app_admin_certification_checklist_test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { assert, assertEquals, assertStringIncludes } from '@std/assert'; -import { formatDateTime } from './admin/layout.ts'; -import { buildCanvasDeploymentBinding } from './test_helpers/lti.ts'; -import { - buildCertificationWorkflowStatus, - buildControlPlaneDeploymentInventoryRow, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; - -function assertTextAppearsInOrder(body: string, labels: string[]): void { - let previousIndex = -1; - - for (const label of labels) { - const index = body.indexOf(label); - assert(index >= 0, `Expected "${label}" to appear in the response body.`); - assert( - index > previousIndex, - `Expected "${label}" to appear after the previous checklist label.`, - ); - previousIndex = index; - } -} - -Deno.test('GET /admin/verification renders one certification checklist in Core, Deep Linking, NRPS, and AGS order', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - }), - ], - controlPlaneDeployments: [ - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 1, - deploymentLabel: 'Chapter 4 Asteroids Pilot Deployment', - binding: buildCanvasDeploymentBinding(), - }), - ], - certificationWorkflowStatuses: [ - buildCertificationWorkflowStatus({ - workflowKey: 'core', - }), - buildCertificationWorkflowStatus({ - workflowKey: 'deepLinking', - }), - buildCertificationWorkflowStatus({ - workflowKey: 'nrps', - }), - buildCertificationWorkflowStatus({ - workflowKey: 'ags', - }), - ], - }); - - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - }).request('http://localhost/admin/verification'); - - assertEquals(response.status, 200); - - const body = await response.text(); - - assertStringIncludes(body, 'Certification checklist'); - assertEquals(body.includes('action="/admin/verification"'), false); - assertTextAppearsInOrder(body, ['LTI Core', 'Deep Linking', 'NRPS', 'AGS']); -}); - -Deno.test('GET /admin/verification shows workflow-specific evidence, checked dates, and guidance links without blending workflows together', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - }), - ], - controlPlaneDeployments: [ - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 1, - appId: 'chapter-4-asteroids', - deploymentLabel: 'Chapter 4 Asteroids Pilot Deployment', - binding: buildCanvasDeploymentBinding(), - }), - ], - certificationWorkflowStatuses: [ - buildCertificationWorkflowStatus({ - workflowKey: 'core', - latestInternal: { - deploymentRecordId: 1, - deploymentLabel: 'Chapter 4 Asteroids Pilot Deployment', - status: 'passed', - checkedAt: '2026-03-24T12:50:00Z', - summary: 'Core launch completed through the official certification harness.', - evidenceUrl: 'https://example.test/certification/core', - }, - }), - buildCertificationWorkflowStatus({ - workflowKey: 'deepLinking', - latestInternal: { - deploymentRecordId: 1, - deploymentLabel: 'Chapter 4 Asteroids Pilot Deployment', - status: 'failed', - checkedAt: '2026-03-24T13:00:00Z', - summary: 'Deep Linking return payload still needs another review pass.', - evidenceUrl: 'https://example.test/certification/deep-linking', - }, - }), - buildCertificationWorkflowStatus({ - workflowKey: 'nrps', - latestInternal: { - deploymentRecordId: 1, - deploymentLabel: 'Chapter 4 Asteroids Pilot Deployment', - status: 'pending', - checkedAt: '2026-03-24T13:10:00Z', - summary: 'Roster verification is still waiting on operator review.', - evidenceUrl: 'https://example.test/certification/nrps', - }, - }), - buildCertificationWorkflowStatus({ - workflowKey: 'ags', - latestInternal: null, - }), - ], - }); - - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - }).request('http://localhost/admin/verification'); - - assertEquals(response.status, 200); - - const body = await response.text(); - - assertStringIncludes(body, 'Core launch completed through the official certification harness.'); - assertStringIncludes(body, 'Deep Linking return payload still needs another review pass.'); - assertStringIncludes(body, 'Roster verification is still waiting on operator review.'); - assertStringIncludes(body, formatDateTime('2026-03-24T12:50:00Z')); - assertStringIncludes(body, formatDateTime('2026-03-24T13:00:00Z')); - assertStringIncludes(body, formatDateTime('2026-03-24T13:10:00Z')); - assertStringIncludes(body, 'https://example.test/certification/core'); - assertStringIncludes(body, 'https://example.test/certification/deep-linking'); - assertStringIncludes(body, 'https://example.test/certification/nrps'); - assertStringIncludes(body, 'Run the LTI Core workflow in the official 1EdTech suite.'); - assertStringIncludes(body, 'Run the Deep Linking workflow in the official 1EdTech suite.'); - assertStringIncludes( - body, - '/admin/packages/chapter-4-asteroids/deployment?lms=canvas&view=activity#activity-details', - ); - assertStringIncludes(body, 'No internal evidence has been recorded for AGS yet.'); - assertEquals(body.includes('A passed Core row does not cover Deep Linking, NRPS, or AGS.'), true); -}); diff --git a/src/app_admin_certification_official_evidence_test.ts b/src/app_admin_certification_official_evidence_test.ts deleted file mode 100644 index 6ecbd0b..0000000 --- a/src/app_admin_certification_official_evidence_test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { formatDateTime } from './admin/layout.ts'; -import { buildCanvasDeploymentBinding } from './test_helpers/lti.ts'; -import { - buildCertificationWorkflowStatus, - buildControlPlaneDeploymentInventoryRow, - buildLatestOfficialCertificationEvidence, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; - -Deno.test('GET /admin/verification/official keeps official 1EdTech evidence on its own dated page', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - }), - ], - controlPlaneDeployments: [ - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 1, - deploymentLabel: 'Chapter 4 Asteroids Pilot Deployment', - binding: buildCanvasDeploymentBinding(), - }), - ], - certificationWorkflowStatuses: [ - buildCertificationWorkflowStatus({ - workflowKey: 'core', - latestInternal: { - deploymentRecordId: 1, - deploymentLabel: 'Chapter 4 Asteroids Pilot Deployment', - status: 'passed', - checkedAt: '2026-03-24T12:50:00Z', - summary: 'Internal core verification passed.', - evidenceUrl: 'https://example.test/certification/core', - }, - }), - buildCertificationWorkflowStatus({ - workflowKey: 'deepLinking', - }), - buildCertificationWorkflowStatus({ - workflowKey: 'nrps', - }), - buildCertificationWorkflowStatus({ - workflowKey: 'ags', - }), - ], - latestOfficialCertificationEvidence: buildLatestOfficialCertificationEvidence({ - workflowKey: 'core', - state: 'ltiAdvantageCertified', - checkedAt: '2026-03-24T13:20:00Z', - summary: '1EdTech lists Lantern as LTI Advantage Certified for Core.', - directoryUrl: 'https://example.test/certification/1edtech-directory', - }), - }); - - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - }).request('http://localhost/admin/verification/official'); - - assertEquals(response.status, 200); - - const body = await response.text(); - - assertStringIncludes(body, 'Official evidence'); - assertStringIncludes( - body, - 'Only the 1EdTech Product Directory supports an official certification claim.', - ); - assertStringIncludes(body, 'Product Directory status'); - assertStringIncludes(body, 'LTI Advantage Certified'); - assertStringIncludes(body, 'Covers workflow'); - assertStringIncludes(body, 'LTI Core'); - assertStringIncludes(body, '1EdTech lists Lantern as LTI Advantage Certified for Core.'); - assertStringIncludes(body, formatDateTime('2026-03-24T13:20:00Z')); - assertStringIncludes(body, 'https://example.test/certification/1edtech-directory'); -}); - -Deno.test('GET /admin/verification/official keeps internal passed rows off the official evidence page', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - }), - ], - controlPlaneDeployments: [ - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 1, - deploymentLabel: 'Chapter 4 Asteroids Pilot Deployment', - binding: buildCanvasDeploymentBinding(), - }), - ], - certificationWorkflowStatuses: [ - buildCertificationWorkflowStatus({ - workflowKey: 'core', - latestInternal: { - deploymentRecordId: 1, - deploymentLabel: 'Chapter 4 Asteroids Pilot Deployment', - status: 'passed', - checkedAt: '2026-03-24T12:50:00Z', - summary: 'Internal core verification passed.', - evidenceUrl: 'https://example.test/certification/core', - }, - }), - buildCertificationWorkflowStatus({ - workflowKey: 'deepLinking', - }), - buildCertificationWorkflowStatus({ - workflowKey: 'nrps', - }), - buildCertificationWorkflowStatus({ - workflowKey: 'ags', - }), - ], - latestOfficialCertificationEvidence: null, - }); - - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - }).request('http://localhost/admin/verification/official'); - - assertEquals(response.status, 200); - - const body = await response.text(); - - assertStringIncludes(body, 'No official claim recorded'); - assertStringIncludes(body, 'Lantern has no recorded 1EdTech Product Directory evidence yet.'); - assertEquals(body.includes('Internal core verification passed.'), false); - assertEquals(body.includes('Lantern is certified.'), false); -}); diff --git a/src/app_admin_certification_recording_test.ts b/src/app_admin_certification_recording_test.ts deleted file mode 100644 index a89bf37..0000000 --- a/src/app_admin_certification_recording_test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { assertEquals, assertExists, assertStringIncludes } from '@std/assert'; -import { - buildControlPlaneDeploymentInventoryRow, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { buildCanvasDeploymentBinding, buildMoodleDeploymentBinding } from './test_helpers/lti.ts'; -import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; - -Deno.test('POST /admin/verification records workflow-keyed internal certification evidence and leaves unrelated workflows untouched', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - controlPlaneDeployments: [ - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 1, - deploymentSlug: 'chapter-4-asteroids-pilot', - deploymentLabel: 'Chapter 4 Asteroids Pilot Deployment', - binding: buildCanvasDeploymentBinding(), - brokerVerification: null, - }), - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 2, - deploymentSlug: 'chapter-4-asteroids-moodle', - deploymentLabel: 'Chapter 4 Asteroids Moodle Deployment', - binding: buildMoodleDeploymentBinding(), - brokerVerification: null, - }), - ], - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const formData = new FormData(); - - formData.set('source', 'manual'); - formData.set('deploymentRecordId', '2'); - formData.set('scope', 'lti13LaunchAgsScore'); - formData.set('workflowKey', 'deepLinking'); - formData.set('status', 'passed'); - formData.set('summary', 'Deep Linking certification evidence passed for the Moodle deployment.'); - formData.set('detailUrl', 'https://example.test/certification/deep-linking'); - formData.set('checkedAt', '2026-03-24T12:50:00Z'); - - const response = await app.request('http://localhost/admin/verification', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }); - - assertEquals(response.status, 303); - assertEquals(response.headers.get('location'), '/admin/verification'); - - const workflowStatuses = await repository.listCertificationWorkflowStatuses(); - const statusesByWorkflow = new Map( - workflowStatuses.map((status) => [status.workflowKey, status] as const), - ); - const latestVerification = await repository.getLatestBrokerVerificationStatus(); - - assertEquals(statusesByWorkflow.get('deepLinking')?.latestInternal?.deploymentRecordId, 2); - assertEquals( - statusesByWorkflow.get('deepLinking')?.latestInternal?.deploymentLabel, - 'Chapter 4 Asteroids Moodle Deployment', - ); - assertEquals(statusesByWorkflow.get('deepLinking')?.latestInternal?.status, 'passed'); - assertEquals( - statusesByWorkflow.get('deepLinking')?.latestInternal?.summary, - 'Deep Linking certification evidence passed for the Moodle deployment.', - ); - assertEquals( - statusesByWorkflow.get('deepLinking')?.latestInternal?.evidenceUrl, - 'https://example.test/certification/deep-linking', - ); - assertEquals(statusesByWorkflow.get('core')?.latestInternal, null); - assertEquals(statusesByWorkflow.get('nrps')?.latestInternal, null); - assertEquals(statusesByWorkflow.get('ags')?.latestInternal, null); - assertEquals(latestVerification?.supportedPath, 'lti13LaunchAgsScore'); -}); - -Deno.test('POST /admin/verification records official certification evidence globally and keeps internal workflow evidence unchanged', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - controlPlaneDeployments: [ - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 1, - binding: buildCanvasDeploymentBinding(), - brokerVerification: null, - }), - ], - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - - const internalFormData = new FormData(); - internalFormData.set('source', 'ci'); - internalFormData.set('deploymentRecordId', '1'); - internalFormData.set('scope', 'lti13LaunchAgsNrps'); - internalFormData.set('workflowKey', 'ags'); - internalFormData.set('status', 'passed'); - internalFormData.set('summary', 'AGS certification evidence passed.'); - internalFormData.set('detailUrl', 'https://example.test/certification/ags'); - internalFormData.set('checkedAt', '2026-03-24T12:45:00Z'); - - const internalResponse = await app.request('http://localhost/admin/verification', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: internalFormData, - }); - - assertEquals(internalResponse.status, 303); - - const officialFormData = new FormData(); - officialFormData.set('source', '1edtech'); - officialFormData.set('scope', 'lti13LaunchAgsNrps'); - officialFormData.set('workflowKey', 'core'); - officialFormData.set('status', 'passed'); - officialFormData.set('certificationState', 'ltiAdvantageCertified'); - officialFormData.set('summary', '1EdTech lists Lantern as LTI Advantage Certified.'); - officialFormData.set('detailUrl', 'https://example.test/certification/1edtech'); - officialFormData.set('checkedAt', '2026-03-24T13:00:00Z'); - - const officialResponse = await app.request('http://localhost/admin/verification', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: officialFormData, - }); - - assertEquals(officialResponse.status, 303); - assertEquals(officialResponse.headers.get('location'), '/admin/verification/official'); - - const workflowStatuses = await repository.listCertificationWorkflowStatuses(); - const statusesByWorkflow = new Map( - workflowStatuses.map((status) => [status.workflowKey, status] as const), - ); - const officialEvidence = await repository.getLatestOfficialCertificationEvidence(); - - assertExists(officialEvidence); - assertEquals(officialEvidence.workflowKey, 'core'); - assertEquals(officialEvidence.state, 'ltiAdvantageCertified'); - assertEquals(officialEvidence.directoryUrl, 'https://example.test/certification/1edtech'); - assertEquals( - statusesByWorkflow.get('ags')?.latestInternal?.summary, - 'AGS certification evidence passed.', - ); - assertEquals(statusesByWorkflow.get('core')?.latestInternal, null); -}); - -Deno.test('POST /admin/verification rejects missing certification workflow identity', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - controlPlaneDeployments: [ - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 1, - binding: buildCanvasDeploymentBinding(), - }), - ], - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const formData = new FormData(); - - formData.set('source', 'manual'); - formData.set('deploymentRecordId', '1'); - formData.set('scope', 'lti13LaunchAgsNrps'); - formData.set('status', 'passed'); - formData.set('summary', 'Manual verification passed.'); - formData.set('checkedAt', '2026-03-24T12:50:00Z'); - - const response = await app.request('http://localhost/admin/verification', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }); - - assertEquals(response.status, 400); - const body = await response.text(); - - assertStringIncludes(body, 'Verification update blocked'); - assertStringIncludes(body, 'Certification workflow is required.'); - assertStringIncludes(body, 'Record one verification result'); -}); - -Deno.test('POST /admin/verification rejects relative certification evidence URLs', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - controlPlaneDeployments: [ - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 1, - binding: buildCanvasDeploymentBinding(), - }), - ], - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const formData = new FormData(); - - formData.set('source', 'manual'); - formData.set('deploymentRecordId', '1'); - formData.set('scope', 'lti13LaunchAgsNrps'); - formData.set('workflowKey', 'core'); - formData.set('status', 'passed'); - formData.set('summary', 'Manual verification passed.'); - formData.set('detailUrl', '/verification/manual-pass'); - formData.set('checkedAt', '2026-03-24T12:50:00Z'); - - const response = await app.request('http://localhost/admin/verification', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }); - - assertEquals(response.status, 400); - const body = await response.text(); - - assertStringIncludes(body, 'Verification update blocked'); - assertStringIncludes(body, 'Verification detail URL must be an absolute URL.'); - assertStringIncludes(body, 'Record one verification result'); -}); diff --git a/src/app_admin_control_plane_test.ts b/src/app_admin_control_plane_test.ts deleted file mode 100644 index 0d8623d..0000000 --- a/src/app_admin_control_plane_test.ts +++ /dev/null @@ -1,433 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import type { OpsRepository } from './ops/repository.ts'; -import { - buildBrokerVerificationStatus, - buildCertificationWorkflowStatus, - buildControlPlaneDeploymentInventoryRow, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { - buildCanvasDeploymentBinding, - buildMoodleDeploymentBinding, - buildSakaiDeploymentBinding, -} from './test_helpers/lti.ts'; -import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; - -function createFailingOpsRepository(message: string): OpsRepository { - return { - listControlPlaneDeployments() { - return Promise.reject(new Error(message)); - }, - getControlPlaneDeploymentDetail() { - return Promise.reject(new Error(message)); - }, - getLatestBrokerVerificationStatus() { - return Promise.reject(new Error(message)); - }, - listCertificationWorkflowStatuses() { - return Promise.reject(new Error(message)); - }, - getLatestOfficialCertificationEvidence() { - return Promise.reject(new Error(message)); - }, - recordBrokerVerificationRun() { - return Promise.reject(new Error(message)); - }, - getRetryableGradePublicationLookup() { - return Promise.reject(new Error(message)); - }, - getPlacementAuditSnapshot() { - return Promise.reject(new Error(message)); - }, - }; -} - -Deno.test('GET /admin/deployments renders deployment operations on a dedicated page', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - controlPlaneDeployments: [buildControlPlaneDeploymentInventoryRow({ deploymentId: 1 })], - }); - - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - }).request('http://localhost/admin/deployments'); - - assertEquals(response.status, 200); - - const body = await response.text(); - - assertStringIncludes(body, 'Connections'); - assertStringIncludes(body, 'All connections'); - assertStringIncludes(body, 'Pilot usage'); - assertStringIncludes(body, 'Next step'); - assertStringIncludes(body, 'Retry grade return'); - assertStringIncludes(body, 'Review grade problem'); - assertStringIncludes(body, 'Open settings'); - assertStringIncludes(body, 'view=activity#activity-details'); - assertEquals(body.includes('Broker verification'), false); -}); - -Deno.test('GET /admin/verification renders verification on a dedicated page', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - controlPlaneDeployments: [ - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 1, - deploymentSlug: 'chapter-4-asteroids-pilot', - deploymentLabel: 'Chapter 4 Asteroids Pilot Deployment', - binding: buildCanvasDeploymentBinding(), - brokerVerification: buildBrokerVerificationStatus({ - internal: { - source: 'manual', - status: 'passed', - checkedAt: '2026-03-24T12:50:00Z', - summary: 'Canvas deployment verification passed.', - evidenceUrl: 'https://example.test/internal-proof', - }, - }), - }), - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 2, - deploymentSlug: 'chapter-4-asteroids-moodle', - deploymentLabel: 'Chapter 4 Asteroids Moodle Deployment', - binding: buildMoodleDeploymentBinding(), - brokerVerification: buildBrokerVerificationStatus({ - supportedPath: 'lti13LaunchAgsScore', - internal: { - source: 'ci', - status: 'failed', - checkedAt: '2026-03-24T12:40:00Z', - summary: 'Moodle deployment verification failed.', - evidenceUrl: 'https://example.test/moodle-proof', - }, - }), - }), - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 3, - deploymentSlug: 'chapter-4-asteroids-sakai', - deploymentLabel: 'Chapter 4 Asteroids Sakai Deployment', - binding: buildSakaiDeploymentBinding(), - brokerVerification: null, - }), - ], - brokerVerifications: [ - buildBrokerVerificationStatus({ - supportedPath: 'lti13LaunchAgsScore', - internal: null, - official: { - state: 'notCertified', - checkedAt: '2026-03-24T12:55:00Z', - directoryUrl: 'https://example.test/official-directory', - }, - }), - ], - certificationWorkflowStatuses: [ - buildCertificationWorkflowStatus({ - workflowKey: 'core', - latestInternal: { - deploymentRecordId: 1, - deploymentLabel: 'Chapter 4 Asteroids Pilot Deployment', - status: 'passed', - checkedAt: '2026-03-24T12:50:00Z', - summary: 'Canvas deployment verification passed.', - evidenceUrl: 'https://example.test/internal-proof', - }, - }), - buildCertificationWorkflowStatus({ - workflowKey: 'deepLinking', - latestInternal: { - deploymentRecordId: 2, - deploymentLabel: 'Chapter 4 Asteroids Moodle Deployment', - status: 'failed', - checkedAt: '2026-03-24T12:40:00Z', - summary: 'Moodle deployment verification failed.', - evidenceUrl: 'https://example.test/moodle-proof', - }, - }), - buildCertificationWorkflowStatus({ workflowKey: 'nrps' }), - buildCertificationWorkflowStatus({ workflowKey: 'ags' }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-24T13:00:00Z', - }, - }); - - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - }).request('http://localhost/admin/verification'); - - assertEquals(response.status, 200); - - const body = await response.text(); - - assertStringIncludes(body, 'Verification'); - assertStringIncludes(body, 'Verification overview'); - assertStringIncludes(body, 'Certification checklist'); - assertStringIncludes(body, 'Chapter 4 Asteroids Pilot Deployment'); - assertStringIncludes(body, 'Chapter 4 Asteroids Moodle Deployment'); - assertStringIncludes(body, 'href="/admin/verification/new"'); - assertStringIncludes(body, 'href="/admin/verification/official"'); - assertStringIncludes(body, 'href="/admin/verification/lti-profile"'); - assertStringIncludes(body, 'class="sidebar-sublink active" href="/admin/verification"'); - assertStringIncludes(body, 'Certification'); - assertEquals(body.includes('One row per LMS connection'), false); - assertEquals(body.includes('Supported Canvas path'), false); - assertEquals(body.includes('Official 1EdTech listing'), false); - assertEquals(body.includes('action="/admin/verification"'), false); - assertEquals(body.includes('action="/admin/verification/lti-profile"'), false); -}); - -Deno.test('GET /admin/verification/official renders official evidence on its own page', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - controlPlaneDeployments: [buildControlPlaneDeploymentInventoryRow({ deploymentId: 1 })], - brokerVerifications: [ - buildBrokerVerificationStatus({ - supportedPath: 'lti13LaunchAgsScore', - internal: null, - official: { - state: 'ltiAdvantageCertified', - checkedAt: '2026-03-24T12:55:00Z', - directoryUrl: 'https://example.test/official-directory', - }, - }), - ], - }); - - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - }).request('http://localhost/admin/verification/official'); - - assertEquals(response.status, 200); - - const body = await response.text(); - - assertStringIncludes(body, 'Official 1EdTech listing'); - assertStringIncludes(body, 'Claim boundary'); - assertStringIncludes(body, 'Open directory entry'); - assertStringIncludes(body, 'class="sidebar-sublink active" href="/admin/verification/official"'); - assertEquals(body.includes('Certification checklist'), false); -}); - -Deno.test('GET /admin/verification/new renders the dedicated verification entry page', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [buildPackageVersionRecord({ id: 1, approvalStatus: 'approved' })], - controlPlaneDeployments: [buildControlPlaneDeploymentInventoryRow({ deploymentId: 1 })], - }); - - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - }).request('http://localhost/admin/verification/new'); - - assertEquals(response.status, 200); - - const body = await response.text(); - - assertStringIncludes(body, 'Record one verification result'); - assertStringIncludes(body, 'action="/admin/verification"'); - assertStringIncludes(body, 'name="deploymentRecordId"'); - assertStringIncludes(body, 'name="workflowKey"'); - assertStringIncludes(body, 'name="scope"'); - assertEquals(body.includes('Certification checklist'), false); -}); - -Deno.test('GET /admin/verification/lti-profile renders the dedicated Lantern default page', async () => { - const repository = createInMemoryPackageReviewRepository({ - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-24T13:00:00Z', - }, - }); - - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - }).request('http://localhost/admin/verification/lti-profile'); - - assertEquals(response.status, 200); - - const body = await response.text(); - - assertStringIncludes(body, 'Lantern default profile'); - assertStringIncludes(body, 'action="/admin/verification/lti-profile"'); - assertStringIncludes(body, 'name="defaultLtiProfile"'); - assertEquals(body.includes('Certification checklist'), false); -}); - -Deno.test('GET /admin/deployments keeps route failures inside the admin shell', async () => { - const app = createAuthenticatedTestApp({ - getOpsRepository: () => createFailingOpsRepository('Connections inventory is unavailable.'), - }); - - const response = await app.request('http://localhost/admin/deployments'); - - assertEquals(response.status, 500); - const body = await response.text(); - - assertStringIncludes(body, 'Connections unavailable'); - assertStringIncludes(body, 'Connections inventory is unavailable.'); - assertStringIncludes(body, 'href="/admin/packages"'); - assertStringIncludes(body, 'href="/admin/deployments"'); -}); - -Deno.test('GET /admin/verification keeps route failures inside the admin shell', async () => { - const app = createAuthenticatedTestApp({ - getOpsRepository: () => createFailingOpsRepository('Verification history is unavailable.'), - }); - - const response = await app.request('http://localhost/admin/verification'); - - assertEquals(response.status, 500); - const body = await response.text(); - - assertStringIncludes(body, 'Verification unavailable'); - assertStringIncludes(body, 'Verification history is unavailable.'); - assertStringIncludes(body, 'href="/admin/packages"'); - assertStringIncludes(body, 'href="/admin/verification"'); -}); - -Deno.test('POST /admin/verification records deployment-scoped broker verification evidence and redirects back to verification', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - controlPlaneDeployments: [ - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 1, - deploymentSlug: 'chapter-4-asteroids-pilot', - deploymentLabel: 'Chapter 4 Asteroids Pilot Deployment', - binding: buildCanvasDeploymentBinding(), - brokerVerification: null, - }), - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 2, - deploymentSlug: 'chapter-4-asteroids-moodle', - deploymentLabel: 'Chapter 4 Asteroids Moodle Deployment', - binding: buildMoodleDeploymentBinding(), - }), - ], - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const formData = new FormData(); - - formData.set('source', 'manual'); - formData.set('deploymentRecordId', '2'); - formData.set('scope', 'lti13LaunchAgsScore'); - formData.set('workflowKey', 'ags'); - formData.set('status', 'passed'); - formData.set('summary', 'Manual verification passed for the saved Moodle deployment.'); - formData.set('detailUrl', 'https://example.test/verification/manual-pass'); - formData.set('checkedAt', '2026-03-24T12:50:00Z'); - - const response = await app.request('http://localhost/admin/verification', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }); - - assertEquals(response.status, 303); - assertEquals(response.headers.get('location'), '/admin/verification'); - - const latestVerification = await repository.getLatestBrokerVerificationStatus(); - const deployments = await repository.listControlPlaneDeployments(); - const moodleVerification = deployments.find( - (deployment) => deployment.deploymentId === 2, - )?.brokerVerification; - const canvasVerification = deployments.find( - (deployment) => deployment.deploymentId === 1, - )?.brokerVerification; - - assertEquals(latestVerification?.supportedPath, 'lti13LaunchAgsScore'); - assertEquals(latestVerification?.internal?.source, 'manual'); - assertEquals(latestVerification?.internal?.status, 'passed'); - assertEquals( - latestVerification?.internal?.summary, - 'Manual verification passed for the saved Moodle deployment.', - ); - assertEquals( - latestVerification?.internal?.evidenceUrl, - 'https://example.test/verification/manual-pass', - ); - assertEquals(moodleVerification?.internal?.status, 'passed'); - assertEquals( - moodleVerification?.internal?.summary, - 'Manual verification passed for the saved Moodle deployment.', - ); - assertEquals(canvasVerification?.internal?.summary ?? null, null); - assertEquals(latestVerification?.official.state, 'notCertified'); -}); - -Deno.test('POST /admin/verification/lti-profile saves the Lantern-wide default profile and redirects back to the profile page', async () => { - const repository = createInMemoryPackageReviewRepository(); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const formData = new FormData(); - - formData.set('defaultLtiProfile', 'certification'); - - const response = await app.request('http://localhost/admin/verification/lti-profile', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }); - - assertEquals(response.status, 303); - assertEquals(response.headers.get('location'), '/admin/verification/lti-profile'); - assertEquals( - (await repository.getLanternLtiProfileSettings()).defaultLtiProfile, - 'certification', - ); -}); - -Deno.test('POST /admin/verification/lti-profile rejects unsupported profile ids on the profile page', async () => { - const repository = createInMemoryPackageReviewRepository(); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const formData = new FormData(); - - formData.set('defaultLtiProfile', 'too-loose'); - - const response = await app.request('http://localhost/admin/verification/lti-profile', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }); - - assertEquals(response.status, 400); - const body = await response.text(); - - assertStringIncludes(body, 'Lantern default blocked'); - assertStringIncludes(body, 'Choose one supported LTI profile.'); - assertStringIncludes(body, 'Lantern default profile'); - assertEquals( - (await repository.getLanternLtiProfileSettings()).defaultLtiProfile, - 'governedCompatibility', - ); -}); diff --git a/src/app_admin_deployment_detail_form_routes.ts b/src/app_admin_deployment_detail_form_routes.ts deleted file mode 100644 index 95082b3..0000000 --- a/src/app_admin_deployment_detail_form_routes.ts +++ /dev/null @@ -1,267 +0,0 @@ -import type { Hono } from '@hono/hono'; -import { getManagedDeploymentSlot } from './admin/deployment_detail.ts'; -import { renderPackageIndexPage } from './admin/package_index.ts'; -import { loadDeploymentDetailState } from './app_deployment_support.ts'; -import { - buildBindingAuditDetail, - buildDeploymentBindingFromFormData, - canPinDeploymentVersion, - formatLmsLabel, - parseManagedDeploymentLms, - parseOptionalManagedDeploymentLms, - parseRequiredPackageVersionId, -} from './app_admin_deployment_detail_route_support.ts'; -import { - buildInstallEditorState, - buildPinEditorState, -} from './app_admin_deployment_editor_state.ts'; -import { parseDeploymentLtiProfileOverrideForm } from './app_request_support.ts'; -import type { LmsType } from './lti/types.ts'; -import type { AppServices } from './app_services.ts'; -import { adminPageInput } from './admin_auth/page_input.ts'; -import { readEnv } from './platform/env.ts'; -import { resolveConfiguredPublicOrigin } from './public_origin.ts'; -import { syncLtiToolDeploymentBinding } from './lti/tool_provider.ts'; - -export function registerAdminDeploymentFormRoutes(app: Hono, services: AppServices): void { - app.post('/admin/packages/:appId/deployment/pin', async (context) => { - const appId = context.req.param('appId'); - let lms: LmsType | null = null; - let formData: FormData | null = null; - - try { - const repository = services.getRepository(); - const appOrigin = resolveConfiguredPublicOrigin({ - requestUrl: context.req.url, - forwardedHeader: context.req.header('forwarded') ?? null, - xForwardedHost: context.req.header('x-forwarded-host') ?? null, - xForwardedProto: context.req.header('x-forwarded-proto') ?? null, - configuredOrigin: readEnv('APP_ORIGIN', services.env), - }); - formData = await context.req.formData(); - lms = parseManagedDeploymentLms(formData); - const history = await repository.listPackageVersionsByApp(appId); - - if (history.length === 0) { - return context.html( - renderPackageIndexPage(adminPageInput(context, { - versions: [], - notice: { - tone: 'error', - title: `${formatLmsLabel(lms)} version picker unavailable`, - detail: `Import the app package before you attempt to save the ${ - formatLmsLabel( - lms, - ) - } deployment pin.`, - }, - })), - 404, - ); - } - - const detail = await loadDeploymentDetailState(repository, appId, appOrigin); - const slot = getManagedDeploymentSlot(detail.slots, lms); - - if (!canPinDeploymentVersion(slot, lms)) { - throw new Error(`Save the ${formatLmsLabel(lms)} binding before you pin a version.`); - } - - const selectedId = parseRequiredPackageVersionId(formData); - - const deployment = await repository.pinDeploymentVersion({ - slug: slot.deployment.slug, - label: slot.deployment.label, - appId, - lmsType: lms, - packageVersionId: selectedId, - }); - await repository.recordAuditEvent({ - eventType: 'deployment.version_pinned', - actorType: 'user', - actorId: null, - deploymentRecordId: deployment.id, - packageVersionId: deployment.enabledPackageVersionId, - attemptId: null, - lineItemBindingId: null, - status: 'succeeded', - summary: `Pinned an exact reviewed package version for the ${ - formatLmsLabel( - lms, - ) - } deployment.`, - detail: { - lms, - deploymentSlug: deployment.slug, - packageVersionId: deployment.enabledPackageVersionId, - packageVersion: deployment.enabledPackageVersion, - }, - occurredAt: new Date().toISOString(), - }); - - return context.redirect(`/admin/packages/${appId}/deployment?lms=${lms}#slot-panel`, 303); - } catch (error) { - return await import('./app_admin_support.ts').then(({ renderDeploymentError }) => - renderDeploymentError( - context, - services, - appId, - lms === null ? 'Version pin blocked' : `${formatLmsLabel(lms)} version pin blocked`, - error, - { - selectedLms: lms, - editorState: buildPinEditorState( - lms, - formData, - lms === null ? 'Version pin blocked' : `${formatLmsLabel(lms)} version pin blocked`, - error, - ), - }, - ) - ); - } - }); - - app.post('/admin/packages/:appId/deployment/install', async (context) => { - const appId = context.req.param('appId'); - let lms: LmsType | null = null; - let formData: FormData | null = null; - - try { - const repository = services.getRepository(); - const appOrigin = resolveConfiguredPublicOrigin({ - requestUrl: context.req.url, - forwardedHeader: context.req.header('forwarded') ?? null, - xForwardedHost: context.req.header('x-forwarded-host') ?? null, - xForwardedProto: context.req.header('x-forwarded-proto') ?? null, - configuredOrigin: readEnv('APP_ORIGIN', services.env), - }); - formData = await context.req.formData(); - lms = parseManagedDeploymentLms(formData); - const history = await repository.listPackageVersionsByApp(appId); - - if (history.length === 0) { - return context.html( - renderPackageIndexPage(adminPageInput(context, { - versions: [], - notice: { - tone: 'error', - title: `${formatLmsLabel(lms)} install unavailable`, - detail: `Import the app package before you attempt to save the ${ - formatLmsLabel( - lms, - ) - } binding.`, - }, - })), - 404, - ); - } - - const detail = await loadDeploymentDetailState(repository, appId, appOrigin); - const slot = getManagedDeploymentSlot(detail.slots, lms); - const binding = buildDeploymentBindingFromFormData(lms, formData); - - const deployment = await repository.saveDeploymentBinding({ - slug: slot.deployment.slug, - label: slot.deployment.label, - appId, - binding, - }); - await syncLtiToolDeploymentBinding({ - storage: services.getLtiToolStorage(), - appTitle: detail.appTitle, - deploymentSlug: deployment.slug, - binding, - }); - await repository.recordAuditEvent({ - eventType: 'deployment.binding_saved', - actorType: 'user', - actorId: null, - deploymentRecordId: deployment.id, - packageVersionId: deployment.enabledPackageVersionId, - attemptId: null, - lineItemBindingId: null, - status: 'succeeded', - summary: `Saved the ${formatLmsLabel(lms)} deployment binding.`, - detail: { - deploymentSlug: deployment.slug, - ...buildBindingAuditDetail(binding), - }, - occurredAt: new Date().toISOString(), - }); - - return context.redirect(`/admin/packages/${appId}/deployment?lms=${lms}#slot-panel`, 303); - } catch (error) { - return await import('./app_admin_support.ts').then(({ renderDeploymentError }) => - renderDeploymentError( - context, - services, - appId, - lms === null ? 'Deployment install blocked' : `${formatLmsLabel(lms)} install blocked`, - error, - { - selectedLms: lms, - editorState: buildInstallEditorState( - lms, - formData, - lms === null - ? 'Deployment install blocked' - : `${formatLmsLabel(lms)} install blocked`, - error, - ), - }, - ) - ); - } - }); - - app.post('/admin/packages/:appId/deployment/lti-profile', async (context) => { - const appId = context.req.param('appId'); - let lms: LmsType | null = null; - - try { - const repository = services.getRepository(); - const appOrigin = resolveConfiguredPublicOrigin({ - requestUrl: context.req.url, - forwardedHeader: context.req.header('forwarded') ?? null, - xForwardedHost: context.req.header('x-forwarded-host') ?? null, - xForwardedProto: context.req.header('x-forwarded-proto') ?? null, - configuredOrigin: readEnv('APP_ORIGIN', services.env), - }); - const formData = await context.req.formData(); - lms = parseManagedDeploymentLms(formData); - const detail = await loadDeploymentDetailState(repository, appId, appOrigin); - const slot = getManagedDeploymentSlot(detail.slots, lms); - - if (!slot.persisted) { - throw new Error( - `Save the ${formatLmsLabel(lms)} binding before you choose an LTI profile.`, - ); - } - - await repository.saveDeploymentLtiProfileOverride({ - deploymentId: slot.deployment.id, - ltiProfileOverride: parseDeploymentLtiProfileOverrideForm(formData), - }); - - return context.redirect(`/admin/packages/${appId}/deployment?lms=${lms}#slot-panel`, 303); - } catch (error) { - return await import('./app_admin_support.ts').then(({ renderDeploymentError }) => - renderDeploymentError( - context, - services, - appId, - lms === null - ? 'LTI profile update blocked' - : `${formatLmsLabel(lms)} LTI profile update blocked`, - error, - { - selectedLms: lms ?? - parseOptionalManagedDeploymentLms(new URL(context.req.url).searchParams.get('lms')), - }, - ) - ); - } - }); -} diff --git a/src/app_admin_deployment_detail_registration_routes.ts b/src/app_admin_deployment_detail_registration_routes.ts deleted file mode 100644 index a1c1197..0000000 --- a/src/app_admin_deployment_detail_registration_routes.ts +++ /dev/null @@ -1,311 +0,0 @@ -import type { Hono } from '@hono/hono'; -import { getManagedDeploymentSlot } from './admin/deployment_detail.ts'; -import { renderDynamicRegistrationStatusPage } from './app_dynamic_registration_views.ts'; -import { loadDeploymentDetailState } from './app_deployment_support.ts'; -import { - buildBindingAuditDetail, - normalizeOptionalQueryValue, - requireTrimmedQueryValue, -} from './app_admin_deployment_detail_route_support.ts'; -import { consumeDynamicRegistrationState } from './lti/dynamic_registration_state.ts'; -import { completeLanternDynamicRegistrationWithLtiTool } from './lti/dynamic_registration_tool.ts'; -import { errorMessage, statusForError } from './app_status_support.ts'; -import { resolveRequestAppOrigin } from './app_origin.ts'; -import type { AppServices } from './app_services.ts'; -import type { DeploymentBinding } from './lti/types.ts'; - -export function registerAdminDeploymentDynamicRegistrationRoutes( - app: Hono, - services: AppServices, -): void { - app.get('/admin/packages/:appId/deployment/register/canvas', async (context) => { - const appId = context.req.param('appId'); - - try { - const repository = services.getRepository(); - const appOrigin = resolveRequestAppOrigin(context, services.env); - const detail = await loadDeploymentDetailState(repository, appId, appOrigin); - const searchParams = new URL(context.req.url).searchParams; - const openidConfigurationUrl = requireTrimmedQueryValue( - searchParams.get('openid_configuration'), - 'Canvas openid_configuration is required.', - ); - const registrationToken = requireTrimmedQueryValue( - searchParams.get('registration_token'), - 'Canvas registration_token is required.', - ); - await consumeDynamicRegistrationState({ - repository, - state: requireTrimmedQueryValue( - searchParams.get('state'), - 'Dynamic registration state is required. Start again from Lantern app settings.', - ), - appId, - lms: 'canvas', - }); - const slot = getManagedDeploymentSlot(detail.slots, 'canvas'); - const registration = await completeLanternDynamicRegistrationWithLtiTool({ - ltiContext: await services.getLtiToolContext(appOrigin), - appId, - lms: 'canvas', - requestPath: new URL(context.req.url).pathname, - openidConfigurationUrl, - registrationToken, - }); - const deployment = await repository.saveCanvasRegistration({ - slug: slot.deployment.slug, - label: slot.deployment.label, - appId, - canvasEnvironment: registration.canvasEnvironment ?? 'production', - issuer: registration.launchConfig.iss, - clientId: registration.launchConfig.clientId, - }); - - await repository.recordAuditEvent({ - eventType: 'deployment.binding_saved', - actorType: 'user', - actorId: null, - deploymentRecordId: deployment.id, - packageVersionId: deployment.enabledPackageVersionId, - attemptId: null, - lineItemBindingId: null, - status: 'succeeded', - summary: - 'Saved the Canvas setup and queued the exact deployment binding for the first launch.', - detail: { - lms: 'canvas', - deploymentSlug: deployment.slug, - registrationMode: 'dynamic', - canvasEnvironment: registration.canvasEnvironment, - issuer: registration.launchConfig.iss, - clientId: registration.launchConfig.clientId, - deploymentId: null, - }, - occurredAt: new Date().toISOString(), - }); - - return context.html( - renderDynamicRegistrationStatusPage({ - tone: 'success', - title: 'Canvas setup saved', - detail: - 'Lantern saved the Canvas environment and Client ID. Finish the tool enablement in Canvas, then launch it once so Lantern can capture the exact deployment ID automatically.', - closeLabel: 'Close and return to Canvas', - returnUrl: `/admin/packages/${appId}/deployment?lms=canvas®istered=canvas#slot-panel`, - returnLabel: 'Open Lantern deployment detail', - }), - ); - } catch (error) { - return context.html( - renderDynamicRegistrationStatusPage({ - tone: 'error', - title: 'Canvas setup blocked', - detail: errorMessage(error), - closeLabel: 'Close and return to Canvas', - returnUrl: `/admin/packages/${appId}/deployment?lms=canvas#slot-panel`, - returnLabel: 'Open Lantern deployment detail', - }), - statusForError(error), - ); - } - }); - - app.get('/admin/packages/:appId/deployment/register/moodle', async (context) => { - const appId = context.req.param('appId'); - - try { - const repository = services.getRepository(); - const appOrigin = resolveRequestAppOrigin(context, services.env); - const detail = await loadDeploymentDetailState(repository, appId, appOrigin); - const searchParams = new URL(context.req.url).searchParams; - const openidConfigurationUrl = requireTrimmedQueryValue( - searchParams.get('openid_configuration'), - 'Moodle openid_configuration is required.', - ); - const registrationToken = normalizeOptionalQueryValue(searchParams.get('registration_token')); - await consumeDynamicRegistrationState({ - repository, - state: requireTrimmedQueryValue( - searchParams.get('state'), - 'Dynamic registration state is required. Start again from Lantern app settings.', - ), - appId, - lms: 'moodle', - }); - const slot = getManagedDeploymentSlot(detail.slots, 'moodle'); - const registration = await completeLanternDynamicRegistrationWithLtiTool({ - ltiContext: await services.getLtiToolContext(appOrigin), - appId, - lms: 'moodle', - requestPath: new URL(context.req.url).pathname, - openidConfigurationUrl, - registrationToken, - }); - const binding = requireMoodleDynamicRegistrationBinding(registration.binding); - const deployment = await repository.saveDeploymentBinding({ - slug: slot.deployment.slug, - label: slot.deployment.label, - appId, - binding, - }); - - await repository.recordAuditEvent({ - eventType: 'deployment.binding_saved', - actorType: 'user', - actorId: null, - deploymentRecordId: deployment.id, - packageVersionId: deployment.enabledPackageVersionId, - attemptId: null, - lineItemBindingId: null, - status: 'succeeded', - summary: 'Saved the Moodle connection through the setup link.', - detail: { - deploymentSlug: deployment.slug, - registrationMode: 'dynamic', - ...buildBindingAuditDetail(binding), - }, - occurredAt: new Date().toISOString(), - }); - - return context.html( - renderDynamicRegistrationStatusPage({ - tone: 'success', - title: 'Moodle connection saved', - detail: 'Lantern finished Moodle setup and saved the exact Moodle connection.', - closeLabel: 'Close and return to Moodle', - returnUrl: `/admin/packages/${appId}/deployment?lms=moodle#slot-panel`, - returnLabel: 'Open Lantern deployment detail', - }), - ); - } catch (error) { - return context.html( - renderDynamicRegistrationStatusPage({ - tone: 'error', - title: 'Moodle setup blocked', - detail: errorMessage(error), - closeLabel: 'Close and return to Moodle', - returnUrl: `/admin/packages/${appId}/deployment?lms=moodle#slot-panel`, - returnLabel: 'Open Lantern deployment detail', - }), - statusForError(error), - ); - } - }); - - app.get('/admin/packages/:appId/deployment/register/sakai', async (context) => { - const appId = context.req.param('appId'); - const searchParams = new URL(context.req.url).searchParams; - const openidConfigurationUrl = normalizeOptionalQueryValue( - searchParams.get('openid_configuration'), - ); - const registrationToken = normalizeOptionalQueryValue(searchParams.get('registration_token')); - - if (openidConfigurationUrl === null || registrationToken === null) { - return context.html( - renderDynamicRegistrationStatusPage({ - tone: 'error', - title: 'Open this from Sakai', - detail: - "This is Lantern's Sakai Dynamic Registration URL. Paste it into Sakai's LTI Dynamic Registration flow instead of opening it directly in a browser.", - closeLabel: 'Close', - returnUrl: `/admin/packages/${appId}/deployment?lms=sakai#slot-panel`, - returnLabel: 'Open Lantern app settings', - }), - 400, - ); - } - - try { - const repository = services.getRepository(); - const appOrigin = resolveRequestAppOrigin(context, services.env); - const detail = await loadDeploymentDetailState(repository, appId, appOrigin); - await consumeDynamicRegistrationState({ - repository, - state: requireTrimmedQueryValue( - searchParams.get('state'), - 'Dynamic registration state is required. Start again from Lantern app settings.', - ), - appId, - lms: 'sakai', - }); - const slot = getManagedDeploymentSlot(detail.slots, 'sakai'); - const registration = await completeLanternDynamicRegistrationWithLtiTool({ - ltiContext: await services.getLtiToolContext(appOrigin), - appId, - lms: 'sakai', - requestPath: new URL(context.req.url).pathname, - openidConfigurationUrl, - registrationToken, - }); - const binding = requireSakaiDynamicRegistrationBinding(registration.binding); - const deployment = await repository.saveDeploymentBinding({ - slug: slot.deployment.slug, - label: slot.deployment.label, - appId, - binding, - }); - - await repository.recordAuditEvent({ - eventType: 'deployment.binding_saved', - actorType: 'user', - actorId: null, - deploymentRecordId: deployment.id, - packageVersionId: deployment.enabledPackageVersionId, - attemptId: null, - lineItemBindingId: null, - status: 'succeeded', - summary: 'Saved the Sakai connection through the setup link.', - detail: { - deploymentSlug: deployment.slug, - registrationMode: 'dynamic', - ...buildBindingAuditDetail(binding), - }, - occurredAt: new Date().toISOString(), - }); - - return context.html( - renderDynamicRegistrationStatusPage({ - tone: 'success', - title: 'Sakai connection saved', - detail: - 'Lantern finished Sakai setup and saved the exact Sakai connection. Continue in Sakai to review and save the tool.', - closeLabel: 'Continue in Sakai', - returnUrl: `/admin/packages/${appId}/deployment?lms=sakai®istered=sakai#slot-panel`, - returnLabel: 'Open Lantern app settings', - }), - ); - } catch (error) { - return context.html( - renderDynamicRegistrationStatusPage({ - tone: 'error', - title: 'Sakai setup blocked', - detail: errorMessage(error), - closeLabel: 'Close and return to Sakai', - returnUrl: `/admin/packages/${appId}/deployment?lms=sakai#slot-panel`, - returnLabel: 'Open Lantern app settings', - }), - statusForError(error), - ); - } - }); -} - -function requireMoodleDynamicRegistrationBinding( - binding: DeploymentBinding | null, -): Extract { - if (binding?.lms !== 'moodle') { - throw new Error('lti-tool dynamic registration did not return a Moodle binding.'); - } - - return binding; -} - -function requireSakaiDynamicRegistrationBinding( - binding: DeploymentBinding | null, -): Extract { - if (binding?.lms !== 'sakai') { - throw new Error('lti-tool dynamic registration did not return a Sakai binding.'); - } - - return binding; -} diff --git a/src/app_admin_deployment_detail_route_support.ts b/src/app_admin_deployment_detail_route_support.ts deleted file mode 100644 index d73cf8f..0000000 --- a/src/app_admin_deployment_detail_route_support.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { getManagedDeploymentSlot } from './admin/deployment_detail.ts'; -import type { AdminNotice } from './admin/layout.ts'; -import { parseCanvasEnvironment, resolveCanvasIssuer } from './lti/config.ts'; -import type { DeploymentBinding, LmsType } from './lti/types.ts'; -import { requireTrimmedFormValue } from './app_request_support.ts'; - -export function parseRequiredPackageVersionId(formData: FormData): number { - const rawValue = requireTrimmedFormValue( - formData.get('packageVersionId'), - 'Choose an approved version.', - ); - const value = Number(rawValue); - - if (!Number.isInteger(value) || value <= 0) { - throw new Error('Choose an approved version.'); - } - - return value; -} - -export function buildDeploymentDetailNotice( - canvasNotice: AdminNotice | null, - registered: string | null, -) { - switch (registered) { - case 'canvas': - return { - tone: 'success' as const, - title: 'Canvas setup saved', - detail: - 'Lantern saved the Canvas environment and Client ID. Finish one real Canvas launch to capture the exact deployment ID automatically.', - }; - case 'sakai': - return { - tone: 'success' as const, - title: 'Sakai connection saved', - detail: 'Lantern finished Sakai setup and saved the exact Sakai connection.', - }; - default: - return canvasNotice; - } -} - -export function requireTrimmedQueryValue(value: string | null, message: string): string { - if (value === null) { - throw new Error(message); - } - - const trimmed = value.trim(); - - if (trimmed === '') { - throw new Error(message); - } - - return trimmed; -} - -export function normalizeOptionalQueryValue(value: string | null): string | null { - if (value === null) { - return null; - } - - const trimmed = value.trim(); - return trimmed === '' ? null : trimmed; -} - -export function canPinDeploymentVersion( - slot: ReturnType, - lms: LmsType, -): boolean { - return slot.deployment.binding?.lms === lms || (lms === 'canvas' && slot.persisted); -} - -export function parseOptionalManagedDeploymentLms(value: string | null): LmsType | null { - switch (value) { - case 'canvas': - case 'moodle': - case 'sakai': - return value; - default: - return null; - } -} - -export function parseManagedDeploymentLms(formData: FormData): LmsType { - const value = requireTrimmedFormValue(formData.get('lms'), 'LMS is required.'); - - switch (value) { - case 'canvas': - case 'moodle': - case 'sakai': - return value; - default: - throw new Error('Choose one supported LMS deployment.'); - } -} - -export function buildDeploymentBindingFromFormData( - lms: LmsType, - formData: FormData, -): DeploymentBinding { - switch (lms) { - case 'canvas': { - const canvasEnvironment = parseCanvasEnvironment(formData.get('canvasEnvironment')); - - return { - lms: 'canvas', - canvasEnvironment, - issuer: resolveCanvasIssuer(canvasEnvironment), - clientId: requireTrimmedFormValue( - formData.get('clientId'), - 'Canvas Client ID is required.', - ), - deploymentId: requireTrimmedFormValue( - formData.get('deploymentId'), - 'Canvas Deployment ID is required.', - ), - }; - } - case 'moodle': - return { - lms: 'moodle', - issuer: requireTrimmedFormValue(formData.get('issuer'), 'Moodle Platform ID is required.'), - clientId: requireTrimmedFormValue( - formData.get('clientId'), - 'Moodle Client ID is required.', - ), - deploymentId: requireTrimmedFormValue( - formData.get('deploymentId'), - 'Moodle Deployment ID is required.', - ), - authorizationEndpoint: requireTrimmedFormValue( - formData.get('authorizationEndpoint'), - 'Moodle Authorization endpoint is required.', - ), - accessTokenUrl: requireTrimmedFormValue( - formData.get('accessTokenUrl'), - 'Moodle Access token URL is required.', - ), - jwksUrl: requireTrimmedFormValue( - formData.get('jwksUrl'), - 'Moodle Public keyset URL is required.', - ), - }; - case 'sakai': - return { - lms: 'sakai', - issuer: requireTrimmedFormValue(formData.get('issuer'), 'Sakai Platform ID is required.'), - clientId: requireTrimmedFormValue(formData.get('clientId'), 'Sakai Client ID is required.'), - deploymentId: requireTrimmedFormValue( - formData.get('deploymentId'), - 'Sakai Deployment ID is required.', - ), - authorizationEndpoint: requireTrimmedFormValue( - formData.get('authorizationEndpoint'), - 'Sakai Authorization endpoint is required.', - ), - accessTokenUrl: requireTrimmedFormValue( - formData.get('accessTokenUrl'), - 'Sakai Access token URL is required.', - ), - jwksUrl: requireTrimmedFormValue( - formData.get('jwksUrl'), - 'Sakai Public keyset URL is required.', - ), - }; - } -} - -export function buildBindingAuditDetail(binding: DeploymentBinding): Record { - switch (binding.lms) { - case 'canvas': - return { - lms: binding.lms, - canvasEnvironment: binding.canvasEnvironment, - issuer: binding.issuer, - clientId: binding.clientId, - deploymentId: binding.deploymentId, - }; - case 'moodle': - case 'sakai': - return { - lms: binding.lms, - issuer: binding.issuer, - clientId: binding.clientId, - deploymentId: binding.deploymentId, - authorizationEndpoint: binding.authorizationEndpoint, - accessTokenUrl: binding.accessTokenUrl, - jwksUrl: binding.jwksUrl, - }; - } -} - -export function formatLmsLabel(lms: LmsType): string { - switch (lms) { - case 'canvas': - return 'Canvas'; - case 'moodle': - return 'Moodle'; - case 'sakai': - return 'Sakai'; - } -} diff --git a/src/app_admin_deployment_detail_routes.ts b/src/app_admin_deployment_detail_routes.ts deleted file mode 100644 index 775dd69..0000000 --- a/src/app_admin_deployment_detail_routes.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { Hono } from '@hono/hono'; -import { - getSelectedManagedDeploymentSlot, - renderDeploymentDetailPage, -} from './admin/deployment_detail.ts'; -import { renderPackageIndexPage } from './admin/package_index.ts'; -import { loadDeploymentDetailState } from './app_deployment_support.ts'; -import { registerAdminDeploymentDynamicRegistrationRoutes } from './app_admin_deployment_detail_registration_routes.ts'; -import { registerAdminDeploymentFormRoutes } from './app_admin_deployment_detail_form_routes.ts'; -import { - buildDeploymentDetailNotice, - parseOptionalManagedDeploymentLms, -} from './app_admin_deployment_detail_route_support.ts'; -import { createErrorNotice } from './app_notice_support.ts'; -import { listCanvasEnvironments } from './lti/config.ts'; -import type { AppServices } from './app_services.ts'; -import { adminPageInput } from './admin_auth/page_input.ts'; -import { errorMessage, statusForError } from './app_status_support.ts'; -import { readEnv } from './platform/env.ts'; -import { resolveConfiguredPublicOrigin } from './public_origin.ts'; - -export function registerAdminDeploymentDetailRoutes(app: Hono, services: AppServices): void { - app.get('/admin/packages/:appId/deployment/evidence/:artifactId', async (context) => { - try { - const repository = services.getRepository(); - const appId = context.req.param('appId'); - const artifactId = context.req.param('artifactId'); - const artifact = await repository.getAttemptEvidenceArtifactById(artifactId); - - if (!artifact) { - return context.text('Evidence artifact was not found.', 404); - } - - const attempt = await repository.getAttemptById(artifact.attemptId); - - if (!attempt || attempt.appId !== appId) { - return context.text('Evidence artifact was not found.', 404); - } - - const bytes = await services.evidenceArtifactStore.readBytes(artifact.storageKey); - const responseBody = new Uint8Array(bytes.byteLength); - - responseBody.set(bytes); - - return new Response(new Blob([responseBody], { type: artifact.contentType }), { - status: 200, - headers: { - 'content-type': artifact.contentType, - 'content-length': String(responseBody.byteLength), - 'content-disposition': `inline; filename="${artifact.fileName.replaceAll('"', '')}"`, - }, - }); - } catch (error) { - return context.text(errorMessage(error), 500); - } - }); - - app.get('/admin/packages/:appId/deployment', async (context) => { - try { - const url = new URL(context.req.url); - const selectedLms = parseOptionalManagedDeploymentLms(url.searchParams.get('lms')); - const openOperationalEvidence = url.searchParams.get('view') === 'activity'; - const repository = services.getRepository(); - const appOrigin = resolveConfiguredPublicOrigin({ - requestUrl: context.req.url, - forwardedHeader: context.req.header('forwarded') ?? null, - xForwardedHost: context.req.header('x-forwarded-host') ?? null, - xForwardedProto: context.req.header('x-forwarded-proto') ?? null, - configuredOrigin: readEnv('APP_ORIGIN', services.env), - }); - const detail = await loadDeploymentDetailState( - repository, - context.req.param('appId'), - appOrigin, - ); - const viewedSlot = getSelectedManagedDeploymentSlot(detail.slots, selectedLms); - const controlPlaneDetail = viewedSlot.persisted - ? await services - .getOpsRepository() - .getControlPlaneDeploymentDetail(viewedSlot.deployment.id) - : null; - - return context.html( - renderDeploymentDetailPage(adminPageInput(context, { - appId: context.req.param('appId'), - appTitle: detail.appTitle, - history: detail.history, - deployments: detail.deployments, - selectedLms, - openOperationalEvidence, - nrpsVerification: detail.nrpsVerification, - lanternLtiProfileSettings: detail.ltiProfileSettings, - controlPlaneDetail, - canvasConfigUrl: detail.canvasConfigUrl.url, - canvasDynamicRegistrationUrl: detail.canvasDynamicRegistrationUrl, - moodleDynamicRegistrationUrl: detail.moodleDynamicRegistrationUrl, - sakaiDynamicRegistrationUrl: detail.sakaiDynamicRegistrationUrl, - supportedCanvasEnvironments: listCanvasEnvironments(), - notice: buildDeploymentDetailNotice( - detail.canvasConfigUrl.notice, - url.searchParams.get('registered'), - ), - })), - ); - } catch (error) { - return context.html( - renderPackageIndexPage(adminPageInput(context, { - versions: [], - notice: createErrorNotice('Connections page unavailable', error), - })), - statusForError(error), - ); - } - }); - - registerAdminDeploymentDynamicRegistrationRoutes(app, services); - registerAdminDeploymentFormRoutes(app, services); -} diff --git a/src/app_admin_deployment_editor_state.ts b/src/app_admin_deployment_editor_state.ts deleted file mode 100644 index 3d28ba8..0000000 --- a/src/app_admin_deployment_editor_state.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { DeploymentEditorField, DeploymentEditorState } from './admin/deployment_detail.ts'; -import { createErrorNotice } from './app_notice_support.ts'; -import type { LmsType } from './lti/types.ts'; -import { errorMessage } from './app_status_support.ts'; - -export function buildInstallEditorState( - lms: LmsType | null, - formData: FormData | null, - title: string, - error: unknown, -): DeploymentEditorState | null { - if (lms === null) { - return null; - } - - return { - lms, - focusSection: 'install', - notice: createErrorNotice(title, error), - fieldErrors: buildFieldErrors(lms, errorMessage(error)), - installValues: collectInstallValues(lms, formData), - pinPackageVersionId: null, - }; -} - -export function buildPinEditorState( - lms: LmsType | null, - formData: FormData | null, - title: string, - error: unknown, -): DeploymentEditorState | null { - if (lms === null) { - return null; - } - - return { - lms, - focusSection: 'pin', - notice: createErrorNotice(title, error), - fieldErrors: buildFieldErrors(lms, errorMessage(error)), - installValues: collectInstallValues(lms, formData), - pinPackageVersionId: formValueString(formData, 'packageVersionId'), - }; -} - -function collectInstallValues( - lms: LmsType, - formData: FormData | null, -): Partial> { - switch (lms) { - case 'canvas': - return collectFieldValues(formData, ['canvasEnvironment', 'clientId', 'deploymentId']); - case 'moodle': - return collectFieldValues(formData, [ - 'issuer', - 'clientId', - 'deploymentId', - 'authorizationEndpoint', - 'accessTokenUrl', - 'jwksUrl', - ]); - case 'sakai': - return collectFieldValues(formData, [ - 'issuer', - 'clientId', - 'deploymentId', - 'authorizationEndpoint', - 'accessTokenUrl', - 'jwksUrl', - ]); - } -} - -function collectFieldValues( - formData: FormData | null, - fields: DeploymentEditorField[], -): Partial> { - const values: Partial> = {}; - - for (const field of fields) { - const value = formValueString(formData, field); - - if (value !== null) { - values[field] = value; - } - } - - return values; -} - -function buildFieldErrors( - lms: LmsType, - message: string, -): Partial> { - const field = resolveFieldError(lms, message); - - return field === null ? {} : { [field]: message }; -} - -function resolveFieldError(lms: LmsType, message: string): DeploymentEditorField | null { - switch (lms) { - case 'canvas': - switch (message) { - case 'Canvas Client ID is required.': - return 'clientId'; - case 'Canvas Deployment ID is required.': - return 'deploymentId'; - case 'Choose an approved version.': - return 'packageVersionId'; - default: - return null; - } - case 'moodle': - switch (message) { - case 'Moodle Platform ID is required.': - return 'issuer'; - case 'Moodle Client ID is required.': - return 'clientId'; - case 'Moodle Deployment ID is required.': - return 'deploymentId'; - case 'Moodle Authorization endpoint is required.': - return 'authorizationEndpoint'; - case 'Moodle Access token URL is required.': - return 'accessTokenUrl'; - case 'Moodle Public keyset URL is required.': - return 'jwksUrl'; - case 'Choose an approved version.': - return 'packageVersionId'; - default: - return null; - } - case 'sakai': - switch (message) { - case 'Sakai Platform ID is required.': - return 'issuer'; - case 'Sakai Client ID is required.': - return 'clientId'; - case 'Sakai Deployment ID is required.': - return 'deploymentId'; - case 'Sakai Authorization endpoint is required.': - return 'authorizationEndpoint'; - case 'Sakai Access token URL is required.': - return 'accessTokenUrl'; - case 'Sakai Public keyset URL is required.': - return 'jwksUrl'; - case 'Choose an approved version.': - return 'packageVersionId'; - default: - return null; - } - } -} - -function formValueString(formData: FormData | null, field: string): string | null { - const value = formData?.get(field); - return typeof value === 'string' ? value : null; -} diff --git a/src/app_admin_deployment_moodle_registration_test.ts b/src/app_admin_deployment_moodle_registration_test.ts deleted file mode 100644 index 06e0ed0..0000000 --- a/src/app_admin_deployment_moodle_registration_test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { restoreEnv, withFetchStub } from './app_test_support.ts'; -import { getTestToolPrivateJwkEnvValue, TEST_LTI_STATE_SECRET } from './test_helpers/lti.ts'; -import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; - -const TOOL_CONFIGURATION_CLAIM = 'https://purl.imsglobal.org/spec/lti-tool-configuration'; - -Deno.test('GET /admin/packages/:appId/deployment/register/moodle completes automatic setup and returns a Moodle close page', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - const previousJwk = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const previousStateSecret = Deno.env.get('LTI_STATE_SECRET'); - Deno.env.set('APP_ORIGIN', 'http://localhost:8417'); - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - Deno.env.set('LTI_STATE_SECRET', TEST_LTI_STATE_SECRET); - - try { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - }); - const registrationState = await repository.createDynamicRegistrationState({ - state: 'moodle-registration-state-123', - appId: 'chapter-4-asteroids', - lms: 'moodle', - createdAt: '2030-03-24T00:00:00Z', - expiresAt: '2030-03-24T00:30:00Z', - usedAt: null, - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const requests: Array<{ - url: string; - method: string; - authorization: string | null; - body: string | null; - }> = []; - - await withFetchStub( - (input, init) => { - const url = typeof input === 'string' ? input : input.toString(); - requests.push({ - url, - method: init?.method ?? 'GET', - authorization: new Headers(init?.headers).get('authorization'), - body: typeof init?.body === 'string' ? init.body : null, - }); - - if (url === 'https://moodle.example/mod/lti/openid-configuration.php') { - return new Response( - JSON.stringify(buildMoodleOpenIdConfiguration()), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - if (url === 'https://moodle.example/mod/lti/openid-registration.php') { - const payload = JSON.parse(String(init?.body ?? '{}')) as Record; - return new Response( - JSON.stringify( - buildRegistrationResponse(payload, 'moodle-client-777', 'moodle-deployment-777'), - ), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - throw new Error(`Unexpected fetch ${url}`); - }, - async () => { - const params = new URLSearchParams({ - state: registrationState.state, - openid_configuration: 'https://moodle.example/mod/lti/openid-configuration.php', - registration_token: 'registration-123', - }); - const response = await app.request( - `http://localhost/admin/packages/chapter-4-asteroids/deployment/register/moodle?${params.toString()}`, - ); - - assertEquals(response.status, 200); - const body = await response.text(); - assertStringIncludes(body, 'Moodle connection saved'); - assertStringIncludes(body, 'Close and return to Moodle'); - assertStringIncludes(body, 'org.imsglobal.lti.close'); - assertStringIncludes( - body, - '/admin/packages/chapter-4-asteroids/deployment?lms=moodle#slot-panel', - ); - }, - ); - - assertEquals(requests.length, 2); - assertEquals(requests[1]?.url, 'https://moodle.example/mod/lti/openid-registration.php'); - assertEquals(requests[1]?.method, 'POST'); - assertEquals(requests[1]?.authorization, 'Bearer registration-123'); - - const registrationRequest = JSON.parse(requests[1]?.body ?? 'null') as { - initiate_login_uri: string; - jwks_uri: string; - redirect_uris: string[]; - scope: string; - 'https://purl.imsglobal.org/spec/lti-tool-configuration': { - messages: Array>; - }; - }; - assertEquals(registrationRequest.initiate_login_uri, 'http://localhost:8417/lti/login'); - assertEquals(registrationRequest.jwks_uri, 'http://localhost:8417/lti/jwks.json'); - assertEquals( - registrationRequest.redirect_uris.includes('http://localhost:8417/lti/launch'), - true, - ); - assertEquals(registrationRequest.scope.includes('lti-ags/scope/score'), true); - assertEquals(registrationRequest[TOOL_CONFIGURATION_CLAIM].messages.length > 0, true); - - const deployment = await repository.getDeploymentBySlug('chapter-4-asteroids-moodle'); - const binding = deployment?.binding; - assertEquals(binding?.lms, 'moodle'); - - if (binding?.lms !== 'moodle') { - throw new Error('Expected the Moodle binding to be saved.'); - } - - assertEquals(binding.issuer, 'https://moodle.example'); - assertEquals(binding.clientId, 'moodle-client-777'); - assertEquals(binding.deploymentId, 'moodle-deployment-777'); - assertEquals(binding.authorizationEndpoint, 'https://moodle.example/mod/lti/auth.php'); - assertEquals(binding.accessTokenUrl, 'https://moodle.example/mod/lti/token.php'); - assertEquals(binding.jwksUrl, 'https://moodle.example/mod/lti/certs.php'); - - const auditEvents = await repository.listAuditEventsByEventType('deployment.binding_saved'); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.detail.lms, 'moodle'); - assertEquals(auditEvents[0]?.detail.registrationMode, 'dynamic'); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousJwk); - restoreEnv('LTI_STATE_SECRET', previousStateSecret); - } -}); - -function buildMoodleOpenIdConfiguration(): Record { - return { - issuer: 'https://moodle.example', - authorization_endpoint: 'https://moodle.example/mod/lti/auth.php', - token_endpoint: 'https://moodle.example/mod/lti/token.php', - jwks_uri: 'https://moodle.example/mod/lti/certs.php', - registration_endpoint: 'https://moodle.example/mod/lti/openid-registration.php', - token_endpoint_auth_methods_supported: ['private_key_jwt'], - token_endpoint_auth_signing_alg_values_supported: ['RS256'], - scopes_supported: [ - 'https://purl.imsglobal.org/spec/lti-ags/scope/score', - 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly', - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', - 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly', - ], - response_types_supported: ['id_token'], - id_token_signing_alg_values_supported: ['RS256'], - claims_supported: ['iss', 'sub', 'name', 'email'], - subject_types_supported: ['public'], - 'https://purl.imsglobal.org/spec/lti-platform-configuration': { - product_family_code: 'moodle', - version: '4', - messages_supported: [{ type: 'LtiDeepLinkingRequest' }], - }, - }; -} - -function buildRegistrationResponse( - payload: Record, - clientId: string, - deploymentId: string, -): Record { - return { - ...payload, - client_id: clientId, - [TOOL_CONFIGURATION_CLAIM]: { - ...(payload[TOOL_CONFIGURATION_CLAIM] as Record), - deployment_id: deploymentId, - }, - }; -} diff --git a/src/app_admin_deployment_ops_routes.ts b/src/app_admin_deployment_ops_routes.ts deleted file mode 100644 index cb4fb31..0000000 --- a/src/app_admin_deployment_ops_routes.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { Hono } from '@hono/hono'; -import type { AppServices } from './app_services.ts'; -import { registerAdminDeploymentRosterRoute } from './app_admin_deployment_roster_route.ts'; -import { registerAdminGradeSmokeRoute } from './app_admin_grade_smoke_route.ts'; - -export function registerAdminDeploymentOpsRoutes(app: Hono, services: AppServices): void { - registerAdminDeploymentRosterRoute(app, services); - registerAdminGradeSmokeRoute(app, services); -} diff --git a/src/app_admin_deployment_registration_test.ts b/src/app_admin_deployment_registration_test.ts deleted file mode 100644 index 06633c6..0000000 --- a/src/app_admin_deployment_registration_test.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { restoreEnv, withFetchStub } from './app_test_support.ts'; -import { getTestToolPrivateJwkEnvValue, TEST_LTI_STATE_SECRET } from './test_helpers/lti.ts'; -import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; - -const TOOL_CONFIGURATION_CLAIM = 'https://purl.imsglobal.org/spec/lti-tool-configuration'; - -Deno.test('GET /admin/packages/:appId/deployment/register/canvas saves a pending Canvas registration and returns a Canvas close page', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - const previousJwk = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const previousStateSecret = Deno.env.get('LTI_STATE_SECRET'); - Deno.env.set('APP_ORIGIN', 'http://localhost:8417'); - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - Deno.env.set('LTI_STATE_SECRET', TEST_LTI_STATE_SECRET); - - try { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - }); - const registrationState = await repository.createDynamicRegistrationState({ - state: 'canvas-registration-state-123', - appId: 'chapter-4-asteroids', - lms: 'canvas', - createdAt: '2030-03-24T00:00:00Z', - expiresAt: '2030-03-24T00:30:00Z', - usedAt: null, - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const requests: Array<{ - url: string; - method: string; - authorization: string | null; - body: string | null; - }> = []; - - await withFetchStub( - (input, init) => { - const url = typeof input === 'string' ? input : input.toString(); - requests.push({ - url, - method: init?.method ?? 'GET', - authorization: new Headers(init?.headers).get('authorization'), - body: typeof init?.body === 'string' ? init.body : null, - }); - - if (url === 'https://canvas.instructure.com/api/lti/security/openid-configuration') { - return new Response( - JSON.stringify(buildCanvasOpenIdConfiguration()), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - if (url === 'https://canvas.instructure.com/api/lti/registrations') { - const payload = JSON.parse(String(init?.body ?? '{}')) as Record; - return new Response( - JSON.stringify(buildRegistrationResponse(payload, 'canvas-client-777')), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - throw new Error(`Unexpected fetch ${url}`); - }, - async () => { - const params = new URLSearchParams({ - state: registrationState.state, - openid_configuration: - 'https://canvas.instructure.com/api/lti/security/openid-configuration', - registration_token: 'registration-123', - }); - const response = await app.request( - `http://localhost/admin/packages/chapter-4-asteroids/deployment/register/canvas?${params.toString()}`, - ); - - assertEquals(response.status, 200); - const body = await response.text(); - assertStringIncludes(body, 'Canvas setup saved'); - assertStringIncludes(body, 'Close and return to Canvas'); - assertStringIncludes(body, 'org.imsglobal.lti.close'); - assertStringIncludes( - body, - '/admin/packages/chapter-4-asteroids/deployment?lms=canvas&registered=canvas#slot-panel', - ); - - const replay = await app.request( - `http://localhost/admin/packages/chapter-4-asteroids/deployment/register/canvas?${params.toString()}`, - ); - assertEquals(replay.status, 409); - assertStringIncludes( - await replay.text(), - 'This dynamic registration link has already been used.', - ); - }, - ); - - assertEquals(requests.length, 2); - assertEquals(requests[0]?.method, 'GET'); - assertEquals(requests[0]?.authorization, 'Bearer registration-123'); - assertEquals(requests[1]?.url, 'https://canvas.instructure.com/api/lti/registrations'); - assertEquals(requests[1]?.method, 'POST'); - assertEquals(requests[1]?.authorization, 'Bearer registration-123'); - - const registrationRequest = JSON.parse(requests[1]?.body ?? 'null') as { - initiate_login_uri: string; - jwks_uri: string; - redirect_uris: string[]; - scope: string; - 'https://purl.imsglobal.org/spec/lti-tool-configuration': { - messages: Array<{ type: string; placements: string[] }>; - }; - }; - assertEquals(registrationRequest.initiate_login_uri, 'http://localhost:8417/lti/login'); - assertEquals(registrationRequest.jwks_uri, 'http://localhost:8417/lti/jwks.json'); - assertEquals( - registrationRequest.redirect_uris.includes('http://localhost:8417/lti/launch'), - true, - ); - assertEquals( - registrationRequest.redirect_uris.includes('http://localhost:8417/lti/deep-linking'), - true, - ); - assertEquals(registrationRequest.scope.includes('lti-ags/scope/score'), true); - assertEquals( - registrationRequest[TOOL_CONFIGURATION_CLAIM].messages[0]?.placements, - ['course_navigation'], - ); - assertEquals( - registrationRequest[TOOL_CONFIGURATION_CLAIM].messages[2]?.placements, - ['resource_selection'], - ); - - const deployment = await repository.getDeploymentBySlug('chapter-4-asteroids-pilot'); - assertEquals(deployment?.lmsType, 'canvas'); - assertEquals(deployment?.binding, null); - - const auditEvents = await repository.listAuditEventsByEventType('deployment.binding_saved'); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.detail.lms, 'canvas'); - assertEquals(auditEvents[0]?.detail.registrationMode, 'dynamic'); - assertEquals(auditEvents[0]?.detail.clientId, 'canvas-client-777'); - assertEquals(auditEvents[0]?.detail.deploymentId, null); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousJwk); - restoreEnv('LTI_STATE_SECRET', previousStateSecret); - } -}); - -Deno.test('GET /admin/packages/:appId/deployment/register/canvas uses the forwarded public origin for registration callbacks', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - const previousJwk = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const previousStateSecret = Deno.env.get('LTI_STATE_SECRET'); - Deno.env.delete('APP_ORIGIN'); - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - Deno.env.set('LTI_STATE_SECRET', TEST_LTI_STATE_SECRET); - - try { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - }); - const registrationState = await repository.createDynamicRegistrationState({ - state: 'canvas-registration-state-forwarded', - appId: 'chapter-4-asteroids', - lms: 'canvas', - createdAt: '2030-03-24T00:00:00Z', - expiresAt: '2030-03-24T00:30:00Z', - usedAt: null, - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - let registrationBody: string | null = null; - - await withFetchStub( - (input, init) => { - const url = typeof input === 'string' ? input : input.toString(); - - if (url === 'https://canvas.instructure.com/api/lti/security/openid-configuration') { - return new Response( - JSON.stringify(buildCanvasOpenIdConfiguration()), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - if (url === 'https://canvas.instructure.com/api/lti/registrations') { - registrationBody = typeof init?.body === 'string' ? init.body : null; - const payload = JSON.parse(String(init?.body ?? '{}')) as Record; - - return new Response( - JSON.stringify(buildRegistrationResponse(payload, 'canvas-client-777')), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - throw new Error(`Unexpected fetch ${url}`); - }, - async () => { - const params = new URLSearchParams({ - state: registrationState.state, - openid_configuration: - 'https://canvas.instructure.com/api/lti/security/openid-configuration', - registration_token: 'registration-123', - }); - const response = await app.request( - `http://worker.internal/admin/packages/chapter-4-asteroids/deployment/register/canvas?${params.toString()}`, - { - headers: { - 'x-forwarded-host': 'lantern.example', - 'x-forwarded-proto': 'https', - }, - }, - ); - - assertEquals(response.status, 200); - }, - ); - - const registrationRequest = JSON.parse(registrationBody ?? 'null') as { - initiate_login_uri: string; - jwks_uri: string; - redirect_uris: string[]; - }; - - assertEquals(registrationRequest.initiate_login_uri, 'https://lantern.example/lti/login'); - assertEquals(registrationRequest.jwks_uri, 'https://lantern.example/lti/jwks.json'); - assertEquals( - registrationRequest.redirect_uris.includes('https://lantern.example/lti/launch'), - true, - ); - assertEquals( - registrationRequest.redirect_uris.includes('https://lantern.example/lti/deep-linking'), - true, - ); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousJwk); - restoreEnv('LTI_STATE_SECRET', previousStateSecret); - } -}); - -function buildCanvasOpenIdConfiguration(): Record { - return { - issuer: 'https://canvas.instructure.com', - authorization_endpoint: 'https://canvas.instructure.com/api/lti/authorize_redirect', - registration_endpoint: 'https://canvas.instructure.com/api/lti/registrations', - jwks_uri: 'https://canvas.instructure.com/api/lti/security/jwks', - token_endpoint: 'https://canvas.instructure.com/login/oauth2/token', - token_endpoint_auth_methods_supported: ['private_key_jwt'], - token_endpoint_auth_signing_alg_values_supported: ['RS256'], - scopes_supported: [ - 'https://purl.imsglobal.org/spec/lti-ags/scope/score', - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', - 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', - 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly', - ], - response_types_supported: ['id_token'], - id_token_signing_alg_values_supported: ['RS256'], - claims_supported: ['iss', 'sub', 'name', 'email'], - subject_types_supported: ['public'], - 'https://purl.imsglobal.org/spec/lti-platform-configuration': { - product_family_code: 'canvas', - version: 'cloud', - messages_supported: [ - { type: 'LtiResourceLinkRequest', placements: ['course_navigation'] }, - { - type: 'LtiDeepLinkingRequest', - placements: ['assignment_selection', 'resource_selection'], - }, - ], - }, - }; -} - -function buildRegistrationResponse( - payload: Record, - clientId: string, -): Record { - return { - ...payload, - client_id: clientId, - [TOOL_CONFIGURATION_CLAIM]: payload[TOOL_CONFIGURATION_CLAIM], - }; -} diff --git a/src/app_admin_deployment_retry_routes.ts b/src/app_admin_deployment_retry_routes.ts deleted file mode 100644 index 2dab8b7..0000000 --- a/src/app_admin_deployment_retry_routes.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { Hono } from '@hono/hono'; -import { renderDeploymentDetailPage } from './admin/deployment_detail.ts'; -import { renderPackageIndexPage } from './admin/package_index.ts'; -import { - loadDeploymentDetailState, - loadDeploymentDetailStateSafe, -} from './app_deployment_support.ts'; -import { combineNotices, createErrorNotice } from './app_notice_support.ts'; -import { requireTrimmedFormValue } from './app_request_support.ts'; -import { - errorMessage, - normalizeRetryFailureCode, - statusForRetryPublishError, -} from './app_status_support.ts'; -import { listCanvasEnvironments } from './lti/config.ts'; -import { retryFailedGradePublication } from './ops/service.ts'; -import type { AppServices } from './app_services.ts'; -import { adminPageInput } from './admin_auth/page_input.ts'; -import { resolveRequestAppOrigin } from './app_origin.ts'; -import { buildRequestAuditEnvelope } from './request_audit.ts'; - -export function registerAdminDeploymentRetryRoutes(app: Hono, services: AppServices): void { - app.post('/admin/packages/:appId/deployment/retry-grade-publish', async (context) => { - const appId = context.req.param('appId'); - const repository = services.getRepository(); - const opsRepository = services.getOpsRepository(); - let attemptId: string | null = null; - let request = buildRequestAuditEnvelope({ context }); - - try { - const appOrigin = resolveRequestAppOrigin(context, services.env); - const detail = await loadDeploymentDetailState(repository, appId, appOrigin); - const canvasDeployment = detail.canvasDeployment; - - if (canvasDeployment === null) { - throw new Error( - 'Save the Canvas binding and exact deployment before retrying a grade publish.', - ); - } - - const formData = await context.req.formData(); - request = buildRequestAuditEnvelope({ - context, - formData, - }); - attemptId = requireTrimmedFormValue(formData.get('attemptId'), 'Retry attempt is required.'); - const retryResult = await retryFailedGradePublication({ - repository: { - getRetryableGradePublicationLookup: (candidateAttemptId) => - opsRepository.getRetryableGradePublicationLookup(candidateAttemptId), - updateGradePublication: (input) => repository.updateGradePublication(input), - }, - attemptId, - getLtiToolContext: () => services.getLtiToolContext(appOrigin), - }); - - if (retryResult.publication.status === 'published') { - await repository.recordAuditEvent({ - eventType: 'grade_publish.retry_succeeded', - actorType: 'user', - actorId: null, - deploymentRecordId: canvasDeployment.id, - packageVersionId: canvasDeployment.enabledPackageVersionId, - attemptId: retryResult.attemptId, - lineItemBindingId: null, - status: 'succeeded', - summary: 'Retried the failed Canvas AGS score publish from the control plane.', - detail: { - attemptId: retryResult.attemptId, - code: 'retry_succeeded', - }, - occurredAt: new Date().toISOString(), - }); - - return context.redirect(`/admin/packages/${appId}/deployment`, 303); - } - - await repository.recordAuditEvent({ - eventType: 'grade_publish.retry_failed', - actorType: 'user', - actorId: null, - deploymentRecordId: canvasDeployment.id, - packageVersionId: canvasDeployment.enabledPackageVersionId, - attemptId: retryResult.attemptId, - lineItemBindingId: null, - status: 'failed', - summary: 'Retrying the Canvas AGS score publish failed.', - detail: { - attemptId: retryResult.attemptId, - code: retryResult.publication.errorCode ?? 'score_publish_failed', - request, - }, - occurredAt: new Date().toISOString(), - }); - - const controlPlaneDetail = await opsRepository.getControlPlaneDeploymentDetail( - canvasDeployment.id, - ); - - return context.html( - renderDeploymentDetailPage(adminPageInput(context, { - appId, - appTitle: detail.appTitle, - history: detail.history, - deployments: detail.deployments, - nrpsVerification: detail.nrpsVerification, - controlPlaneDetail, - canvasConfigUrl: detail.canvasConfigUrl.url, - supportedCanvasEnvironments: listCanvasEnvironments(), - notice: combineNotices( - detail.canvasConfigUrl.notice, - createErrorNotice( - 'Grade publish retry failed', - new Error(retryResult.publication.errorCode ?? 'Canvas AGS score publish failed.'), - ), - ), - })), - 500, - ); - } catch (error) { - const detail = await loadDeploymentDetailStateSafe( - repository, - appId, - resolveRequestAppOrigin(context, services.env), - ); - const canvasDeployment = detail.canvasDeployment; - - if (canvasDeployment !== null) { - await repository.recordAuditEvent({ - eventType: 'grade_publish.retry_failed', - actorType: 'user', - actorId: null, - deploymentRecordId: canvasDeployment.id, - packageVersionId: canvasDeployment.enabledPackageVersionId, - attemptId, - lineItemBindingId: null, - status: 'failed', - summary: 'Retrying the Canvas AGS score publish failed.', - detail: { - attemptId, - code: normalizeRetryFailureCode(error), - message: errorMessage(error), - request, - }, - occurredAt: new Date().toISOString(), - }); - } - - if (detail.history.length === 0) { - return context.html( - renderPackageIndexPage(adminPageInput(context, { - versions: [], - notice: createErrorNotice('Connections page unavailable', error), - })), - statusForRetryPublishError(error), - ); - } - - const controlPlaneDetail = canvasDeployment === null - ? null - : await opsRepository.getControlPlaneDeploymentDetail(canvasDeployment.id); - - return context.html( - renderDeploymentDetailPage(adminPageInput(context, { - appId, - appTitle: detail.appTitle, - history: detail.history, - deployments: detail.deployments, - nrpsVerification: detail.nrpsVerification, - controlPlaneDetail, - canvasConfigUrl: detail.canvasConfigUrl.url, - supportedCanvasEnvironments: listCanvasEnvironments(), - notice: combineNotices( - detail.canvasConfigUrl.notice, - createErrorNotice('Grade publish retry failed', error), - ), - })), - statusForRetryPublishError(error), - ); - } - }); -} diff --git a/src/app_admin_deployment_roster_route.ts b/src/app_admin_deployment_roster_route.ts deleted file mode 100644 index 4e598cc..0000000 --- a/src/app_admin_deployment_roster_route.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { Hono } from '@hono/hono'; -import { renderDeploymentDetailPage } from './admin/deployment_detail.ts'; -import { renderPackageIndexPage } from './admin/package_index.ts'; -import { - getLatestNrpsVerification, - loadDeploymentDetailState, - loadDeploymentDetailStateSafe, -} from './app_deployment_support.ts'; -import { combineNotices, createErrorNotice } from './app_notice_support.ts'; -import { errorMessage, statusForNrpsError } from './app_status_support.ts'; -import { listCanvasEnvironments } from './lti/config.ts'; -import { - buildResolvedLtiProfileDetail, - resolveLtiProfileForDeployment, -} from './lti/profile_resolution.ts'; -import type { AppServices } from './app_services.ts'; -import { adminPageInput } from './admin_auth/page_input.ts'; -import { resolveRequestAppOrigin } from './app_origin.ts'; -import { readNrpsMemberCount } from './lti/nrps_roster_verification.ts'; - -export function registerAdminDeploymentRosterRoute(app: Hono, services: AppServices): void { - app.post('/admin/packages/:appId/deployment/verify-roster', async (context) => { - const appId = context.req.param('appId'); - const repository = services.getRepository(); - - try { - const appOrigin = resolveRequestAppOrigin(context, services.env); - const detail = await loadDeploymentDetailState(repository, appId, appOrigin); - const canvasDeployment = detail.canvasDeployment; - - if (canvasDeployment === null) { - throw new Error( - 'Save the Canvas binding and exact deployment before verifying roster access.', - ); - } - - if (canvasDeployment.binding === null || canvasDeployment.binding.lms !== 'canvas') { - throw new Error( - 'Canvas deployment binding is required before roster verification can run.', - ); - } - - const latestSession = await repository.getLatestRuntimeSessionByDeploymentId( - canvasDeployment.id, - ); - - if (latestSession === null) { - throw new Error('Launch the deployment from Canvas once before verifying roster access.'); - } - - if (latestSession.services.nrps === null) { - throw new Error('Launch did not provide NRPS service context for this deployment.'); - } - const ltiProfile = await resolveLtiProfileForDeployment({ - repository, - deployment: canvasDeployment, - }); - const memberCount = await readNrpsMemberCount({ - services, - session: latestSession, - }); - - await repository.recordAuditEvent({ - eventType: 'deployment.nrps_verified', - actorType: 'system', - actorId: null, - deploymentRecordId: canvasDeployment.id, - packageVersionId: canvasDeployment.enabledPackageVersionId, - attemptId: latestSession.attemptId, - lineItemBindingId: null, - status: 'succeeded', - summary: 'Read Canvas roster memberships through the launch-scoped NRPS service.', - detail: { - contextId: latestSession.launch.courseId, - memberCount, - ...buildResolvedLtiProfileDetail(ltiProfile), - }, - occurredAt: new Date().toISOString(), - }); - - return context.redirect(`/admin/packages/${appId}/deployment`, 303); - } catch (error) { - const detail = await loadDeploymentDetailStateSafe( - repository, - appId, - resolveRequestAppOrigin(context, services.env), - ); - const canvasDeployment = detail.canvasDeployment; - const ltiProfile = canvasDeployment === null ? null : await resolveLtiProfileForDeployment({ - repository, - deployment: canvasDeployment, - }); - - if (canvasDeployment !== null) { - await repository.recordAuditEvent({ - eventType: 'deployment.nrps_verified', - actorType: 'system', - actorId: null, - deploymentRecordId: canvasDeployment.id, - packageVersionId: canvasDeployment.enabledPackageVersionId, - attemptId: null, - lineItemBindingId: null, - status: 'failed', - summary: 'Canvas roster verification failed.', - detail: { - message: errorMessage(error), - ...(ltiProfile === null ? {} : buildResolvedLtiProfileDetail(ltiProfile)), - }, - occurredAt: new Date().toISOString(), - }); - } - const nrpsVerification = canvasDeployment === null - ? detail.nrpsVerification - : await getLatestNrpsVerification(repository, canvasDeployment.id); - - if (detail.history.length === 0) { - return context.html( - renderPackageIndexPage(adminPageInput(context, { - versions: [], - notice: createErrorNotice('Connections page unavailable', error), - })), - statusForNrpsError(error), - ); - } - - return context.html( - renderDeploymentDetailPage(adminPageInput(context, { - appId, - appTitle: detail.appTitle, - history: detail.history, - deployments: detail.deployments, - nrpsVerification, - lanternLtiProfileSettings: detail.ltiProfileSettings, - canvasConfigUrl: detail.canvasConfigUrl.url, - supportedCanvasEnvironments: listCanvasEnvironments(), - notice: combineNotices( - detail.canvasConfigUrl.notice, - createErrorNotice('Roster verification failed', error), - ), - })), - statusForNrpsError(error), - ); - } - }); -} diff --git a/src/app_admin_deployment_sakai_registration_test.ts b/src/app_admin_deployment_sakai_registration_test.ts deleted file mode 100644 index 6357b9f..0000000 --- a/src/app_admin_deployment_sakai_registration_test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { restoreEnv, withFetchStub } from './app_test_support.ts'; -import { getTestToolPrivateJwkEnvValue, TEST_LTI_STATE_SECRET } from './test_helpers/lti.ts'; -import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; - -const TOOL_CONFIGURATION_CLAIM = 'https://purl.imsglobal.org/spec/lti-tool-configuration'; - -Deno.test('GET /admin/packages/:appId/deployment/register/sakai completes automatic setup and saves the Sakai binding', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - const previousJwk = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const previousStateSecret = Deno.env.get('LTI_STATE_SECRET'); - Deno.env.set('APP_ORIGIN', 'http://localhost:8417'); - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - Deno.env.set('LTI_STATE_SECRET', TEST_LTI_STATE_SECRET); - - try { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - }); - const registrationState = await repository.createDynamicRegistrationState({ - state: 'sakai-registration-state-123', - appId: 'chapter-4-asteroids', - lms: 'sakai', - createdAt: '2030-03-24T00:00:00Z', - expiresAt: '2030-03-24T00:30:00Z', - usedAt: null, - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const requests: Array<{ - url: string; - method: string; - authorization: string | null; - body: string | null; - }> = []; - - await withFetchStub( - (input, init) => { - const url = typeof input === 'string' ? input : input.toString(); - requests.push({ - url, - method: init?.method ?? 'GET', - authorization: new Headers(init?.headers).get('authorization'), - body: typeof init?.body === 'string' ? init.body : null, - }); - - if (url === 'https://sakai.example/imsblis/lti13/well_known') { - return new Response( - JSON.stringify(buildSakaiOpenIdConfiguration()), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - if (url === 'https://sakai.example/imsblis/lti13/registration_endpoint/91') { - const payload = JSON.parse(String(init?.body ?? '{}')) as Record; - return new Response( - JSON.stringify( - buildRegistrationResponse(payload, '7dbe6a13-f948-498c-87d7-768947ac5c56', '1'), - ), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - throw new Error(`Unexpected fetch ${url}`); - }, - async () => { - const params = new URLSearchParams({ - state: registrationState.state, - openid_configuration: 'https://sakai.example/imsblis/lti13/well_known', - registration_token: 'registration-123', - }); - const response = await app.request( - `http://localhost/admin/packages/chapter-4-asteroids/deployment/register/sakai?${params.toString()}`, - ); - - assertEquals(response.status, 200); - const body = await response.text(); - assertStringIncludes(body, 'Sakai connection saved'); - assertStringIncludes(body, 'Continue in Sakai to review and save the tool.'); - assertStringIncludes(body, 'Continue in Sakai'); - assertStringIncludes(body, 'org.imsglobal.lti.close'); - }, - ); - - assertEquals(requests.length, 2); - assertEquals(requests[0]?.method, 'GET'); - assertEquals(requests[1]?.url, 'https://sakai.example/imsblis/lti13/registration_endpoint/91'); - assertEquals(requests[1]?.method, 'POST'); - assertEquals(requests[1]?.authorization, 'Bearer registration-123'); - - const registrationRequest = JSON.parse(requests[1]?.body ?? 'null') as { - initiate_login_uri: string; - jwks_uri: string; - redirect_uris: string[]; - 'https://purl.imsglobal.org/spec/lti-tool-configuration': { - messages: Array>; - }; - }; - assertEquals(registrationRequest.initiate_login_uri, 'http://localhost:8417/lti/login'); - assertEquals(registrationRequest.jwks_uri, 'http://localhost:8417/lti/jwks.json'); - assertEquals( - registrationRequest.redirect_uris.includes('http://localhost:8417/lti/launch'), - true, - ); - assertEquals(registrationRequest[TOOL_CONFIGURATION_CLAIM].messages.length > 0, true); - - const deployment = await repository.getDeploymentBySlug('chapter-4-asteroids-sakai'); - const binding = deployment?.binding; - assertEquals(binding?.lms, 'sakai'); - - if (binding?.lms !== 'sakai') { - throw new Error('Expected the Sakai binding to be saved.'); - } - - assertEquals(binding.issuer, 'https://sakai.example'); - assertEquals(binding.clientId, '7dbe6a13-f948-498c-87d7-768947ac5c56'); - assertEquals(binding.deploymentId, '1'); - assertEquals(binding.authorizationEndpoint, 'https://sakai.example/imsoidc/lti13/oidc_auth'); - assertEquals(binding.accessTokenUrl, 'https://sakai.example/imsblis/lti13/token/91'); - assertEquals(binding.jwksUrl, 'https://sakai.example/imsblis/lti13/keyset'); - - const auditEvents = await repository.listAuditEventsByEventType('deployment.binding_saved'); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.detail.lms, 'sakai'); - assertEquals(auditEvents[0]?.detail.registrationMode, 'dynamic'); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousJwk); - restoreEnv('LTI_STATE_SECRET', previousStateSecret); - } -}); - -Deno.test('GET /admin/packages/:appId/deployment/register/sakai explains direct browser access clearly', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - Deno.env.set('APP_ORIGIN', 'http://localhost:8417'); - - try { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - }); - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment/register/sakai'); - - assertEquals(response.status, 400); - const body = await response.text(); - assertEquals(body.includes('Open this from Sakai'), true); - assertEquals(body.includes('Dynamic Registration URL'), true); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - } -}); - -function buildSakaiOpenIdConfiguration(): Record { - return { - issuer: 'https://sakai.example', - authorization_endpoint: 'https://sakai.example/imsoidc/lti13/oidc_auth', - token_endpoint: 'https://sakai.example/imsblis/lti13/token/91', - jwks_uri: 'https://sakai.example/imsblis/lti13/keyset', - registration_endpoint: 'https://sakai.example/imsblis/lti13/registration_endpoint/91', - token_endpoint_auth_methods_supported: ['private_key_jwt'], - token_endpoint_auth_signing_alg_values_supported: ['RS256'], - scopes_supported: [ - 'https://purl.imsglobal.org/spec/lti-ags/scope/score', - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', - 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly', - ], - response_types_supported: ['id_token'], - id_token_signing_alg_values_supported: ['RS256'], - claims_supported: ['iss', 'sub', 'name', 'email'], - subject_types_supported: ['public'], - 'https://purl.imsglobal.org/spec/lti-platform-configuration': { - product_family_code: 'sakailms.org', - version: '23', - messages_supported: [ - { type: 'LtiResourceLinkRequest' }, - { type: 'LtiDeepLinkingRequest' }, - ], - }, - }; -} - -function buildRegistrationResponse( - payload: Record, - clientId: string, - deploymentId: string, -): Record { - return { - ...payload, - client_id: clientId, - [TOOL_CONFIGURATION_CLAIM]: { - ...(payload[TOOL_CONFIGURATION_CLAIM] as Record), - deployment_id: deploymentId, - }, - }; -} diff --git a/src/app_admin_deployment_test.ts b/src/app_admin_deployment_test.ts deleted file mode 100644 index 7394772..0000000 --- a/src/app_admin_deployment_test.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { resolveCanvasIssuer } from './lti/config.ts'; -import { - buildAccessibilityReview, - buildControlPlaneDeploymentDetailSnapshot, - buildControlPlaneDeploymentInventoryRow, - buildDeploymentActivitySnapshot, - buildDeploymentGradePublicationSnapshot, - buildDeploymentRecord, - buildPackageVersionRecord, - buildPilotUsageMetrics, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { buildDeploymentBinding, buildMoodleDeploymentBinding } from './test_helpers/lti.ts'; -import { restoreEnv } from './app_test_support.ts'; -import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; - -Deno.test('POST /admin/packages/:appId/deployment/install saves the Canvas binding and redirects back to deployment detail', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - Deno.env.set('APP_ORIGIN', 'http://localhost:8417'); - - try { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 3, - slug: 'chapter-4-asteroids-pilot', - label: 'Chapter 4 Asteroids Pilot Deployment', - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - }), - ], - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const formData = new FormData(); - - formData.set('lms', 'canvas'); - formData.set('canvasEnvironment', 'production'); - formData.set('clientId', '10000000000001'); - formData.set('deploymentId', 'deployment-123'); - - const response = await app.request( - 'http://localhost/admin/packages/chapter-4-asteroids/deployment/install', - { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }, - ); - - assertEquals(response.status, 303); - assertEquals( - response.headers.get('location'), - '/admin/packages/chapter-4-asteroids/deployment?lms=canvas#slot-panel', - ); - - const deployment = await repository.getDeploymentBySlug('chapter-4-asteroids-pilot'); - assertEquals(deployment?.binding?.issuer, resolveCanvasIssuer('production')); - assertEquals(deployment?.binding?.clientId, '10000000000001'); - assertEquals(deployment?.binding?.deploymentId, 'deployment-123'); - const auditEvents = await repository.listAuditEventsByEventType('deployment.binding_saved'); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.deploymentRecordId, 3); - assertEquals(auditEvents[0]?.detail.lms, 'canvas'); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - } -}); - -Deno.test('GET /admin/packages/:appId/deployment renders an LMS tab strip with one focused editor', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - Deno.env.set('APP_ORIGIN', 'http://localhost:8417'); - - try { - const response = await createAuthenticatedTestApp({ - getRepository: () => - createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - accessibilityReview: buildAccessibilityReview({ - contrast: 'fail', - failureNotes: 'Contrast fixes still pending for the score meter.', - exceptionNote: 'Reviewed for faculty-supervised pilot use.', - }), - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 3, - slug: 'chapter-4-asteroids-pilot', - label: 'Chapter 4 Asteroids Pilot Deployment', - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-24T13:00:00Z', - }, - controlPlaneDeploymentDetails: [ - buildControlPlaneDeploymentDetailSnapshot({ - inventory: buildControlPlaneDeploymentInventoryRow({ - deploymentId: 3, - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - latestLaunch: buildDeploymentActivitySnapshot({ - occurredAt: '2026-03-24T12:30:00Z', - summary: 'Latest launch reached the governed runtime handoff.', - }), - latestNrpsRead: buildDeploymentActivitySnapshot({ - occurredAt: '2026-03-24T12:33:00Z', - summary: 'Latest roster verification succeeded.', - }), - latestGradePublish: buildDeploymentGradePublicationSnapshot({ - updatedAt: '2026-03-24T12:35:00Z', - status: 'failed', - }), - pilotUsage: buildPilotUsageMetrics({ - totalLaunches: 6, - attemptsCompleted: 5, - gradePublishesSucceeded: 4, - gradePublishesFailed: 1, - recentActiveUsers: 3, - }), - }), - ], - }), - }).request( - 'http://localhost/admin/packages/chapter-4-asteroids/deployment?lms=moodle&view=activity', - ); - - assertEquals(response.status, 200); - const body = await response.text(); - - assertStringIncludes(body, 'App settings'); - assertStringIncludes( - body, - 'Connect this app to one LMS and choose what version people should open.', - ); - assertStringIncludes(body, 'class="deployment-tab '); - assertStringIncludes(body, 'deployment-tab-label">Canvas'); - assertStringIncludes(body, 'deployment-tab-label">Moodle'); - assertStringIncludes(body, 'deployment-tab-label">Sakai'); - assertStringIncludes( - body, - 'href="/admin/packages/chapter-4-asteroids/deployment?lms=moodle#slot-panel" aria-current="page"', - ); - assertStringIncludes(body, 'id="slot-panel" class="deployment-tab-panel stack"'); - assertStringIncludes(body, 'Set up Moodle'); - assertStringIncludes(body, 'Automatic setup'); - assertStringIncludes( - body, - 'http://localhost:8417/admin/packages/chapter-4-asteroids/deployment/register/moodle', - ); - assertStringIncludes(body, 'Advanced Moodle settings'); - assertStringIncludes(body, 'Platform ID'); - assertStringIncludes(body, 'Authorization endpoint'); - assertStringIncludes(body, 'Public keyset URL'); - assertStringIncludes(body, 'Not connected'); - assertStringIncludes(body, 'Current status'); - assertStringIncludes(body, 'Last launch'); - assertStringIncludes(body, 'Last grade write'); - assertStringIncludes(body, 'Last NRPS read'); - assertStringIncludes(body, 'Pilot usage'); - assertStringIncludes(body, 'Grade publishes'); - assertStringIncludes(body, 'Recent launches'); - assertStringIncludes(body, 'Open checks and troubleshooting'); - assertStringIncludes(body, 'details id="activity-details" open'); - assertStringIncludes(body, 'Save Moodle settings'); - assertStringIncludes(body, 'Advanced LTI behavior override'); - assertStringIncludes( - body, - 'action="/admin/packages/chapter-4-asteroids/deployment/lti-profile"', - ); - assertStringIncludes(body, 'LTI behavior'); - assertStringIncludes(body, 'Use Lantern default'); - assertStringIncludes(body, 'Certification'); - assertStringIncludes(body, 'Governed interoperability'); - assertStringIncludes(body, 'Save version for learners'); - assertStringIncludes(body, 'Save the app settings first'); - assertStringIncludes(body, 'Accessibility'); - assertStringIncludes(body, 'Flagged review'); - assertStringIncludes(body, 'Failed checks: Contrast.'); - assertStringIncludes(body, 'Reviewed for faculty-supervised pilot use.'); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - } -}); - -Deno.test('POST /admin/packages/:appId/deployment/lti-profile saves an override for only the selected deployment slot', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 3, - slug: 'chapter-4-asteroids-pilot', - label: 'Chapter 4 Asteroids Pilot Deployment', - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - }), - buildDeploymentRecord({ - id: 4, - slug: 'chapter-4-asteroids-moodle', - label: 'Chapter 4 Asteroids Moodle Deployment', - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - lmsType: 'moodle', - binding: buildMoodleDeploymentBinding(), - }), - ], - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const formData = new FormData(); - - formData.set('lms', 'moodle'); - formData.set('ltiProfileOverride', 'certification'); - - const response = await app.request( - 'http://localhost/admin/packages/chapter-4-asteroids/deployment/lti-profile', - { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }, - ); - - assertEquals(response.status, 303); - assertEquals( - response.headers.get('location'), - '/admin/packages/chapter-4-asteroids/deployment?lms=moodle#slot-panel', - ); - assertEquals( - (await repository.getDeploymentBySlug('chapter-4-asteroids-moodle'))?.ltiProfileOverride, - 'certification', - ); - assertEquals( - (await repository.getDeploymentBySlug('chapter-4-asteroids-pilot'))?.ltiProfileOverride, - null, - ); - assertEquals( - (await repository.getLanternLtiProfileSettings()).defaultLtiProfile, - 'governedCompatibility', - ); -}); - -Deno.test('POST /admin/packages/:appId/deployment/lti-profile clears the selected deployment override back to inherited mode', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 4, - slug: 'chapter-4-asteroids-moodle', - label: 'Chapter 4 Asteroids Moodle Deployment', - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - lmsType: 'moodle', - binding: buildMoodleDeploymentBinding(), - ltiProfileOverride: 'certification', - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'governedCompatibility', - updatedAt: '2026-03-24T13:00:00Z', - }, - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const formData = new FormData(); - - formData.set('lms', 'moodle'); - formData.set('ltiProfileOverride', ''); - - const response = await app.request( - 'http://localhost/admin/packages/chapter-4-asteroids/deployment/lti-profile', - { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }, - ); - - assertEquals(response.status, 303); - assertEquals( - (await repository.getDeploymentBySlug('chapter-4-asteroids-moodle'))?.ltiProfileOverride, - null, - ); - assertEquals( - (await repository.getLanternLtiProfileSettings()).defaultLtiProfile, - 'governedCompatibility', - ); -}); diff --git a/src/app_admin_governance_routes.ts b/src/app_admin_governance_routes.ts new file mode 100644 index 0000000..f75c774 --- /dev/null +++ b/src/app_admin_governance_routes.ts @@ -0,0 +1,291 @@ +import type { Context, Hono } from '@hono/hono'; +import { + renderGovernancePage, + renderInstitutionOnboardingPage, + renderInviteAcceptancePage, +} from './admin/governance_page.ts'; +import { adminIdentityFromContext, getAdminSession } from './admin_auth/context.ts'; +import type { AppServices } from './app_services.ts'; + +type GovernanceRouteServices = Pick< + AppServices, + 'getLmsKernelRepository' | 'getRepository' | 'lmsKernelAdminService' +>; +import { getKernelAdminContext } from './lms_kernel/admin_context.ts'; +import { parseRosterCapability } from './lms_kernel/capabilities.ts'; +import type { CapabilityGrantRecord, LmsPlatformType } from './lms_kernel/types.ts'; + +export function registerAdminGovernanceRoutes(app: Hono, services: GovernanceRouteServices): void { + app.get('/admin/onboarding', (context) => + context.html(renderInstitutionOnboardingPage({ + identity: adminIdentityFromContext(context), + }))); + + app.post('/admin/onboarding', async (context) => { + const form = await context.req.raw.formData(); + const result = await services.lmsKernelAdminService.createInstitution({ + userId: getAdminSession(context).user.id, + name: formString(form, 'name'), + }); + if (!result.ok) { + return context.html( + renderInstitutionOnboardingPage({ + identity: adminIdentityFromContext(context), + error: result.error.message, + }), + 400, + ); + } + return context.redirect('/admin/governance', 303); + }); + + app.get('/admin/invites/:token', (context) => + context.html(renderInviteAcceptancePage({ + identity: adminIdentityFromContext(context), + token: context.req.param('token'), + }))); + + app.post('/admin/invites/:token/accept', async (context) => { + const result = await services.lmsKernelAdminService.acceptInvite({ + userId: getAdminSession(context).user.id, + token: context.req.param('token'), + }); + return result.ok + ? context.redirect('/admin/governance', 303) + : context.text(result.error.message, 400); + }); + + app.get('/admin/governance', (context) => renderDashboard(context, services)); + + app.post('/admin/governance/connections', async (context) => { + const form = await context.req.raw.formData(); + const platformType = parsePlatformType(formString(form, 'platformType')); + if (platformType === null) { + return await renderDashboard(context, services, 'Unsupported LMS platform.', 400); + } + const result = await services.lmsKernelAdminService.createConnection({ + membership: getKernelAdminContext(context).membership, + label: formString(form, 'label'), + platformType, + issuer: formString(form, 'issuer'), + clientId: formString(form, 'clientId'), + deploymentId: formString(form, 'deploymentId'), + authorizationEndpoint: formString(form, 'authorizationEndpoint'), + accessTokenUrl: formString(form, 'accessTokenUrl'), + jwksUrl: formString(form, 'jwksUrl'), + }); + return result.ok + ? context.redirect('/admin/governance', 303) + : await renderDashboard(context, services, result.error.message, 400); + }); + + app.post('/admin/governance/connections/:connectionId/disable', async (context) => { + const result = await services.lmsKernelAdminService.disableConnection({ + membership: getKernelAdminContext(context).membership, + connectionId: context.req.param('connectionId'), + }); + return result.ok + ? context.redirect('/admin/governance', 303) + : await renderDashboard(context, services, result.error.message, 400); + }); + + app.post('/admin/governance/installations', async (context) => { + const form = await context.req.raw.formData(); + const packageVersionId = Number(formString(form, 'packageVersionId')); + if (!Number.isSafeInteger(packageVersionId) || packageVersionId <= 0) { + return await renderDashboard(context, services, 'Choose a reviewed package version.', 400); + } + const result = await services.lmsKernelAdminService.installApp({ + membership: getKernelAdminContext(context).membership, + packageVersionId, + }); + return result.ok + ? context.redirect('/admin/governance', 303) + : await renderDashboard(context, services, result.error.message, 400); + }); + + app.post('/admin/governance/installations/:installationId/grants', async (context) => { + const form = await context.req.raw.formData(); + const capability = parseRosterCapability(formString(form, 'capability')); + if (capability === null) { + return await renderDashboard(context, services, 'Unsupported capability.', 400); + } + const scope = formString(form, 'scope'); + const result = await services.lmsKernelAdminService.grantCapability({ + membership: getKernelAdminContext(context).membership, + installationId: context.req.param('installationId'), + capability, + lmsContextId: scope === 'institution' ? null : scope, + }); + return result.ok + ? context.redirect('/admin/governance', 303) + : await renderDashboard(context, services, result.error.message, 400); + }); + + app.post('/admin/governance/installations/:installationId/activate', async (context) => { + const result = await services.lmsKernelAdminService.activateInstallation({ + membership: getKernelAdminContext(context).membership, + installationId: context.req.param('installationId'), + }); + return result.ok + ? context.redirect('/admin/governance', 303) + : await renderDashboard(context, services, result.error.message, 409); + }); + + app.post('/admin/governance/installations/:installationId/synthetic-launch', async (context) => { + const result = await services.lmsKernelAdminService.createSyntheticLaunch({ + membership: getKernelAdminContext(context).membership, + installationId: context.req.param('installationId'), + }); + if (!result.ok) { + return await renderDashboard(context, services, result.error.message, 400); + } + const path = `/governed/sessions/${encodeURIComponent(result.value.runtimeSessionId)}` + + `?token=${encodeURIComponent(result.value.runtimeSessionToken)}`; + return context.redirect(path, 303); + }); + + app.post('/admin/governance/installations/:installationId/disable', async (context) => { + const result = await services.lmsKernelAdminService.disableInstallation({ + membership: getKernelAdminContext(context).membership, + installationId: context.req.param('installationId'), + }); + return result.ok + ? context.redirect('/admin/governance', 303) + : await renderDashboard(context, services, result.error.message, 400); + }); + + app.post('/admin/governance/grants/:grantId/revoke', async (context) => { + const result = await services.lmsKernelAdminService.revokeGrant({ + membership: getKernelAdminContext(context).membership, + grantId: context.req.param('grantId'), + }); + return result.ok + ? context.redirect('/admin/governance', 303) + : await renderDashboard(context, services, result.error.message, 400); + }); + + app.post('/admin/governance/invites', async (context) => { + const form = await context.req.raw.formData(); + const role = formString(form, 'role'); + if (role !== 'admin' && role !== 'auditor') { + return await renderDashboard(context, services, 'Choose administrator or auditor.', 400); + } + const result = await services.lmsKernelAdminService.createInvite({ + membership: getKernelAdminContext(context).membership, + role, + }); + return result.ok + ? await renderDashboard(context, services, undefined, 200, result.value) + : await renderDashboard(context, services, result.error.message, 400); + }); +} + +async function renderDashboard( + context: Context, + services: GovernanceRouteServices, + error?: string, + status: 200 | 400 | 409 = 200, + invite?: { readonly token: string; readonly expiresAt: string }, +): Promise { + const admin = getKernelAdminContext(context); + const repository = services.getLmsKernelRepository(); + const [connections, contexts, installations, packages, events, grantRecords] = await Promise.all([ + repository.listLmsConnections(admin.institution.id), + repository.listLmsContexts(admin.institution.id), + repository.listInstallations(admin.institution.id), + services.getRepository().listPackageVersions(), + repository.listGovernanceEvents(buildGovernanceFilter(context, admin.institution.id)), + repository.listCapabilityGrantsForInstitution(admin.institution.id), + ]); + const grants = new Map(); + for (const installation of installations) { + grants.set(installation.id, []); + } + for (const grant of grantRecords) { + const existing = grants.get(grant.appInstallationId); + if (existing === undefined) { + grants.set(grant.appInstallationId, [grant]); + } else { + existing.push(grant); + } + } + return context.html( + renderGovernancePage({ + identity: adminIdentityFromContext(context), + publicOrigin: new URL(context.req.url).origin, + institution: admin.institution, + membership: admin.membership, + connections, + hasSyntheticConnection: connections.some((connection) => + connection.platformType === 'synthetic' && connection.status === 'active' + ), + contexts, + installations, + packages, + grants, + events, + ...(invite === undefined ? {} : { invite }), + ...(error === undefined ? {} : { notice: { tone: 'error', detail: error } }), + }), + status, + ); +} + +function buildGovernanceFilter(context: Context, institutionId: string) { + const traceId = optionalQuery(context, 'traceId'); + const capability = optionalQuery(context, 'capability'); + const decision = parseDecision(optionalQuery(context, 'decision')); + const outcome = parseOutcome(optionalQuery(context, 'outcome')); + const lmsConnectionId = optionalQuery(context, 'connectionId'); + const appInstallationId = optionalQuery(context, 'installationId'); + const occurredAfter = parseDateTimeQuery(optionalQuery(context, 'after')); + const occurredBefore = parseDateTimeQuery(optionalQuery(context, 'before')); + return { + institutionId, + limit: 100, + ...(traceId === undefined ? {} : { traceId }), + ...(capability === undefined ? {} : { capability }), + ...(decision === undefined ? {} : { decision }), + ...(outcome === undefined ? {} : { outcome }), + ...(lmsConnectionId === undefined ? {} : { lmsConnectionId }), + ...(appInstallationId === undefined ? {} : { appInstallationId }), + ...(occurredAfter === undefined ? {} : { occurredAfter }), + ...(occurredBefore === undefined ? {} : { occurredBefore }), + }; +} + +function parseDateTimeQuery(value: string | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString(); +} + +function formString(form: FormData, key: string): string { + const value = form.get(key); + return typeof value === 'string' ? value.trim() : ''; +} + +function optionalQuery(context: Context, key: string): string | undefined { + const value = context.req.query(key)?.trim(); + return value ? value : undefined; +} + +function parsePlatformType(value: string): LmsPlatformType | null { + return value === 'canvas' || value === 'moodle' || value === 'brightspace' || + value === 'sakai' || value === 'synthetic' + ? value + : null; +} + +function parseDecision(value: string | undefined): 'allowed' | 'denied' | undefined { + return value === 'allowed' || value === 'denied' ? value : undefined; +} + +function parseOutcome( + value: string | undefined, +): 'completed' | 'failed' | 'limited' | undefined { + return value === 'completed' || value === 'failed' || value === 'limited' ? value : undefined; +} diff --git a/src/app_admin_grade_smoke_moodle_test.ts b/src/app_admin_grade_smoke_moodle_test.ts deleted file mode 100644 index a1067c2..0000000 --- a/src/app_admin_grade_smoke_moodle_test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { restoreEnv, withFetchStub } from './admin/deployment_detail_test_helpers.ts'; -import { getTestToolPrivateJwkEnvValue } from './test_helpers/lti.ts'; -import { createTestLtiToolContext } from './test_helpers/lti_tool_context.ts'; -import { - buildSmokeRouteFixture, - buildSmokeVerificationFormData, - createSmokeRouteRepository, - createSuccessfulSmokeFetchHandler, -} from './app_admin_grade_smoke_test_support.ts'; -import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; - -Deno.test('POST /admin/packages/:appId/deployment/verify-grade-smoke runs the Moodle grade-return check and records deployment-scoped evidence', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const fixture = buildSmokeRouteFixture('moodle'); - const repository = createSmokeRouteRepository(fixture); - const requestedUrls: string[] = []; - const ltiToolContext = createTestLtiToolContext({ - sessionId: fixture.session.ltiToolSessionId ?? 'missing-lti-tool-session', - lineItemUrl: fixture.smokeLineItemUrl, - }); - const formData = buildSmokeVerificationFormData(fixture); - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - await repository.saveLanternDefaultLtiProfile({ - defaultLtiProfile: 'certification', - }); - - try { - await withFetchStub(createSuccessfulSmokeFetchHandler(fixture, requestedUrls), async () => { - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - }).request( - 'http://localhost/admin/packages/chapter-4-asteroids/deployment/verify-grade-smoke', - { - method: 'POST', - headers: { - Origin: 'http://localhost', - }, - body: formData, - }, - ); - - assertEquals(response.status, 303); - assertEquals( - response.headers.get('location'), - '/admin/packages/chapter-4-asteroids/deployment?lms=moodle#slot-panel', - ); - - const auditEvents = await repository.listAuditEventsByEventType( - 'deployment.ags_smoke_verified', - ); - - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.status, 'succeeded'); - assertEquals(auditEvents[0]?.deploymentRecordId, fixture.deploymentId); - assertEquals(auditEvents[0]?.detail.lms, 'moodle'); - assertEquals(auditEvents[0]?.detail.agsCapable, true); - assertEquals(auditEvents[0]?.detail.publicationStatus, 'succeeded'); - assertEquals(auditEvents[0]?.detail.ltiProfileId, 'certification'); - assertEquals(auditEvents[0]?.detail.ltiProfileSource, 'lanternDefault'); - assertEquals(auditEvents[0]?.detail.lineItemUrl, fixture.smokeLineItemUrl); - assertEquals(requestedUrls.includes(`POST ${fixture.finalGradeLineItemUrl}/scores`), false); - }); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } -}); - -Deno.test('POST /admin/packages/:appId/deployment/verify-grade-smoke records bounded deployment-scoped failure evidence when lti-tool score publish fails', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const fixture = buildSmokeRouteFixture('moodle'); - const repository = createSmokeRouteRepository(fixture); - const ltiToolContext = createTestLtiToolContext({ - sessionId: fixture.session.ltiToolSessionId ?? 'missing-lti-tool-session', - submitScoreSuccess: false, - submitScoreMessage: 'simulated score failure', - }); - const formData = buildSmokeVerificationFormData(fixture); - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - await withFetchStub( - (input) => { - throw new Error(`Unexpected smoke request GET ${String(input)}`); - }, - async () => { - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - }).request( - 'http://localhost/admin/packages/chapter-4-asteroids/deployment/verify-grade-smoke', - { - method: 'POST', - headers: { - Origin: 'http://localhost', - }, - body: formData, - }, - ); - const body = await response.text(); - - assertEquals(response.status, 500); - assertStringIncludes(body, 'Grade return check failed'); - - const auditEvents = await repository.listAuditEventsByEventType( - 'deployment.ags_smoke_verified', - ); - const errorDetail = (auditEvents[0]?.detail.error ?? null) as { - code?: string; - message?: string; - } | null; - - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.status, 'failed'); - assertEquals(auditEvents[0]?.detail.lms, 'moodle'); - assertEquals(auditEvents[0]?.detail.agsCapable, true); - assertEquals(auditEvents[0]?.detail.publicationStatus, 'failed'); - assertEquals(errorDetail?.code, 'score_publish_failed'); - assertEquals(errorDetail?.message, 'simulated score failure'); - assertEquals(JSON.stringify(auditEvents[0]?.detail ?? {}).includes('final-grade'), false); - }, - ); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } -}); - -Deno.test('deployment smoke verification renders the latest Moodle result back on the existing deployment detail page', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const fixture = buildSmokeRouteFixture('moodle'); - const repository = createSmokeRouteRepository(fixture, { - includeControlPlaneDetail: true, - }); - const ltiToolContext = createTestLtiToolContext({ - sessionId: fixture.session.ltiToolSessionId ?? 'missing-lti-tool-session', - lineItemUrl: fixture.smokeLineItemUrl, - }); - const formData = buildSmokeVerificationFormData(fixture); - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - await withFetchStub(createSuccessfulSmokeFetchHandler(fixture), async () => { - const postResponse = await createAuthenticatedTestApp({ - getRepository: () => repository, - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - }).request( - 'http://localhost/admin/packages/chapter-4-asteroids/deployment/verify-grade-smoke', - { - method: 'POST', - headers: { - Origin: 'http://localhost', - }, - body: formData, - }, - ); - - assertEquals(postResponse.status, 303); - - const getResponse = await createAuthenticatedTestApp({ - getRepository: () => repository, - }).request('http://localhost/admin/packages/chapter-4-asteroids/deployment?lms=moodle'); - const body = await getResponse.text(); - - assertEquals(getResponse.status, 200); - assertStringIncludes(body, 'Latest grade return check'); - assertStringIncludes(body, 'Grade return access'); - assertStringIncludes(body, fixture.smokeLineItemUrl); - assertStringIncludes(body, 'Run grade return check'); - }); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } -}); diff --git a/src/app_admin_grade_smoke_route.ts b/src/app_admin_grade_smoke_route.ts deleted file mode 100644 index bfa3664..0000000 --- a/src/app_admin_grade_smoke_route.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { Hono } from '@hono/hono'; -import { renderDeploymentDetailPage } from './admin/deployment_detail.ts'; -import { renderPackageIndexPage } from './admin/package_index.ts'; -import { - loadDeploymentDetailState, - loadDeploymentDetailStateSafe, -} from './app_deployment_support.ts'; -import { combineNotices, createErrorNotice } from './app_notice_support.ts'; -import { listCanvasEnvironments } from './lti/config.ts'; -import { resolveLtiProfileForDeployment } from './lti/profile_resolution.ts'; -import type { AppServices } from './app_services.ts'; -import { adminPageInput } from './admin_auth/page_input.ts'; -import { - recordGradeSmokeAuditEvent, - runGradeSmokeVerification, -} from './app_admin_grade_smoke_support.ts'; -import { resolveRequestAppOrigin } from './app_origin.ts'; -import { - formatGradeSmokeLmsLabel, - parseDeploymentRecordId, - parseGradeSmokeLms, - requireGradeSmokeBinding, - requireGradeSmokeDeployment, - statusForGradeSmokeError, - statusForGradeSmokeFailureCode, -} from './app_admin_grade_smoke_validation.ts'; - -export function registerAdminGradeSmokeRoute(app: Hono, services: AppServices): void { - app.post('/admin/packages/:appId/deployment/verify-grade-smoke', async (context) => { - const appId = context.req.param('appId'); - const repository = services.getRepository(); - const opsRepository = services.getOpsRepository(); - let smokeLms: 'moodle' | 'sakai' | null = null; - let deploymentRecordId: number | null = null; - - try { - const appOrigin = resolveRequestAppOrigin(context, services.env); - const detail = await loadDeploymentDetailState(repository, appId, appOrigin); - const formData = await context.req.formData(); - - smokeLms = parseGradeSmokeLms(formData.get('lms')); - deploymentRecordId = parseDeploymentRecordId(formData.get('deploymentRecordId')); - - const targetDeployment = requireGradeSmokeDeployment( - detail.deployments, - smokeLms, - deploymentRecordId, - ); - const ltiProfile = await resolveLtiProfileForDeployment({ - repository, - deployment: targetDeployment, - }); - const binding = requireGradeSmokeBinding(targetDeployment, smokeLms); - const latestSession = await repository.getLatestRuntimeSessionByDeploymentId( - targetDeployment.id, - ); - - if (latestSession === null) { - throw new Error( - `Launch the ${ - formatGradeSmokeLmsLabel( - smokeLms, - ) - } setup once before running a grade return check.`, - ); - } - - const attempt = await repository.getAttemptById(latestSession.attemptId); - - if (attempt === null) { - throw new Error( - 'Launch state is incomplete for this setup. Try a fresh launch before running a grade return check.', - ); - } - - const result = await runGradeSmokeVerification({ - repository, - appTitle: detail.appTitle, - binding, - session: latestSession, - attempt, - getLtiToolContext: () => services.getLtiToolContext(appOrigin), - ltiProfile, - }); - - await recordGradeSmokeAuditEvent( - repository, - targetDeployment, - latestSession, - result, - ltiProfile, - ); - - if (result.status === 'succeeded') { - return context.redirect( - `/admin/packages/${appId}/deployment?lms=${smokeLms}#slot-panel`, - 303, - ); - } - - const controlPlaneDetail = await opsRepository.getControlPlaneDeploymentDetail( - targetDeployment.id, - ); - - return context.html( - renderDeploymentDetailPage(adminPageInput(context, { - appId, - appTitle: detail.appTitle, - history: detail.history, - deployments: detail.deployments, - selectedLms: smokeLms, - nrpsVerification: detail.nrpsVerification, - lanternLtiProfileSettings: detail.ltiProfileSettings, - controlPlaneDetail, - canvasConfigUrl: detail.canvasConfigUrl.url, - supportedCanvasEnvironments: listCanvasEnvironments(), - notice: combineNotices( - detail.canvasConfigUrl.notice, - createErrorNotice( - 'Grade return check failed', - new Error(result.detail.error?.message ?? result.summary), - ), - ), - })), - statusForGradeSmokeFailureCode(result.detail.error?.code ?? null), - ); - } catch (error) { - const detail = await loadDeploymentDetailStateSafe( - repository, - appId, - resolveRequestAppOrigin(context, services.env), - ); - - if (detail.history.length === 0) { - return context.html( - renderPackageIndexPage(adminPageInput(context, { - versions: [], - notice: createErrorNotice('Connections page unavailable', error), - })), - statusForGradeSmokeError(error), - ); - } - - const controlPlaneDetail = deploymentRecordId === null - ? null - : await opsRepository.getControlPlaneDeploymentDetail(deploymentRecordId); - - return context.html( - renderDeploymentDetailPage(adminPageInput(context, { - appId, - appTitle: detail.appTitle, - history: detail.history, - deployments: detail.deployments, - selectedLms: smokeLms, - nrpsVerification: detail.nrpsVerification, - lanternLtiProfileSettings: detail.ltiProfileSettings, - controlPlaneDetail, - canvasConfigUrl: detail.canvasConfigUrl.url, - supportedCanvasEnvironments: listCanvasEnvironments(), - notice: combineNotices( - detail.canvasConfigUrl.notice, - createErrorNotice('Grade return check failed', error), - ), - })), - statusForGradeSmokeError(error), - ); - } - }); -} diff --git a/src/app_admin_grade_smoke_sakai_test.ts b/src/app_admin_grade_smoke_sakai_test.ts deleted file mode 100644 index a58c26e..0000000 --- a/src/app_admin_grade_smoke_sakai_test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { assertEquals } from '@std/assert'; -import { restoreEnv, withFetchStub } from './admin/deployment_detail_test_helpers.ts'; -import { getTestToolPrivateJwkEnvValue } from './test_helpers/lti.ts'; -import { createTestLtiToolContext } from './test_helpers/lti_tool_context.ts'; -import { - buildSmokeRouteFixture, - buildSmokeVerificationFormData, - createSmokeRouteRepository, - createSuccessfulSmokeFetchHandler, -} from './app_admin_grade_smoke_test_support.ts'; -import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; - -Deno.test('POST /admin/packages/:appId/deployment/verify-grade-smoke runs the blessed Sakai smoke path and records deployment-scoped evidence', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const fixture = buildSmokeRouteFixture('sakai'); - const repository = createSmokeRouteRepository(fixture); - const requestedUrls: string[] = []; - const ltiToolContext = createTestLtiToolContext({ - sessionId: fixture.session.ltiToolSessionId ?? 'missing-lti-tool-session', - lineItemUrl: fixture.smokeLineItemUrl, - }); - const formData = buildSmokeVerificationFormData(fixture); - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - await withFetchStub(createSuccessfulSmokeFetchHandler(fixture, requestedUrls), async () => { - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - }).request( - 'http://localhost/admin/packages/chapter-4-asteroids/deployment/verify-grade-smoke', - { - method: 'POST', - headers: { - Origin: 'http://localhost', - }, - body: formData, - }, - ); - - assertEquals(response.status, 303); - assertEquals( - response.headers.get('location'), - '/admin/packages/chapter-4-asteroids/deployment?lms=sakai#slot-panel', - ); - - const auditEvents = await repository.listAuditEventsByEventType( - 'deployment.ags_smoke_verified', - ); - - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.status, 'succeeded'); - assertEquals(auditEvents[0]?.deploymentRecordId, fixture.deploymentId); - assertEquals(auditEvents[0]?.detail.lms, 'sakai'); - assertEquals(auditEvents[0]?.detail.agsCapable, true); - assertEquals(auditEvents[0]?.detail.publicationStatus, 'succeeded'); - assertEquals(auditEvents[0]?.detail.lineItemUrl, fixture.smokeLineItemUrl); - assertEquals(requestedUrls.includes(`POST ${fixture.finalGradeLineItemUrl}/scores`), false); - }); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } -}); diff --git a/src/app_admin_grade_smoke_support.ts b/src/app_admin_grade_smoke_support.ts deleted file mode 100644 index f068cb8..0000000 --- a/src/app_admin_grade_smoke_support.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { errorMessage } from './app_status_support.ts'; -import { buildResolvedLtiProfileDetail } from './lti/profile_resolution.ts'; -import type { ResolvedLtiProfile } from './lti/profile.ts'; -import type { DeploymentBinding, RuntimeSessionRecord } from './lti/types.ts'; -import { LTI_AGS_LINEITEM_SCOPE, LTI_AGS_SCORE_SCOPE } from './lti/types.ts'; -import type { LanternLtiToolContext } from './lti/tool_provider.ts'; -import type { LTISession } from '@longsightgroup/lti-tool'; -import { requireVerifiedLtiSessionWithContext } from './lti/session_boundary.ts'; -import type { PackageReviewRepository } from './package_review/repository.ts'; -import type { AttemptRecord, DeploymentRecord } from './package_review/types.ts'; -import { buildSmokeVerificationLineItemSpec } from './runtime/gateway_publication_support.ts'; -import { - formatGradeSmokeLmsLabel, - type SupportedSmokeLms, -} from './app_admin_grade_smoke_validation.ts'; - -export interface GradeSmokeAuditDetail { - lms: SupportedSmokeLms; - contextId: string | null; - agsCapable: boolean; - publicationStatus: 'succeeded' | 'failed' | 'not_attempted'; - lineItemUrl: string | null; - error: { - code: string; - message: string; - } | null; -} - -export interface GradeSmokeVerificationResult { - status: 'succeeded' | 'failed'; - summary: string; - attemptId: string; - detail: GradeSmokeAuditDetail; -} - -export async function runGradeSmokeVerification(input: { - repository: Pick; - appTitle: string; - binding: Extract; - session: RuntimeSessionRecord; - attempt: AttemptRecord; - getLtiToolContext: () => Promise; - ltiProfile?: ResolvedLtiProfile | null; -}): Promise { - const ags = input.session.services.ags; - - if (ags === null || ags.lineitemsUrl === null) { - return buildGradeSmokeFailureResult({ - lms: input.binding.lms, - contextId: input.attempt.contextId, - attemptId: input.attempt.attemptId, - agsCapable: false, - publicationStatus: 'not_attempted', - lineItemUrl: null, - code: 'missing_ags_context', - message: 'Launch did not provide the grade return access Lantern needs for this check.', - }); - } - - if (!ags.scope.includes(LTI_AGS_SCORE_SCOPE) || !ags.scope.includes(LTI_AGS_LINEITEM_SCOPE)) { - return buildGradeSmokeFailureResult({ - lms: input.binding.lms, - contextId: input.attempt.contextId, - attemptId: input.attempt.attemptId, - agsCapable: false, - publicationStatus: 'not_attempted', - lineItemUrl: null, - code: 'missing_ags_scope', - message: 'Launch did not grant the grade return scopes Lantern needs for this check.', - }); - } - - const ltiSession = await loadSmokeVerificationLtiSession({ - getLtiToolContext: input.getLtiToolContext, - session: input.session, - }); - - if ('error' in ltiSession) { - return buildGradeSmokeFailureResult({ - lms: input.binding.lms, - contextId: input.attempt.contextId, - attemptId: input.attempt.attemptId, - agsCapable: true, - publicationStatus: 'not_attempted', - lineItemUrl: null, - code: ltiSession.error.code, - message: ltiSession.error.message, - }); - } - - let lineItemUrl: string | null = null; - - try { - const spec = buildSmokeVerificationLineItemSpec({ - appId: input.session.appId, - appTitle: input.appTitle, - lms: input.binding.lms, - }); - const lineItemResult = await ltiSession.ltiContext.tool.createAdvantage(ltiSession.session) - .findOrCreateLineItem({ - resourceLinkId: input.attempt.resourceLinkId, - resourceId: spec.resourceId, - tag: spec.tag, - create: { - label: spec.label, - scoreMaximum: spec.scoreMaximum, - }, - }); - - if (!lineItemResult.success) { - throw new Error(lineItemResult.error.message); - } - - lineItemUrl = lineItemResult.data.id; - } catch (error) { - return buildGradeSmokeFailureResult({ - lms: input.binding.lms, - contextId: input.attempt.contextId, - attemptId: input.attempt.attemptId, - agsCapable: true, - publicationStatus: 'not_attempted', - lineItemUrl: null, - code: 'line_item_failed', - message: errorMessage(error), - }); - } - - try { - const scoreResult = await ltiSession.ltiContext.tool.createAdvantage(ltiSession.session) - .submitScore( - { - userId: input.attempt.userId, - scoreGiven: 1, - scoreMaximum: 1, - activityProgress: 'Completed', - gradingProgress: 'FullyGraded', - }, - { lineItemUrl }, - ); - - if (!scoreResult.success) { - throw new Error(scoreResult.error.message); - } - } catch (error) { - return buildGradeSmokeFailureResult({ - lms: input.binding.lms, - contextId: input.attempt.contextId, - attemptId: input.attempt.attemptId, - agsCapable: true, - publicationStatus: 'failed', - lineItemUrl, - code: 'score_publish_failed', - message: errorMessage(error), - }); - } - - return { - status: 'succeeded', - summary: `${formatGradeSmokeLmsLabel(input.binding.lms)} grade return check passed.`, - attemptId: input.attempt.attemptId, - detail: { - lms: input.binding.lms, - contextId: input.attempt.contextId, - agsCapable: true, - publicationStatus: 'succeeded', - lineItemUrl, - error: null, - }, - }; -} - -async function loadSmokeVerificationLtiSession(input: { - getLtiToolContext: () => Promise; - session: RuntimeSessionRecord; -}): Promise< - | { - ltiContext: LanternLtiToolContext; - session: LTISession; - } - | { - error: { - code: string; - message: string; - }; - } -> { - try { - const loaded = await requireVerifiedLtiSessionWithContext({ - getContext: input.getLtiToolContext, - sessionId: input.session.ltiToolSessionId, - missingMessage: 'A verified lti-tool session is required for this grade return check.', - notFoundMessage: 'The verified LTI session for this grade return check was not found.', - }); - - return { ltiContext: loaded.context, session: loaded.session }; - } catch (error) { - return { - error: { - code: 'lti_session_missing', - message: errorMessage(error), - }, - }; - } -} - -export async function recordGradeSmokeAuditEvent( - repository: PackageReviewRepository, - deployment: DeploymentRecord, - session: RuntimeSessionRecord, - result: GradeSmokeVerificationResult, - ltiProfile: ResolvedLtiProfile | null = null, -): Promise { - await repository.recordAuditEvent({ - eventType: 'deployment.ags_smoke_verified', - actorType: 'system', - actorId: null, - deploymentRecordId: deployment.id, - packageVersionId: deployment.enabledPackageVersionId ?? session.packageVersionId, - attemptId: result.attemptId, - lineItemBindingId: null, - status: result.status, - summary: result.summary, - detail: { - lms: result.detail.lms, - contextId: result.detail.contextId, - agsCapable: result.detail.agsCapable, - publicationStatus: result.detail.publicationStatus, - lineItemUrl: result.detail.lineItemUrl, - error: result.detail.error, - ...(ltiProfile === null ? {} : buildResolvedLtiProfileDetail(ltiProfile)), - }, - occurredAt: new Date().toISOString(), - }); -} - -function buildGradeSmokeFailureResult(input: { - lms: SupportedSmokeLms; - contextId: string | null; - attemptId: string; - agsCapable: boolean; - publicationStatus: GradeSmokeAuditDetail['publicationStatus']; - lineItemUrl: string | null; - code: string; - message: string; -}): GradeSmokeVerificationResult { - return { - status: 'failed', - summary: `${formatGradeSmokeLmsLabel(input.lms)} grade return check failed.`, - attemptId: input.attemptId, - detail: { - lms: input.lms, - contextId: input.contextId, - agsCapable: input.agsCapable, - publicationStatus: input.publicationStatus, - lineItemUrl: input.lineItemUrl, - error: { - code: input.code, - message: input.message, - }, - }, - }; -} diff --git a/src/app_admin_grade_smoke_test.ts b/src/app_admin_grade_smoke_test.ts deleted file mode 100644 index a444273..0000000 --- a/src/app_admin_grade_smoke_test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { assertEquals } from '@std/assert'; -import { runGradeSmokeVerification } from './app_admin_grade_smoke_support.ts'; -import { - buildFinalGradeLineItemSpec, - buildSmokeVerificationLineItemSpec, -} from './runtime/gateway_publication_support.ts'; -import { - buildAttemptRecord, - buildPackageVersionRecord, - buildRuntimeSessionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { buildSmokeFixture } from './app_admin_grade_smoke_test_support.ts'; - -Deno.test('grade smoke scaffolding seeds one blessed Moodle smoke line item identity', () => { - const fixture = buildSmokeFixture('moodle'); - - assertEquals(fixture.binding.lms, 'moodle'); - assertEquals( - buildSmokeVerificationLineItemSpec({ - appId: 'chapter-4-asteroids', - appTitle: fixture.appTitle, - lms: fixture.binding.lms, - }), - fixture.expectedSmokeLineItem, - ); -}); - -Deno.test('grade smoke scaffolding seeds one blessed Sakai smoke line item identity', () => { - const fixture = buildSmokeFixture('sakai'); - - assertEquals(fixture.binding.lms, 'sakai'); - assertEquals( - buildSmokeVerificationLineItemSpec({ - appId: 'chapter-4-asteroids', - appTitle: fixture.appTitle, - lms: fixture.binding.lms, - }), - fixture.expectedSmokeLineItem, - ); -}); - -Deno.test('runtime final-grade publication keeps a managed final-grade line item identity', () => { - const session = buildRuntimeSessionRecord(); - const packageVersion = buildPackageVersionRecord(); - - assertEquals( - buildFinalGradeLineItemSpec({ - session, - packageVersion, - scoreMaximum: 100, - }), - { - resourceId: 'lantern:chapter-4-asteroids:0.1.0:activity-123', - tag: 'final-grade', - label: 'Chapter 4 Asteroids Final Grade', - scoreMaximum: 100, - }, - ); -}); - -Deno.test('grade smoke verification requires a verified lti-tool session', async () => { - const fixture = buildSmokeFixture('moodle'); - const repository = createInMemoryPackageReviewRepository({}); - const attempt = buildAttemptRecord({ - attemptId: fixture.session.attemptId, - deploymentRecordId: fixture.session.deploymentRecordId, - deploymentSlug: fixture.session.deploymentSlug, - contextId: fixture.session.launch.courseId, - activityId: fixture.session.launch.activityId, - }); - - const result = await runGradeSmokeVerification({ - repository, - appTitle: fixture.appTitle, - binding: fixture.binding, - session: { - ...fixture.session, - ltiToolSessionId: null, - }, - attempt, - getLtiToolContext: () => { - throw new Error('Should not load lti-tool context without a session id.'); - }, - }); - - assertEquals(result.status, 'failed'); - assertEquals(result.detail.publicationStatus, 'not_attempted'); - assertEquals(result.detail.error?.code, 'lti_session_missing'); -}); diff --git a/src/app_admin_grade_smoke_test_support.ts b/src/app_admin_grade_smoke_test_support.ts deleted file mode 100644 index 5e5b39b..0000000 --- a/src/app_admin_grade_smoke_test_support.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { createInMemoryPackageReviewRepository } from './test_helpers/package_review.ts'; -import { - buildAttemptRecord, - buildControlPlaneDeploymentDetailSnapshot, - buildControlPlaneDeploymentInventoryRow, - buildDeploymentRecord, - buildPackageVersionRecord, -} from './test_helpers/package_review.ts'; -import { - buildLaunchServiceClaims, - buildMoodleDeploymentBinding, - buildRuntimeSessionRecord, - buildSakaiDeploymentBinding, -} from './test_helpers/lti.ts'; - -export interface SmokeFixture { - binding: - | ReturnType - | ReturnType; - session: ReturnType; - appTitle: string; - expectedSmokeLineItem: { - resourceId: string; - tag: 'smoke-verification'; - label: string; - scoreMaximum: 1; - }; -} - -export interface SmokeRouteFixture extends SmokeFixture { - deploymentId: number; - deploymentSlug: string; - attemptId: string; - userId: string; - finalGradeLineItemUrl: string; - smokeLineItemUrl: string; -} - -export function buildSmokeFixture(lms: 'moodle' | 'sakai'): SmokeFixture { - const binding = lms === 'moodle' ? buildMoodleDeploymentBinding() : buildSakaiDeploymentBinding(); - const appTitle = 'Chapter 4 Asteroids'; - - return { - binding, - session: buildRuntimeSessionRecord({ - ltiToolSessionId: `${lms}-lti-tool-session-123`, - deploymentRecordId: lms === 'moodle' ? 2 : 3, - deploymentSlug: `chapter-4-asteroids-${lms}`, - attemptId: `${lms}-attempt-123`, - servicesLms: lms, - services: buildLaunchServiceClaims({ - lms, - ags: { - lineitemUrl: null, - }, - }), - launch: { - userRole: 'learner', - courseId: `${lms}-course-42`, - assignmentId: `${lms}-assignment-9`, - activityId: 'activity-123', - submissionMode: 'standard', - }, - }), - appTitle, - expectedSmokeLineItem: { - resourceId: `lantern:chapter-4-asteroids:${lms}:smoke`, - tag: 'smoke-verification', - label: `${appTitle} Smoke Verification`, - scoreMaximum: 1, - }, - }; -} - -export function buildSmokeRouteFixture(lms: 'moodle' | 'sakai'): SmokeRouteFixture { - const base = buildSmokeFixture(lms); - const deploymentId = lms === 'moodle' ? 2 : 3; - const deploymentSlug = `chapter-4-asteroids-${lms}`; - const attemptId = `${lms}-attempt-123`; - const finalGradeLineItemUrl = lms === 'moodle' - ? 'https://moodle.example/mod/lti/services.php/2/lineitems/final-grade' - : 'https://sakai.example/direct/lti/lineitems/course-42/items/final-grade'; - const smokeLineItemUrl = lms === 'moodle' - ? 'https://moodle.example/mod/lti/services.php/2/lineitems/9' - : 'https://sakai.example/direct/lti/lineitems/course-42/items/9'; - - return { - ...base, - deploymentId, - deploymentSlug, - attemptId, - userId: `${lms}-user-123`, - finalGradeLineItemUrl, - smokeLineItemUrl, - session: buildRuntimeSessionRecord({ - ltiToolSessionId: `${lms}-lti-tool-session-123`, - deploymentRecordId: deploymentId, - deploymentSlug, - attemptId, - servicesLms: lms, - services: buildLaunchServiceClaims({ - lms, - ags: { - lineitemUrl: finalGradeLineItemUrl, - }, - }), - launch: { - userRole: 'learner', - courseId: `${lms}-course-42`, - assignmentId: `${lms}-assignment-9`, - activityId: 'activity-123', - submissionMode: 'standard', - }, - }), - }; -} - -export function createSmokeRouteRepository( - fixture: SmokeRouteFixture, - input: { - includeControlPlaneDetail?: boolean; - } = {}, -) { - return createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: fixture.deploymentId, - slug: fixture.deploymentSlug, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - lmsType: fixture.binding.lms, - binding: fixture.binding, - }), - ], - attempts: [ - buildAttemptRecord({ - attemptId: fixture.attemptId, - deploymentRecordId: fixture.deploymentId, - deploymentSlug: fixture.deploymentSlug, - userId: fixture.userId, - contextId: fixture.session.launch.courseId, - activityId: fixture.session.launch.activityId, - }), - ], - runtimeSessions: [fixture.session], - controlPlaneDeploymentDetails: input.includeControlPlaneDetail - ? [ - buildControlPlaneDeploymentDetailSnapshot({ - inventory: buildControlPlaneDeploymentInventoryRow({ - deploymentId: fixture.deploymentId, - deploymentSlug: fixture.deploymentSlug, - binding: fixture.binding, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - }), - latestAgsSmoke: null, - }), - ] - : [], - }); -} - -export function buildSmokeVerificationFormData(fixture: SmokeRouteFixture): FormData { - const formData = new FormData(); - formData.set('lms', fixture.binding.lms); - formData.set('deploymentRecordId', String(fixture.deploymentId)); - return formData; -} - -export function createSuccessfulSmokeFetchHandler( - fixture: SmokeRouteFixture, - requestedUrls: string[] = [], -) { - return (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - requestedUrls.push(`${init?.method ?? 'GET'} ${url}`); - - if (url === fixture.binding.accessTokenUrl) { - return new Response( - JSON.stringify({ - access_token: `${fixture.binding.lms}-access-token`, - token_type: 'bearer', - }), - { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }, - ); - } - - if (url === fixture.session.services.ags?.lineitemsUrl && (init?.method ?? 'GET') === 'GET') { - return new Response(JSON.stringify([]), { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }); - } - - if (url === fixture.session.services.ags?.lineitemsUrl && init?.method === 'POST') { - return new Response( - JSON.stringify({ - id: fixture.smokeLineItemUrl, - label: fixture.expectedSmokeLineItem.label, - scoreMaximum: fixture.expectedSmokeLineItem.scoreMaximum, - }), - { - status: 201, - headers: { - 'content-type': 'application/json', - }, - }, - ); - } - - if (url === `${fixture.smokeLineItemUrl}/scores` && init?.method === 'POST') { - return new Response('{}', { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }); - } - - throw new Error(`Unexpected smoke request ${init?.method ?? 'GET'} ${url}`); - }; -} diff --git a/src/app_admin_grade_smoke_validation.ts b/src/app_admin_grade_smoke_validation.ts deleted file mode 100644 index 53f6465..0000000 --- a/src/app_admin_grade_smoke_validation.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { requireTrimmedFormValue } from './app_request_support.ts'; -import type { DeploymentBinding } from './lti/types.ts'; -import type { DeploymentRecord } from './package_review/types.ts'; - -export type SupportedSmokeLms = Extract; - -export function parseGradeSmokeLms(value: FormDataEntryValue | null): SupportedSmokeLms { - const lms = requireTrimmedFormValue(value, 'Grade return check target is required.'); - - if (lms !== 'moodle' && lms !== 'sakai') { - throw new Error('Choose a saved Moodle or Sakai setup first.'); - } - - return lms; -} - -export function parseDeploymentRecordId(value: FormDataEntryValue | null): number { - const rawValue = requireTrimmedFormValue(value, 'Grade return check target is required.'); - const parsed = Number(rawValue); - - if (!Number.isInteger(parsed) || parsed < 1) { - throw new Error('Grade return check target is required.'); - } - - return parsed; -} - -export function requireGradeSmokeDeployment( - deployments: DeploymentRecord[], - lms: SupportedSmokeLms, - deploymentRecordId: number, -): DeploymentRecord { - const deployment = deployments.find((candidate) => candidate.id === deploymentRecordId); - - if (deployment === undefined || deployment.lmsType !== lms || deployment.binding?.lms !== lms) { - throw new Error('Choose a saved Moodle or Sakai setup first.'); - } - - return deployment; -} - -export function requireGradeSmokeBinding( - deployment: DeploymentRecord, - lms: SupportedSmokeLms, -): Extract { - if (deployment.binding === null || deployment.binding.lms !== lms) { - throw new Error( - `Save the exact ${formatGradeSmokeLmsLabel(lms)} setup before running a grade return check.`, - ); - } - - return deployment.binding; -} - -export function statusForGradeSmokeError(error: unknown): 409 | 500 { - if (!(error instanceof Error)) { - return 500; - } - - if ( - error.message.includes('required') || - error.message.includes('Choose one supported') || - error.message.includes('Save the exact') || - error.message.includes('Import a package version') || - error.message.includes('Launch ') - ) { - return 409; - } - - return 500; -} - -export function statusForGradeSmokeFailureCode(code: string | null): 409 | 500 { - if (code === 'missing_ags_context' || code === 'missing_ags_scope') { - return 409; - } - - return 500; -} - -export function formatGradeSmokeLmsLabel(lms: SupportedSmokeLms): string { - switch (lms) { - case 'moodle': - return 'Moodle'; - case 'sakai': - return 'Sakai'; - } -} diff --git a/src/app_admin_inventory_routes.ts b/src/app_admin_inventory_routes.ts index f234b33..683f0f0 100644 --- a/src/app_admin_inventory_routes.ts +++ b/src/app_admin_inventory_routes.ts @@ -9,7 +9,6 @@ import { import { createErrorNotice, packageOverviewPath } from './app_notice_support.ts'; import { requireTrimmedFormValue } from './app_request_support.ts'; import { statusForError } from './app_status_support.ts'; -import { loadPreviewCapabilityLog } from './app_deployment_support.ts'; import type { AppServices } from './app_services.ts'; import { adminPageInput } from './admin_auth/page_input.ts'; import { @@ -29,11 +28,9 @@ import { renderPackageDiffPage } from './admin/package_diff_page.ts'; import { renderPackageImportPage } from './admin/package_import_page.ts'; import { renderPackageIndexPage } from './admin/package_index.ts'; import { renderPackageOverviewPage } from './admin/package_overview.ts'; -import { renderPackageReportsPage } from './admin/package_reports.ts'; import { renderReferencePackagePage } from './admin/package_reference_page.ts'; import { renderAppRevisionPage } from './admin/app_revision_page.ts'; import { comparePackageVersions } from './package_review/package_diff.ts'; -import { buildInstructorReport } from './reports/instructor_report.ts'; export function registerAdminInventoryRoutes(app: Hono, services: AppServices): void { app.get('/admin/packages', async (context) => { @@ -143,6 +140,29 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): return context.html(renderReferencePackagePage(adminPageInput(context, {}))); }); + app.get('/admin/packages/:appId/evidence/:artifactId', async (context) => { + const repository = services.getRepository(); + const artifact = await repository.getAttemptEvidenceArtifactById( + context.req.param('artifactId'), + ); + if (artifact === null) { + return context.text('Evidence artifact not found.', 404); + } + const attempt = await repository.getAttemptById(artifact.attemptId); + if (attempt === null || attempt.appId !== context.req.param('appId')) { + return context.text('Evidence artifact not found.', 404); + } + const bytes = await services.evidenceArtifactStore.readBytes(artifact.storageKey); + return new Response(new Uint8Array(bytes).buffer, { + headers: { + 'content-type': artifact.contentType, + 'content-disposition': `inline; filename="${safeEvidenceFileName(artifact.fileName)}"`, + 'cache-control': 'private, no-store', + 'x-content-type-options': 'nosniff', + }, + }); + }); + app.get('/admin/packages/:appId', async (context) => { const appId = context.req.param('appId'); @@ -164,14 +184,11 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): ); } - const deployments = await repository.listDeploymentsByApp(appId); - return context.html( renderPackageOverviewPage(adminPageInput(context, { appId, appTitle: history[0]?.title ?? appId, history, - deployments, })), ); } catch (error) { @@ -197,52 +214,6 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): } }); - app.get('/admin/packages/:appId/reports', async (context) => { - const appId = context.req.param('appId'); - - try { - const repository = services.getRepository(); - const history = await repository.listPackageVersionsByApp(appId); - - if (history.length === 0) { - return context.html( - renderPackageIndexPage(adminPageInput(context, { - versions: await repository.listPackageVersions(), - notice: { - tone: 'error', - title: 'App not found', - detail: 'Lantern could not find that app.', - }, - })), - 404, - ); - } - - const report = await buildInstructorReport({ - repository, - appId, - generatedAt: new Date().toISOString(), - }); - - return context.html( - renderPackageReportsPage(adminPageInput(context, { - appId, - appTitle: history[0]?.title ?? appId, - history, - report, - })), - ); - } catch (error) { - return context.html( - renderPackageIndexPage(adminPageInput(context, { - versions: [], - notice: createErrorNotice('Instructor reports unavailable', error), - })), - statusForError(error), - ); - } - }); - app.get('/admin/packages/:appId/versions/:version', async (context) => { try { const repository = services.getRepository(); @@ -266,27 +237,20 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): } const history = await repository.listPackageVersionsByApp(packageVersion.appId); - const generationActivityEvents = await repository.listAuditEventsByEventType( - 'app_generation.saved_pending_version', - ); - const reviewedPlacements = await repository.listReviewedPlacementsByPackageVersion( + const latestPreviewSession = await repository.getLatestPreviewSessionByPackageVersion( packageVersion.id, + 'adminTestLaunch', ); - const runtimeLog = await loadPreviewCapabilityLog({ - repository, - packageVersionId: packageVersion.id, - }); + const previewEvidence = latestPreviewSession === null + ? [] + : await repository.listPreviewEvidence(latestPreviewSession.sessionId); return context.html( renderPackageDetailPage(adminPageInput(context, { packageVersion, history, - generationActivityEvents: generationActivityEvents.filter( - (event) => event.packageVersionId === packageVersion.id, - ), - reviewedPlacements, - latestPreviewSession: runtimeLog.session, - previewEvidence: runtimeLog.evidence, + latestPreviewSession, + previewEvidence, })), ); } catch (error) { @@ -534,3 +498,7 @@ export function registerAdminInventoryRoutes(app: Hono, services: AppServices): return await handleReviewDecision(context, services, 'reject'); }); } + +function safeEvidenceFileName(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) || 'evidence'; +} diff --git a/src/app_admin_lms_bindings_test.ts b/src/app_admin_lms_bindings_test.ts deleted file mode 100644 index 91a9bff..0000000 --- a/src/app_admin_lms_bindings_test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { assertEquals } from '@std/assert'; -import { - buildDeploymentRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { - buildCanvasDeploymentBinding, - buildMoodleDeploymentBinding, - buildSakaiDeploymentBinding, -} from './test_helpers/lti.ts'; -import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; - -Deno.test('POST /admin/packages/:appId/deployment/install saves the Moodle binding without mutating Canvas or Sakai slots', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 3, - slug: 'chapter-4-asteroids-pilot', - label: 'Chapter 4 Asteroids Pilot Deployment', - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding: buildCanvasDeploymentBinding(), - }), - buildDeploymentRecord({ - id: 4, - slug: 'chapter-4-asteroids-sakai', - label: 'Chapter 4 Asteroids Sakai Deployment', - binding: buildSakaiDeploymentBinding(), - }), - ], - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const formData = new FormData(); - - formData.set('lms', 'moodle'); - formData.set('issuer', 'https://moodle.example'); - formData.set('clientId', 'moodle-client-999'); - formData.set('deploymentId', 'moodle-deployment-999'); - formData.set('authorizationEndpoint', 'https://moodle.example/mod/lti/auth.php'); - formData.set('accessTokenUrl', 'https://moodle.example/mod/lti/token.php'); - formData.set('jwksUrl', 'https://moodle.example/mod/lti/certs.php'); - - const response = await app.request( - 'http://localhost/admin/packages/chapter-4-asteroids/deployment/install', - { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }, - ); - - assertEquals(response.status, 303); - assertEquals( - response.headers.get('location'), - '/admin/packages/chapter-4-asteroids/deployment?lms=moodle#slot-panel', - ); - - const canvas = await repository.getDeploymentBySlug('chapter-4-asteroids-pilot'); - const moodle = await repository.getDeploymentBySlug('chapter-4-asteroids-moodle'); - const sakai = await repository.getDeploymentBySlug('chapter-4-asteroids-sakai'); - - assertEquals(canvas?.binding?.lms, 'canvas'); - assertEquals(canvas?.binding?.clientId, '10000000000001'); - assertEquals(moodle?.binding?.lms, 'moodle'); - assertEquals(moodle?.binding?.issuer, 'https://moodle.example'); - assertEquals( - moodle?.binding?.lms === 'moodle' ? moodle.binding.authorizationEndpoint : null, - 'https://moodle.example/mod/lti/auth.php', - ); - assertEquals(sakai?.binding?.lms, 'sakai'); - - const auditEvents = await repository.listAuditEventsByEventType('deployment.binding_saved'); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.detail.lms, 'moodle'); - assertEquals(auditEvents[0]?.detail.deploymentSlug, 'chapter-4-asteroids-moodle'); -}); - -Deno.test('POST /admin/packages/:appId/deployment/install saves the Sakai binding without mutating Canvas or Moodle slots', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 3, - slug: 'chapter-4-asteroids-pilot', - label: 'Chapter 4 Asteroids Pilot Deployment', - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding: buildCanvasDeploymentBinding(), - }), - buildDeploymentRecord({ - id: 4, - slug: 'chapter-4-asteroids-moodle', - label: 'Chapter 4 Asteroids Moodle Deployment', - binding: buildMoodleDeploymentBinding(), - }), - ], - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const formData = new FormData(); - - formData.set('lms', 'sakai'); - formData.set('issuer', 'https://sakai.example'); - formData.set('clientId', 'sakai-client-999'); - formData.set('deploymentId', 'sakai-deployment-999'); - formData.set('authorizationEndpoint', 'https://sakai.example/imsoidc/lti13/oidc_auth'); - formData.set('accessTokenUrl', 'https://sakai.example/imsblis/lti13/token/3'); - formData.set('jwksUrl', 'https://sakai.example/imsblis/lti13/keyset'); - - const response = await app.request( - 'http://localhost/admin/packages/chapter-4-asteroids/deployment/install', - { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }, - ); - - assertEquals(response.status, 303); - - const canvas = await repository.getDeploymentBySlug('chapter-4-asteroids-pilot'); - const moodle = await repository.getDeploymentBySlug('chapter-4-asteroids-moodle'); - const sakai = await repository.getDeploymentBySlug('chapter-4-asteroids-sakai'); - - assertEquals(canvas?.binding?.lms, 'canvas'); - assertEquals(moodle?.binding?.lms, 'moodle'); - assertEquals(sakai?.binding?.lms, 'sakai'); - assertEquals(sakai?.binding?.issuer, 'https://sakai.example'); - assertEquals( - sakai?.binding?.lms === 'sakai' ? sakai.binding.authorizationEndpoint : null, - 'https://sakai.example/imsoidc/lti13/oidc_auth', - ); - - const auditEvents = await repository.listAuditEventsByEventType('deployment.binding_saved'); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.detail.lms, 'sakai'); - assertEquals(auditEvents[0]?.detail.deploymentSlug, 'chapter-4-asteroids-sakai'); -}); - -Deno.test('POST /admin/packages/:appId/deployment/pin pins the selected LMS slot and records its identity', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - version: '0.1.0', - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - buildPackageVersionRecord({ - id: 6, - version: '0.2.0', - approvalStatus: 'approved', - reviewNotes: 'Ready for second slot.', - reviewedAt: '2026-03-24T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 3, - slug: 'chapter-4-asteroids-pilot', - label: 'Chapter 4 Asteroids Pilot Deployment', - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding: buildCanvasDeploymentBinding(), - }), - buildDeploymentRecord({ - id: 4, - slug: 'chapter-4-asteroids-moodle', - label: 'Chapter 4 Asteroids Moodle Deployment', - enabledPackageVersionId: null, - enabledPackageVersion: null, - binding: buildMoodleDeploymentBinding(), - }), - ], - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const formData = new FormData(); - - formData.set('lms', 'moodle'); - formData.set('packageVersionId', '6'); - - const response = await app.request( - 'http://localhost/admin/packages/chapter-4-asteroids/deployment/pin', - { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }, - ); - - assertEquals(response.status, 303); - - const canvas = await repository.getDeploymentBySlug('chapter-4-asteroids-pilot'); - const moodle = await repository.getDeploymentBySlug('chapter-4-asteroids-moodle'); - - assertEquals(canvas?.enabledPackageVersionId, 5); - assertEquals(canvas?.enabledPackageVersion, '0.1.0'); - assertEquals(moodle?.enabledPackageVersionId, 6); - assertEquals(moodle?.enabledPackageVersion, '0.2.0'); - - const auditEvents = await repository.listAuditEventsByEventType('deployment.version_pinned'); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.detail.lms, 'moodle'); - assertEquals(auditEvents[0]?.detail.deploymentSlug, 'chapter-4-asteroids-moodle'); - assertEquals(auditEvents[0]?.packageVersionId, 6); -}); diff --git a/src/app_admin_lms_pin_validation_test.ts b/src/app_admin_lms_pin_validation_test.ts deleted file mode 100644 index 6014345..0000000 --- a/src/app_admin_lms_pin_validation_test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { - buildDeploymentRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { buildMoodleDeploymentBinding } from './test_helpers/lti.ts'; -import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; - -Deno.test('POST /admin/packages/:appId/deployment/pin keeps the selected LMS tab open when the version is missing', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - version: '0.1.0', - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 4, - slug: 'chapter-4-asteroids-moodle', - label: 'Chapter 4 Asteroids Moodle Deployment', - enabledPackageVersionId: null, - enabledPackageVersion: null, - binding: buildMoodleDeploymentBinding(), - }), - ], - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const formData = new FormData(); - - formData.set('lms', 'moodle'); - - const response = await app.request( - 'http://localhost/admin/packages/chapter-4-asteroids/deployment/pin', - { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }, - ); - - assertEquals(response.status, 409); - const body = await response.text(); - - assertStringIncludes(body, 'Moodle version pin blocked'); - assertStringIncludes(body, 'Set up Moodle'); - assertStringIncludes(body, 'Choose an approved version.'); - assertStringIncludes(body, 'name="packageVersionId"'); - assertStringIncludes(body, 'aria-invalid="true"'); -}); diff --git a/src/app_admin_operations_routes.ts b/src/app_admin_operations_routes.ts deleted file mode 100644 index 6e73c73..0000000 --- a/src/app_admin_operations_routes.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { Hono } from '@hono/hono'; -import { - renderDeploymentsPage as renderDeploymentsResponse, - renderVerificationPage as renderVerificationResponse, -} from './app_admin_support.ts'; -import { - renderDeploymentsPage as renderDeploymentsView, - renderVerificationPage as renderVerificationView, -} from './admin/control_plane.ts'; -import type { VerificationPageSection } from './admin/verification_navigation.ts'; -import { createErrorNotice } from './app_notice_support.ts'; -import { - parseBrokerVerificationRunForm, - parseLanternDefaultLtiProfileForm, -} from './app_request_support.ts'; -import { statusForError, statusForVerificationError } from './app_status_support.ts'; -import type { AppServices } from './app_services.ts'; -import { adminPageInput } from './admin_auth/page_input.ts'; - -export function registerAdminOperationsRoutes(app: Hono, services: AppServices): void { - app.get('/admin/deployments', async (context) => { - try { - return await renderDeploymentsResponse(context, services); - } catch (error) { - return context.html( - renderDeploymentsView(adminPageInput(context, { - deployments: [], - notice: createErrorNotice('Connections unavailable', error), - })), - statusForError(error), - ); - } - }); - - app.get('/admin/verification', async (context) => { - const section: VerificationPageSection = 'checklist'; - - try { - return await renderVerificationResponse(context, services, { section }); - } catch (error) { - return context.html( - renderVerificationView(adminPageInput(context, { - deployments: [], - latestBrokerVerification: null, - ltiProfileSettings: null, - section, - notice: createErrorNotice('Verification unavailable', error), - })), - statusForError(error), - ); - } - }); - - app.get('/admin/verification/official', async (context) => { - const section: VerificationPageSection = 'official'; - - try { - return await renderVerificationResponse(context, services, { section }); - } catch (error) { - return context.html( - renderVerificationView(adminPageInput(context, { - deployments: [], - latestBrokerVerification: null, - ltiProfileSettings: null, - section, - notice: createErrorNotice('Verification unavailable', error), - })), - statusForError(error), - ); - } - }); - - app.get('/admin/verification/new', async (context) => { - const section: VerificationPageSection = 'new'; - - try { - return await renderVerificationResponse(context, services, { section }); - } catch (error) { - return context.html( - renderVerificationView(adminPageInput(context, { - deployments: [], - latestBrokerVerification: null, - ltiProfileSettings: null, - section, - notice: createErrorNotice('Verification unavailable', error), - })), - statusForError(error), - ); - } - }); - - app.get('/admin/verification/lti-profile', async (context) => { - const section: VerificationPageSection = 'profile'; - - try { - return await renderVerificationResponse(context, services, { section }); - } catch (error) { - return context.html( - renderVerificationView(adminPageInput(context, { - deployments: [], - latestBrokerVerification: null, - ltiProfileSettings: null, - section, - notice: createErrorNotice('Verification unavailable', error), - })), - statusForError(error), - ); - } - }); - - app.post('/admin/verification', async (context) => { - try { - const verificationRun = parseBrokerVerificationRunForm(await context.req.formData()); - - await services.getOpsRepository().recordBrokerVerificationRun(verificationRun); - - return context.redirect( - verificationRun.source === '1edtech' - ? '/admin/verification/official' - : '/admin/verification', - 303, - ); - } catch (error) { - return await renderVerificationResponse(context, services, { - section: 'new', - notice: createErrorNotice('Verification update blocked', error), - status: statusForVerificationError(error), - }); - } - }); - - app.post('/admin/verification/lti-profile', async (context) => { - try { - const defaultLtiProfile = parseLanternDefaultLtiProfileForm(await context.req.formData()); - - await services.getRepository().saveLanternDefaultLtiProfile({ - defaultLtiProfile, - }); - - return context.redirect('/admin/verification/lti-profile', 303); - } catch (error) { - return await renderVerificationResponse(context, services, { - section: 'profile', - notice: createErrorNotice('Lantern default blocked', error), - status: statusForVerificationError(error), - }); - } - }); -} diff --git a/src/app_admin_package_revision_test.ts b/src/app_admin_package_revision_test.ts index e782b6b..0d23635 100644 --- a/src/app_admin_package_revision_test.ts +++ b/src/app_admin_package_revision_test.ts @@ -1,5 +1,5 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; +import { createTestApp as createApp } from './test_helpers/app.ts'; import { buildRevisionAuthoringPrompt } from './app_writer/revision_authoring_prompt.ts'; import { createObjectEnvReader } from './platform/env.ts'; import { diff --git a/src/app_admin_packages_test.ts b/src/app_admin_packages_test.ts deleted file mode 100644 index 700d1e7..0000000 --- a/src/app_admin_packages_test.ts +++ /dev/null @@ -1,880 +0,0 @@ -import { assert, assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; -import { createObjectEnvReader } from './platform/env.ts'; -import { - buildAccessibilityReview, - buildAttemptEventRecord, - buildAttemptRecord, - buildBrokerVerificationStatus, - buildControlPlaneDeploymentInventoryRow, - buildDeploymentRecord, - buildGradePublicationRecord, - buildImportedPackageVersion, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { buildCanvasDeploymentBinding } from './test_helpers/lti.ts'; -import type { PackageSnapshotStore } from './package_review/snapshot_store.ts'; -import { createAdminTestApp } from './test_helpers/admin_auth.ts'; - -Deno.test('GET /admin/packages renders the generic package import action when no versions exist', async () => { - const response = await createAdminApp({ - getRepository: () => createInMemoryPackageReviewRepository(), - }).request('http://localhost/admin/packages'); - - assertEquals(response.status, 200); - const body = await response.text(); - - assertStringIncludes(body, 'No apps yet.'); - assertStringIncludes(body, 'Import package'); - assertStringIncludes(body, 'Open reference apps'); - assertStringIncludes(body, 'href="/admin/packages/import"'); - assertStringIncludes(body, 'Apps'); -}); - -Deno.test('GET /admin/packages renders the app library when package data exists', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - controlPlaneDeployments: [ - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 1, - deploymentSlug: 'chapter-4-asteroids-pilot', - deploymentLabel: 'Chapter 4 Asteroids Pilot Deployment', - lastGradePublishStatus: 'failed', - }), - ], - brokerVerifications: [buildBrokerVerificationStatus()], - }); - const response = await createAdminApp({ - getRepository: () => repository, - }).request('http://localhost/admin/packages'); - - assertEquals(response.status, 200); - const body = await response.text(); - - assertStringIncludes(body, 'Apps'); - assertStringIncludes(body, '1 app'); - assertStringIncludes(body, 'Open app'); - assertStringIncludes(body, 'href="/admin/packages/chapter-4-asteroids"'); - assertStringIncludes(body, 'App settings'); - assertStringIncludes(body, 'Import package'); - assertStringIncludes(body, 'href="/admin/packages/import"'); - assertStringIncludes(body, 'Import reference app'); - assertStringIncludes(body, 'Test Admin'); - assertStringIncludes(body, 'admin@example.test'); - assertStringIncludes(body, 'href="/admin/deployments"'); - assertStringIncludes(body, 'href="/admin/verification"'); - assertStringIncludes(body, 'href="/admin/placements"'); - assertEquals(body.includes('Pilot usage'), false); - assertEquals(body.includes('Broker verification'), false); - assertEquals(body.includes('Save check result'), false); -}); - -Deno.test('GET /admin/packages/import renders the generic package import page', async () => { - const response = await createAdminApp({ - getRepository: () => createInMemoryPackageReviewRepository(), - }).request('http://localhost/admin/packages/import'); - - assertEquals(response.status, 200); - const body = await response.text(); - - assertStringIncludes(body, 'Import package'); - assertStringIncludes(body, 'Choose one package directory.'); - assertStringIncludes(body, 'name="packageFiles"'); - assertStringIncludes(body, 'Open reference apps'); -}); - -Deno.test('GET /admin/packages/reference renders the reference app catalog on its own page', async () => { - const response = await createAdminApp({ - getRepository: () => createInMemoryPackageReviewRepository(), - }).request('http://localhost/admin/packages/reference'); - - assertEquals(response.status, 200); - const body = await response.text(); - - assertStringIncludes(body, 'Reference apps'); - assertStringIncludes(body, 'Import Chapter 4 Asteroids'); - assertStringIncludes(body, 'Import TypeScript Ladder Game'); - assertStringIncludes(body, 'Import Quick Study'); - assertStringIncludes(body, 'Import package'); - assertStringIncludes(body, 'Back to apps'); -}); - -Deno.test('GET /admin/packages/:appId renders the app overview with versions and settings links', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - buildPackageVersionRecord({ - id: 2, - version: '0.2.0', - approvalStatus: 'pending', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 3, - slug: 'chapter-4-asteroids-pilot', - label: 'Chapter 4 Asteroids Pilot Deployment', - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildCanvasDeploymentBinding(), - }), - ], - }); - - const response = await createAdminApp({ - getRepository: () => repository, - }).request('http://localhost/admin/packages/chapter-4-asteroids'); - - assertEquals(response.status, 200); - const body = await response.text(); - - assertStringIncludes(body, 'Versions, governed launch tools, and LMS settings for this app.'); - assertStringIncludes(body, 'Reviewed versions'); - assertStringIncludes( - body, - 'Each version shows whether it is the current reviewed baseline and whether it is live in LMS now.', - ); - assertStringIncludes(body, 'LMS setup'); - assertStringIncludes(body, 'Open latest version'); - assertStringIncludes(body, 'Manage settings'); - assertStringIncludes(body, 'Test launch'); - assertStringIncludes(body, 'Newest upload'); - assertStringIncludes(body, 'Current reviewed baseline'); - assertStringIncludes(body, 'Live now in 1 LMS setup'); - assertStringIncludes(body, 'version-row-actions'); - assertStringIncludes(body, 'App ID chapter-4-asteroids'); - assertEquals(body.includes('

    App ID chapter-4-asteroids

    '), false); - assertStringIncludes(body, 'page-nav-link-current'); - assertStringIncludes(body, 'Latest version'); -}); - -Deno.test('GET /admin/packages/:appId/versions/:version/diff renders package snapshot changes', async () => { - const baseVersion = buildPackageVersionRecord({ - id: 1, - version: '0.1.0', - importedAt: '2026-05-16T13:00:00.000Z', - approvalStatus: 'approved', - reviewedAt: '2026-05-16T14:00:00.000Z', - artifact: { - snapshotRoot: 'snapshots/chapter-4-asteroids/0.1.0', - manifestPath: 'snapshots/chapter-4-asteroids/0.1.0/manifest.json', - entrypointPath: 'snapshots/chapter-4-asteroids/0.1.0/dist/index.html', - digest: 'sha256:base', - }, - manifestJson: { - app_id: 'chapter-4-asteroids', - version: '0.1.0', - title: 'Chapter 4 Asteroids', - entrypoint: '/dist/index.html', - }, - }); - const targetVersion = buildPackageVersionRecord({ - id: 2, - version: '0.2.0', - title: 'Chapter 4 Asteroids Practice', - importedAt: '2026-05-17T13:00:00.000Z', - artifact: { - snapshotRoot: 'snapshots/chapter-4-asteroids/0.2.0', - manifestPath: 'snapshots/chapter-4-asteroids/0.2.0/manifest.json', - entrypointPath: 'snapshots/chapter-4-asteroids/0.2.0/dist/index.html', - digest: 'sha256:target', - }, - manifestJson: { - app_id: 'chapter-4-asteroids', - version: '0.2.0', - title: 'Chapter 4 Asteroids Practice', - entrypoint: '/dist/index.html', - }, - }); - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [targetVersion, baseVersion], - }); - const snapshotStore = createMemoryPackageSnapshotStore({ - [baseVersion.artifact.snapshotRoot]: { - 'manifest.json': JSON.stringify(baseVersion.manifestJson), - 'dist/index.html': '
    Old practice
    ', - 'content/activity.json': '{"rounds":1}', - }, - [targetVersion.artifact.snapshotRoot]: { - 'manifest.json': JSON.stringify(targetVersion.manifestJson), - 'dist/index.html': '
    New practice
    ', - 'content/activity.json': '{"rounds":2}', - 'dist/app.css': 'main { color: #123; }', - }, - }); - const response = await createAdminApp({ - getRepository: () => repository, - packageSnapshotStore: snapshotStore, - }).request('http://localhost/admin/packages/chapter-4-asteroids/versions/0.2.0/diff'); - - assertEquals(response.status, 200); - const body = await response.text(); - - assertStringIncludes(body, 'Version 0.2.0 from 0.1.0'); - assertStringIncludes(body, 'Contract changes'); - assertStringIncludes(body, 'Chapter 4 Asteroids Practice'); - assertStringIncludes(body, 'dist/app.css'); - assertStringIncludes(body, 'Added'); - assertStringIncludes(body, 'Modified'); -}); - -Deno.test('GET /admin/packages/:appId/reports renders instructor progress from learner attempts only', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - appId: 'phonics-match', - title: 'Phonics Match', - capabilities: ['read_activity_content', 'submit_attempt_event', 'finalize_attempt'], - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 1, - appId: 'phonics-match', - slug: 'phonics-match-canvas', - lmsType: 'canvas', - binding: buildCanvasDeploymentBinding(), - }), - buildDeploymentRecord({ - id: 2, - appId: 'phonics-match', - slug: 'phonics-match-preview', - lmsType: 'preview', - binding: null, - }), - ], - attempts: [ - buildAttemptRecord({ - id: 1, - attemptId: 'attempt-ada', - appId: 'phonics-match', - deploymentRecordId: 1, - deploymentSlug: 'phonics-match-canvas', - userId: 'student-ada', - userDisplayName: 'Ada Learner', - userEmail: 'ada@example.edu', - status: 'completed', - completionState: 'completed', - startedAt: '2026-05-16T13:00:00.000Z', - finalizedAt: '2026-05-16T13:08:00.000Z', - }), - buildAttemptRecord({ - id: 2, - attemptId: 'attempt-bea', - appId: 'phonics-match', - deploymentRecordId: 1, - deploymentSlug: 'phonics-match-canvas', - userId: 'student-bea', - userDisplayName: 'Bea Learner', - status: 'in_progress', - startedAt: '2026-05-16T14:00:00.000Z', - }), - buildAttemptRecord({ - id: 3, - attemptId: 'attempt-preview', - appId: 'phonics-match', - deploymentRecordId: 2, - deploymentSlug: 'phonics-match-preview', - userId: 'preview-user', - userDisplayName: 'Preview Student', - status: 'completed', - completionState: 'completed', - }), - ], - attemptEvents: [ - buildAttemptEventRecord({ - id: 1, - attemptId: 'attempt-ada', - sequence: 1, - eventType: 'answer', - event: { - type: 'answer', - questionId: 'card-1', - answer: 'cat', - timestamp: '2026-05-16T13:02:00.000Z', - }, - receivedAt: '2026-05-16T13:02:00.000Z', - }), - buildAttemptEventRecord({ - id: 2, - attemptId: 'attempt-ada', - sequence: 2, - eventType: 'progress', - event: { - type: 'progress', - checkpoint: 'card-count', - value: 10, - timestamp: '2026-05-16T13:07:00.000Z', - }, - receivedAt: '2026-05-16T13:07:00.000Z', - }), - buildAttemptEventRecord({ - id: 3, - attemptId: 'attempt-ada', - sequence: 3, - eventType: 'complete', - event: { - type: 'complete', - timestamp: '2026-05-16T13:08:00.000Z', - }, - receivedAt: '2026-05-16T13:08:00.000Z', - }), - buildAttemptEventRecord({ - id: 4, - attemptId: 'attempt-bea', - sequence: 1, - eventType: 'answer', - event: { - type: 'answer', - questionId: 'card-2', - answer: 'bat', - timestamp: '2026-05-16T14:03:00.000Z', - }, - receivedAt: '2026-05-16T14:03:00.000Z', - }), - ], - gradePublications: [ - buildGradePublicationRecord({ - attemptId: 'attempt-ada', - scoreGiven: 90, - scoreMaximum: 100, - }), - ], - }); - - const response = await createAdminApp({ - getRepository: () => repository, - }).request('http://localhost/admin/packages/phonics-match/reports'); - - assertEquals(response.status, 200); - const body = await response.text(); - - assertStringIncludes(body, 'Instructor reports'); - assertStringIncludes(body, 'Learner progress from Lantern-managed attempts'); - assertStringIncludes(body, 'Class health'); - assertStringIncludes(body, 'Instructor takeaways'); - assertStringIncludes(body, 'Student progress'); - assertStringIncludes(body, 'Attempts by day'); - assertStringIncludes(body, 'Score distribution'); - assertStringIncludes(body, 'Event mix'); - assertStringIncludes(body, 'Most practiced items'); - assertStringIncludes(body, 'Needs follow-up'); - assertStringIncludes(body, 'Ada Learner'); - assertStringIncludes(body, 'Bea Learner'); - assertStringIncludes(body, 'card-1'); - assertStringIncludes(body, 'Average score'); - assertStringIncludes(body, 'Completion rate'); - assertStringIncludes(body, '90%'); - assertStringIncludes(body, 'page-nav-link-current'); - assertEquals(body.includes('Preview Student'), false); -}); - -Deno.test('POST /admin/packages/import-reference imports the selected reference app and redirects to the app overview page', async () => { - const repository = createInMemoryPackageReviewRepository(); - const chapter4 = buildImportedPackageVersion({ version: '0.1.0' }); - const app = createAdminApp({ - getRepository: () => repository, - readReferencePackageReviewData: () => Promise.resolve(chapter4.reviewData), - loadReferencePackageSnapshot: () => Promise.resolve(null), - importReferencePackage: () => - Promise.resolve(buildImportedPackageVersion({ version: '0.1.0' })), - }); - const formData = new FormData(); - - formData.set('appId', 'chapter-4-asteroids'); - - const response = await app.request('http://localhost/admin/packages/import-reference', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }); - - assertEquals(response.status, 303); - assertEquals(response.headers.get('location'), '/admin/packages/chapter-4-asteroids'); - - const saved = await repository.getPackageVersionByAppVersion('chapter-4-asteroids', '0.1.0'); - assertEquals(saved?.approvalStatus, 'pending'); -}); - -Deno.test('POST /admin/packages/import imports the uploaded reviewed package and redirects to the app overview page', async () => { - const repository = createInMemoryPackageReviewRepository(); - const imported = buildImportedPackageVersion({ - appId: 'uploaded-quiz', - title: 'Uploaded Quiz', - version: '0.1.0', - }); - const app = createAdminApp({ - getRepository: () => repository, - loadPackageSnapshotFromSource: () => Promise.resolve(null), - importPackageFromSource: () => Promise.resolve(imported), - }); - - const response = await app.request('http://localhost/admin/packages/import', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: buildPackageImportFormData(), - }); - - assertEquals(response.status, 303); - assertEquals(response.headers.get('location'), '/admin/packages/uploaded-quiz'); - - const saved = await repository.getPackageVersionByAppVersion('uploaded-quiz', '0.1.0'); - assertEquals(saved?.approvalStatus, 'pending'); -}); - -Deno.test('POST /admin/packages/import reopens the existing app overview when the exact version is already present', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - appId: 'uploaded-quiz', - version: '0.1.0', - }), - ], - }); - const app = createAdminApp({ - getRepository: () => repository, - loadPackageSnapshotFromSource: () => Promise.reject(new Error('load should not run')), - importPackageFromSource: () => Promise.reject(new Error('import should not run')), - }); - - const response = await app.request('http://localhost/admin/packages/import', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: buildPackageImportFormData(), - }); - - assertEquals(response.status, 303); - assertEquals(response.headers.get('location'), '/admin/packages/uploaded-quiz'); -}); - -Deno.test('POST /admin/packages/import rejects an invalid uploaded package with an explicit notice', async () => { - const repository = createInMemoryPackageReviewRepository(); - const app = createAdminApp({ - getRepository: () => repository, - loadPackageSnapshotFromSource: () => Promise.reject(new Error('load should not run')), - importPackageFromSource: () => Promise.reject(new Error('import should not run')), - }); - - const response = await app.request('http://localhost/admin/packages/import', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: buildPackageImportFormData({ includeManifest: false }), - }); - - assertEquals(response.status, 409); - const body = await response.text(); - - assertStringIncludes(body, 'Package import blocked'); - assertStringIncludes(body, 'Fix these items before import.'); - assertStringIncludes(body, 'File /manifest.json'); - assertStringIncludes(body, 'Referenced file /manifest.json is missing from the package.'); - assertStringIncludes( - body, - 'Add /manifest.json to the reviewed package or update /manifest.json to point at an existing file.', - ); -}); - -Deno.test('POST /admin/packages/import-reference reopens the existing reference app overview when the exact version is already present', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [buildPackageVersionRecord()], - }); - const chapter4 = buildImportedPackageVersion({ version: '0.1.0' }); - const app = createAdminApp({ - getRepository: () => repository, - readReferencePackageReviewData: () => Promise.resolve(chapter4.reviewData), - importReferencePackage: () => Promise.reject(new Error('import should not run')), - }); - const formData = new FormData(); - - formData.set('appId', 'chapter-4-asteroids'); - - const response = await app.request('http://localhost/admin/packages/import-reference', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }); - - assertEquals(response.status, 303); - assertEquals(response.headers.get('location'), '/admin/packages/chapter-4-asteroids'); -}); - -Deno.test('POST /admin/packages/import-reference restores the selected reference app from the stored snapshot when the database row is missing', async () => { - const repository = createInMemoryPackageReviewRepository(); - const chapter4 = buildImportedPackageVersion({ version: '0.1.0' }); - const app = createAdminApp({ - getRepository: () => repository, - readReferencePackageReviewData: () => Promise.resolve(chapter4.reviewData), - loadReferencePackageSnapshot: () => - Promise.resolve(buildImportedPackageVersion({ version: '0.1.0' })), - importReferencePackage: () => Promise.reject(new Error('import should not run')), - }); - const formData = new FormData(); - - formData.set('appId', 'chapter-4-asteroids'); - - const response = await app.request('http://localhost/admin/packages/import-reference', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }); - - assertEquals(response.status, 303); - assertEquals(response.headers.get('location'), '/admin/packages/chapter-4-asteroids'); - - const saved = await repository.getPackageVersionByAppVersion('chapter-4-asteroids', '0.1.0'); - assertEquals(saved?.approvalStatus, 'pending'); -}); - -Deno.test('POST /admin/packages/import-reference imports quick-study when requested', async () => { - const repository = createInMemoryPackageReviewRepository(); - const quickStudy = buildImportedPackageVersion({ - appId: 'quick-study', - title: 'Quick Study', - version: '0.1.0', - }); - const app = createAdminApp({ - getRepository: () => repository, - readReferencePackageReviewData: () => Promise.resolve(quickStudy.reviewData), - loadReferencePackageSnapshot: () => Promise.resolve(null), - importReferencePackage: () => Promise.resolve(quickStudy), - }); - const formData = new FormData(); - - formData.set('appId', 'quick-study'); - - const response = await app.request('http://localhost/admin/packages/import-reference', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }); - - assertEquals(response.status, 303); - assertEquals(response.headers.get('location'), '/admin/packages/quick-study'); - - const saved = await repository.getPackageVersionByAppVersion('quick-study', '0.1.0'); - assertEquals(saved?.approvalStatus, 'pending'); -}); - -Deno.test('POST /admin/packages/:id/approve records notes and keeps status visible on reload', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [buildPackageVersionRecord({ id: 7 })], - }); - const app = createAdminApp({ getRepository: () => repository }); - const formData = new FormData(); - - formData.set('reviewNotes', 'Ready for the pilot deployment.'); - formData.set('accessibilityKeyboard', 'pass'); - formData.set('accessibilityFocusVisible', 'pass'); - formData.set('accessibilityFocusNotObscured', 'pass'); - formData.set('accessibilityStructure', 'pass'); - formData.set('accessibilityContrast', 'pass'); - formData.set('accessibilityReducedMotion', 'fail'); - formData.set('accessibilityEquivalentAlternatives', 'not_applicable'); - formData.set( - 'accessibilityFailureNotes', - 'Reduced-motion toggle is still missing on animated scenes.', - ); - formData.set( - 'accessibilityExceptionNote', - 'Pilot exception approved for instructor-led use only.', - ); - const accessibilityReview = buildAccessibilityReview({ - reducedMotion: 'fail', - failureNotes: 'Reduced-motion toggle is still missing on animated scenes.', - exceptionNote: 'Pilot exception approved for instructor-led use only.', - }); - - const response = await app.request('http://localhost/admin/packages/7/approve', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }); - - assertEquals(response.status, 303); - assertEquals( - response.headers.get('location'), - '/admin/packages/chapter-4-asteroids/versions/0.1.0', - ); - - const saved = await repository.getPackageVersionById(7); - assert(saved); - assertEquals(saved?.approvalStatus, 'approved'); - assertEquals(saved?.reviewNotes, 'Ready for the pilot deployment.'); - assertEquals(saved?.accessibilityReview, accessibilityReview); - const auditEvents = await repository.listAuditEventsByEventType('package.approved'); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.packageVersionId, 7); - - const detailResponse = await app.request( - 'http://localhost/admin/packages/chapter-4-asteroids/versions/0.1.0', - ); - const detailBody = await detailResponse.text(); - - assertStringIncludes(detailBody, 'Approved'); - assertStringIncludes(detailBody, 'Ready for the pilot deployment.'); - assertStringIncludes(detailBody, 'Accessibility review'); - assertStringIncludes(detailBody, 'Reduced motion'); - assertStringIncludes(detailBody, 'Pilot exception approved for instructor-led use only.'); -}); - -Deno.test('POST /admin/packages/:id/reject refuses to reverse a frozen decision', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 9, - approvalStatus: 'approved', - reviewNotes: 'Already approved.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - }); - const app = createAdminApp({ getRepository: () => repository }); - - const response = await app.request('http://localhost/admin/packages/9/reject', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: new FormData(), - }); - - assertEquals(response.status, 409); - const body = await response.text(); - - assertStringIncludes(body, 'Rejection blocked'); - assertStringIncludes(body, 'already been reviewed and cannot change state'); -}); - -Deno.test('POST /admin/packages/:appId/deployment/pin stores the exact approved version id', async () => { - const seededRepository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - buildPackageVersionRecord({ - id: 6, - version: '0.2.0', - approvalStatus: 'pending', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 3, - slug: 'chapter-4-asteroids-pilot', - label: 'Chapter 4 Asteroids Pilot Deployment', - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding: buildCanvasDeploymentBinding(), - }), - ], - }); - const app = createAdminApp({ getRepository: () => seededRepository }); - const formData = new FormData(); - - formData.set('lms', 'canvas'); - formData.set('packageVersionId', '5'); - - const response = await app.request( - 'http://localhost/admin/packages/chapter-4-asteroids/deployment/pin', - { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }, - ); - - assertEquals(response.status, 303); - assertEquals( - response.headers.get('location'), - '/admin/packages/chapter-4-asteroids/deployment?lms=canvas#slot-panel', - ); - - const deployment = await seededRepository.getDeploymentBySlug('chapter-4-asteroids-pilot'); - assertEquals(deployment?.enabledPackageVersionId, 5); - assertEquals(deployment?.enabledPackageVersion, '0.1.0'); - const auditEvents = await seededRepository.listAuditEventsByEventType( - 'deployment.version_pinned', - ); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.deploymentRecordId, 3); -}); - -function createAdminApp(services: Parameters[0] = {}) { - return createAdminTestApp({ - env: createObjectEnvReader({ - APP_ORIGIN: 'http://localhost', - }), - ...services, - }); -} - -function createMemoryPackageSnapshotStore( - roots: Record>, -): PackageSnapshotStore { - const files = new Map>(); - - for (const [root, rootFiles] of Object.entries(roots)) { - files.set( - root, - new Map( - Object.entries(rootFiles).map(([path, contents]) => [ - path, - new TextEncoder().encode(contents), - ]), - ), - ); - } - - return { - readBytes(snapshotRoot, relativePath) { - const bytes = files.get(snapshotRoot)?.get(relativePath) ?? null; - - if (bytes === null) { - throw new Error(`Snapshot file ${snapshotRoot}/${relativePath} was not found.`); - } - - return Promise.resolve(bytes.slice()); - }, - writeBytes(snapshotRoot, relativePath, bytes) { - const rootFiles = files.get(snapshotRoot) ?? new Map(); - - rootFiles.set(relativePath, bytes.slice()); - files.set(snapshotRoot, rootFiles); - - return Promise.resolve(); - }, - fileExists(snapshotRoot, relativePath) { - return Promise.resolve(files.get(snapshotRoot)?.has(relativePath) ?? false); - }, - listFiles(snapshotRoot) { - return Promise.resolve([...(files.get(snapshotRoot)?.keys() ?? [])].sort()); - }, - }; -} - -function buildPackageImportFormData( - input: { - appId?: string; - packageRoot?: string; - includeManifest?: boolean; - } = {}, -): FormData { - const appId = input.appId ?? 'uploaded-quiz'; - const packageRoot = input.packageRoot ?? appId; - const formData = new FormData(); - - if (input.includeManifest !== false) { - formData.append( - 'packageFiles', - new File([buildUploadedManifest(appId)], `${packageRoot}/manifest.json`, { - type: 'application/json', - }), - ); - } - - formData.append( - 'packageFiles', - new File( - ['
    Uploaded Quiz
    '], - `${packageRoot}/dist/index.html`, - { type: 'text/html' }, - ), - ); - formData.append( - 'packageFiles', - new File(['{"cards":[]}\n'], `${packageRoot}/content/activity.json`, { - type: 'application/json', - }), - ); - formData.append( - 'packageFiles', - new File( - [ - JSON.stringify({ - launch: { - user_role: 'learner', - course_id: 'course_123', - assignment_id: 'assignment_456', - activity_id: 'activity_1', - }, - attempt_id: 'attempt_123', - local_state: null, - }), - ], - `${packageRoot}/preview/fixtures.json`, - { - type: 'application/json', - }, - ), - ); - formData.append( - 'packageFiles', - new File( - [ - JSON.stringify([ - { - name: 'renders main content', - assert: { - selector: 'main', - contains: 'Uploaded Quiz', - }, - }, - ]), - ], - `${packageRoot}/preview/tests.json`, - { - type: 'application/json', - }, - ), - ); - - return formData; -} - -function buildUploadedManifest(appId: string): string { - return JSON.stringify({ - schema_version: '1', - app_id: appId, - version: '0.1.0', - title: 'Uploaded Quiz', - description: 'A reviewed package uploaded through admin inventory.', - owner: { - type: 'user', - id: 'instructor_123', - }, - entrypoint: '/dist/index.html', - roles: ['learner', 'instructor'], - install_scope: 'course', - capabilities: [ - 'read_launch_context', - 'read_activity_content', - 'submit_attempt_event', - 'finalize_attempt', - 'read_local_state', - 'write_local_state', - ], - grading: { - mode: 'completion', - max_score: 100, - }, - content_files: ['/content/activity.json'], - preview: { - fixtures_file: '/preview/fixtures.json', - tests_file: '/preview/tests.json', - }, - }); -} diff --git a/src/app_admin_placement_audit_test.ts b/src/app_admin_placement_audit_test.ts deleted file mode 100644 index 772aea4..0000000 --- a/src/app_admin_placement_audit_test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { - buildAuditEventRecord, - buildPackageVersionRecord, - buildPreviewEvidenceRecord, - buildPreviewSessionRecord, - buildReviewedPlacementRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; - -Deno.test('GET /admin/placements/:placementId renders selected content, reviewed version, Canvas context, and evidence timeline', async () => { - const repository = createInMemoryPackageReviewRepository({ - reviewedPlacements: [ - buildReviewedPlacementRecord({ - placementId: 'placement-audit-123', - packageVersionId: 8, - packageVersion: '0.8.0', - packageTitle: 'Chapter 4 Asteroids', - contentPath: '/content/bonus.json', - contentTitle: 'Bonus Activity', - contextId: 'course-42', - contextTitle: 'Physics 101', - resourceLinkId: 'resource-link-123', - }), - ], - previewSessions: [ - buildPreviewSessionRecord({ - sessionId: 'preview-session-123', - packageVersionId: 8, - packageVersion: '0.8.0', - }), - ], - previewEvidence: [ - buildPreviewEvidenceRecord({ - previewSessionId: 'preview-session-123', - eventType: 'preview.launch', - }), - ], - auditEvents: [ - buildAuditEventRecord({ - eventType: 'deep_linking.request.accepted', - packageVersionId: 8, - }), - buildAuditEventRecord({ - eventType: 'deep_linking.placement.created', - packageVersionId: 8, - summary: 'Created reviewed placement from Deep Linking selection.', - detail: { placementId: 'placement-audit-123' }, - }), - buildAuditEventRecord({ - eventType: 'reviewer.preview_viewed', - packageVersionId: 8, - summary: 'Reviewer opened the test launch page.', - detail: { placementId: 'placement-audit-123' }, - }), - ], - }); - - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - }).request('http://localhost/admin/placements/placement-audit-123'); - - assertEquals(response.status, 200); - const body = await response.text(); - - assertStringIncludes(body, 'Placement audit'); - assertStringIncludes(body, 'placement-audit-123'); - assertStringIncludes(body, 'Chapter 4 Asteroids'); - assertStringIncludes(body, 'Version 0.8.0'); - assertStringIncludes(body, '/content/bonus.json'); - assertStringIncludes(body, 'Physics 101'); - assertStringIncludes(body, 'reviewer.preview_viewed'); - assertStringIncludes(body, 'Open test activity'); -}); - -Deno.test('GET /admin/placements renders a lookup page and unknown placement ids still fail clearly', async () => { - const repository = createInMemoryPackageReviewRepository(); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - - const missingIdResponse = await app.request('http://localhost/admin/placements'); - assertEquals(missingIdResponse.status, 200); - const missingIdBody = await missingIdResponse.text(); - assertStringIncludes(missingIdBody, 'Open one reviewed placement.'); - assertStringIncludes(missingIdBody, 'name="placementId"'); - - const unknownResponse = await app.request('http://localhost/admin/placements/placement-missing'); - assertEquals(unknownResponse.status, 404); - const unknownBody = await unknownResponse.text(); - assertStringIncludes(unknownBody, 'Placement audit unavailable'); - assertStringIncludes(unknownBody, 'Reviewed placement placement-missing was not found.'); -}); - -Deno.test('approved package review page includes a test launch link', async () => { - const seededRepository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 41, - approvalStatus: 'approved', - reviewNotes: 'Ready for test launch.', - reviewedAt: '2026-03-25T00:40:00Z', - }), - ], - }); - - const response = await createAuthenticatedTestApp({ - getRepository: () => seededRepository, - }).request('http://localhost/admin/packages/chapter-4-asteroids/versions/0.1.0'); - - assertEquals(response.status, 200); - const body = await response.text(); - - assertStringIncludes(body, '/admin/packages/chapter-4-asteroids/versions/0.1.0/preview'); - assertStringIncludes(body, 'Open test launch'); - assertStringIncludes(body, 'href="/admin/placements"'); -}); diff --git a/src/app_admin_placement_routes.ts b/src/app_admin_placement_routes.ts deleted file mode 100644 index 4ac19c8..0000000 --- a/src/app_admin_placement_routes.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Hono } from '@hono/hono'; -import { - renderPlacementAuditPage, - renderPlacementAuditRequestPage, -} from './admin/placement_audit_page.ts'; -import { loadPlacementAuditTimeline } from './app_deployment_support.ts'; -import { createErrorNotice } from './app_notice_support.ts'; -import { normalizeOptionalString, requireTrimmedString } from './app_request_support.ts'; -import { statusForPlacementAuditError } from './app_status_support.ts'; -import type { AppServices } from './app_services.ts'; -import { adminPageInput } from './admin_auth/page_input.ts'; - -export function registerAdminPlacementRoutes(app: Hono, services: AppServices): void { - app.get('/admin/placements', (context) => { - const url = new URL(context.req.url); - const placementId = normalizeOptionalString(url.searchParams.get('placementId')); - - if (placementId !== null) { - return context.redirect(`/admin/placements/${encodeURIComponent(placementId)}`, 303); - } - - return context.html( - renderPlacementAuditRequestPage(adminPageInput(context, { - notice: null, - })), - ); - }); - - app.get('/admin/placements/:placementId', async (context) => { - const repository = services.getRepository(); - const placementId = context.req.param('placementId'); - - try { - const snapshot = await repository.requirePlacementAuditSnapshotById( - requireTrimmedString(placementId, 'Placement id is required.'), - ); - const timeline = await loadPlacementAuditTimeline(repository, snapshot.placement); - - return context.html( - renderPlacementAuditPage(adminPageInput(context, { - snapshot, - timeline, - })), - ); - } catch (error) { - return context.html( - renderPlacementAuditRequestPage(adminPageInput(context, { - notice: createErrorNotice('Placement audit unavailable', error), - })), - statusForPlacementAuditError(error), - ); - } - }); -} diff --git a/src/app_admin_preview_log_test.ts b/src/app_admin_preview_log_test.ts index fcf8515..2b760c9 100644 --- a/src/app_admin_preview_log_test.ts +++ b/src/app_admin_preview_log_test.ts @@ -1,6 +1,6 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; import type { Capability } from '../sdk/app-sdk.ts'; -import { createApp } from './app.ts'; +import { createTestApp as createApp } from './test_helpers/app.ts'; import { EXAMPLE_SNAPSHOT_ROOT, withRuntimeOriginEnv } from './app_test_support.ts'; import { buildAdminPreviewSessionRecord, @@ -405,10 +405,8 @@ Deno.test('test-launch runtime diagnostics exclude authoring preview session fai eventType: 'runtime.capability.denied', actorType: 'system', actorId: null, - deploymentRecordId: null, packageVersionId: 52, attemptId: 'preview-attempt-authoring-diagnostics', - lineItemBindingId: null, status: 'failed', summary: 'Denied authoring preview capability.', detail: { diff --git a/src/app_admin_preview_routes.ts b/src/app_admin_preview_routes.ts index 9478016..00e00bf 100644 --- a/src/app_admin_preview_routes.ts +++ b/src/app_admin_preview_routes.ts @@ -3,7 +3,7 @@ import type { UserRole } from '../sdk/app-sdk.ts'; import { renderPackageDetailPage } from './admin/package_detail.ts'; import { renderPackageIndexPage } from './admin/package_index.ts'; import { renderPreviewPage, type TestLaunchFormValues } from './admin/preview_page.ts'; -import { loadPreviewCapabilityLog } from './app_deployment_support.ts'; +import { loadPreviewCapabilityLog } from './preview/capability_log.ts'; import { createErrorNotice } from './app_notice_support.ts'; import { formValueAsString } from './app_request_support.ts'; import { statusForError } from './app_status_support.ts'; @@ -53,10 +53,8 @@ export function registerAdminPreviewRoutes(app: Hono, services: AppServices): vo eventType: 'reviewer.preview_viewed', actorType: 'user', actorId: null, - deploymentRecordId: null, packageVersionId: packageVersion.id, attemptId: null, - lineItemBindingId: null, status: 'succeeded', summary: 'Reviewer opened the test launch page.', detail: { @@ -143,10 +141,8 @@ export function registerAdminPreviewRoutes(app: Hono, services: AppServices): vo eventType: 'preview.launch', actorType: 'user', actorId: null, - deploymentRecordId: null, packageVersionId: packageVersion.id, attemptId: launched.runtimeSession.attemptId, - lineItemBindingId: null, status: 'succeeded', summary: 'Started a test launch without LMS sign-in.', detail: { diff --git a/src/app_admin_preview_test.ts b/src/app_admin_preview_test.ts index e93936e..3bb85f6 100644 --- a/src/app_admin_preview_test.ts +++ b/src/app_admin_preview_test.ts @@ -142,12 +142,9 @@ Deno.test('POST /admin/packages/:appId/versions/:version/preview launches pendin const runtimeLocation = new URL(response.headers.get('location') ?? ''); const sessionId = runtimeLocation.pathname.split('/').at(-1) ?? ''; const session = await repository.getRuntimeSessionById(sessionId); - const deployment = await repository.getDeploymentBySlug('chapter-4-asteroids-preview'); assertEquals(session?.preview?.previewSessionId.startsWith('preview-session-'), true); assertEquals(session?.packageVersionId, 43); - assertEquals(deployment?.enabledPackageVersionId, 43); - assertEquals(deployment?.lmsType, 'preview'); const runtimeResponse = await app.request(runtimeLocation.toString()); assertEquals(runtimeResponse.status, 200); @@ -209,18 +206,15 @@ Deno.test('POST /admin/packages/:appId/versions/:version/preview creates a runti const runtimeLocation = new URL(location); const sessionId = runtimeLocation.pathname.split('/').at(-1) ?? ''; const session = await repository.getRuntimeSessionById(sessionId); - const deployment = await repository.getDeploymentBySlug('chapter-4-asteroids-preview'); assertEquals(session?.services.ags, null); assertEquals(session?.services.nrps, null); - assertEquals(session?.deploymentSlug, 'chapter-4-asteroids-preview'); + assertEquals(session?.preview?.previewSessionId.startsWith('preview-session-'), true); assertEquals(session?.launch.userRole, 'instructor'); assertEquals(session?.launch.courseId, 'physics-101'); assertEquals(session?.launch.assignmentId ?? null, null); assertEquals(session?.launch.activityId, 'asteroids-boss-stage'); assertEquals(session?.sessionToken, runtimeLocation.searchParams.get('token')); - assertEquals(deployment?.enabledPackageVersionId, 44); - assertEquals(deployment?.enabledPackageVersion, '0.1.0'); }); }); @@ -345,7 +339,7 @@ Deno.test("POST /admin/packages/:appId/versions/:version/preview launches commit const sessionId = runtimeLocation.pathname.split('/').at(-1) ?? ''; const session = await repository.getRuntimeSessionById(sessionId); - assertEquals(session?.deploymentSlug, 'quick-study-preview'); + assertEquals(session?.preview?.previewSessionId.startsWith('preview-session-'), true); assertEquals(session?.appId, 'quick-study'); assertEquals(session?.contentPath, `${snapshotRoot}/content/activity.json`); assertEquals(session?.launch.userRole, 'learner'); diff --git a/src/app_admin_python_lab_routes.ts b/src/app_admin_python_lab_routes.ts new file mode 100644 index 0000000..d73d447 --- /dev/null +++ b/src/app_admin_python_lab_routes.ts @@ -0,0 +1,64 @@ +import type { Hono } from '@hono/hono'; +import { createErrorNotice } from './app_notice_support.ts'; +import type { AppServices } from './app_services.ts'; + +type AdminPythonLabRouteServices = Pick; +import { adminPageInput } from './admin_auth/page_input.ts'; +import { renderPythonLabRequestsPage } from './admin/python_lab_requests_page.ts'; +import type { PythonLabRequestStatus } from './python_lab/repository.ts'; + +export function registerAdminPythonLabRoutes( + app: Hono, + services: AdminPythonLabRouteServices, +): void { + app.get('/admin/python-lab-requests', async (context) => { + try { + return context.html(renderPythonLabRequestsPage(adminPageInput(context, { + requests: await services.pythonLabRepository.listAccessRequests(), + }))); + } catch (error) { + return context.html( + renderPythonLabRequestsPage(adminPageInput(context, { + requests: [], + notice: createErrorNotice('Python Lab requests unavailable', error), + })), + 500, + ); + } + }); + + app.post('/admin/python-lab-requests/:requestId', async (context) => { + try { + const requestId = context.req.param('requestId'); + + if (requestId === undefined || requestId === '') { + throw new Error('Python Lab access request was not found.'); + } + + const form = await context.req.formData(); + await services.pythonLabRepository.updateAccessRequestStatus({ + requestId, + status: requireRequestStatus(form.get('status')), + updatedAt: new Date().toISOString(), + }); + + return context.redirect('/admin/python-lab-requests', 303); + } catch (error) { + return context.html( + renderPythonLabRequestsPage(adminPageInput(context, { + requests: await services.pythonLabRepository.listAccessRequests(), + notice: createErrorNotice('Python Lab request was not updated', error), + })), + 400, + ); + } + }); +} + +function requireRequestStatus(value: FormDataEntryValue | null): PythonLabRequestStatus { + if (value === 'new' || value === 'contacted' || value === 'closed') { + return value; + } + + throw new Error('Choose a valid Python Lab request status.'); +} diff --git a/src/app_admin_retry_grade_publish_test.ts b/src/app_admin_retry_grade_publish_test.ts deleted file mode 100644 index 10009b3..0000000 --- a/src/app_admin_retry_grade_publish_test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { assertEquals, assertObjectMatch, assertStringIncludes } from '@std/assert'; -import { restoreEnv, withFetchStub } from './app_test_support.ts'; -import { - buildAttemptRecord, - buildControlPlaneDeploymentDetailSnapshot, - buildControlPlaneDeploymentInventoryRow, - buildControlPlaneDiagnosticItem, - buildDeploymentRecord, - buildGradePublicationRecord, - buildPackageVersionRecord, - buildRetryableGradePublicationLookup, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { - buildDeploymentBinding, - buildRuntimeSessionRecord, - getTestToolPrivateJwkEnvValue, -} from './test_helpers/lti.ts'; -import { createTestLtiToolContext } from './test_helpers/lti_tool_context.ts'; -import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; - -Deno.test('POST /admin/packages/:appId/deployment/retry-grade-publish retries the failed grade publish through the SSR control plane', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 3, - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - attempts: [ - buildAttemptRecord({ - id: 1, - attemptId: 'attempt-123', - deploymentRecordId: 3, - packageVersionId: 5, - }), - ], - gradePublications: [ - buildGradePublicationRecord({ - id: 1, - attemptId: 'attempt-123', - status: 'failed', - publishedAt: null, - updatedAt: '2026-03-24T12:35:00Z', - errorCode: 'token_request_failed', - }), - ], - runtimeSessions: [ - buildRuntimeSessionRecord({ - ltiToolSessionId: 'lti-tool-session-123', - attemptId: 'attempt-123', - deploymentRecordId: 3, - packageVersionId: 5, - packageVersion: '0.1.0', - expiresAt: '2026-03-26T12:30:00Z', - }), - ], - controlPlaneDeploymentDetails: [ - buildControlPlaneDeploymentDetailSnapshot({ - inventory: buildControlPlaneDeploymentInventoryRow({ - deploymentId: 3, - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - diagnostics: [ - buildControlPlaneDiagnosticItem({ - id: 3, - kind: 'gradePublication', - eventType: 'grade_publish.failed', - status: 'failed', - attemptId: 'attempt-123', - code: 'token_request_failed', - retryable: true, - }), - ], - retryableGradePublication: buildRetryableGradePublicationLookup({ - attemptId: 'attempt-123', - deploymentRecordId: 3, - }), - }), - ], - }); - const ltiToolContext = createTestLtiToolContext({ - sessionId: 'lti-tool-session-123', - }); - const formData = new FormData(); - - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - formData.set('attemptId', 'attempt-123'); - - try { - await withFetchStub( - (input) => { - const url = String(input); - - if (url === 'https://sso.canvaslms.com/login/oauth2/token') { - return new Response( - JSON.stringify({ - access_token: 'canvas-access-token', - token_type: 'bearer', - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - return new Response(null, { status: 200 }); - }, - async () => { - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - }).request( - 'http://localhost/admin/packages/chapter-4-asteroids/deployment/retry-grade-publish', - { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }, - ); - - assertEquals(response.status, 303); - assertEquals( - response.headers.get('location'), - '/admin/packages/chapter-4-asteroids/deployment', - ); - }, - ); - - const publication = await repository.getGradePublicationByAttemptId('attempt-123'); - const auditEvents = await repository.listAuditEventsByEventType( - 'grade_publish.retry_succeeded', - ); - - assertEquals(publication?.status, 'published'); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.attemptId, 'attempt-123'); - assertEquals( - JSON.stringify(auditEvents[0]?.detail ?? {}).includes('canvas-access-token'), - false, - ); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } -}); - -Deno.test('POST /admin/packages/:appId/deployment/retry-grade-publish records bounded request context when the retry fails', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 3, - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - attempts: [ - buildAttemptRecord({ - id: 1, - attemptId: 'attempt-123', - deploymentRecordId: 3, - packageVersionId: 5, - }), - ], - gradePublications: [ - buildGradePublicationRecord({ - id: 1, - attemptId: 'attempt-123', - status: 'failed', - publishedAt: null, - updatedAt: '2026-03-24T12:35:00Z', - errorCode: 'token_request_failed', - }), - ], - runtimeSessions: [ - buildRuntimeSessionRecord({ - ltiToolSessionId: 'lti-tool-session-123', - attemptId: 'attempt-123', - deploymentRecordId: 3, - packageVersionId: 5, - packageVersion: '0.1.0', - expiresAt: '2026-03-26T12:30:00Z', - }), - ], - controlPlaneDeploymentDetails: [ - buildControlPlaneDeploymentDetailSnapshot({ - inventory: buildControlPlaneDeploymentInventoryRow({ - deploymentId: 3, - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - diagnostics: [ - buildControlPlaneDiagnosticItem({ - id: 3, - kind: 'gradePublication', - eventType: 'grade_publish.failed', - status: 'failed', - attemptId: 'attempt-123', - code: 'token_request_failed', - retryable: true, - }), - ], - retryableGradePublication: buildRetryableGradePublicationLookup({ - attemptId: 'attempt-123', - deploymentRecordId: 3, - }), - }), - ], - }); - const ltiToolContext = createTestLtiToolContext({ - sessionId: 'lti-tool-session-123', - submitScoreSuccess: false, - submitScoreMessage: 'simulated retry failure', - }); - const formData = new FormData(); - - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - formData.set('attemptId', 'attempt-123'); - - try { - await withFetchStub( - () => - new Response(JSON.stringify({ error: 'invalid_client' }), { - status: 401, - headers: { 'content-type': 'application/json' }, - }), - async () => { - const response = await createAuthenticatedTestApp({ - getRepository: () => repository, - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - }).request( - 'http://localhost/admin/packages/chapter-4-asteroids/deployment/retry-grade-publish', - { - method: 'POST', - headers: { - Origin: 'http://localhost', - 'x-real-ip': '203.0.113.91', - }, - body: formData, - }, - ); - const body = await response.text(); - - assertEquals(response.status, 500); - assertStringIncludes(body, 'Grade publish retry failed'); - }, - ); - - const auditEvents = await repository.listAuditEventsByEventType('grade_publish.retry_failed'); - - assertEquals(auditEvents.length, 1); - assertObjectMatch(auditEvents[0]?.detail.request ?? {}, { - method: 'POST', - path: '/admin/packages/chapter-4-asteroids/deployment/retry-grade-publish', - formKeys: ['attemptId'], - clientIpMasked: '203.0.113.x', - }); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } -}); diff --git a/src/app_admin_runtime_evidence_test.ts b/src/app_admin_runtime_evidence_test.ts index f411118..42de268 100644 --- a/src/app_admin_runtime_evidence_test.ts +++ b/src/app_admin_runtime_evidence_test.ts @@ -7,7 +7,7 @@ import { import type { EvidenceArtifactStore } from './runtime/evidence_artifact_store.ts'; import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; -Deno.test('GET /admin/packages/:appId/deployment/evidence/:artifactId returns the stored anonymous evidence artifact', async () => { +Deno.test('GET /admin/packages/:appId/evidence/:artifactId returns the stored anonymous evidence artifact', async () => { const evidenceBytes = new TextEncoder().encode( JSON.stringify({ submissionMode: 'anonymous_submission', @@ -45,7 +45,7 @@ Deno.test('GET /admin/packages/:appId/deployment/evidence/:artifactId returns th getRepository: () => repository, evidenceArtifactStore, }).request( - 'http://localhost/admin/packages/chapter-4-asteroids/deployment/evidence/artifact-001', + 'http://localhost/admin/packages/chapter-4-asteroids/evidence/artifact-001', ); assertEquals(response.status, 200); @@ -54,7 +54,7 @@ Deno.test('GET /admin/packages/:appId/deployment/evidence/:artifactId returns th assertEquals(await response.text(), new TextDecoder().decode(evidenceBytes)); }); -Deno.test('GET /admin/packages/:appId/deployment/evidence/:artifactId returns screenshot evidence inline through the same route', async () => { +Deno.test('GET /admin/packages/:appId/evidence/:artifactId returns screenshot evidence inline through the same route', async () => { const evidenceBytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const repository = createInMemoryPackageReviewRepository({ attempts: [ @@ -87,7 +87,7 @@ Deno.test('GET /admin/packages/:appId/deployment/evidence/:artifactId returns sc getRepository: () => repository, evidenceArtifactStore, }).request( - 'http://localhost/admin/packages/chapter-4-asteroids/deployment/evidence/artifact-002', + 'http://localhost/admin/packages/chapter-4-asteroids/evidence/artifact-002', ); assertEquals(response.status, 200); diff --git a/src/app_admin_support.ts b/src/app_admin_support.ts index fa99ec7..854445e 100644 --- a/src/app_admin_support.ts +++ b/src/app_admin_support.ts @@ -1,28 +1,12 @@ import type { Context } from '@hono/hono'; -import { - renderDeploymentsPage as renderAdminDeploymentsPage, - renderVerificationPage as renderAdminVerificationPage, -} from './admin/control_plane.ts'; -import type { VerificationPageSection } from './admin/verification_navigation.ts'; -import { - type DeploymentEditorState, - renderDeploymentDetailPage, -} from './admin/deployment_detail.ts'; import type { AdminNotice } from './admin/layout.ts'; -import { listCanvasEnvironments } from './lti/config.ts'; -import { resolveConfiguredPublicOrigin } from './public_origin.ts'; -import { - combineNotices, - createErrorNotice, - getCanvasConfigUrlNoticeSafe, - packageDetailPath, -} from './app_notice_support.ts'; -import { loadDeploymentDetailStateSafe } from './app_deployment_support.ts'; +import { createErrorNotice, packageDetailPath } from './app_notice_support.ts'; import { normalizeOptionalString } from './app_request_support.ts'; import { statusForError } from './app_status_support.ts'; import type { AppServices } from './app_services.ts'; import { adminPageInput } from './admin_auth/page_input.ts'; -import { readEnv } from './platform/env.ts'; +import { getKernelAdminContext } from './lms_kernel/admin_context.ts'; +import { governanceEventBase } from './lms_kernel/governance_events.ts'; import { renderPackageDetailPage } from './admin/package_detail.ts'; import { renderPackageIndexPage } from './admin/package_index.ts'; import { @@ -55,26 +39,19 @@ export async function handleReviewDecision( reviewNotes, accessibilityReview, }); - await repository.recordAuditEvent({ + const actor = getKernelAdminContext(context); + await services.getLmsKernelRepository().appendGovernanceEvent(governanceEventBase({ + traceId: crypto.randomUUID(), eventType: decision === 'approve' ? 'package.approved' : 'package.rejected', actorType: 'user', - actorId: null, - deploymentRecordId: null, + actorId: actor.membership.userId, + institutionId: actor.membership.institutionId, + appId: packageVersion.appId, packageVersionId: packageVersion.id, - attemptId: null, - lineItemBindingId: null, - status: 'succeeded', - summary: decision === 'approve' - ? 'Approved the reviewed package version.' - : 'Rejected the reviewed package version.', - detail: { - appId: packageVersion.appId, - version: packageVersion.version, - reviewNotes, - accessibilityReview, - }, + decision: decision === 'approve' ? 'allowed' : 'denied', + outcome: 'completed', occurredAt: new Date().toISOString(), - }); + })); return context.redirect(packageDetailPath(packageVersion.appId, packageVersion.version), 303); } catch (error) { @@ -155,56 +132,6 @@ export async function renderPackagesPage( ); } -export async function renderDeploymentsPage( - context: Context, - services: AppServices, - input: { - notice?: AdminNotice | null; - status?: 200 | 400 | 500; - } = {}, -) { - const deployments = await services.getOpsRepository().listControlPlaneDeployments(); - - return context.html( - renderAdminDeploymentsPage(adminPageInput(context, { - deployments, - notice: input.notice ?? null, - })), - input.status ?? 200, - ); -} - -export async function renderVerificationPage( - context: Context, - services: AppServices, - input: { - notice?: AdminNotice | null; - status?: 200 | 400 | 500; - section?: VerificationPageSection; - } = {}, -) { - const opsRepository = services.getOpsRepository(); - const deployments = await opsRepository.listControlPlaneDeployments(); - const latestBrokerVerification = await opsRepository.getLatestBrokerVerificationStatus(); - const certificationWorkflowStatuses = await opsRepository.listCertificationWorkflowStatuses(); - const latestOfficialCertificationEvidence = await opsRepository - .getLatestOfficialCertificationEvidence(); - const ltiProfileSettings = await services.getRepository().getLanternLtiProfileSettings(); - - return context.html( - renderAdminVerificationPage(adminPageInput(context, { - deployments, - latestBrokerVerification, - certificationWorkflowStatuses, - latestOfficialCertificationEvidence, - ltiProfileSettings, - notice: input.notice ?? null, - section: input.section ?? 'checklist', - })), - input.status ?? 200, - ); -} - export async function renderPackageDetailError( context: Context, services: AppServices, @@ -246,71 +173,3 @@ export async function renderPackageDetailError( ); } } - -export async function renderDeploymentError( - context: Context, - services: AppServices, - appId: string, - title: string, - error: unknown, - input: { - selectedLms?: 'canvas' | 'moodle' | 'sakai' | null; - editorState?: DeploymentEditorState | null; - } = {}, -) { - try { - const repository = services.getRepository(); - const history = await repository.listPackageVersionsByApp(appId); - - if (history.length === 0) { - return context.html( - renderPackageIndexPage(adminPageInput(context, { - versions: [], - notice: createErrorNotice(title, error), - })), - statusForError(error), - ); - } - - const appTitle = history[0]?.title ?? history[0]?.appId ?? 'Package'; - const deployments = await repository.listDeploymentsByApp(appId); - const appOrigin = resolveConfiguredPublicOrigin({ - requestUrl: context.req.url, - forwardedHeader: context.req.header('forwarded') ?? null, - xForwardedHost: context.req.header('x-forwarded-host') ?? null, - xForwardedProto: context.req.header('x-forwarded-proto') ?? null, - configuredOrigin: readEnv('APP_ORIGIN', services.env), - }); - const canvasConfigUrl = getCanvasConfigUrlNoticeSafe(appOrigin); - const detailState = await loadDeploymentDetailStateSafe(repository, appId, appOrigin); - - return context.html( - renderDeploymentDetailPage(adminPageInput(context, { - appId, - appTitle, - history, - deployments, - selectedLms: input.selectedLms ?? null, - editorState: input.editorState ?? null, - lanternLtiProfileSettings: detailState.ltiProfileSettings, - canvasConfigUrl: canvasConfigUrl.url, - canvasDynamicRegistrationUrl: detailState.canvasDynamicRegistrationUrl, - moodleDynamicRegistrationUrl: detailState.moodleDynamicRegistrationUrl, - sakaiDynamicRegistrationUrl: detailState.sakaiDynamicRegistrationUrl, - supportedCanvasEnvironments: listCanvasEnvironments(), - notice: input.editorState === undefined || input.editorState === null - ? combineNotices(canvasConfigUrl.notice, createErrorNotice(title, error)) - : canvasConfigUrl.notice, - })), - statusForError(error), - ); - } catch { - return context.html( - renderPackageIndexPage(adminPageInput(context, { - versions: [], - notice: createErrorNotice(title, error), - })), - statusForError(error), - ); - } -} diff --git a/src/app_admin_verification_validation_test.ts b/src/app_admin_verification_validation_test.ts deleted file mode 100644 index 596e9f2..0000000 --- a/src/app_admin_verification_validation_test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { - buildControlPlaneDeploymentInventoryRow, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { buildCanvasDeploymentBinding, buildMoodleDeploymentBinding } from './test_helpers/lti.ts'; -import { createAuthenticatedTestApp } from './test_helpers/admin_auth.ts'; - -Deno.test('POST /admin/verification rejects internal verification rows that include an official certification state', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - controlPlaneDeployments: [ - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 1, - binding: buildCanvasDeploymentBinding(), - }), - ], - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const formData = new FormData(); - - formData.set('source', 'manual'); - formData.set('deploymentRecordId', '1'); - formData.set('scope', 'lti13LaunchAgsNrps'); - formData.set('workflowKey', 'core'); - formData.set('status', 'passed'); - formData.set('certificationState', 'ltiAdvantageCertified'); - formData.set('summary', 'Manual verification passed.'); - formData.set('checkedAt', '2026-03-24T12:50:00Z'); - - const response = await app.request('http://localhost/admin/verification', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }); - - assertEquals(response.status, 400); - const body = await response.text(); - - assertStringIncludes(body, 'Verification update blocked'); - assertStringIncludes( - body, - 'Internal verification runs cannot carry an official certification state.', - ); - assertStringIncludes(body, 'Record one verification result'); -}); - -Deno.test('POST /admin/verification rejects internal verification rows without an explicit deployment', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - controlPlaneDeployments: [ - buildControlPlaneDeploymentInventoryRow({ - deploymentId: 2, - deploymentSlug: 'chapter-4-asteroids-moodle', - deploymentLabel: 'Chapter 4 Asteroids Moodle Deployment', - binding: buildMoodleDeploymentBinding(), - }), - ], - }); - const app = createAuthenticatedTestApp({ - getRepository: () => repository, - }); - const formData = new FormData(); - - formData.set('source', 'manual'); - formData.set('scope', 'lti13LaunchAgsScore'); - formData.set('workflowKey', 'ags'); - formData.set('status', 'passed'); - formData.set('summary', 'Manual verification passed.'); - formData.set('checkedAt', '2026-03-24T12:50:00Z'); - - const response = await app.request('http://localhost/admin/verification', { - method: 'POST', - headers: { Origin: 'http://localhost' }, - body: formData, - }); - - assertEquals(response.status, 400); - const body = await response.text(); - - assertStringIncludes(body, 'Verification update blocked'); - assertStringIncludes( - body, - 'Internal verification runs require an explicit deployment record id.', - ); - assertStringIncludes(body, 'Record one verification result'); -}); diff --git a/src/app_basic_routes.ts b/src/app_basic_routes.ts index 7661be5..a7ae1e5 100644 --- a/src/app_basic_routes.ts +++ b/src/app_basic_routes.ts @@ -1,7 +1,6 @@ import type { Hono } from '@hono/hono'; import type { AppServices } from './app_services.ts'; import { renderHomePage } from './pages/home.ts'; -import { buildCanvasConfigDocument } from './lti/config.ts'; import { statusForError } from './app_status_support.ts'; import { resolveRequestAppOrigin } from './app_origin.ts'; @@ -16,24 +15,6 @@ export function registerBasicRoutes(app: Hono, services: AppServices): void { return context.json({ ok: true, appWriterHarness: APP_WRITER_HARNESS }); }); - app.get('/lti/canvas/config.json', async (context) => { - try { - return context.json( - await buildCanvasConfigDocument( - resolveRequestAppOrigin(context, services.env), - services.env, - ), - ); - } catch (error) { - return context.json( - { - error: error instanceof Error ? error.message : 'Config unavailable.', - }, - statusForError(error), - ); - } - }); - app.get('/lti/jwks', async (context) => { try { const ltiContext = await services.getLtiToolContext( diff --git a/src/app_core.ts b/src/app_core.ts index b79b9bf..36a48f5 100644 --- a/src/app_core.ts +++ b/src/app_core.ts @@ -7,46 +7,50 @@ import { } from './app_admin_auth_routes.ts'; import { registerAdminAuthoringRoutes } from './app_admin_authoring_routes.ts'; import { registerAdminAuthoringPreviewRoutes } from './app_admin_authoring_preview_routes.ts'; -import { registerAdminDeploymentDetailRoutes } from './app_admin_deployment_detail_routes.ts'; -import { registerAdminDeploymentOpsRoutes } from './app_admin_deployment_ops_routes.ts'; -import { registerAdminDeploymentRetryRoutes } from './app_admin_deployment_retry_routes.ts'; import { registerAdminInventoryRoutes } from './app_admin_inventory_routes.ts'; -import { registerAdminOperationsRoutes } from './app_admin_operations_routes.ts'; -import { registerAdminPlacementRoutes } from './app_admin_placement_routes.ts'; import { registerAdminPreviewRoutes } from './app_admin_preview_routes.ts'; import { registerBasicRoutes } from './app_basic_routes.ts'; -import { registerDeepLinkingRoutes } from './app_deep_linking_routes.ts'; -import { registerDeepLinkingSubmitRoutes } from './app_deep_linking_submit_routes.ts'; import { registerLaunchRoutes } from './app_launch_routes.ts'; +import { registerPythonLabRoutes } from './app_python_lab_routes.ts'; +import { registerAdminPythonLabRoutes } from './app_admin_python_lab_routes.ts'; +import { registerLmsKernelRoutes } from './app_lms_kernel_routes.ts'; +import { registerGovernedRuntimeRoutes } from './app_governed_runtime_routes.ts'; import { registerRuntimeRoutes } from './app_runtime_routes.ts'; import { createAdminAuthMiddleware } from './admin_auth/middleware.ts'; +import { createKernelAdminContextMiddleware } from './lms_kernel/admin_middleware.ts'; +import { registerAdminGovernanceRoutes } from './app_admin_governance_routes.ts'; import type { AppServices } from './app_services.ts'; -export function buildApp(services: AppServices): Hono { +export function buildApp( + services: AppServices, + options: { readonly kernelAdminContext: boolean }, +): Hono { const app = new Hono(); registerBasicRoutes(app, services); registerLaunchRoutes(app, services); - registerDeepLinkingRoutes(app, services); - registerDeepLinkingSubmitRoutes(app, services); + registerGovernedRuntimeRoutes(app, services); registerRuntimeRoutes(app, services); + registerLmsKernelRoutes(app, services); + app.use('/autograder/*', csrf()); + registerPythonLabRoutes(app, services); registerPublicAdminAuthRoutes(app, services); app.use('/admin/*', createAdminAuthMiddleware(services)); + if (options.kernelAdminContext) { + app.use('/admin/*', createKernelAdminContextMiddleware(services)); + } app.use('/admin/*', csrf()); registerProtectedAdminAuthRoutes(app, services); + registerAdminGovernanceRoutes(app, services); registerAdminAppWriterRoutes(app, services); registerAdminInventoryRoutes(app, services); registerAdminAuthoringRoutes(app, services); registerAdminAuthoringPreviewRoutes(app, services); - registerAdminOperationsRoutes(app, services); - registerAdminPlacementRoutes(app, services); registerAdminPreviewRoutes(app, services); - registerAdminDeploymentDetailRoutes(app, services); - registerAdminDeploymentOpsRoutes(app, services); - registerAdminDeploymentRetryRoutes(app, services); + registerAdminPythonLabRoutes(app, services); return app; } diff --git a/src/app_deep_linking_authoring_preview_test.ts b/src/app_deep_linking_authoring_preview_test.ts deleted file mode 100644 index 6bb90fa..0000000 --- a/src/app_deep_linking_authoring_preview_test.ts +++ /dev/null @@ -1,498 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; -import { withCanvasReturnEnv, withRuntimeOriginEnv } from './app_test_support.ts'; -import { buildDeepLinkingSelectionValue } from './lti/deep_linking.ts'; -import { - buildDeepLinkingResourceOption, - buildDeepLinkingResourceSelection, - buildDeploymentRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { - buildDeepLinkingSessionRecord, - buildDeploymentBinding, - createSyncedTestLtiStorage, -} from './test_helpers/lti.ts'; -import { buildTestLtiJwtPayload } from './test_helpers/lti_session_builders.ts'; - -Deno.test('Deep Linking picker shows preview only after a reviewed selection is saved and posts it in a new tab', async () => { - const repository = createInMemoryPackageReviewRepository({ - deepLinkingSessions: [ - buildDeepLinkingSessionRecord({ - sessionId: 'deep-linking-session-picker-preview', - sessionToken: 'deep-linking-token-picker-preview', - expiresAt: '2030-03-25T16:20:00Z', - }), - ], - deepLinkingResourceOptions: buildAssignmentResources({ - packageVersionId: 3, - packageVersion: '0.3.0', - }), - }); - const app = createApp({ getRepository: () => repository }); - - const initialResponse = await app.request( - 'http://localhost/lti/deep-linking/sessions/deep-linking-session-picker-preview?token=deep-linking-token-picker-preview', - ); - - assertEquals(initialResponse.status, 200); - - const initialBody = await initialResponse.text(); - - assertEquals(initialBody.includes('/preview'), false); - assertEquals(initialBody.includes('target="_blank"'), false); - - const saveForm = new FormData(); - - saveForm.set('token', 'deep-linking-token-picker-preview'); - saveForm.set( - 'selection', - buildDeepLinkingSelectionValue({ - packageVersionId: 3, - contentPath: '/content/bonus.json', - }), - ); - - const savedResponse = await app.request( - 'http://localhost/lti/deep-linking/sessions/deep-linking-session-picker-preview', - { - method: 'POST', - body: saveForm, - }, - ); - - assertEquals(savedResponse.status, 200); - - const savedBody = await savedResponse.text(); - - assertStringIncludes( - savedBody, - 'action="/lti/deep-linking/sessions/deep-linking-session-picker-preview/preview"', - ); - assertStringIncludes(savedBody, 'target="_blank"'); - assertStringIncludes(savedBody, 'name="token" value="deep-linking-token-picker-preview"'); -}); - -Deno.test('POST /lti/deep-linking/sessions/:id/preview rejects missing saved selections and keeps the Deep Linking session usable', async () => { - const repository = createInMemoryPackageReviewRepository({ - deepLinkingSessions: [ - buildDeepLinkingSessionRecord({ - sessionId: 'deep-linking-session-preview-blocked', - sessionToken: 'deep-linking-token-preview-blocked', - selection: null, - expiresAt: '2030-03-25T16:20:00Z', - }), - ], - deepLinkingResourceOptions: [buildDeepLinkingResourceOption()], - }); - const app = createApp({ getRepository: () => repository }); - const formData = new FormData(); - - formData.set('token', 'deep-linking-token-preview-blocked'); - - const previewResponse = await app.request( - 'http://localhost/lti/deep-linking/sessions/deep-linking-session-preview-blocked/preview', - { - method: 'POST', - body: formData, - }, - ); - - assertEquals(previewResponse.status, 409); - assertEquals(previewResponse.headers.get('location'), null); - - const previewBody = await previewResponse.text(); - - assertStringIncludes(previewBody, 'Preview blocked'); - assertStringIncludes(previewBody, 'Save one reviewed assignment resource'); - - const savedSession = await repository.getDeepLinkingSessionById( - 'deep-linking-session-preview-blocked', - ); - - assertEquals(savedSession?.usedAt, null); - - const submitResponse = await app.request( - 'http://localhost/lti/deep-linking/sessions/deep-linking-session-preview-blocked/submit', - { - method: 'POST', - body: formData, - }, - ); - - assertEquals(submitResponse.status, 409); - assertStringIncludes(await submitResponse.text(), 'Return blocked'); -}); - -Deno.test('POST /lti/deep-linking/sessions/:id/preview launches a governed runtime session for the saved reviewed selection', async () => { - await withRuntimeOriginEnv(async () => { - await withAuthoringPreviewSnapshot(async (snapshotRoot) => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildAuthoringPreviewPackageVersion({ - id: 7, - version: '0.7.0', - snapshotRoot, - }), - ], - deepLinkingSessions: [ - buildDeepLinkingSessionRecord({ - sessionId: 'deep-linking-session-preview-launch', - sessionToken: 'deep-linking-token-preview-launch', - contextId: 'physics-101', - selection: buildDeepLinkingResourceSelection({ - packageVersionId: 7, - packageVersion: '0.7.0', - contentPath: '/content/bonus.json', - activityId: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - expiresAt: '2030-03-25T16:20:00Z', - }), - ], - deepLinkingResourceOptions: buildAssignmentResources({ - packageVersionId: 7, - packageVersion: '0.7.0', - }), - }); - const app = createApp({ getRepository: () => repository }); - const formData = new FormData(); - - formData.set('token', 'deep-linking-token-preview-launch'); - - const previewResponse = await app.request( - 'https://lantern.example/lti/deep-linking/sessions/deep-linking-session-preview-launch/preview', - { - method: 'POST', - body: formData, - }, - ); - - assertEquals(previewResponse.status, 303); - - const location = previewResponse.headers.get('location') ?? ''; - - assertStringIncludes(location, 'https://runtime.lantern.example/runtime/sessions/'); - assertStringIncludes(location, '?token='); - - const runtimeLocation = new URL(location); - const runtimeSessionId = runtimeLocation.pathname.split('/').at(-1) ?? ''; - const runtimeSession = await repository.getRuntimeSessionById(runtimeSessionId); - const previewSession = await repository.getLatestPreviewSessionByPackageVersion( - 7, - 'deepLinkingAuthoring', - ); - - assertEquals(runtimeSession?.sessionToken, runtimeLocation.searchParams.get('token')); - assertEquals(runtimeSession?.launch.userRole, 'instructor'); - assertEquals(runtimeSession?.launch.courseId, 'physics-101'); - assertEquals(runtimeSession?.launch.assignmentId ?? null, null); - assertEquals(runtimeSession?.launch.activityId, '/content/bonus.json'); - assertEquals(runtimeSession?.contentPath, `${snapshotRoot}/content/bonus.json`); - assertEquals(previewSession?.origin, 'deepLinkingAuthoring'); - assertEquals(previewSession?.contentPath, '/content/bonus.json'); - assertEquals(previewSession?.deepLinkingSessionId, 'deep-linking-session-preview-launch'); - - const contentResponse = await app.request( - `https://runtime.lantern.example/runtime/sessions/${runtimeSessionId}/content`, - { - headers: { - Authorization: `Bearer ${runtimeSession?.sessionToken ?? ''}`, - }, - }, - ); - - assertEquals(contentResponse.status, 200); - - const content = (await contentResponse.json()) as { - title: string; - questions: Array<{ id: string }>; - }; - - assertEquals(content.title, 'Bonus Activity'); - assertEquals(content.questions[0]?.id, 'bonus-q1'); - }); - }); -}); - -Deno.test('POST /lti/deep-linking/sessions/:id/preview does not consume the Deep Linking session or break the later LMS return', async () => { - await withRuntimeOriginEnv(async () => { - await withAuthoringPreviewSnapshot(async (snapshotRoot) => { - const binding = buildDeploymentBinding(); - const ltiStorage = await createSyncedTestLtiStorage({ binding }); - await ltiStorage.addSession({ - id: 'lti-tool-session-preview-submit', - jwtPayload: buildTestLtiJwtPayload({ - messageType: 'LtiDeepLinkingRequest', - targetLinkUri: 'https://lantern.example/lti/deep-linking', - }), - user: { - id: 'canvas-user-123', - roles: ['http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'], - }, - context: { - id: 'course-42', - label: 'Physics 101', - title: 'Physics 101', - }, - platform: { - issuer: binding.issuer, - clientId: binding.clientId, - deploymentId: binding.deploymentId, - name: 'Canvas', - }, - launch: { - target: 'https://lantern.example/lti/deep-linking', - }, - services: { - deepLinking: { - returnUrl: 'https://canvas.example/courses/42/deep_link_return', - acceptTypes: ['ltiResourceLink'], - acceptPresentationDocumentTargets: ['iframe'], - acceptMultiple: false, - autoCreate: false, - data: 'deep-linking-state-token', - }, - }, - customParameters: {}, - isAdmin: false, - isInstructor: true, - isStudent: false, - isAssignmentAndGradesAvailable: false, - isDeepLinkingAvailable: true, - isNameAndRolesAvailable: false, - }); - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildAuthoringPreviewPackageVersion({ - id: 8, - version: '0.8.0', - snapshotRoot, - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 1, - enabledPackageVersionId: 8, - enabledPackageVersion: '0.8.0', - binding, - }), - ], - deepLinkingSessions: [ - buildDeepLinkingSessionRecord({ - sessionId: 'deep-linking-session-preview-submit', - sessionToken: 'deep-linking-token-preview-submit', - ltiToolSessionId: 'lti-tool-session-preview-submit', - selection: buildDeepLinkingResourceSelection({ - packageVersionId: 8, - packageVersion: '0.8.0', - contentPath: '/content/bonus.json', - activityId: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - expiresAt: '2030-03-25T16:20:00Z', - }), - ], - deepLinkingResourceOptions: buildAssignmentResources({ - packageVersionId: 8, - packageVersion: '0.8.0', - }), - }); - const app = createApp({ - getRepository: () => repository, - getLtiToolStorage: () => ltiStorage, - }); - const formData = new FormData(); - - formData.set('token', 'deep-linking-token-preview-submit'); - - const previewResponse = await app.request( - 'https://lantern.example/lti/deep-linking/sessions/deep-linking-session-preview-submit/preview', - { - method: 'POST', - body: formData, - }, - ); - - assertEquals(previewResponse.status, 303); - - const sessionAfterPreview = await repository.getDeepLinkingSessionById( - 'deep-linking-session-preview-submit', - ); - - assertEquals(sessionAfterPreview?.usedAt, null); - assertEquals(sessionAfterPreview?.selection?.contentPath, '/content/bonus.json'); - - await withCanvasReturnEnv(async () => { - const submitResponse = await app.request( - 'https://lantern.example/lti/deep-linking/sessions/deep-linking-session-preview-submit/submit', - { - method: 'POST', - body: formData, - }, - ); - - assertEquals(submitResponse.status, 200); - - const body = await submitResponse.text(); - const auditEvents = await repository.listAuditEventsByEventType( - 'deep_linking.placement.created', - ); - const placementId = String(auditEvents[0]?.detail.placementId ?? ''); - const savedPlacement = await repository.getReviewedPlacementById(placementId); - const sessionAfterSubmit = await repository.getDeepLinkingSessionById( - 'deep-linking-session-preview-submit', - ); - - assertStringIncludes(body, 'name="JWT"'); - assertEquals(savedPlacement?.contentPath, '/content/bonus.json'); - assertEquals(savedPlacement?.activityId, '/content/bonus.json'); - assertEquals(sessionAfterSubmit?.usedAt !== null, true); - }); - }); - }); -}); - -function buildAssignmentResources(input: { packageVersionId: number; packageVersion: string }) { - return [ - buildDeepLinkingResourceOption({ - packageVersionId: input.packageVersionId, - packageVersion: input.packageVersion, - installScope: 'assignment', - contentPath: '/content/activity.json', - activityId: '/content/activity.json', - contentTitle: 'Default Activity', - }), - buildDeepLinkingResourceOption({ - packageVersionId: input.packageVersionId, - packageVersion: input.packageVersion, - installScope: 'assignment', - contentPath: '/content/bonus.json', - activityId: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - ]; -} - -function buildAuthoringPreviewPackageVersion(input: { - id: number; - version: string; - snapshotRoot: string; -}) { - return buildPackageVersionRecord({ - id: input.id, - version: input.version, - approvalStatus: 'approved', - reviewedAt: '2026-04-01T12:00:00Z', - installScope: 'assignment', - manifestJson: { - app_id: 'chapter-4-asteroids', - version: input.version, - title: 'Chapter 4 Asteroids', - content_files: ['/content/activity.json', '/content/bonus.json'], - preview: { - fixtures_file: '/preview/fixtures.json', - tests_file: '/preview/tests.json', - }, - }, - artifact: { - snapshotRoot: input.snapshotRoot, - manifestPath: `${input.snapshotRoot}/manifest.json`, - entrypointPath: `${input.snapshotRoot}/dist/index.html`, - digest: `sha256:${input.version}-authoring-preview`, - }, - }); -} - -async function withAuthoringPreviewSnapshot( - run: (snapshotRoot: string) => Promise, -): Promise { - const snapshotRoot = await Deno.makeTempDir({ - prefix: 'lantern-authoring-preview-', - }); - - try { - await Deno.mkdir(`${snapshotRoot}/content`, { recursive: true }); - await Deno.mkdir(`${snapshotRoot}/dist`, { recursive: true }); - await Deno.mkdir(`${snapshotRoot}/preview`, { recursive: true }); - await Deno.mkdir(`${snapshotRoot}/scoring`, { recursive: true }); - - await Deno.writeTextFile( - `${snapshotRoot}/manifest.json`, - JSON.stringify({ - schema_version: '1', - app_id: 'chapter-4-asteroids', - version: '0.1.0', - title: 'Chapter 4 Asteroids', - owner: { - type: 'user', - id: 'instructor_123', - }, - entrypoint: '/dist/index.html', - roles: ['learner', 'instructor'], - install_scope: 'assignment', - capabilities: [ - 'read_launch_context', - 'read_activity_content', - 'submit_attempt_event', - 'finalize_attempt', - 'read_local_state', - 'write_local_state', - ], - grading: { - mode: 'declarative', - rubric_file: '/scoring/rubric.json', - max_score: 100, - }, - content_files: ['/content/activity.json', '/content/bonus.json'], - preview: { - fixtures_file: '/preview/fixtures.json', - tests_file: '/preview/tests.json', - }, - }), - ); - await Deno.writeTextFile( - `${snapshotRoot}/dist/index.html`, - 'Authoring Preview', - ); - await Deno.writeTextFile( - `${snapshotRoot}/content/activity.json`, - JSON.stringify({ - title: 'Default Activity', - questions: [{ id: 'q1' }], - }), - ); - await Deno.writeTextFile( - `${snapshotRoot}/content/bonus.json`, - JSON.stringify({ - title: 'Bonus Activity', - questions: [{ id: 'bonus-q1' }], - }), - ); - await Deno.writeTextFile( - `${snapshotRoot}/preview/fixtures.json`, - JSON.stringify({ - launch: { - user_role: 'instructor', - course_id: 'course_demo', - assignment_id: null, - activity_id: '/content/activity.json', - }, - attempt_id: 'preview-attempt-demo', - local_state: null, - }), - ); - await Deno.writeTextFile(`${snapshotRoot}/preview/tests.json`, JSON.stringify([])); - await Deno.writeTextFile( - `${snapshotRoot}/scoring/rubric.json`, - JSON.stringify({ - rubric: [], - }), - ); - - await run(snapshotRoot); - } finally { - await Deno.remove(snapshotRoot, { recursive: true }); - } -} diff --git a/src/app_deep_linking_request_test.ts b/src/app_deep_linking_request_test.ts deleted file mode 100644 index 4ae6409..0000000 --- a/src/app_deep_linking_request_test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; -import { LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE } from './lti/types.ts'; -import { - buildDeploymentRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { - buildDeploymentBinding, - buildMoodleDeploymentBinding, - createSyncedTestLtiStorage, - getTestCanvasJwks, - signCanvasIdToken, - signLtiToolState, -} from './test_helpers/lti.ts'; -import { withFetchStub, withRuntimeOriginEnv } from './app_test_support.ts'; - -Deno.test('POST /lti/deep-linking accepts lti-tool verified Canvas Deep Linking launches', async () => { - const binding = buildDeploymentBinding(); - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - installScope: 'assignment', - approvalStatus: 'approved', - reviewedAt: '2026-03-24T16:15:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 7, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding, - }), - ], - }); - const ltiStorage = await createSyncedTestLtiStorage({ binding }); - const formData = new FormData(); - - formData.set( - 'state', - await signLtiToolState({ - nonce: 'nonce-canvas-deep-linking', - issuer: binding.issuer, - clientId: binding.clientId, - targetLinkUri: 'http://localhost/lti/deep-linking?placement=assignment_selection', - }), - ); - formData.set( - 'id_token', - await signCanvasIdToken({ - nonce: 'nonce-canvas-deep-linking', - subject: 'canvas-author-123', - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://localhost/lti/deep-linking?placement=assignment_selection', - deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', - deepLinkData: 'dl-state-123', - deepLinkAcceptLineItem: true, - }), - ); - - await withRuntimeOriginEnv(async () => { - await withFetchStub( - () => - Promise.resolve( - new Response(JSON.stringify(getTestCanvasJwks()), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ), - async () => { - const response = await createApp({ - getRepository: () => repository, - getLtiToolStorage: () => ltiStorage, - }).request('http://localhost/lti/deep-linking', { - method: 'POST', - body: formData, - }); - const location = response.headers.get('location') ?? ''; - const sessionLocation = new URL(`http://localhost${location}`); - const sessionId = sessionLocation.pathname.split('/').at(-1) ?? ''; - const savedSession = await repository.getDeepLinkingSessionById(sessionId); - const runtimeSession = await repository.getLatestRuntimeSessionByDeploymentId(7); - - assertEquals(response.status, 303, await response.clone().text()); - assertStringIncludes(location, '/lti/deep-linking/sessions/'); - assertEquals(savedSession?.deepLinkReturnUrl.includes('deep_link_return'), true); - assertEquals(savedSession?.sessionToken, sessionLocation.searchParams.get('token')); - assertEquals(savedSession?.ltiToolSessionId !== null, true); - assertEquals(savedSession?.placement, 'assignment_selection'); - assertEquals(savedSession?.acceptLineItem, true); - assertEquals(runtimeSession, null); - }, - ); - }); -}); - -Deno.test('POST /lti/deep-linking accepts lti-tool verified Moodle Deep Linking launches', async () => { - const binding = buildMoodleDeploymentBinding(); - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - installScope: 'course', - approvalStatus: 'approved', - reviewedAt: '2026-03-24T16:15:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 12, - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding, - }), - ], - }); - const ltiStorage = await createSyncedTestLtiStorage({ binding }); - const formData = new FormData(); - - formData.set( - 'state', - await signLtiToolState({ - nonce: 'nonce-moodle-deep-linking', - issuer: binding.issuer, - clientId: binding.clientId, - targetLinkUri: 'http://localhost/lti/deep-linking?placement=resource_selection', - }), - ); - formData.set( - 'id_token', - await signCanvasIdToken({ - deploymentBinding: { - issuer: binding.issuer, - clientId: binding.clientId, - deploymentId: binding.deploymentId, - }, - audience: binding.clientId, - nonce: 'nonce-moodle-deep-linking', - subject: 'moodle-user-9', - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - targetLinkUri: 'http://localhost/lti/deep-linking?placement=resource_selection', - deepLinkReturnUrl: 'https://moodle.example/mod/lti/deep_link_return', - deepLinkData: 'moodle-state', - }), - ); - - await withRuntimeOriginEnv(async () => { - await withFetchStub( - () => - Promise.resolve( - new Response(JSON.stringify(getTestCanvasJwks()), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ), - async () => { - const response = await createApp({ - getRepository: () => repository, - getLtiToolStorage: () => ltiStorage, - }).request('http://localhost/lti/deep-linking', { - method: 'POST', - body: formData, - }); - const location = response.headers.get('location') ?? ''; - const sessionId = new URL(`http://localhost${location}`).pathname.split('/').at(-1) ?? ''; - const savedSession = await repository.getDeepLinkingSessionById(sessionId); - - assertEquals(response.status, 303); - assertEquals(savedSession?.deploymentRecordId, 12); - assertEquals(savedSession?.placement, 'resource_selection'); - assertEquals(savedSession?.userId, 'moodle-user-9'); - }, - ); - }); -}); diff --git a/src/app_deep_linking_routes.ts b/src/app_deep_linking_routes.ts deleted file mode 100644 index c0125bf..0000000 --- a/src/app_deep_linking_routes.ts +++ /dev/null @@ -1,195 +0,0 @@ -import type { Hono } from '@hono/hono'; -import { renderDeepLinkingPickerResponse } from './app_deep_linking_views.ts'; -import { requireTrimmedFormValue, requireTrimmedString } from './app_request_support.ts'; -import { errorMessage, statusForDeepLinkingSessionError } from './app_status_support.ts'; -import type { AppServices } from './app_services.ts'; -import { readEnv } from './platform/env.ts'; -import { launchPreviewRuntimeSession } from './preview/service.ts'; -import { buildRuntimeSessionUrl, requireConfiguredRuntimeOrigin } from './runtime_origin.ts'; -import { - listDeepLinkingResources, - requireAuthorizedDeepLinkingSession, - resolveDeepLinkingSelection, - saveDeepLinkingSessionSelection, -} from './lti/deep_linking.ts'; - -export function registerDeepLinkingRoutes(app: Hono, services: AppServices): void { - app.get('/lti/deep-linking/sessions/:sessionId', async (context) => { - try { - const repository = services.getRepository(); - const url = new URL(context.req.url); - const session = await requireAuthorizedDeepLinkingSession({ - repository, - sessionId: context.req.param('sessionId'), - token: requireTrimmedString( - url.searchParams.get('token'), - 'Deep Linking session token is required.', - ), - }); - - return await renderDeepLinkingPickerResponse({ - context, - repository, - session, - token: session.sessionToken, - notice: null, - }); - } catch (error) { - return context.text(errorMessage(error), statusForDeepLinkingSessionError(error)); - } - }); - - app.post('/lti/deep-linking/sessions/:sessionId', async (context) => { - const repository = services.getRepository(); - const formData = await context.req.formData(); - - try { - const session = await requireAuthorizedDeepLinkingSession({ - repository, - sessionId: context.req.param('sessionId'), - token: requireTrimmedFormValue( - formData.get('token'), - 'Deep Linking session token is required.', - ), - }); - - try { - const saved = await saveDeepLinkingSessionSelection({ - repository, - session, - selectionValue: requireTrimmedFormValue( - formData.get('selection'), - 'Choose one reviewed resource before continuing.', - ), - }); - - return await renderDeepLinkingPickerResponse({ - context, - repository, - session: saved.session, - token: session.sessionToken, - notice: { - tone: 'success', - title: 'Selection saved', - detail: - 'Lantern saved the reviewed version and content path. Use the verified return action to post this selection back to the LMS.', - }, - }); - } catch (error) { - return await renderDeepLinkingPickerResponse({ - context, - repository, - session, - token: session.sessionToken, - notice: { - tone: 'error', - title: 'Selection blocked', - detail: errorMessage(error), - }, - status: 400, - }); - } - } catch (error) { - return context.text(errorMessage(error), statusForDeepLinkingSessionError(error)); - } - }); - - app.post('/lti/deep-linking/sessions/:sessionId/preview', async (context) => { - const repository = services.getRepository(); - const formData = await context.req.formData(); - - try { - const session = await requireAuthorizedDeepLinkingSession({ - repository, - sessionId: context.req.param('sessionId'), - token: requireTrimmedFormValue( - formData.get('token'), - 'Deep Linking session token is required.', - ), - }); - - try { - const selection = resolveDeepLinkingSelection({ - session, - resources: await listDeepLinkingResources({ - repository, - session, - }), - }); - - if (selection === null) { - return await renderDeepLinkingPickerResponse({ - context, - repository, - session, - token: session.sessionToken, - notice: { - tone: 'error', - title: 'Preview blocked', - detail: buildDeepLinkingPreviewMissingSelectionDetail(session.placement), - }, - status: 409, - }); - } - - const packageVersion = await repository.getPackageVersionById(selection.packageVersionId); - - if (packageVersion === null) { - throw new Error( - `Reviewed package version ${selection.packageVersionId} could not be loaded for preview.`, - ); - } - - const launched = await launchPreviewRuntimeSession({ - repository, - packageVersion, - artifactStore: services.runtimeArtifactStore, - launch: { - userRole: 'instructor', - courseId: session.contextId ?? 'deep-linking-context', - assignmentId: null, - activityId: selection.activityId, - contentPath: selection.contentPath, - }, - previewOrigin: 'deepLinkingAuthoring', - deepLinkingSessionId: session.sessionId, - }); - const runtimeOrigin = requireConfiguredRuntimeOrigin( - readEnv('APP_RUNTIME_ORIGIN', services.env), - ); - - return context.redirect( - buildRuntimeSessionUrl({ - runtimeOrigin, - sessionId: launched.runtimeSession.sessionId, - token: launched.runtimeSession.sessionToken, - }), - 303, - ); - } catch (error) { - return await renderDeepLinkingPickerResponse({ - context, - repository, - session, - token: session.sessionToken, - notice: { - tone: 'error', - title: 'Preview blocked', - detail: errorMessage(error), - }, - status: 409, - }); - } - } catch (error) { - return context.text(errorMessage(error), statusForDeepLinkingSessionError(error)); - } - }); -} - -function buildDeepLinkingPreviewMissingSelectionDetail( - placement: 'assignment_selection' | 'resource_selection', -): string { - const resource = placement === 'resource_selection' ? 'course resource' : 'assignment resource'; - - return `Save one reviewed ${resource} before previewing it in Lantern.`; -} diff --git a/src/app_deep_linking_session_test.ts b/src/app_deep_linking_session_test.ts deleted file mode 100644 index 5cd32a2..0000000 --- a/src/app_deep_linking_session_test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; -import { buildDeepLinkingSelectionValue } from './lti/deep_linking.ts'; -import { - buildDeepLinkingResourceOption, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { buildDeepLinkingSessionRecord } from './test_helpers/lti.ts'; - -Deno.test('GET /lti/deep-linking/sessions/:id renders only approved assignment resources for the bound app', async () => { - const repository = createInMemoryPackageReviewRepository({ - deepLinkingSessions: [ - buildDeepLinkingSessionRecord({ - sessionId: 'deep-linking-session-picker', - sessionToken: 'deep-linking-token-picker', - appId: 'chapter-4-asteroids', - expiresAt: '2030-03-25T16:20:00Z', - }), - ], - deepLinkingResourceOptions: [ - buildDeepLinkingResourceOption(), - buildDeepLinkingResourceOption({ - packageVersionId: 2, - packageVersion: '0.2.0', - contentPath: '/content/bonus.json', - activityId: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - buildDeepLinkingResourceOption({ - appId: 'other-app', - packageTitle: 'Other App', - contentPath: '/content/ignore.json', - activityId: '/content/ignore.json', - }), - ], - }); - - const response = await createApp({ - getRepository: () => repository, - }).request( - 'http://localhost/lti/deep-linking/sessions/deep-linking-session-picker?token=deep-linking-token-picker', - ); - - assertEquals(response.status, 200); - const body = await response.text(); - - assertStringIncludes(body, 'Chapter 4 Asteroids'); - assertStringIncludes(body, '0.2.0'); - assertStringIncludes(body, '/content/bonus.json'); - assertStringIncludes( - body, - 'Save one reviewed assignment resource before returning it to the LMS.', - ); - assertEquals(body.includes('Other App'), false); -}); - -Deno.test('GET /lti/deep-linking/sessions/:id renders only approved course resources for resource_selection placement', async () => { - const repository = createInMemoryPackageReviewRepository({ - deepLinkingSessions: [ - buildDeepLinkingSessionRecord({ - sessionId: 'deep-linking-session-course-picker', - sessionToken: 'deep-linking-token-course-picker', - appId: 'chapter-4-asteroids', - placement: 'resource_selection', - expiresAt: '2030-03-25T16:20:00Z', - }), - ], - deepLinkingResourceOptions: [ - buildDeepLinkingResourceOption({ - installScope: 'course', - packageVersion: '0.3.0', - contentPath: '/content/course.json', - activityId: '/content/course.json', - contentTitle: 'Course Activity', - }), - buildDeepLinkingResourceOption({ - installScope: 'assignment', - packageVersion: '0.2.0', - contentPath: '/content/bonus.json', - activityId: '/content/bonus.json', - contentTitle: 'Assignment Activity', - }), - ], - }); - - const response = await createApp({ - getRepository: () => repository, - }).request( - 'http://localhost/lti/deep-linking/sessions/deep-linking-session-course-picker?token=deep-linking-token-course-picker', - ); - - assertEquals(response.status, 200); - const body = await response.text(); - - assertStringIncludes(body, 'Select one reviewed resource for course placement.'); - assertStringIncludes(body, 'Course Activity'); - assertEquals(body.includes('Assignment Activity'), false); - assertStringIncludes(body, 'Choose one approved course resource below.'); - assertStringIncludes(body, 'Save one reviewed course resource before returning it to the LMS.'); -}); - -Deno.test('POST /lti/deep-linking/sessions/:id stores one explicit reviewed selection and re-renders the summary', async () => { - const repository = createInMemoryPackageReviewRepository({ - deepLinkingSessions: [ - buildDeepLinkingSessionRecord({ - sessionId: 'deep-linking-session-picker', - sessionToken: 'deep-linking-token-picker', - appId: 'chapter-4-asteroids', - expiresAt: '2030-03-25T16:20:00Z', - }), - ], - deepLinkingResourceOptions: [ - buildDeepLinkingResourceOption(), - buildDeepLinkingResourceOption({ - packageVersionId: 2, - packageVersion: '0.2.0', - contentPath: '/content/bonus.json', - activityId: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - ], - }); - const formData = new FormData(); - - formData.set('token', 'deep-linking-token-picker'); - formData.set( - 'selection', - buildDeepLinkingSelectionValue({ - packageVersionId: 2, - contentPath: '/content/bonus.json', - }), - ); - - const response = await createApp({ - getRepository: () => repository, - }).request('http://localhost/lti/deep-linking/sessions/deep-linking-session-picker', { - method: 'POST', - body: formData, - }); - const savedSession = await repository.getDeepLinkingSessionById('deep-linking-session-picker'); - - assertEquals(response.status, 200); - assertEquals(savedSession?.selection?.packageVersionId, 2); - assertEquals(savedSession?.selection?.contentPath, '/content/bonus.json'); - - const body = await response.text(); - - assertStringIncludes(body, 'Selection saved'); - assertStringIncludes(body, 'Bonus Activity'); - assertStringIncludes(body, '/content/bonus.json'); - assertStringIncludes(body, 'Ready to return this reviewed assignment resource to the LMS.'); -}); - -Deno.test('GET /lti/deep-linking/sessions/:id fails clearly when the session token is missing', async () => { - const repository = createInMemoryPackageReviewRepository({ - deepLinkingSessions: [buildDeepLinkingSessionRecord({ expiresAt: '2030-03-25T16:20:00Z' })], - deepLinkingResourceOptions: [buildDeepLinkingResourceOption()], - }); - - const response = await createApp({ - getRepository: () => repository, - }).request('http://localhost/lti/deep-linking/sessions/deep-linking-session-123'); - - assertEquals(response.status, 409); - assertStringIncludes(await response.text(), 'Deep Linking session token is required.'); -}); diff --git a/src/app_deep_linking_submit_routes.ts b/src/app_deep_linking_submit_routes.ts deleted file mode 100644 index e9bd6f2..0000000 --- a/src/app_deep_linking_submit_routes.ts +++ /dev/null @@ -1,236 +0,0 @@ -import type { Context, Hono } from '@hono/hono'; -import { - renderDeepLinkingPickerResponse, - renderDeepLinkingSubmitStatusPage, -} from './app_deep_linking_views.ts'; -import { normalizeOptionalString } from './app_request_support.ts'; -import { deepLinkingReturnErrorMessage } from './app_status_support.ts'; -import type { AppServices } from './app_services.ts'; -import { createDeepLinkingReturnResponseWithLtiTool } from './lti/deep_linking_response_tool.ts'; -import { - authorizeDeepLinkingSession, - createReviewedPlacementFromDeepLinkingSession, - listDeepLinkingResources, - resolveDeepLinkingSelection, -} from './lti/deep_linking.ts'; -import type { DeepLinkingSessionRecord, LtiPlacement } from './lti/types.ts'; -import { resolveRequestAppOrigin } from './app_origin.ts'; - -const sessionVerificationFailureDetail = - 'Lantern could not verify this Deep Linking session. Reopen Deep Linking authoring from the LMS and try again.'; - -export function registerDeepLinkingSubmitRoutes(app: Hono, services: AppServices): void { - app.post('/lti/deep-linking/sessions/:sessionId/submit', async (context) => { - const repository = services.getRepository(); - const formData = await context.req.formData(); - const sessionId = context.req.param('sessionId'); - const token = normalizeOptionalString(formData.get('token')); - const session = await repository.getDeepLinkingSessionById(sessionId); - - if (session === null) { - return renderSessionVerificationFailure(context, 404); - } - - if (token === null) { - return renderSessionVerificationFailure(context, 409); - } - - try { - authorizeDeepLinkingSession({ - token, - expected: session, - }); - } catch (error) { - if (error instanceof Error && error.message.includes('has already been used')) { - return context.html( - renderDeepLinkingSubmitStatusPage({ - tone: 'error', - title: buildDeepLinkingReturnTitle(session.placement, 'alreadyUsed'), - detail: buildDeepLinkingReplayFailureDetail(session.placement), - session, - selection: resolveDeepLinkingSelection({ - session, - resources: await listDeepLinkingResources({ - repository, - session, - }), - }), - }), - 409, - ); - } - - return renderSessionVerificationFailure(context, 409); - } - - try { - const selection = resolveDeepLinkingSelection({ - session, - resources: await listDeepLinkingResources({ - repository, - session, - }), - }); - - if (selection === null) { - return await renderDeepLinkingPickerResponse({ - context, - repository, - session, - token, - notice: { - tone: 'error', - title: 'Return blocked', - detail: buildDeepLinkingMissingSelectionDetail(session.placement), - }, - status: 409, - }); - } - - const deployment = await repository.getDeploymentBySlug(session.deploymentSlug); - - if (deployment === null || deployment.id !== session.deploymentRecordId) { - throw new Error( - `Deployment ${session.deploymentSlug} could not be loaded for this Deep Linking session.`, - ); - } - - const packageVersion = await repository.getPackageVersionById(selection.packageVersionId); - - if (packageVersion === null) { - throw new Error( - `Reviewed package version ${selection.packageVersionId} could not be loaded for the LMS return.`, - ); - } - - try { - await repository.consumeDeepLinkingSession({ - sessionId: session.sessionId, - usedAt: new Date().toISOString(), - }); - } catch { - return context.html( - renderDeepLinkingSubmitStatusPage({ - tone: 'error', - title: buildDeepLinkingReturnTitle(session.placement, 'alreadyUsed'), - detail: buildDeepLinkingReplayFailureDetail(session.placement), - session, - selection, - }), - 409, - ); - } - - const { placement } = await createReviewedPlacementFromDeepLinkingSession({ - repository, - session, - }); - await repository.recordAuditEvent({ - eventType: 'deep_linking.placement.created', - actorType: 'platform', - actorId: session.userId, - deploymentRecordId: session.deploymentRecordId, - packageVersionId: placement.packageVersionId, - attemptId: null, - lineItemBindingId: null, - status: 'succeeded', - summary: 'Created a reviewed placement for Deep Linking return.', - detail: { - deepLinkingSessionId: session.sessionId, - placementId: placement.placementId, - contentPath: placement.contentPath, - activityId: placement.activityId, - contextId: placement.contextId, - }, - occurredAt: new Date().toISOString(), - }); - const appOrigin = resolveRequestAppOrigin(context, services.env); - const response = await createDeepLinkingReturnResponseWithLtiTool({ - ltiContext: await services.getLtiToolContext(appOrigin), - session, - placement, - packageVersion, - appOrigin, - }); - - return response; - } catch (error) { - return context.html( - renderDeepLinkingSubmitStatusPage({ - tone: 'error', - title: buildDeepLinkingReturnTitle(session.placement, 'failed'), - detail: deepLinkingReturnErrorMessage(error), - session, - selection: resolveDeepLinkingSelection({ - session, - resources: await listDeepLinkingResources({ - repository, - session, - }), - }), - }), - 500, - ); - } - }); -} - -function renderSessionVerificationFailure(context: Context, status: 404 | 409) { - return context.html( - renderDeepLinkingSubmitStatusPage({ - tone: 'error', - title: 'Session verification failed', - detail: sessionVerificationFailureDetail, - }), - status, - ); -} - -function buildDeepLinkingReturnTitle( - placement: LtiPlacement, - state: 'alreadyUsed' | 'failed', -): string { - const resourceLabel = describeDeepLinkingPlacement(placement).capitalized; - - switch (state) { - case 'alreadyUsed': - return `${resourceLabel} return already used`; - case 'failed': - return `${resourceLabel} return failed`; - } -} - -function buildDeepLinkingReplayFailureDetail(placement: LtiPlacement): string { - return ( - 'This Deep Linking return has already been used. Reopen Deep Linking authoring from the LMS, confirm the reviewed ' + - describeDeepLinkingPlacement(placement).resource + - ', and return once.' - ); -} - -function buildDeepLinkingMissingSelectionDetail(placement: LtiPlacement): string { - return ( - 'Save one reviewed ' + - describeDeepLinkingPlacement(placement).resource + - ' before returning it to the LMS.' - ); -} - -function describeDeepLinkingPlacement( - placement: Pick['placement'], -): { - resource: string; - capitalized: string; -} { - if (placement === 'resource_selection') { - return { - resource: 'course resource', - capitalized: 'Course resource', - }; - } - - return { - resource: 'assignment resource', - capitalized: 'Assignment resource', - }; -} diff --git a/src/app_deep_linking_submit_test.ts b/src/app_deep_linking_submit_test.ts deleted file mode 100644 index 3197894..0000000 --- a/src/app_deep_linking_submit_test.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; -import { - buildDeepLinkingResourceOption, - buildDeepLinkingResourceSelection, - buildDeploymentRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { - buildDeepLinkingSessionRecord, - buildDeploymentBinding, - createSyncedTestLtiStorage, -} from './test_helpers/lti.ts'; -import { buildTestLtiJwtPayload } from './test_helpers/lti_session_builders.ts'; -import { - extractHiddenInputValue, - verifyDeepLinkingResponseJwt, - withCanvasReturnEnv, -} from './app_test_support.ts'; - -Deno.test('POST /lti/deep-linking/sessions/:id/submit creates one reviewed placement and returns lti-tool auto-post HTML', async () => { - const binding = buildDeploymentBinding(); - const ltiStorage = await createSyncedTestLtiStorage({ binding }); - await ltiStorage.addSession({ - id: 'lti-tool-session-submit', - jwtPayload: buildTestLtiJwtPayload({ - messageType: 'LtiDeepLinkingRequest', - targetLinkUri: 'https://lantern.example/lti/deep-linking', - }), - user: { - id: 'canvas-user-123', - roles: ['http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'], - }, - context: { - id: 'course-42', - label: 'Physics 101', - title: 'Physics 101', - }, - platform: { - issuer: binding.issuer, - clientId: binding.clientId, - deploymentId: binding.deploymentId, - name: 'Canvas', - }, - launch: { - target: 'https://lantern.example/lti/deep-linking', - }, - services: { - deepLinking: { - returnUrl: 'https://canvas.example/courses/42/deep_link_return', - acceptTypes: ['ltiResourceLink'], - acceptPresentationDocumentTargets: ['iframe'], - acceptMultiple: false, - autoCreate: false, - data: 'deep-linking-state-token', - }, - }, - customParameters: {}, - isAdmin: false, - isInstructor: true, - isStudent: false, - isAssignmentAndGradesAvailable: false, - isDeepLinkingAvailable: true, - isNameAndRolesAvailable: false, - }); - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 2, - version: '0.2.0', - installScope: 'assignment', - grading: { - mode: 'declarative', - rubricFile: '/scoring/rubric.json', - maxScore: 25, - }, - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 1, - enabledPackageVersionId: 2, - enabledPackageVersion: '0.2.0', - binding, - }), - ], - deepLinkingSessions: [ - buildDeepLinkingSessionRecord({ - sessionId: 'deep-linking-session-submit', - sessionToken: 'deep-linking-token-submit', - ltiToolSessionId: 'lti-tool-session-submit', - acceptLineItem: true, - selection: { - packageVersionId: 2, - packageVersion: '0.2.0', - activityId: '/content/bonus.json', - contentPath: '/content/bonus.json', - }, - expiresAt: '2030-03-25T16:20:00Z', - }), - ], - deepLinkingResourceOptions: [ - buildDeepLinkingResourceOption(), - buildDeepLinkingResourceOption({ - packageVersionId: 2, - packageVersion: '0.2.0', - contentPath: '/content/bonus.json', - activityId: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - ], - }); - const formData = new FormData(); - - formData.set('token', 'deep-linking-token-submit'); - - await withCanvasReturnEnv(async () => { - const response = await createApp({ - getRepository: () => repository, - getLtiToolStorage: () => ltiStorage, - }).request('http://localhost/lti/deep-linking/sessions/deep-linking-session-submit/submit', { - method: 'POST', - body: formData, - }); - - assertEquals(response.status, 200); - - const body = await response.text(); - const responseJwt = extractHiddenInputValue(body, 'JWT'); - const verified = await verifyDeepLinkingResponseJwt(responseJwt); - const contentItems = verified.payload[ - 'https://purl.imsglobal.org/spec/lti-dl/claim/content_items' - ] as Array>; - const contentItem = contentItems[0] ?? {}; - const lineItem = contentItem.lineItem as Record | undefined; - const presentation = contentItem.presentation as Record | undefined; - const savedSession = await repository.getDeepLinkingSessionById('deep-linking-session-submit'); - const auditEvents = await repository.listAuditEventsByEventType( - 'deep_linking.placement.created', - ); - const placementId = String(auditEvents[0]?.detail.placementId ?? ''); - const savedPlacement = await repository.getReviewedPlacementById(placementId); - - assertStringIncludes(body, ''); - assertStringIncludes(body, 'action="https://canvas.example/courses/42/deep_link_return"'); - assertStringIncludes(body, 'name="JWT"'); - assertEquals(lineItem?.scoreMaximum, 25); - assertEquals(lineItem?.tag, 'final-grade'); - assertEquals(presentation?.documentTarget, 'iframe'); - assertEquals(savedPlacement?.packageVersionId, 2); - assertEquals(savedPlacement?.contentPath, '/content/bonus.json'); - assertEquals(savedPlacement?.resourceLinkId, null); - assertEquals(savedSession?.usedAt !== null, true); - assertEquals(auditEvents.length, 1); - assertEquals(String(auditEvents[0]?.detail.placementId ?? ''), savedPlacement?.placementId); - assertEquals(String(auditEvents[0]?.detail.contentPath ?? ''), '/content/bonus.json'); - - const replay = await createApp({ - getRepository: () => repository, - getLtiToolStorage: () => ltiStorage, - }).request('http://localhost/lti/deep-linking/sessions/deep-linking-session-submit/submit', { - method: 'POST', - body: formData, - }); - assertEquals(replay.status, 409); - assertStringIncludes(await replay.text(), 'Assignment resource return already used'); - }); -}); - -Deno.test('POST /lti/deep-linking/sessions/:id/submit keeps missing reviewed selections on Lantern HTML', async () => { - const repository = createInMemoryPackageReviewRepository({ - deepLinkingSessions: [ - buildDeepLinkingSessionRecord({ - sessionId: 'deep-linking-session-submit-blocked', - sessionToken: 'deep-linking-token-submit-blocked', - selection: null, - expiresAt: '2030-03-25T16:20:00Z', - }), - ], - deepLinkingResourceOptions: [buildDeepLinkingResourceOption()], - }); - const formData = new FormData(); - - formData.set('token', 'deep-linking-token-submit-blocked'); - - const response = await createApp({ - getRepository: () => repository, - }).request( - 'http://localhost/lti/deep-linking/sessions/deep-linking-session-submit-blocked/submit', - { - method: 'POST', - body: formData, - }, - ); - - assertEquals(response.status, 409); - - const body = await response.text(); - - assertStringIncludes(body, ''); - assertStringIncludes(body, 'Return blocked'); - assertStringIncludes( - body, - 'Save one reviewed assignment resource before returning it to the LMS.', - ); -}); - -Deno.test('POST /lti/deep-linking/sessions/:id/submit blocks missing session tokens with Lantern-owned HTML', async () => { - const repository = createInMemoryPackageReviewRepository({ - deepLinkingSessions: [ - buildDeepLinkingSessionRecord({ - sessionId: 'deep-linking-session-submit-auth', - sessionToken: 'deep-linking-token-submit-auth', - selection: buildDeepLinkingResourceSelection(), - expiresAt: '2030-03-25T16:20:00Z', - }), - ], - deepLinkingResourceOptions: [buildDeepLinkingResourceOption()], - }); - - const response = await createApp({ - getRepository: () => repository, - }).request('http://localhost/lti/deep-linking/sessions/deep-linking-session-submit-auth/submit', { - method: 'POST', - body: new FormData(), - }); - - assertEquals(response.status, 409); - - const body = await response.text(); - - assertStringIncludes(body, ''); - assertStringIncludes(body, 'Session verification failed'); - assertStringIncludes(body, 'Reopen Deep Linking authoring from the LMS and try again.'); -}); - -Deno.test('POST /lti/deep-linking/sessions/:id/submit keeps resource-selection replay copy placement-aware', async () => { - const repository = createInMemoryPackageReviewRepository({ - deepLinkingSessions: [ - buildDeepLinkingSessionRecord({ - sessionId: 'deep-linking-session-submit-resource-replay', - sessionToken: 'deep-linking-token-submit-resource-replay', - placement: 'resource_selection', - selection: { - packageVersionId: 3, - packageVersion: '0.3.0', - activityId: '/content/course.json', - contentPath: '/content/course.json', - }, - usedAt: '2026-03-25T16:21:00Z', - expiresAt: '2030-03-25T16:20:00Z', - }), - ], - deepLinkingResourceOptions: [ - buildDeepLinkingResourceOption({ - packageVersionId: 3, - packageVersion: '0.3.0', - installScope: 'course', - contentPath: '/content/course.json', - activityId: '/content/course.json', - contentTitle: 'Course Activity', - }), - ], - }); - const formData = new FormData(); - - formData.set('token', 'deep-linking-token-submit-resource-replay'); - - const response = await createApp({ - getRepository: () => repository, - }).request( - 'http://localhost/lti/deep-linking/sessions/deep-linking-session-submit-resource-replay/submit', - { - method: 'POST', - body: formData, - }, - ); - - assertEquals(response.status, 409); - - const body = await response.text(); - - assertStringIncludes(body, 'Course resource return already used'); - assertStringIncludes(body, 'confirm the reviewed course resource, and return once.'); - assertEquals(body.includes('assignment picker'), false); -}); - -Deno.test('POST /lti/deep-linking/sessions/:id/submit keeps placement-creation failures on a Lantern-owned error surface', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 2, - version: '0.2.0', - installScope: 'assignment', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 1, - enabledPackageVersionId: 2, - enabledPackageVersion: '0.2.0', - binding: buildDeploymentBinding(), - }), - ], - deepLinkingSessions: [ - buildDeepLinkingSessionRecord({ - sessionId: 'deep-linking-session-submit-failure', - sessionToken: 'deep-linking-token-submit-failure', - selection: { - packageVersionId: 2, - packageVersion: '0.2.0', - activityId: '/content/bonus.json', - contentPath: '/content/bonus.json', - }, - expiresAt: '2030-03-25T16:20:00Z', - }), - ], - deepLinkingResourceOptions: [ - buildDeepLinkingResourceOption({ - packageVersionId: 2, - packageVersion: '0.2.0', - contentPath: '/content/bonus.json', - activityId: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - ], - }); - const formData = new FormData(); - - formData.set('token', 'deep-linking-token-submit-failure'); - - await withCanvasReturnEnv(async () => { - const response = await createApp({ - getRepository: () => ({ - ...repository, - createReviewedPlacement() { - return Promise.reject(new Error('Lantern could not create the reviewed placement.')); - }, - }), - }).request( - 'http://localhost/lti/deep-linking/sessions/deep-linking-session-submit-failure/submit', - { - method: 'POST', - body: formData, - }, - ); - - assertEquals(response.status, 500); - - const body = await response.text(); - - assertStringIncludes(body, ''); - assertStringIncludes(body, 'Assignment resource return failed'); - assertStringIncludes(body, 'Lantern could not create the reviewed placement.'); - assertEquals(body.includes('id="lms-return-form"'), false); - }); -}); diff --git a/src/app_deep_linking_views.ts b/src/app_deep_linking_views.ts deleted file mode 100644 index c60a91d..0000000 --- a/src/app_deep_linking_views.ts +++ /dev/null @@ -1,287 +0,0 @@ -import type { Context } from '@hono/hono'; -import { renderDeepLinkingPickerPage } from './admin/deep_linking_picker.ts'; -import { type AdminNotice, escapeHtml } from './admin/layout.ts'; -import { listDeepLinkingResources, resolveDeepLinkingSelection } from './lti/deep_linking.ts'; -import type { DeepLinkingSessionRecord } from './lti/types.ts'; -import type { PackageReviewRepository } from './package_review/repository.ts'; -import type { DeepLinkingResourceSelection } from './package_review/types.ts'; - -interface DeepLinkingResponseSubmissionView { - returnUrl: string; - formFields: Record; -} - -export async function renderDeepLinkingPickerResponse(input: { - context: Context; - repository: PackageReviewRepository; - session: DeepLinkingSessionRecord; - token: string; - notice: AdminNotice | null; - status?: 200 | 400 | 409; -}) { - const resources = await listDeepLinkingResources({ - repository: input.repository, - session: input.session, - }); - const selection = resolveDeepLinkingSelection({ - session: input.session, - resources, - }); - - return input.context.html( - renderDeepLinkingPickerPage({ - sessionId: input.session.sessionId, - token: input.token, - session: input.session, - resources, - selection, - notice: input.notice, - }), - input.status ?? 200, - ); -} - -export function renderDeepLinkingSubmitStatusPage(input: { - tone: 'success' | 'error'; - title: string; - detail: string; - session?: Pick< - DeepLinkingSessionRecord, - 'appId' | 'contextTitle' | 'deploymentSlug' | 'placement' - >; - selection?: DeepLinkingResourceSelection | null; - submission?: DeepLinkingResponseSubmissionView; -}): string { - const surfaceClass = input.tone === 'success' ? 'success' : 'error'; - const session = input.session ?? null; - const selection = input.selection ?? null; - const submission = input.submission ?? null; - const selectionSummaryLabel = session?.placement === 'resource_selection' - ? 'Saved reviewed course resource' - : 'Saved reviewed assignment resource'; - const returnSummaryLabel = session?.placement === 'resource_selection' - ? 'Course resource return' - : 'Assignment resource return'; - - return ` - - - - - ${escapeHtml(input.title)} - - - -
    -
    -
    -

    Lantern Deep Linking

    -

    ${escapeHtml(input.title)}

    -

    ${escapeHtml(input.detail)}

    -
    -
    - ${ - session === null ? '' : `
    -

    Session

    -

    Course context${ - escapeHtml( - session.contextTitle ?? 'LMS context', - ) - }

    -

    Bound app${escapeHtml(session.appId)}

    -

    Deployment${ - escapeHtml( - session.deploymentSlug, - ) - }

    -
    ` - } - ${ - selection === null ? '' : `
    -

    ${escapeHtml(selectionSummaryLabel)}

    -

    ${ - escapeHtml( - selection.contentTitle ?? `${selection.packageVersion} reviewed activity`, - ) - }${escapeHtml(selection.packageVersion)}

    -

    ${escapeHtml(selection.contentPath)}

    -
    ` - } - ${ - submission === null ? '' : `
    -

    ${escapeHtml(returnSummaryLabel)}

    -

    Signed Deep Linking responseLantern is posting the reviewed placement back now.

    -
    - ${ - Object.entries(submission.formFields) - .map( - ([name, value]) => - ``, - ) - .join('') - } - -
    -

    If the LMS does not resume automatically, use the button above.

    -
    ` - } -
    -
    - ${ - submission === null ? '' : `` - } -
    - -`; -} diff --git a/src/app_deployment_support.ts b/src/app_deployment_support.ts deleted file mode 100644 index 91fecda..0000000 --- a/src/app_deployment_support.ts +++ /dev/null @@ -1,276 +0,0 @@ -import type { AdminNotice } from './admin/layout.ts'; -import { - buildManagedDeploymentSlots, - type DeploymentNrpsVerificationSummary, - getPersistedManagedDeployment, - getPrimaryManagedDeployment, - type ManagedDeploymentSlot, -} from './admin/deployment_detail.ts'; -import { getCanvasConfigUrlNoticeSafe } from './app_notice_support.ts'; -import { requireAppOrigin } from './lti/config.ts'; -import { createDynamicRegistrationState } from './lti/dynamic_registration_state.ts'; -import { buildDynamicRegistrationUrl } from './lti/dynamic_registration_tool.ts'; -import type { PackageReviewRepository } from './package_review/repository.ts'; -import type { - AuditEventRecord, - DeploymentRecord, - LanternLtiProfileSettingsRecord, - PackageVersionRecord, - PreviewEvidenceRecord, - PreviewSessionRecord, -} from './package_review/types.ts'; -import { - loadPreviewRuntimeDiagnostics, - type PreviewRuntimeDiagnostic, -} from './preview/runtime_diagnostics.ts'; - -export interface CanvasConfigUrlState { - url: string | null; - notice: AdminNotice | null; -} - -export interface DeploymentDetailState { - history: PackageVersionRecord[]; - appTitle: string; - deployments: DeploymentRecord[]; - slots: ManagedDeploymentSlot[]; - primaryDeployment: DeploymentRecord | null; - canvasDeployment: DeploymentRecord | null; - nrpsVerification: DeploymentNrpsVerificationSummary | null; - ltiProfileSettings: LanternLtiProfileSettingsRecord; - canvasConfigUrl: CanvasConfigUrlState; - canvasDynamicRegistrationUrl: string | null; - moodleDynamicRegistrationUrl: string | null; - sakaiDynamicRegistrationUrl: string | null; -} - -export async function loadDeploymentDetailState( - repository: PackageReviewRepository, - appId: string, - appOrigin?: string, -): Promise { - const history = await repository.listPackageVersionsByApp(appId); - - if (history.length === 0) { - throw new Error('Import a package version first so Lantern has an exact app to pin.'); - } - - const appTitle = history[0]?.title ?? history[0]?.appId ?? 'Package'; - const deployments = await repository.listDeploymentsByApp(appId); - const ltiProfileSettings = await repository.getLanternLtiProfileSettings(); - const slots = buildManagedDeploymentSlots({ - appId, - appTitle, - deployments, - }); - const primaryDeployment = getPrimaryManagedDeployment(slots); - const canvasDeployment = getPersistedManagedDeployment(slots, 'canvas'); - const nrpsVerification = canvasDeployment === null - ? null - : await getLatestNrpsVerification(repository, canvasDeployment.id); - const [canvasDynamicRegistrationUrl, moodleDynamicRegistrationUrl, sakaiDynamicRegistrationUrl] = - await Promise.all([ - getCanvasDynamicRegistrationUrlSafe(repository, appId, appOrigin), - getMoodleDynamicRegistrationUrlSafe(repository, appId, appOrigin), - getSakaiDynamicRegistrationUrlSafe(repository, appId, appOrigin), - ]); - - return { - history, - appTitle, - deployments, - slots, - primaryDeployment, - canvasDeployment, - nrpsVerification, - ltiProfileSettings, - canvasConfigUrl: getCanvasConfigUrlNoticeSafe(appOrigin), - canvasDynamicRegistrationUrl, - moodleDynamicRegistrationUrl, - sakaiDynamicRegistrationUrl, - }; -} - -export async function loadDeploymentDetailStateSafe( - repository: PackageReviewRepository, - appId: string, - appOrigin?: string, -): Promise { - try { - return await loadDeploymentDetailState(repository, appId, appOrigin); - } catch { - return { - history: [], - appTitle: 'Package', - deployments: [], - slots: [], - primaryDeployment: null, - canvasDeployment: null, - nrpsVerification: null, - ltiProfileSettings: { - defaultLtiProfile: 'governedCompatibility', - updatedAt: '', - }, - canvasConfigUrl: getCanvasConfigUrlNoticeSafe(appOrigin), - canvasDynamicRegistrationUrl: null, - moodleDynamicRegistrationUrl: null, - sakaiDynamicRegistrationUrl: null, - }; - } -} - -async function getCanvasDynamicRegistrationUrlSafe( - repository: PackageReviewRepository, - appId: string, - appOrigin?: string, -): Promise { - try { - if (appOrigin === undefined) { - requireAppOrigin(); - } - const state = await createDynamicRegistrationState({ - repository, - appId, - lms: 'canvas', - }); - return buildDynamicRegistrationUrl(appId, 'canvas', state.state, appOrigin); - } catch { - return null; - } -} - -async function getMoodleDynamicRegistrationUrlSafe( - repository: PackageReviewRepository, - appId: string, - appOrigin?: string, -): Promise { - try { - if (appOrigin === undefined) { - requireAppOrigin(); - } - const state = await createDynamicRegistrationState({ - repository, - appId, - lms: 'moodle', - }); - return buildDynamicRegistrationUrl(appId, 'moodle', state.state, appOrigin); - } catch { - return null; - } -} - -async function getSakaiDynamicRegistrationUrlSafe( - repository: PackageReviewRepository, - appId: string, - appOrigin?: string, -): Promise { - try { - if (appOrigin === undefined) { - requireAppOrigin(); - } - const state = await createDynamicRegistrationState({ - repository, - appId, - lms: 'sakai', - }); - return buildDynamicRegistrationUrl(appId, 'sakai', state.state, appOrigin); - } catch { - return null; - } -} - -export async function getLatestNrpsVerification( - repository: PackageReviewRepository, - deploymentRecordId: number, -): Promise { - const events = await repository.listAuditEventsByEventType('deployment.nrps_verified'); - const event = [...events] - .reverse() - .find((candidate) => candidate.deploymentRecordId === deploymentRecordId); - - if (!event) { - return null; - } - - const memberCount = typeof event.detail.memberCount === 'number' - ? event.detail.memberCount - : null; - const contextId = typeof event.detail.contextId === 'string' ? event.detail.contextId : null; - - return { - status: event.status === 'succeeded' ? 'succeeded' : 'failed', - checkedAt: event.occurredAt, - contextId, - memberCount, - }; -} - -export async function loadPreviewCapabilityLog(input: { - repository: PackageReviewRepository; - packageVersionId: number; -}): Promise<{ - session: PreviewSessionRecord | null; - evidence: PreviewEvidenceRecord[]; - diagnostics: PreviewRuntimeDiagnostic[]; -}> { - const session = await input.repository.getLatestPreviewSessionByPackageVersion( - input.packageVersionId, - 'adminTestLaunch', - ); - - if (session === null) { - return { - session: null, - evidence: [], - diagnostics: [], - }; - } - - const [evidence, diagnostics] = await Promise.all([ - input.repository.listPreviewEvidence(session.sessionId), - loadPreviewRuntimeDiagnostics({ - repository: input.repository, - session, - }), - ]); - - return { - session, - evidence, - diagnostics, - }; -} - -export async function loadPlacementAuditTimeline( - repository: PackageReviewRepository, - placement: { - placementId: string; - deploymentRecordId: number; - packageVersionId: number; - }, -): Promise { - const eventTypes = [ - 'deep_linking.request.accepted', - 'deep_linking.placement.created', - 'reviewer.preview_viewed', - ] as const; - - const groups = await Promise.all( - eventTypes.map((eventType) => repository.listAuditEventsByEventType(eventType)), - ); - - return groups.flat().filter((event) => { - if ( - event.deploymentRecordId !== placement.deploymentRecordId || - event.packageVersionId !== placement.packageVersionId - ) { - return false; - } - - if (event.eventType === 'deep_linking.request.accepted') { - return true; - } - - return event.detail.placementId === placement.placementId; - }); -} diff --git a/src/app_dynamic_registration_views.ts b/src/app_dynamic_registration_views.ts deleted file mode 100644 index a29248f..0000000 --- a/src/app_dynamic_registration_views.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { escapeHtml } from './admin/layout.ts'; - -export function renderDynamicRegistrationStatusPage(input: { - tone: 'success' | 'error'; - title: string; - detail: string; - closeLabel: string; - returnUrl: string; - returnLabel: string; -}): string { - const accent = input.tone === 'success' ? '#166534' : '#b42318'; - const surface = input.tone === 'success' - ? 'linear-gradient(180deg, rgba(232, 245, 235, 0.82), #ffffff)' - : 'linear-gradient(180deg, rgba(254, 243, 242, 0.88), #ffffff)'; - - return ` - - - - - ${escapeHtml(input.title)} - - - -
    -
    -

    Lantern Dynamic Registration

    -

    ${escapeHtml(input.title)}

    -

    ${escapeHtml(input.detail)}

    -
    - - ${escapeHtml(input.returnLabel)} -
    -
    -
    - - -`; -} diff --git a/src/app_governed_runtime_routes.ts b/src/app_governed_runtime_routes.ts new file mode 100644 index 0000000..185197d --- /dev/null +++ b/src/app_governed_runtime_routes.ts @@ -0,0 +1,152 @@ +/** + * Governed LMS runtime delivery plane. + * + * These routes serve `governed_runtime_sessions` minted by `lms_kernel` from an + * authorized LMS launch (see `src/lms_kernel/launch_service.ts`). This is the + * only runtime plane that may serve reviewed Worker apps launched from an LMS. + * Preview/authoring runs use the separate plane in `src/app_runtime_routes.ts`. + */ +import type { Hono } from '@hono/hono'; +import type { AppServices } from './app_services.ts'; + +type GovernedRuntimeRouteServices = Pick< + AppServices, + 'getLmsKernelRepository' | 'getRepository' | 'runtimeDelivery' +>; +import { + buildReviewedWorkerRequest, + sanitizeReviewedWorkerResponse, +} from './runtime/reviewed_worker_http.ts'; +import type { GovernedRuntimeSession } from './lms_kernel/types.ts'; +import { runtimeResponseEvent } from './lms_kernel/governance_events.ts'; + +export function registerGovernedRuntimeRoutes( + app: Hono, + services: GovernedRuntimeRouteServices, +): void { + app.get('/governed/sessions/:sessionId', async (context) => { + const token = new URL(context.req.url).searchParams.get('token')?.trim() ?? ''; + const repository = services.getLmsKernelRepository(); + const session = token === '' ? null : await repository.getGovernedRuntimeSession({ + runtimeSessionId: context.req.param('sessionId'), + runtimeSessionToken: token, + }); + if (session === null || !isRunnableSession(session, new Date())) { + return appUnavailable('This governed app session is unavailable.', 401); + } + const traceId = session.launchTraceId; + + const packageVersion = await services.getRepository().getPackageVersionById( + session.packageVersionId, + ); + if ( + packageVersion === null || + packageVersion.approvalStatus !== 'approved' || + packageVersion.runtime.kind !== 'worker' || + packageVersion.appId !== session.appId || + packageVersion.version !== session.packageVersion || + !sameCapabilities(packageVersion.capabilities, session.packageCapabilities) + ) { + await recordLaunchEvent(repository, session, traceId, 'failed', 'package_mismatch'); + return appUnavailable('This governed app package is unavailable.', 409); + } + + try { + const response = sanitizeReviewedWorkerResponse( + await services.runtimeDelivery.invokeReviewedWorker({ + session: { snapshotRoot: packageVersion.artifact.snapshotRoot }, + reviewedPackage: packageVersion, + request: buildReviewedWorkerRequest( + context, + { + sessionId: session.runtimeSessionId, + sessionToken: session.runtimeSessionToken, + }, + traceId, + ), + }), + ); + response.headers.set('x-lantern-trace-id', traceId); + await recordLaunchEvent(repository, session, traceId, 'completed', null); + return response; + } catch { + await recordLaunchEvent(repository, session, traceId, 'limited', 'app_unavailable'); + return appUnavailable( + 'This governed app is temporarily unavailable. Try again or share the trace ID with an administrator.', + 503, + traceId, + ); + } + }); +} + +function isRunnableSession(session: GovernedRuntimeSession, now: Date): boolean { + return Date.parse(session.runtimeExpiresAt) > now.getTime() && + session.institutionStatus === 'active' && + session.connectionStatus === 'active' && + session.installationStatus === 'active'; +} + +function sameCapabilities( + left: readonly string[], + right: readonly string[], +): boolean { + return left.length === right.length && + [...left].sort().every((value, index) => value === [...right].sort()[index]); +} + +async function recordLaunchEvent( + repository: ReturnType, + session: GovernedRuntimeSession, + traceId: string, + outcome: 'completed' | 'failed' | 'limited', + errorCode: string | null, +): Promise { + await repository.appendGovernanceEvent(runtimeResponseEvent({ + traceId, + institutionId: session.institutionId, + lmsConnectionId: session.lmsConnectionId, + lmsContextId: session.lmsContextId, + appInstallationId: session.appInstallationId, + appId: session.appId, + packageVersionId: session.packageVersionId, + runtimeSessionId: session.runtimeSessionId, + actorType: session.actorType, + actorId: session.actorSubjectId, + outcome, + connectorKind: session.connectorKind, + errorCode, + occurredAt: new Date().toISOString(), + })); +} + +function appUnavailable( + message: string, + status: 401 | 409 | 503, + traceId?: string, +): Response { + const trace = traceId === undefined ? '' : `

    Trace: ${escapeHtml(traceId)}

    `; + return new Response( + `App unavailable

    App unavailable

    ${ + escapeHtml(message) + }

    ${trace}
    `, + { + status, + headers: { + 'content-type': 'text/html; charset=UTF-8', + 'cache-control': 'private, no-store', + 'referrer-policy': 'no-referrer', + ...(traceId === undefined ? {} : { 'x-lantern-trace-id': traceId }), + }, + }, + ); +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/src/app_governed_runtime_routes_test.ts b/src/app_governed_runtime_routes_test.ts new file mode 100644 index 0000000..12e7bca --- /dev/null +++ b/src/app_governed_runtime_routes_test.ts @@ -0,0 +1,92 @@ +import { assertEquals, assertStringIncludes } from '@std/assert'; +import { createTestApp as createApp } from './test_helpers/app.ts'; +import { + buildGovernedSyntheticSession, + MemoryLmsKernelRepository, +} from './lms_kernel/repository_memory_test_support.ts'; +import type { GovernedRuntimeSession } from './lms_kernel/types.ts'; +import { createInMemoryPackageReviewRepository } from './test_helpers/package_review.ts'; +import { buildPackageVersionRecord } from './test_helpers/package_review.ts'; +import type { RuntimeDelivery } from './runtime/delivery.ts'; + +Deno.test('governed Worker route uses the standalone session and strips app-controlled headers', async () => { + const session: GovernedRuntimeSession = { + ...buildGovernedSyntheticSession(), + runtimeExpiresAt: '2099-07-19T17:00:00.000Z', + }; + const kernelRepository = new MemoryLmsKernelRepository({ + session, + grants: [], + }); + const packageVersion = buildPackageVersionRecord({ + id: session.packageVersionId, + appId: session.appId, + version: session.packageVersion, + runtime: { kind: 'worker', entrypoint: '/dist/worker.js' }, + capabilities: [ + 'lms.roster.members.read', + 'lms.roster.contact.read', + 'lms.roster.profile_photo.read', + ], + approvalStatus: 'approved', + }); + let internalRequest: Request | null = null; + const runtimeDelivery: RuntimeDelivery = { + substrate: 'dynamic_worker', + describeDelivery() { + return { substrate: 'dynamic_worker', workerId: 'worker-1' }; + }, + loadReviewedAsset() { + return Promise.reject(new Error('not used')); + }, + loadBrowserGraderAsset() { + return Promise.resolve(null); + }, + invokeReviewedWorker(input) { + internalRequest = input.request; + return Promise.resolve( + new Response('Photo Roster', { + headers: { + 'content-type': 'text/html', + 'content-language': 'en', + 'set-cookie': 'app=forbidden', + 'access-control-allow-origin': '*', + 'content-security-policy': 'default-src *', + location: 'https://outside.example', + }, + }), + ); + }, + }; + const app = createApp({ + runtimeDelivery, + getLmsKernelRepository: () => kernelRepository, + getRepository: () => + createInMemoryPackageReviewRepository({ + packageVersions: [packageVersion], + }), + }); + + const response = await app.request( + `https://runtime.lantern.example/governed/sessions/${session.runtimeSessionId}?token=${session.runtimeSessionToken}`, + ); + const workerRequest = internalRequest as Request | null; + if (workerRequest === null) { + throw new Error('Expected the governed route to invoke the reviewed Worker.'); + } + + assertEquals(response.status, 200); + assertEquals(response.headers.get('set-cookie'), null); + assertEquals(response.headers.get('access-control-allow-origin'), null); + assertEquals(response.headers.get('location'), null); + assertEquals(response.headers.get('cache-control'), 'private, no-store'); + assertStringIncludes(response.headers.get('content-security-policy') ?? '', "default-src 'none'"); + assertEquals(response.headers.get('x-lantern-trace-id'), session.launchTraceId); + assertEquals( + workerRequest.headers.get('x-lantern-runtime-session-id'), + session.runtimeSessionId, + ); + assertEquals(workerRequest.headers.get('x-lantern-trace-id'), session.launchTraceId); + assertEquals(kernelRepository.events.at(-1)?.traceId, session.launchTraceId); + assertEquals(kernelRepository.events.at(-1)?.eventType, 'runtime.response'); +}); diff --git a/src/app_launch_routes.ts b/src/app_launch_routes.ts index 0808260..576c18a 100644 --- a/src/app_launch_routes.ts +++ b/src/app_launch_routes.ts @@ -1,33 +1,28 @@ import type { Context, Hono, Next } from '@hono/hono'; import { - type CustomDeepLinkingLaunchContext, customLaunchRouteHandler, type CustomResourceLinkLaunchContext, } from '@longsightgroup/lti-tool/hono'; import type { LtiAuthorizedLaunch } from '@longsightgroup/lti-tool'; -import { createRuntimeSession } from './lti/runtime_session.ts'; -import { formatLmsLabel } from './lti/platform_binding.ts'; -import { - buildResolvedLtiProfileDetail, - resolveLtiProfileForDeployment, -} from './lti/profile_resolution.ts'; import { handleLoginInitiation } from './app_lti_support.ts'; -import { buildRuntimeSessionUrl, requireConfiguredRuntimeOrigin } from './runtime_origin.ts'; +import { requireConfiguredRuntimeOrigin } from './runtime_origin.ts'; import { errorMessage, statusForError } from './app_status_support.ts'; import type { AppServices } from './app_services.ts'; import { readEnv } from './platform/env.ts'; -import type { PackageReviewRepository } from './package_review/repository.ts'; -import { createDeepLinkingSession } from './lti/deep_linking_session.ts'; import { resolveRequestAppOrigin } from './app_origin.ts'; -import { - authorizeLanternLaunch, - type LanternLaunchAuthorization, - mapDeepLinkingLaunch, - mapResourceLinkLaunch, -} from './lti/launch_mapping.ts'; import { adaptLtiToolHonoHandler } from './lti/hono_handler_adapter.ts'; -import { recordLaunchRejectedAudit } from './lti/launch_audit.ts'; -import { isLtiBoundaryDenialError } from './lti/lti_boundary_denial.ts'; +import type { LmsConnectionRecord } from './lms_kernel/types.ts'; +import { createOpaqueToken } from './lti/token_support.ts'; +import { buildRequestAuditEnvelope } from './request_audit.ts'; +import { + acceptResourceLinkLaunch, + type GovernedLaunchPackage, +} from './lms_kernel/launch_service.ts'; +import { launchDeniedEvent } from './lms_kernel/governance_events.ts'; + +interface AppLaunchAuthorization { + readonly connection: LmsConnectionRecord; +} export function registerLaunchRoutes(app: Hono, services: AppServices): void { app.get('/lti/login', async (context) => { @@ -41,173 +36,126 @@ export function registerLaunchRoutes(app: Hono, services: AppServices): void { const handleLaunch = async (context: Context, next: Next) => { const appOrigin = resolveRequestAppOrigin(context, services.env); const ltiContext = await services.getLtiToolContext(appOrigin); - const repository = services.getRepository(); - const verificationAuditWrites: Array> = []; - const handler = customLaunchRouteHandler({ + const handler = customLaunchRouteHandler({ ltiTool: ltiContext.tool, logger: ltiContext.logger, - authorizeLaunch: (launch) => authorizeLanternLaunch(repository, launch), + authorizeLaunch: async (launch) => { + const connection = await services.getLmsKernelRepository() + .getLmsConnectionByPlatformIdentity({ + issuer: launch.issuer, + clientId: launch.clientId, + deploymentId: launch.deploymentId, + }); + if (connection === null || connection.status === 'disabled') { + return { + success: false, + code: 'connection_not_found', + message: 'No active institution LMS connection matches this launch.', + }; + } + return { + success: true, + data: { connection }, + }; + }, renderResourceLink: (launchContext) => - renderLanternResourceLinkLaunch({ + renderGovernedResourceLinkLaunch({ services, - repository, routeContext: context, launchContext, }), renderDeepLinkingRequest: (launchContext) => - renderLanternDeepLinkingLaunch({ - repository, - launchContext, - }), - onVerificationFailure: async ({ hono, error }) => { - await recordLaunchRejectedAudit({ - repository, - context, - error, - }); - - return hono.text(error.message, 400); - }, - onVerificationEvent: ({ event }) => { - if (event.type !== 'jwks_kid_miss_refetch') { - return; - } - - verificationAuditWrites.push( - repository.recordAuditEvent({ - eventType: 'interop.path_used', - actorType: 'platform', - actorId: null, - deploymentRecordId: null, - packageVersionId: null, - attemptId: null, - lineItemBindingId: null, - status: 'succeeded', - summary: 'Lantern refetched platform JWKS during LTI launch verification.', - detail: { - scope: 'launch', - path: 'jwks_refetch', - issuer: event.issuer, - clientId: event.clientId, - deploymentId: event.deploymentId, - jwksUrl: event.jwksUrl, - }, - occurredAt: new Date().toISOString(), - }), - ); - }, + launchContext.hono.text('Governed Worker apps do not use deep linking.', 400), + onVerificationFailure: ({ hono, error }) => hono.text(error.message, 400), onError: ({ hono, error }) => hono.text(errorMessage(error), statusForError(error)), }); const lanternHandler = adaptLtiToolHonoHandler(handler); - const response = await lanternHandler(context, next); - await Promise.all(verificationAuditWrites); - - return response; + return await lanternHandler(context, next); }; app.post('/lti/launch', handleLaunch); - app.post('/lti/deep-linking', handleLaunch); } -async function renderLanternResourceLinkLaunch(input: { +async function renderGovernedResourceLinkLaunch(input: { services: AppServices; - repository: PackageReviewRepository; routeContext: Context; - launchContext: CustomResourceLinkLaunchContext>; + launchContext: CustomResourceLinkLaunchContext>; }): Promise { - const deployment = input.launchContext.launch.authorization.deployment; - const ltiProfile = await resolveLtiProfileForDeployment({ - repository: input.repository, - deployment, - }); - let launch: Awaited>; - try { - launch = await mapResourceLinkLaunch({ - repository: input.repository, - verifiedLaunch: input.launchContext.launch, - session: input.launchContext.session, - message: input.launchContext.message, - }); + return await performGovernedResourceLinkLaunch(input); } catch (error) { - if (isLtiBoundaryDenialError(error)) { - await recordLaunchRejectedAudit({ - repository: input.repository, - context: input.routeContext, - error, - deployment, - ltiProfile, - }); - } - + const connection = input.launchContext.launch.authorization.connection; + const audit = buildRequestAuditEnvelope({ context: input.routeContext }); + await input.services.getLmsKernelRepository().appendGovernanceEvent(launchDeniedEvent({ + traceId: crypto.randomUUID(), + institutionId: connection.institutionId, + lmsConnectionId: connection.id, + errorCode: 'launch_denied', + occurredAt: new Date().toISOString(), + requestMetadata: { + path: audit.path, + ...(audit.cfRay === null ? {} : { cfRay: audit.cfRay }), + ...(audit.clientIpMasked === null ? {} : { maskedIp: audit.clientIpMasked }), + }, + })); throw error; } +} + +async function performGovernedResourceLinkLaunch(input: { + services: AppServices; + routeContext: Context; + launchContext: CustomResourceLinkLaunchContext>; +}): Promise { + const connection = input.launchContext.launch.authorization.connection; + const target = new URL(input.launchContext.launch.targetLinkUri); + const nrps = input.launchContext.session.services?.nrps; + const minted = await acceptResourceLinkLaunch({ + repository: input.services.getLmsKernelRepository(), + connection, + installationId: target.searchParams.get('installation_id') ?? '', + providerContextId: input.launchContext.message.context?.id ?? '', + contextLabel: input.launchContext.message.context?.title ?? null, + providerSubjectId: input.launchContext.session.user.id ?? '', + roles: input.launchContext.message.roles, + ltiToolSessionId: input.launchContext.session.id, + nrpsContextMembershipsUrl: nrps?.membershipUrl ?? null, + nrpsServiceVersions: nrps === undefined ? [] : [...nrps.versions], + loadPackageVersion: (lookup) => loadGovernedLaunchPackage(input.services, lookup), + createId: createOpaqueToken, + traceId: crypto.randomUUID(), + }); const runtimeOrigin = requireConfiguredRuntimeOrigin( readEnv('APP_RUNTIME_ORIGIN', input.services.env), ); - const runtimeSession = await createRuntimeSession({ - repository: input.repository, - launch, - ltiToolSessionId: input.launchContext.session.id, - }); - await input.repository.recordAuditEvent({ - eventType: 'launch.accepted', - actorType: 'platform', - actorId: launch.userId, - deploymentRecordId: launch.internalDeploymentId, - packageVersionId: launch.packageVersionId, - attemptId: runtimeSession.attemptId, - lineItemBindingId: null, - status: 'accepted', - summary: `Accepted the governed ${formatLmsLabel(launch.lms)} launch.`, - detail: { - lms: launch.lms, - internalDeploymentSlug: launch.internalDeploymentSlug, - issuer: launch.issuer, - clientId: launch.clientId, - deploymentId: launch.deploymentId, - userId: launch.userId, - userDisplayName: launch.userDisplayName, - userEmail: launch.userEmail, - userLogin: launch.userLogin, - resourceLinkId: launch.resourceLinkId, - contextId: launch.contextId, - ...buildResolvedLtiProfileDetail(ltiProfile), - }, - occurredAt: new Date().toISOString(), - }); - - return input.launchContext.hono.redirect( - buildRuntimeSessionUrl({ - runtimeOrigin, - sessionId: runtimeSession.sessionId, - token: runtimeSession.sessionToken, - }), - 303, + const url = new URL( + `/governed/sessions/${encodeURIComponent(minted.runtimeSessionId)}`, + runtimeOrigin, ); + url.searchParams.set('token', minted.runtimeSessionToken); + return input.launchContext.hono.redirect(url.toString(), 303); } -async function renderLanternDeepLinkingLaunch(input: { - repository: PackageReviewRepository; - launchContext: CustomDeepLinkingLaunchContext>; -}): Promise { - const request = mapDeepLinkingLaunch({ - verifiedLaunch: input.launchContext.launch, - session: input.launchContext.session, - message: input.launchContext.message, - }); - const session = await createDeepLinkingSession({ - repository: input.repository, - request, - }); - - return input.launchContext.hono.redirect( - `/lti/deep-linking/sessions/${session.sessionId}?token=${ - encodeURIComponent( - session.sessionToken, - ) - }`, - 303, +async function loadGovernedLaunchPackage( + services: AppServices, + lookup: { readonly packageVersionId: number; readonly appId: string }, +): Promise { + const packageVersion = await services.getRepository().getPackageVersionById( + lookup.packageVersionId, ); + if ( + packageVersion === null || + packageVersion.approvalStatus !== 'approved' || + packageVersion.runtime.kind !== 'worker' || + packageVersion.appId !== lookup.appId + ) { + return null; + } + return { + appId: packageVersion.appId, + id: packageVersion.id, + version: packageVersion.version, + capabilities: packageVersion.capabilities, + }; } diff --git a/src/app_lms_kernel_routes.ts b/src/app_lms_kernel_routes.ts new file mode 100644 index 0000000..d3d8d12 --- /dev/null +++ b/src/app_lms_kernel_routes.ts @@ -0,0 +1,13 @@ +import type { Hono } from '@hono/hono'; +import type { AppServices } from './app_services.ts'; + +type LmsKernelRouteServices = Pick; + +export function registerLmsKernelRoutes(app: Hono, services: LmsKernelRouteServices): void { + app.get('/runtime/media/:mediaId', async (context) => { + return await services.protectedMediaService.fetch({ + mediaId: context.req.param('mediaId'), + now: new Date(), + }); + }); +} diff --git a/src/app_lti_config_login_test.ts b/src/app_lti_config_login_test.ts deleted file mode 100644 index c9b94b1..0000000 --- a/src/app_lti_config_login_test.ts +++ /dev/null @@ -1,816 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import type { Hono } from '@hono/hono'; -import { decodeJwt } from 'jose'; -import { createApp } from './app.ts'; -import { - createLanternLtiToolContext, - createMemoryLtiStorage, - type LanternLtiToolContext, - syncLtiToolDeploymentBinding, -} from './lti/tool_provider.ts'; -import { - buildDeploymentRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import type { InMemoryRepository } from './test_helpers/package_review_in_memory_shared.ts'; -import { - buildCanvasLoginRequest, - buildDeploymentBinding, - buildMoodleDeploymentBinding, - buildSakaiDeploymentBinding, - buildSakaiLoginRequest, - getTestToolPrivateJwkEnvValue, - TEST_LTI_STATE_SECRET, - TEST_NOW, -} from './test_helpers/lti.ts'; -import { restoreEnv } from './app_test_support.ts'; - -interface LtiToolStateClaims { - readonly iss: string; - readonly clientId: string; - readonly targetLinkUri: string; -} - -const EXPECTED_CANVAS_STATIC_SCOPES = [ - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', - 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', - 'https://purl.imsglobal.org/spec/lti-ags/scope/score', - 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly', -]; - -Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); -Deno.env.set('LTI_STATE_SECRET', TEST_LTI_STATE_SECRET); - -async function createLoginTestApp(repository: InMemoryRepository): Promise { - const storage = createMemoryLtiStorage(); - const contexts = new Map>(); - - for (const deployment of await repository.listDeploymentsByApp('chapter-4-asteroids')) { - if (deployment.binding === null) { - continue; - } - - await syncLtiToolDeploymentBinding({ - storage, - appTitle: deployment.label, - deploymentSlug: deployment.slug, - binding: deployment.binding, - }); - } - - return createApp({ - getRepository: () => repository, - getLtiToolContext(appOrigin) { - const cacheKey = appOrigin ?? ''; - let context = contexts.get(cacheKey); - - if (context === undefined) { - context = createLanternLtiToolContext({ - env: Deno.env, - storage, - ...(appOrigin === undefined ? {} : { appOrigin }), - }); - contexts.set(cacheKey, context); - } - - return context; - }, - }); -} - -Deno.test('GET /lti/canvas/config.json publishes the pilot Canvas config document', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - const previousJwk = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - Deno.env.set('APP_ORIGIN', 'http://localhost:8417'); - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - const response = await createApp({ - getRepository: () => createInMemoryPackageReviewRepository(), - }).request('http://localhost/lti/canvas/config.json'); - - assertEquals(response.status, 200); - const body = (await response.json()) as { - oidc_initiation_url: string; - scopes: string[]; - extensions: Array<{ settings: { placements: Array<{ placement: string }> } }>; - }; - - assertEquals(typeof body.oidc_initiation_url, 'string'); - assertEquals(body.scopes, EXPECTED_CANVAS_STATIC_SCOPES); - assertEquals(body.extensions[0]?.settings.placements[0]?.placement, 'course_navigation'); - assertEquals( - body.extensions[0]?.settings.placements.some( - (placement) => placement.placement === 'resource_selection', - ), - true, - ); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousJwk); - } -}); - -Deno.test('GET /lti/canvas/config.json uses the forwarded public origin when Lantern runs behind a proxy', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - const previousJwk = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - Deno.env.delete('APP_ORIGIN'); - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - const response = await createApp({ - getRepository: () => createInMemoryPackageReviewRepository(), - }).request('http://worker.internal/lti/canvas/config.json', { - headers: { - 'x-forwarded-host': 'lantern.example', - 'x-forwarded-proto': 'https', - }, - }); - - assertEquals(response.status, 200); - const body = (await response.json()) as { - oidc_initiation_url: string; - target_link_uri: string; - extensions: Array<{ - settings: { - placements: Array<{ - placement: string; - target_link_uri: string; - }>; - }; - }>; - }; - - assertEquals(body.oidc_initiation_url, 'https://lantern.example/lti/login'); - assertEquals(body.target_link_uri, 'https://lantern.example/lti/launch'); - const placements = body.extensions.flatMap((extension) => extension.settings.placements); - - assertEquals( - placements.some((placement) => - placement.target_link_uri === - 'https://lantern.example/lti/deep-linking?placement=resource_selection' - ), - true, - ); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousJwk); - } -}); - -Deno.test('GET /lti/login persists login state and redirects to the Canvas authorization endpoint', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [buildDeploymentRecord({ binding: buildDeploymentBinding() })], - }); - const loginRequest = buildCanvasLoginRequest(); - const response = await (await createLoginTestApp(repository)).request( - `http://localhost/lti/login?iss=${ - encodeURIComponent( - loginRequest.iss, - ) - }&login_hint=${ - encodeURIComponent( - loginRequest.loginHint, - ) - }&target_link_uri=${ - encodeURIComponent( - loginRequest.targetLinkUri ?? '', - ) - }&client_id=${ - encodeURIComponent( - loginRequest.clientId ?? '', - ) - }&deployment_id=${ - encodeURIComponent( - loginRequest.deploymentId, - ) - }<i_message_hint=${encodeURIComponent(loginRequest.ltiMessageHint ?? '')}`, - ); - - assertEquals(response.status, 302); - - const location = response.headers.get('location'); - - if (!location) { - throw new Error('Expected Canvas authorization redirect location.'); - } - - const redirected = new URL(location); - const state = redirected.searchParams.get('state'); - - if (!state) { - throw new Error('Expected saved login state in the Canvas redirect.'); - } - - const saved = readLtiToolStateClaims(state); - - assertEquals( - redirected.origin + redirected.pathname, - 'https://sso.canvaslms.com/api/lti/authorize_redirect', - ); - assertEquals(saved.clientId, loginRequest.clientId); - assertEquals(redirected.searchParams.get('lti_deployment_id'), loginRequest.deploymentId); -}); - -Deno.test('GET /lti/login returns a top-level launch escape page for iframe-embedded initiation', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [buildDeploymentRecord({ binding: buildDeploymentBinding() })], - }); - const loginRequest = buildCanvasLoginRequest(); - const response = await (await createLoginTestApp(repository)).request( - `http://localhost/lti/login?iss=${ - encodeURIComponent( - loginRequest.iss, - ) - }&login_hint=${ - encodeURIComponent( - loginRequest.loginHint, - ) - }&target_link_uri=${ - encodeURIComponent( - loginRequest.targetLinkUri ?? '', - ) - }&client_id=${ - encodeURIComponent( - loginRequest.clientId ?? '', - ) - }&deployment_id=${encodeURIComponent(loginRequest.deploymentId)}`, - { - headers: { - 'sec-fetch-dest': 'iframe', - }, - }, - ); - const body = await response.text(); - - assertEquals(response.status, 200); - assertStringIncludes(body, 'Continue the LMS launch'); - assertStringIncludes(body, 'window.top.location'); - assertStringIncludes(body, 'Continue launch'); - - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - assertEquals( - interopEvents.some((event) => event.detail.path === 'iframe_top_level_escape'), - true, - ); -}); - -Deno.test('GET /lti/login uses LTI platform storage when the LMS advertises lti_storage_target', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [buildDeploymentRecord({ binding: buildDeploymentBinding() })], - }); - const loginRequest = buildCanvasLoginRequest(); - const response = await (await createLoginTestApp(repository)).request( - `http://localhost/lti/login?iss=${ - encodeURIComponent( - loginRequest.iss, - ) - }&login_hint=${ - encodeURIComponent( - loginRequest.loginHint, - ) - }&target_link_uri=${ - encodeURIComponent( - loginRequest.targetLinkUri ?? '', - ) - }&client_id=${ - encodeURIComponent( - loginRequest.clientId ?? '', - ) - }&deployment_id=${ - encodeURIComponent( - loginRequest.deploymentId, - ) - }<i_storage_target=post_message_forwarding`, - { - headers: { - 'sec-fetch-dest': 'iframe', - }, - }, - ); - const body = await response.text(); - - assertEquals(response.status, 200); - assertStringIncludes(body, 'Continuing the LMS launch'); - assertStringIncludes(body, 'lti.put_data'); - assertStringIncludes(body, 'post_message_forwarding'); - assertStringIncludes(body, 'https://sso.canvaslms.com'); - assertEquals(body.includes('window.top.location'), false); - - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - assertEquals( - interopEvents.some((event) => event.detail.path === 'lti_platform_storage'), - true, - ); - assertEquals( - interopEvents.some((event) => event.detail.path === 'iframe_top_level_escape'), - false, - ); -}); - -Deno.test('GET /lti/login accepts Sakai-style login initiation and redirects to the saved OIDC endpoint', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - binding: buildSakaiDeploymentBinding({ - issuer: 'https://sakai.example', - clientId: '7dbe6a13-f948-498c-87d7-768947ac5c56', - deploymentId: '1', - }), - }), - ], - }); - const loginRequest = buildSakaiLoginRequest({ - iss: 'https://sakai.example', - clientId: '7dbe6a13-f948-498c-87d7-768947ac5c56', - deploymentId: '1', - }); - const response = await (await createLoginTestApp(repository)).request( - `http://localhost/lti/login?iss=${ - encodeURIComponent( - loginRequest.iss, - ) - }&login_hint=${ - encodeURIComponent( - loginRequest.loginHint, - ) - }&target_link_uri=${ - encodeURIComponent( - loginRequest.targetLinkUri ?? '', - ) - }&client_id=${ - encodeURIComponent( - loginRequest.clientId ?? '', - ) - }<i_deployment_id=${encodeURIComponent(loginRequest.deploymentId)}`, - ); - - assertEquals(response.status, 302); - - const location = response.headers.get('location'); - - if (!location) { - throw new Error('Expected Sakai authorization redirect location.'); - } - - const redirected = new URL(location); - const state = redirected.searchParams.get('state'); - - if (!state) { - throw new Error('Expected saved login state in the Sakai redirect.'); - } - - const saved = readLtiToolStateClaims(state); - - assertEquals( - redirected.origin + redirected.pathname, - 'https://sakai.example/imsoidc/lti13/oidc_auth', - ); - assertEquals(saved.iss, loginRequest.iss); - assertEquals(saved.clientId, loginRequest.clientId ?? null); - assertEquals(redirected.searchParams.get('lti_deployment_id'), loginRequest.deploymentId); -}); - -Deno.test('GET /lti/login accepts Moodle-style login initiation and redirects to the saved authentication endpoint', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - binding: buildMoodleDeploymentBinding({ - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - }), - }), - ], - }); - const response = await (await createLoginTestApp(repository)).request( - 'http://localhost/lti/login?iss=https%3A%2F%2Fmoodle.example&login_hint=opaque-login-hint&target_link_uri=http%3A%2F%2Flocalhost%3A8417%2Flti%2Flaunch&client_id=moodle-client-123&deployment_id=moodle-deployment-123', - ); - - assertEquals(response.status, 302); - - const location = response.headers.get('location'); - - if (!location) { - throw new Error('Expected Moodle authorization redirect location.'); - } - - const redirected = new URL(location); - const state = redirected.searchParams.get('state'); - - if (!state) { - throw new Error('Expected saved login state in the Moodle redirect.'); - } - - const saved = readLtiToolStateClaims(state); - - assertEquals(redirected.origin + redirected.pathname, 'https://moodle.example/mod/lti/auth.php'); - assertEquals(saved.iss, 'https://moodle.example'); - assertEquals(saved.clientId, 'moodle-client-123'); - assertEquals(redirected.searchParams.get('lti_deployment_id'), 'moodle-deployment-123'); -}); - -Deno.test('GET /lti/login tolerates one extra percent-encoding layer on opaque LMS hints', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - binding: buildMoodleDeploymentBinding({ - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - }), - }), - ], - }); - const response = await (await createLoginTestApp(repository)).request( - 'http://localhost/lti/login?iss=https%3A%2F%2Fmoodle.example&login_hint=opaque%252Flogin%253Fhint&target_link_uri=http%3A%2F%2Flocalhost%3A8417%2Flti%2Flaunch&client_id=moodle-client-123&deployment_id=moodle-deployment-123<i_message_hint=context%2523value', - ); - - assertEquals(response.status, 302); - - const location = response.headers.get('location'); - - if (!location) { - throw new Error('Expected Moodle authorization redirect location.'); - } - - const redirected = new URL(location); - const state = redirected.searchParams.get('state'); - - if (!state) { - throw new Error('Expected saved login state in the Moodle redirect.'); - } - - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - - assertEquals(redirected.searchParams.get('login_hint'), 'opaque/login?hint'); - assertEquals(redirected.searchParams.get('lti_message_hint'), 'context#value'); - assertEquals( - interopEvents.some((event) => event.detail.path === 'opaque_login_hint_decode'), - true, - ); - assertEquals( - interopEvents.some((event) => event.detail.path === 'opaque_lti_message_hint_decode'), - true, - ); -}); - -Deno.test('GET /lti/login applies deployment override compatibility behavior only after resolving the saved deployment', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - Deno.env.delete('APP_ORIGIN'); - - try { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - binding: buildMoodleDeploymentBinding({ - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - }), - ltiProfileOverride: 'governedCompatibility', - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: TEST_NOW, - }, - }); - const response = await (await createLoginTestApp(repository)).request( - 'http://localhost/lti/login?iss=https%3A%2F%2Fmoodle.example&login_hint=opaque%252Flogin%253Fhint&client_id=moodle-client-123&deployment_id=moodle-deployment-123<i_message_hint=context%2523value', - ); - - assertEquals(response.status, 302); - - const location = response.headers.get('location'); - - if (!location) { - throw new Error('Expected Moodle authorization redirect location.'); - } - - const redirected = new URL(location); - const state = redirected.searchParams.get('state'); - - if (!state) { - throw new Error('Expected saved login state in the Moodle redirect.'); - } - - const saved = readLtiToolStateClaims(state); - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - - assertEquals(redirected.searchParams.get('login_hint'), 'opaque/login?hint'); - assertEquals(redirected.searchParams.get('lti_message_hint'), 'context#value'); - assertEquals(saved.targetLinkUri, 'http://localhost/lti/launch'); - assertEquals( - interopEvents.some( - (event) => - event.detail.path === 'opaque_login_hint_decode' && - event.detail.ltiProfileId === 'governedCompatibility' && - event.detail.ltiProfileSource === 'deploymentOverride', - ), - true, - ); - assertEquals( - interopEvents.some( - (event) => - event.detail.path === 'opaque_lti_message_hint_decode' && - event.detail.ltiProfileId === 'governedCompatibility' && - event.detail.ltiProfileSource === 'deploymentOverride', - ), - true, - ); - assertEquals( - interopEvents.some( - (event) => - event.detail.path === 'platform_default_launch_target' && - event.detail.ltiProfileId === 'governedCompatibility' && - event.detail.ltiProfileSource === 'deploymentOverride', - ), - true, - ); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - } -}); - -Deno.test('GET /lti/login rejects opaque hint compatibility decoding when the resolved profile is certification', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - binding: buildMoodleDeploymentBinding({ - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - }), - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: TEST_NOW, - }, - }); - const response = await (await createLoginTestApp(repository)).request( - 'http://localhost/lti/login?iss=https%3A%2F%2Fmoodle.example&login_hint=opaque%252Flogin%253Fhint&target_link_uri=http%3A%2F%2Flocalhost%3A8417%2Flti%2Flaunch&client_id=moodle-client-123&deployment_id=moodle-deployment-123<i_message_hint=context%2523value', - ); - const body = await response.text(); - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - - assertEquals(response.status, 409); - assertStringIncludes(body, 'active LTI profile does not allow opaque'); - assertEquals(interopEvents.length, 0); -}); - -Deno.test("GET /lti/login falls back to Lantern's singular launch route when a non-Canvas LMS omits target_link_uri", async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - Deno.env.set('APP_ORIGIN', 'http://localhost:8417'); - - try { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - binding: buildMoodleDeploymentBinding({ - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - }), - }), - ], - }); - const response = await (await createLoginTestApp(repository)).request( - 'http://localhost/lti/login?iss=https%3A%2F%2Fmoodle.example&login_hint=opaque-login-hint&client_id=moodle-client-123&deployment_id=moodle-deployment-123', - ); - - assertEquals(response.status, 302); - - const location = response.headers.get('location'); - - if (!location) { - throw new Error('Expected Moodle authorization redirect location.'); - } - - const redirected = new URL(location); - const state = redirected.searchParams.get('state'); - - if (!state) { - throw new Error('Expected saved login state in the Moodle redirect.'); - } - - const saved = readLtiToolStateClaims(state); - - assertEquals(redirected.searchParams.get('redirect_uri'), 'http://localhost:8417/lti/launch'); - assertEquals(saved.targetLinkUri, 'http://localhost:8417/lti/launch'); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - } -}); - -Deno.test('GET /lti/login rejects an omitted non-Canvas target_link_uri when the resolved profile is certification', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - Deno.env.set('APP_ORIGIN', 'http://localhost:8417'); - - try { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - binding: buildMoodleDeploymentBinding({ - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - }), - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: TEST_NOW, - }, - }); - const response = await (await createLoginTestApp(repository)).request( - 'http://localhost/lti/login?iss=https%3A%2F%2Fmoodle.example&login_hint=opaque-login-hint&client_id=moodle-client-123&deployment_id=moodle-deployment-123', - ); - const body = await response.text(); - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - - assertEquals(response.status, 409); - assertStringIncludes(body, 'active LTI profile requires target_link_uri'); - assertEquals(interopEvents.length, 0); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - } -}); - -Deno.test('GET /lti/login uses the forwarded public origin when Lantern fills a missing non-Canvas target_link_uri', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - Deno.env.delete('APP_ORIGIN'); - - try { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - binding: buildMoodleDeploymentBinding({ - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - }), - }), - ], - }); - const response = await (await createLoginTestApp(repository)).request( - 'http://worker.internal/lti/login?iss=https%3A%2F%2Fmoodle.example&login_hint=opaque-login-hint&client_id=moodle-client-123&deployment_id=moodle-deployment-123', - { - headers: { - 'x-forwarded-host': 'lantern.example', - 'x-forwarded-proto': 'https', - }, - }, - ); - - assertEquals(response.status, 302); - - const location = response.headers.get('location'); - - if (!location) { - throw new Error('Expected Moodle authorization redirect location.'); - } - - const redirected = new URL(location); - const state = redirected.searchParams.get('state'); - - if (!state) { - throw new Error('Expected saved login state in the Moodle redirect.'); - } - - const saved = readLtiToolStateClaims(state); - - assertEquals(redirected.searchParams.get('redirect_uri'), 'https://lantern.example/lti/launch'); - assertEquals(saved.targetLinkUri, 'https://lantern.example/lti/launch'); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - } -}); - -Deno.test('GET /lti/login still rejects an omitted Canvas target_link_uri when more than one Lantern callback route is possible', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [buildDeploymentRecord({ binding: buildDeploymentBinding() })], - }); - const response = await (await createLoginTestApp(repository)).request( - 'http://localhost/lti/login?iss=https%3A%2F%2Fcanvas.instructure.com&login_hint=opaque-login-hint&client_id=10000000000001&deployment_id=deployment-123', - ); - const body = await response.text(); - - assertEquals(response.status, 409); - assertStringIncludes(body, 'Canvas launches use more than one Lantern callback route'); -}); - -Deno.test('GET /lti/login recovers when client_id is omitted but issuer and deployment_id still identify one binding', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - binding: buildMoodleDeploymentBinding({ - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - }), - }), - ], - }); - const response = await (await createLoginTestApp(repository)).request( - 'http://localhost/lti/login?iss=https%3A%2F%2Fmoodle.example&login_hint=opaque-login-hint&target_link_uri=http%3A%2F%2Flocalhost%3A8417%2Flti%2Flaunch&deployment_id=moodle-deployment-123', - ); - - assertEquals(response.status, 302); - - const location = response.headers.get('location'); - - if (!location) { - throw new Error('Expected Moodle authorization redirect location.'); - } - - const redirected = new URL(location); - const state = redirected.searchParams.get('state'); - - if (!state) { - throw new Error('Expected saved login state in the Moodle redirect.'); - } - - const saved = readLtiToolStateClaims(state); - - assertEquals(redirected.searchParams.get('client_id'), 'moodle-client-123'); - assertEquals(saved.clientId, 'moodle-client-123'); - assertEquals(redirected.searchParams.get('lti_deployment_id'), 'moodle-deployment-123'); -}); - -function readLtiToolStateClaims(state: string): LtiToolStateClaims { - const claims = decodeJwt(state); - const iss = readStringClaim(claims.iss, 'iss'); - const clientId = readStringClaim(claims.client_id, 'client_id'); - const targetLinkUri = readStringClaim(claims.target_link_uri, 'target_link_uri'); - - return { - iss, - clientId, - targetLinkUri, - }; -} - -function readStringClaim(value: unknown, name: string): string { - if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`Expected lti-tool state claim ${name}.`); - } - - return value; -} - -Deno.test('GET /lti/login rejects ambiguous platform identity before redirecting', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - slug: 'shared-moodle', - binding: buildMoodleDeploymentBinding({ - issuer: 'https://shared.example', - clientId: 'shared-client-123', - deploymentId: 'shared-deployment-123', - }), - }), - buildDeploymentRecord({ - slug: 'shared-sakai', - binding: buildSakaiDeploymentBinding({ - issuer: 'https://shared.example', - clientId: 'shared-client-123', - deploymentId: 'shared-deployment-123', - }), - }), - ], - }); - const response = await createApp({ - getRepository: () => repository, - }).request( - 'http://localhost/lti/login?iss=https%3A%2F%2Fshared.example&login_hint=opaque-login-hint&target_link_uri=http%3A%2F%2Flocalhost%3A8417%2Flti%2Flaunch&client_id=shared-client-123&deployment_id=shared-deployment-123', - ); - const body = await response.text(); - - assertEquals(response.status, 409); - assertEquals(response.headers.get('location'), null); - assertStringIncludes(body, 'Resolve the duplicate LMS bindings'); -}); - -Deno.test('GET /lti/login fails clearly when Canvas sends a different deployment_id than the saved binding', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - binding: buildDeploymentBinding({ - deploymentId: 'saved-deployment-123', - }), - }), - ], - }); - const response = await createApp({ - getRepository: () => repository, - }).request( - 'http://localhost/lti/login?iss=https%3A%2F%2Fcanvas.instructure.com&login_hint=opaque-login-hint&target_link_uri=http%3A%2F%2Flocalhost%3A8417%2Flti%2Flaunch&client_id=10000000000001&deployment_id=other-deployment-999', - ); - const body = await response.text(); - - assertEquals(response.status, 409); - assertStringIncludes(body, 'Canvas sent deployment other-deployment-999'); - assertStringIncludes(body, 'saved deployment saved-deployment-123'); -}); diff --git a/src/app_lti_launch_authoring_test.ts b/src/app_lti_launch_authoring_test.ts deleted file mode 100644 index 94136bc..0000000 --- a/src/app_lti_launch_authoring_test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; -import { LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE } from './lti/types.ts'; -import { - buildDeploymentRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { - buildSakaiDeploymentBinding, - createSyncedTestLtiStorage, - getTestCanvasJwks, - signCanvasIdToken, - signLtiToolState, -} from './test_helpers/lti.ts'; -import { withFetchStub, withRuntimeOriginEnv } from './app_test_support.ts'; - -Deno.test('POST /lti/launch accepts Sakai Deep Linking launches and creates a picker session', async () => { - const binding = buildSakaiDeploymentBinding({ - issuer: 'https://sakai.example', - clientId: 'sakai-client-123', - deploymentId: 'sakai-deployment-123', - }); - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 17, - slug: 'chapter-4-asteroids-sakai', - label: 'Chapter 4 Asteroids Sakai Deployment', - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding, - }), - ], - }); - const ltiStorage = await createSyncedTestLtiStorage({ binding }); - const formData = new FormData(); - - formData.set( - 'state', - await signLtiToolState({ - nonce: 'nonce-sakai-authoring', - issuer: binding.issuer, - clientId: binding.clientId, - }), - ); - formData.set( - 'id_token', - await signCanvasIdToken({ - deploymentBinding: { - issuer: 'https://sakai.example', - clientId: 'sakai-client-123', - deploymentId: 'sakai-deployment-123', - }, - nonce: 'nonce-sakai-authoring', - audience: 'sakai-client-123', - issuedAt: '2026-03-24T00:45:00Z', - expirationTime: '2h', - messageType: LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE, - deepLinkReturnUrl: 'https://sakai.example/portal/site/course', - }), - ); - - await withRuntimeOriginEnv(async () => { - await withFetchStub( - () => - Promise.resolve( - new Response(JSON.stringify(getTestCanvasJwks()), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ), - async () => { - const response = await createApp({ - getRepository: () => repository, - getLtiToolStorage: () => ltiStorage, - }).request('http://localhost/lti/launch', { - method: 'POST', - body: formData, - }); - const location = response.headers.get('location'); - const runtimeSession = await repository.getLatestRuntimeSessionByDeploymentId(17); - - assertEquals(response.status, 303); - if (location === null) { - throw new Error('Expected Deep Linking picker redirect.'); - } - assertStringIncludes(location, '/lti/deep-linking/sessions/'); - assertEquals(runtimeSession, null); - - const sessionId = location.match(/\/lti\/deep-linking\/sessions\/([^?]+)/)?.[1]; - - if (sessionId === undefined) { - throw new Error('Expected Deep Linking session id.'); - } - - const session = await repository.getDeepLinkingSessionById(sessionId); - - assertEquals(session?.deploymentRecordId, 17); - assertEquals(session?.deepLinkReturnUrl, 'https://sakai.example/portal/site/course'); - }, - ); - }); -}); diff --git a/src/app_lti_launch_edge_test.ts b/src/app_lti_launch_edge_test.ts deleted file mode 100644 index 8a28182..0000000 --- a/src/app_lti_launch_edge_test.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; -import { withRuntimeOriginEnv } from './app_test_support.ts'; -import { isLaunchRejectionError } from './lti/launch_rejection.ts'; -import { rejectPackageNotApproved } from './lti/launch_support_matrix.ts'; -import { - buildDeploymentRecord, - buildPackageVersionRecord, - buildReviewedPlacementRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { - buildDeploymentBinding, - createSyncedTestLtiStorage, - getTestCanvasJwks, - signCanvasIdToken, - signLtiToolState, -} from './test_helpers/lti.ts'; -import { withFetchStub } from './app_test_support.ts'; - -Deno.test('launch rejection helpers classify governed approval denials as policy denials', () => { - try { - rejectPackageNotApproved({ - appId: 'chapter-4-asteroids', - packageVersion: '0.1.0', - }); - } catch (error) { - if (!isLaunchRejectionError(error)) { - throw error; - } - - const rejection = error.rejection; - - assertEquals(rejection.category, 'policyDenied'); - assertEquals(rejection.code, 'package_not_approved'); - assertEquals(rejection.detail.appId, 'chapter-4-asteroids'); - assertEquals(rejection.detail.packageVersion, '0.1.0'); - return; - } - - throw new Error('Expected launch rejection.'); -}); - -Deno.test('POST /lti/launch records governed rejection audit with deployment and profile context', async () => { - await withRuntimeOriginEnv(async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'pending', - reviewedAt: null, - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 3, - slug: 'chapter-4-asteroids-pilot', - label: 'Chapter 4 Asteroids Pilot Deployment', - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - ltiProfileOverride: 'certification', - }), - ], - }); - const ltiStorage = await createSyncedTestLtiStorage(); - const formData = new FormData(); - - formData.set('state', await signLtiToolState({ nonce: 'nonce-unapproved-launch' })); - formData.set( - 'id_token', - await signCanvasIdToken({ - nonce: 'nonce-unapproved-launch', - audience: '10000000000001', - issuedAt: '2026-03-24T00:45:00Z', - expirationTime: '2h', - }), - ); - - await withFetchStub( - () => - Promise.resolve( - new Response(JSON.stringify(getTestCanvasJwks()), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ), - async () => { - const response = await createApp({ - getRepository: () => repository, - getLtiToolStorage: () => ltiStorage, - }).request('http://localhost/lti/launch', { - method: 'POST', - body: formData, - }); - const auditEvents = await repository.listAuditEventsByEventType('launch.rejected'); - - assertEquals(response.status, 409); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.deploymentRecordId, 3); - assertEquals(auditEvents[0]?.packageVersionId, 5); - assertEquals(auditEvents[0]?.detail.code, 'package_not_approved'); - assertEquals(auditEvents[0]?.detail.category, 'policyDenied'); - assertEquals(auditEvents[0]?.detail.internalDeploymentSlug, 'chapter-4-asteroids-pilot'); - assertEquals(auditEvents[0]?.detail.issuer, 'https://canvas.instructure.com'); - assertEquals(auditEvents[0]?.detail.clientId, '10000000000001'); - assertEquals(auditEvents[0]?.detail.deploymentId, 'deployment-123'); - assertEquals(auditEvents[0]?.detail.ltiProfileId, 'certification'); - assertEquals(auditEvents[0]?.detail.ltiProfileSource, 'deploymentOverride'); - }, - ); - }); -}); - -Deno.test('POST /lti/launch keeps reviewed assignment launches on the reviewed version and content after the deployment pin changes', async () => { - await withRuntimeOriginEnv(async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - version: '0.1.0', - installScope: 'assignment', - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - manifestJson: { - app_id: 'chapter-4-asteroids', - version: '0.1.0', - title: 'Chapter 4 Asteroids', - content_files: ['/content/activity.json', '/content/bonus.json'], - }, - artifact: { - snapshotRoot: 'var/packages/chapter-4-asteroids/0.1.0', - manifestPath: 'var/packages/chapter-4-asteroids/0.1.0/manifest.json', - entrypointPath: 'var/packages/chapter-4-asteroids/0.1.0/dist/index.html', - digest: 'sha256:chapter-4-asteroids-0.1.0', - }, - }), - buildPackageVersionRecord({ - id: 6, - version: '0.2.0', - installScope: 'assignment', - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:10:00Z', - manifestJson: { - app_id: 'chapter-4-asteroids', - version: '0.2.0', - title: 'Chapter 4 Asteroids', - content_files: ['/content/replacement.json'], - }, - artifact: { - snapshotRoot: 'var/packages/chapter-4-asteroids/0.2.0', - manifestPath: 'var/packages/chapter-4-asteroids/0.2.0/manifest.json', - entrypointPath: 'var/packages/chapter-4-asteroids/0.2.0/dist/index.html', - digest: 'sha256:chapter-4-asteroids-0.2.0', - }, - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 3, - slug: 'chapter-4-asteroids-pilot', - label: 'Chapter 4 Asteroids Pilot Deployment', - enabledPackageVersionId: 6, - enabledPackageVersion: '0.2.0', - binding: buildDeploymentBinding(), - }), - ], - reviewedPlacements: [ - buildReviewedPlacementRecord({ - placementId: 'placement-123', - deploymentRecordId: 3, - deploymentSlug: 'chapter-4-asteroids-pilot', - packageVersionId: 5, - packageVersion: '0.1.0', - activityId: '/content/bonus.json', - contentPath: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - ], - }); - const ltiStorage = await createSyncedTestLtiStorage(); - const formData = new FormData(); - - formData.set('state', await signLtiToolState({ nonce: 'nonce-reviewed-launch' })); - formData.set( - 'id_token', - await signCanvasIdToken({ - nonce: 'nonce-reviewed-launch', - audience: '10000000000001', - issuedAt: '2026-03-24T00:45:00Z', - expirationTime: '2h', - resourceLinkId: 'resource-link-reviewed', - custom: { lantern_placement_id: 'placement-123' }, - }), - ); - - await withFetchStub( - () => - Promise.resolve( - new Response(JSON.stringify(getTestCanvasJwks()), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ), - async () => { - const response = await createApp({ - getRepository: () => repository, - getLtiToolStorage: () => ltiStorage, - }).request('https://lantern.example/lti/launch', { - method: 'POST', - body: formData, - }); - - assertEquals(response.status, 303); - const location = response.headers.get('location'); - - if (!location) { - throw new Error('Expected runtime-session handoff redirect.'); - } - - assertStringIncludes(location, 'https://runtime.lantern.example/runtime/sessions/'); - - const sessionId = location.match(/\/runtime\/sessions\/([^?]+)/)?.[1]; - - if (!sessionId) { - throw new Error('Expected runtime session id in redirect.'); - } - - const saved = await repository.getRuntimeSessionById(sessionId); - const attempt = saved ? await repository.getAttemptById(saved.attemptId) : null; - const placement = await repository.getReviewedPlacementById('placement-123'); - - assertEquals(saved?.packageVersionId, 5); - assertEquals(saved?.packageVersion, '0.1.0'); - assertEquals( - saved?.contentPath, - 'var/packages/chapter-4-asteroids/0.1.0/content/bonus.json', - ); - assertEquals(saved?.launch.activityId, '/content/bonus.json'); - assertEquals(attempt?.packageVersionId, 5); - assertEquals(attempt?.activityId, '/content/bonus.json'); - assertEquals(placement?.resourceLinkId, 'resource-link-reviewed'); - }, - ); - }); -}); - -Deno.test('POST /lti/launch rejects bad signed launches before any runtime handoff', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 3, - slug: 'chapter-4-asteroids-pilot', - label: 'Chapter 4 Asteroids Pilot Deployment', - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }), - ], - }); - const ltiStorage = await createSyncedTestLtiStorage(); - const formData = new FormData(); - - formData.set('state', await signLtiToolState({ nonce: 'nonce-invalid-launch' })); - formData.set( - 'id_token', - await signCanvasIdToken({ - nonce: 'nonce-invalid-launch', - issuedAt: '2026-03-24T00:45:00Z', - expirationTime: '2h', - }), - ); - - await withRuntimeOriginEnv(async () => { - await withFetchStub( - () => - Promise.resolve( - new Response(JSON.stringify({ keys: [] }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ), - async () => { - const response = await createApp({ - getRepository: () => repository, - getLtiToolStorage: () => ltiStorage, - }).request('http://localhost/lti/launch', { - method: 'POST', - headers: { - 'x-real-ip': '203.0.113.44', - }, - body: formData, - }); - const body = await response.text(); - - assertEquals(response.status, 400); - assertStringIncludes(body, 'JWT verification failed'); - - const auditEvents = await repository.listAuditEventsByEventType('launch.rejected'); - - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.deploymentRecordId, null); - assertEquals(auditEvents[0]?.packageVersionId, null); - assertEquals(auditEvents[0]?.detail.code, 'jwt_verification_failed'); - assertEquals( - JSON.stringify(auditEvents[0]?.detail ?? {}).includes('secret-id-token'), - false, - ); - }, - ); - }); -}); - -Deno.test('POST /lti/launch rejects target_link_uri drift at the lti-tool protocol boundary', async () => { - await withRuntimeOriginEnv(async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 3, - slug: 'chapter-4-asteroids-pilot', - label: 'Chapter 4 Asteroids Pilot Deployment', - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - ltiProfileOverride: 'governedCompatibility', - }), - ], - lanternLtiProfileSettings: { - defaultLtiProfile: 'certification', - updatedAt: '2026-03-23T18:05:00Z', - }, - }); - const ltiStorage = await createSyncedTestLtiStorage(); - const formData = new FormData(); - - formData.set( - 'state', - await signLtiToolState({ - nonce: 'nonce-target-drift-launch', - targetLinkUri: 'https://lantern.example/lti/launch/', - }), - ); - formData.set( - 'id_token', - await signCanvasIdToken({ - nonce: 'nonce-target-drift-launch', - audience: '10000000000001', - issuedAt: '2026-03-24T00:45:00Z', - expirationTime: '2h', - targetLinkUri: 'http://lantern.example/lti/launch?placement=course_navigation', - }), - ); - - await withFetchStub( - () => - Promise.resolve( - new Response(JSON.stringify(getTestCanvasJwks()), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ), - async () => { - const response = await createApp({ - getRepository: () => repository, - getLtiToolStorage: () => ltiStorage, - }).request('https://lantern.example/lti/launch', { - method: 'POST', - body: formData, - }); - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - - assertEquals(response.status, 400); - assertEquals(interopEvents.length, 0); - }, - ); - }); -}); diff --git a/src/app_lti_launch_multilms_test.ts b/src/app_lti_launch_multilms_test.ts deleted file mode 100644 index 4038d2a..0000000 --- a/src/app_lti_launch_multilms_test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { assertEquals } from '@std/assert'; -import { createApp } from './app.ts'; -import { - buildDeploymentRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { - buildMoodleDeploymentBinding, - buildSakaiDeploymentBinding, - createSyncedTestLtiStorage, - getTestCanvasJwks, - signCanvasIdToken, - signLtiToolState, -} from './test_helpers/lti.ts'; -import { withFetchStub, withRuntimeOriginEnv } from './app_test_support.ts'; - -Deno.test('POST /lti/launch accepts a signed Moodle resource-link launch and records shared audit evidence', async () => { - const binding = buildMoodleDeploymentBinding({ - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - }); - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 13, - slug: 'chapter-4-asteroids-moodle', - label: 'Chapter 4 Asteroids Moodle Deployment', - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding, - }), - ], - }); - const ltiStorage = await createSyncedTestLtiStorage({ binding }); - const formData = new FormData(); - - formData.set( - 'state', - await signLtiToolState({ - nonce: 'nonce-moodle-launch', - issuer: binding.issuer, - clientId: binding.clientId, - }), - ); - formData.set( - 'id_token', - await signCanvasIdToken({ - deploymentBinding: { - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - }, - nonce: 'nonce-moodle-launch', - audience: 'moodle-client-123', - issuedAt: '2026-03-24T00:45:00Z', - expirationTime: '2h', - resourceLinkId: 'moodle-resource-link', - contextId: 'moodle-course-42', - contextTitle: 'Moodle Physics 101', - returnUrl: 'https://moodle.example/mod/lti/return.php', - }), - ); - - await withRuntimeOriginEnv(async () => { - await withFetchStub( - () => - Promise.resolve( - new Response(JSON.stringify(getTestCanvasJwks()), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ), - async () => { - const response = await createApp({ - getRepository: () => repository, - getLtiToolStorage: () => ltiStorage, - }).request('http://localhost/lti/launch', { - method: 'POST', - body: formData, - }); - - assertEquals(response.status, 303); - const location = response.headers.get('location'); - - if (!location) { - throw new Error('Expected runtime-session handoff redirect.'); - } - - const sessionId = location.match(/\/runtime\/sessions\/([^?]+)/)?.[1]; - - if (!sessionId) { - throw new Error('Expected runtime session id in redirect.'); - } - - const saved = await repository.getRuntimeSessionById(sessionId); - const auditEvents = await repository.listAuditEventsByEventType('launch.accepted'); - - assertEquals(saved?.deploymentRecordId, 13); - assertEquals(saved?.launch.courseId, 'moodle-course-42'); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.detail.lms, 'moodle'); - assertEquals(auditEvents[0]?.detail.issuer, 'https://moodle.example'); - assertEquals(auditEvents[0]?.detail.clientId, 'moodle-client-123'); - assertEquals(auditEvents[0]?.detail.deploymentId, 'moodle-deployment-123'); - assertEquals(auditEvents[0]?.detail.resourceLinkId, 'moodle-resource-link'); - assertEquals(auditEvents[0]?.detail.contextId, 'moodle-course-42'); - }, - ); - }); -}); - -Deno.test('POST /lti/launch accepts a signed Sakai resource-link launch and records shared audit evidence', async () => { - const binding = buildSakaiDeploymentBinding({ - issuer: 'https://sakai.example', - clientId: 'sakai-client-123', - deploymentId: 'sakai-deployment-123', - }); - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 17, - slug: 'chapter-4-asteroids-sakai', - label: 'Chapter 4 Asteroids Sakai Deployment', - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding, - }), - ], - }); - const ltiStorage = await createSyncedTestLtiStorage({ binding }); - const formData = new FormData(); - - formData.set( - 'state', - await signLtiToolState({ - nonce: 'nonce-sakai-launch', - issuer: binding.issuer, - clientId: binding.clientId, - }), - ); - formData.set( - 'id_token', - await signCanvasIdToken({ - deploymentBinding: { - issuer: 'https://sakai.example', - clientId: 'sakai-client-123', - deploymentId: 'sakai-deployment-123', - }, - nonce: 'nonce-sakai-launch', - audience: 'sakai-client-123', - issuedAt: '2026-03-24T00:45:00Z', - expirationTime: '2h', - resourceLinkId: 'sakai-resource-link', - contextId: 'sakai-course-42', - contextTitle: 'Sakai Physics 101', - returnUrl: 'https://sakai.example/portal/site/course', - }), - ); - - await withRuntimeOriginEnv(async () => { - await withFetchStub( - () => - Promise.resolve( - new Response(JSON.stringify(getTestCanvasJwks()), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ), - async () => { - const response = await createApp({ - getRepository: () => repository, - getLtiToolStorage: () => ltiStorage, - }).request('http://localhost/lti/launch', { - method: 'POST', - body: formData, - }); - - assertEquals(response.status, 303); - const location = response.headers.get('location'); - - if (!location) { - throw new Error('Expected runtime-session handoff redirect.'); - } - - const sessionId = location.match(/\/runtime\/sessions\/([^?]+)/)?.[1]; - - if (!sessionId) { - throw new Error('Expected runtime session id in redirect.'); - } - - const saved = await repository.getRuntimeSessionById(sessionId); - const auditEvents = await repository.listAuditEventsByEventType('launch.accepted'); - - assertEquals(saved?.deploymentRecordId, 17); - assertEquals(saved?.launch.courseId, 'sakai-course-42'); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.detail.lms, 'sakai'); - assertEquals(auditEvents[0]?.detail.issuer, 'https://sakai.example'); - assertEquals(auditEvents[0]?.detail.clientId, 'sakai-client-123'); - assertEquals(auditEvents[0]?.detail.deploymentId, 'sakai-deployment-123'); - assertEquals(auditEvents[0]?.detail.resourceLinkId, 'sakai-resource-link'); - assertEquals(auditEvents[0]?.detail.contextId, 'sakai-course-42'); - }, - ); - }); -}); diff --git a/src/app_lti_launch_test.ts b/src/app_lti_launch_test.ts deleted file mode 100644 index 7d2c705..0000000 --- a/src/app_lti_launch_test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { compactVerify, createLocalJWKSet } from 'jose'; -import type { BootstrapPayload } from '../sdk/app-sdk.ts'; -import { createApp } from './app.ts'; -import { createObjectEnvReader } from './platform/env.ts'; -import { getPublicJwkSet } from './lti/tool_key.ts'; -import { CANVAS_LTI_SCOPES } from './lti/types.ts'; -import { - buildDeploymentRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { - buildDeploymentBinding, - createSyncedTestLtiStorage, - getTestCanvasJwks, - getTestToolPrivateJwkEnvValue, - signCanvasIdToken, - signLtiToolState, - TEST_LTI_STATE_SECRET, -} from './test_helpers/lti.ts'; -import { EXAMPLE_SNAPSHOT_ROOT, withFetchStub, withRuntimeOriginEnv } from './app_test_support.ts'; - -Deno.test('POST /lti/launch validates the signed launch and redirects to a runtime-session handoff with a signed runtime bootstrap', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - runtimeContractSignature: 'test-reviewed-runtime-contract-signature', - artifact: { - snapshotRoot: EXAMPLE_SNAPSHOT_ROOT, - manifestPath: `${EXAMPLE_SNAPSHOT_ROOT}/manifest.json`, - entrypointPath: `${EXAMPLE_SNAPSHOT_ROOT}/dist/index.html`, - digest: 'sha256:chapter-4-asteroids-0.1.0', - }, - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 3, - slug: 'chapter-4-asteroids-pilot', - label: 'Chapter 4 Asteroids Pilot Deployment', - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - ltiProfileOverride: 'certification', - }), - ], - }); - const ltiStorage = await createSyncedTestLtiStorage(); - const state = await signLtiToolState({ nonce: 'nonce-launch-123' }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-launch-123', - audience: '10000000000001', - issuedAt: '2026-03-24T00:45:00Z', - expirationTime: '2h', - name: 'Ada Lovelace', - email: 'ada@example.com', - preferredUsername: 'adal', - }); - const formData = new FormData(); - const env = createObjectEnvReader({ - APP_ORIGIN: 'https://lantern.example', - APP_RUNTIME_ORIGIN: 'https://runtime.lantern.example', - LTI_STATE_SECRET: TEST_LTI_STATE_SECRET, - LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), - }); - - formData.set('state', state); - formData.set('id_token', idToken); - - await withFetchStub( - () => - Promise.resolve( - new Response(JSON.stringify(getTestCanvasJwks()), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ), - async () => { - const app = createApp({ - env, - getRepository: () => repository, - getLtiToolStorage: () => ltiStorage, - }); - const response = await app.request('https://lantern.example/lti/launch', { - method: 'POST', - body: formData, - }); - - assertEquals(response.status, 303); - const location = response.headers.get('location'); - - if (!location) { - throw new Error('Expected runtime-session handoff redirect.'); - } - - assertStringIncludes(location, 'https://runtime.lantern.example/runtime/sessions/'); - assertStringIncludes(location, 'token='); - - const sessionId = location.match(/\/runtime\/sessions\/([^?]+)/)?.[1]; - - if (!sessionId) { - throw new Error('Expected runtime session id in redirect.'); - } - - const saved = await repository.getRuntimeSessionById(sessionId); - - if (!saved) { - throw new Error('Expected saved runtime session.'); - } - - const attempt = await repository.getAttemptById(saved.attemptId); - const auditEvents = await repository.listAuditEventsByEventType('launch.accepted'); - const runtimeResponse = await app.request(location); - const runtimeBody = await runtimeResponse.text(); - - assertEquals(runtimeResponse.status, 200, runtimeBody); - const bootstrap = extractBootstrapFromHtml(runtimeBody); - - assertEquals(saved.packageVersionId, 5); - assertEquals(typeof saved.attemptId, 'string'); - assertEquals(saved.launch.userRole, 'learner'); - assertEquals(saved.services.ags?.scope, [...CANVAS_LTI_SCOPES].slice(0, 2)); - assertEquals(saved.services.nrps?.contextMembershipsUrl?.includes('names_and_roles'), true); - assertEquals(attempt?.attemptId, saved.attemptId); - assertEquals(attempt?.userDisplayName, 'Ada Lovelace'); - assertEquals(attempt?.userEmail, 'ada@example.com'); - assertEquals(attempt?.userLogin, null); - assertEquals(auditEvents.length, 1); - assertEquals(auditEvents[0]?.attemptId, saved.attemptId); - assertEquals(auditEvents[0]?.detail.userDisplayName, 'Ada Lovelace'); - assertEquals(auditEvents[0]?.detail.userEmail, 'ada@example.com'); - assertEquals(auditEvents[0]?.detail.userLogin, null); - assertEquals(auditEvents[0]?.detail.ltiProfileId, 'certification'); - assertEquals(auditEvents[0]?.detail.ltiProfileSource, 'deploymentOverride'); - assertEquals( - bootstrap.app.runtime_contract_signature, - 'test-reviewed-runtime-contract-signature', - ); - assertEquals(bootstrap.app.version, '0.1.0'); - assertEquals(bootstrap.session.attempt_id, saved.attemptId); - assertEquals( - runtimeBody.includes('https://canvas.example/api/lti/courses/42/line_items'), - false, - ); - await assertBootstrapSignature(bootstrap); - }, - ); -}); - -Deno.test('POST /lti/launch records lti-tool JWKS refetch verification evidence', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 5, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - reviewedAt: '2026-03-23T18:05:00Z', - }), - ], - deployments: [ - buildDeploymentRecord({ - id: 3, - slug: 'chapter-4-asteroids-pilot', - label: 'Chapter 4 Asteroids Pilot Deployment', - enabledPackageVersionId: 5, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - ltiProfileOverride: 'certification', - }), - ], - }); - const ltiStorage = await createSyncedTestLtiStorage(); - const state = await signLtiToolState({ nonce: 'nonce-launch-cert-jwks' }); - const idToken = await signCanvasIdToken({ - nonce: 'nonce-launch-cert-jwks', - audience: '10000000000001', - issuedAt: '2026-03-24T00:45:00Z', - expirationTime: '2h', - }); - const formData = new FormData(); - let jwksRequests = 0; - - formData.set('state', state); - formData.set('id_token', idToken); - - await withRuntimeOriginEnv(async () => { - await withFetchStub( - () => { - jwksRequests += 1; - - return Promise.resolve( - new Response(JSON.stringify(jwksRequests === 1 ? { keys: [] } : getTestCanvasJwks()), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ); - }, - async () => { - const response = await createApp({ - getRepository: () => repository, - getLtiToolStorage: () => ltiStorage, - }).request('http://localhost/lti/launch', { - method: 'POST', - body: formData, - }); - const body = await response.text(); - const interopEvents = await repository.listAuditEventsByEventType('interop.path_used'); - - assertEquals(response.status, 303, body); - assertEquals(jwksRequests, 2); - assertEquals(interopEvents.length, 1); - assertEquals(interopEvents[0]?.detail.path, 'jwks_refetch'); - }, - ); - }); -}); - -function extractBootstrapFromHtml(html: string): BootstrapPayload { - const match = html.match(/window\.GatewayBootstrap = (.+?);\nwindow\.GatewayPreview =/s); - - if (!match?.[1]) { - throw new Error('Expected GatewayBootstrap in runtime HTML.'); - } - - return JSON.parse(match[1]) as BootstrapPayload; -} - -async function assertBootstrapSignature(bootstrap: BootstrapPayload): Promise { - const verified = await compactVerify( - bootstrap.signature, - createLocalJWKSet(await getPublicJwkSet(createToolKeyEnv())), - ); - const payload = JSON.parse(new TextDecoder().decode(verified.payload)); - - assertEquals(payload, { - launch: bootstrap.launch, - app: { - app_id: bootstrap.app.app_id, - version: bootstrap.app.version, - capabilities: bootstrap.app.capabilities, - runtime_contract_signature: bootstrap.app.runtime_contract_signature, - }, - session: { - attempt_id: bootstrap.session.attempt_id, - token: bootstrap.session.token, - expires_at: bootstrap.session.expires_at, - }, - }); -} - -function createToolKeyEnv(): { get(name: string): string | undefined } { - return { - get(name: string): string | undefined { - return name === 'LTI_TOOL_PRIVATE_JWK' - ? getTestToolPrivateJwkEnvValue() - : name === 'LTI_STATE_SECRET' - ? TEST_LTI_STATE_SECRET - : undefined; - }, - }; -} diff --git a/src/app_lti_support.ts b/src/app_lti_support.ts index 08d2ba9..01c9b9a 100644 --- a/src/app_lti_support.ts +++ b/src/app_lti_support.ts @@ -1,158 +1,111 @@ import type { Context } from '@hono/hono'; -import { resolveLtiProfileForDeployment } from './lti/profile_resolution.ts'; import { readLoginRequest } from './app_request_support.ts'; import { errorMessage, statusForError } from './app_status_support.ts'; import { renderLtiPlatformStorageLaunchPage, renderTopLevelLaunchPage } from './app_lti_views.ts'; import type { AppServices } from './app_services.ts'; -import { recordInteropPathUsed } from './interop_audit.ts'; -import type { PackageReviewRepository } from './package_review/repository.ts'; -import type { DeploymentRecord } from './package_review/types.ts'; -import { resolveLoginPolicy } from './lti/login_policy.ts'; -import { syncLtiToolDeploymentBinding } from './lti/tool_provider.ts'; +import { syncLtiToolConnection } from './lti/tool_provider.ts'; import { resolveRequestAppOrigin } from './app_origin.ts'; export async function handleLoginInitiation(context: Context, services: AppServices) { try { const loginRequest = await readLoginRequest(context); - const repository = services.getRepository(); const appOrigin = resolveRequestAppOrigin(context, services.env); - const resolvedDeployment = await resolveLoginDeployment({ - repository, - issuer: loginRequest.request.iss, - clientId: loginRequest.request.clientId, - deploymentId: loginRequest.request.deploymentId, - }); - const deployment = resolvedDeployment.deployment; - const binding = deployment.binding; - - if (binding === null) { - throw new Error( - `Deployment ${loginRequest.request.deploymentId} does not have a saved LMS binding.`, - ); + if (loginRequest.request.clientId === null) { + throw new Error('LTI login initiation requires client_id.'); } - - const ltiContext = await services.getLtiToolContext(appOrigin); - if (resolvedDeployment.completedPendingCanvasBinding) { - await syncLtiToolDeploymentBinding({ - storage: ltiContext.storage, - appTitle: deployment.label, - deploymentSlug: deployment.slug, - binding, + const connection = await services.getLmsKernelRepository() + .getLmsConnectionByPlatformIdentity({ + issuer: loginRequest.request.iss, + clientId: loginRequest.request.clientId, + deploymentId: loginRequest.request.deploymentId, }); + if (connection === null || connection.status === 'disabled') { + throw new Error('No active institution LMS connection matches this login request.'); } - const ltiProfile = await resolveLtiProfileForDeployment({ repository, deployment }); - const loginPolicy = resolveLoginPolicy({ + const targetLinkUri = requireGovernedTargetLinkUri({ appOrigin, - binding, - loginRequest, - ltiProfile, + targetLinkUri: loginRequest.request.targetLinkUri, }); - + const installationId = new URL(targetLinkUri).searchParams.get('installation_id'); + if (installationId === null) { + throw new Error('Governed app launch target is missing installation_id.'); + } + const installation = await services.getLmsKernelRepository().getInstallationById( + installationId, + ); + if ( + installation === null || + installation.institutionId !== connection.institutionId || + installation.status !== 'active' + ) { + throw new Error('The governed app installation is not active for this LMS connection.'); + } + const ltiContext = await services.getLtiToolContext(appOrigin); + await syncLtiToolConnection({ storage: ltiContext.storage, connection }); const location = await ltiContext.tool.handleLogin({ - iss: binding.issuer, - client_id: binding.clientId, - lti_deployment_id: binding.deploymentId, - login_hint: loginPolicy.loginHint, - target_link_uri: loginPolicy.targetLinkUri, + iss: connection.issuer, + client_id: connection.clientId, + lti_deployment_id: connection.deploymentId, + login_hint: loginRequest.compatibility.decodedLoginHint ?? + loginRequest.request.loginHint, + target_link_uri: targetLinkUri, launchUrl: `${appOrigin}/lti/launch`, ...(loginRequest.request.ltiMessageHint === null ? {} : { - lti_message_hint: loginPolicy.ltiMessageHint, + lti_message_hint: loginRequest.compatibility.decodedLtiMessageHint ?? + loginRequest.request.ltiMessageHint, }), }); - const redirectUrl = new URL(location); - const ltiStorageTarget = loginRequest.ltiStorageTarget; - const usesLtiPlatformStorage = ltiStorageTarget !== null; - const compatibilityPaths = [ - ...loginPolicy.compatibilityPaths, - ...(usesLtiPlatformStorage ? ['lti_platform_storage'] : []), - ...(!usesLtiPlatformStorage && context.req.header('sec-fetch-dest') === 'iframe' - ? ['iframe_top_level_escape'] - : []), - ]; - - await Promise.all( - compatibilityPaths.map((path) => - recordInteropPathUsed({ - repository, - scope: 'login', - path, - actorType: 'platform', - deploymentRecordId: deployment.id, - packageVersionId: deployment.enabledPackageVersionId, - summary: 'Lantern used an LTI login compatibility path.', - detail: { - deploymentSlug: deployment.slug, - issuer: binding.issuer, - clientId: binding.clientId, - deploymentId: binding.deploymentId, - }, - ltiProfile, - }) - ), - ); - - if (usesLtiPlatformStorage) { - return context.html( - renderLtiPlatformStorageLaunchPage({ - location, - platformOrigin: redirectUrl.origin, - storageTarget: ltiStorageTarget, - state: redirectUrl.searchParams.get('state') ?? '', - nonce: redirectUrl.searchParams.get('nonce') ?? '', - }), - ); - } - - if (context.req.header('sec-fetch-dest') === 'iframe') { - return context.html( - renderTopLevelLaunchPage({ - location, - }), - ); - } - - return context.redirect(location, 302); + return renderLoginRedirect(context, { + location, + storageTarget: loginRequest.ltiStorageTarget, + }); } catch (error) { return context.text(errorMessage(error), statusForError(error)); } } -async function resolveLoginDeployment(input: { - repository: PackageReviewRepository; - issuer: string; - clientId: string | null; - deploymentId: string; -}): Promise<{ - deployment: DeploymentRecord; - completedPendingCanvasBinding: boolean; -}> { - let deployment = await input.repository.getDeploymentByPlatformIdentity({ - issuer: input.issuer, - clientId: input.clientId, - deploymentId: input.deploymentId, - }); - - let completedPendingCanvasBinding = false; - - if (deployment === null && input.clientId !== null) { - deployment = await input.repository.completePendingCanvasBinding({ - issuer: input.issuer, - clientId: input.clientId, - deploymentId: input.deploymentId, - }); - completedPendingCanvasBinding = deployment !== null; +function requireGovernedTargetLinkUri(input: { + readonly appOrigin: string; + readonly targetLinkUri: string | null; +}): string { + if (input.targetLinkUri === null) { + throw new Error('Governed app launches require target_link_uri.'); } - - if (deployment === null) { + const target = new URL(input.targetLinkUri); + const expectedOrigin = new URL(input.appOrigin).origin; + if ( + target.origin !== expectedOrigin || + target.pathname !== '/lti/launch' || + (target.searchParams.get('installation_id')?.trim() ?? '') === '' + ) { throw new Error( - `Deployment ${ - input.clientId ?? '(missing client_id)' - } / ${input.deploymentId} was not found for issuer ${input.issuer}.`, + 'Governed app target_link_uri must be Lantern’s launch URL with an installation_id.', ); } + return target.toString(); +} - return { - deployment, - completedPendingCanvasBinding, - }; +function renderLoginRedirect( + context: Context, + input: { + readonly location: string; + readonly storageTarget: string | null; + }, +): Response { + const redirectUrl = new URL(input.location); + if (input.storageTarget !== null) { + return context.html( + renderLtiPlatformStorageLaunchPage({ + location: input.location, + platformOrigin: redirectUrl.origin, + storageTarget: input.storageTarget, + state: redirectUrl.searchParams.get('state') ?? '', + nonce: redirectUrl.searchParams.get('nonce') ?? '', + }), + ); + } + if (context.req.header('sec-fetch-dest') === 'iframe') { + return context.html(renderTopLevelLaunchPage({ location: input.location })); + } + return context.redirect(input.location, 302); } diff --git a/src/app_notice_support.ts b/src/app_notice_support.ts index 93606a4..69c1c63 100644 --- a/src/app_notice_support.ts +++ b/src/app_notice_support.ts @@ -1,5 +1,4 @@ import type { AdminNotice } from './admin/layout.ts'; -import { buildCanvasConfigUrl } from './lti/config.ts'; import { errorMessage } from './app_status_support.ts'; export function createErrorNotice(title: string, error: unknown): AdminNotice { @@ -27,23 +26,6 @@ export function combineNotices(primary: AdminNotice | null, secondary: AdminNoti }; } -export function getCanvasConfigUrlNoticeSafe(appOrigin?: string): { - url: string | null; - notice: AdminNotice | null; -} { - try { - return { - url: buildCanvasConfigUrl(appOrigin), - notice: null, - }; - } catch (error) { - return { - url: null, - notice: createErrorNotice('Canvas config unavailable', error), - }; - } -} - export function packageDetailPath(appId: string, version: string): string { return `/admin/packages/${appId}/versions/${version}`; } diff --git a/src/app_public_routes_test.ts b/src/app_public_routes_test.ts index e0fa6b8..5c34f9c 100644 --- a/src/app_public_routes_test.ts +++ b/src/app_public_routes_test.ts @@ -1,5 +1,5 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; +import { createTestApp as createApp } from './test_helpers/app.ts'; import { renderHomePage } from './pages/home.ts'; import { createInMemoryPackageReviewRepository } from './test_helpers/package_review.ts'; diff --git a/src/app_python_lab_routes.ts b/src/app_python_lab_routes.ts new file mode 100644 index 0000000..78a8943 --- /dev/null +++ b/src/app_python_lab_routes.ts @@ -0,0 +1,353 @@ +import type { Context, Hono } from '@hono/hono'; +import { readEnv } from './platform/env.ts'; +import type { AppServices } from './app_services.ts'; + +type PythonLabRouteServices = Pick< + AppServices, + | 'env' + | 'getRepository' + | 'pythonGraderSandboxFactory' + | 'pythonLabArtifactStore' + | 'pythonLabGenerationScheduler' + | 'pythonLabHumanVerifier' + | 'pythonLabRepository' + | 'runtimeArtifactStore' +>; +import type { PythonLabRecord } from './python_lab/repository.ts'; +import { buildPythonLabPrivateArtifactKey } from './python_lab/artifact_store.ts'; +import { verifyPythonLabCandidate } from './python_lab/lab_service.ts'; +import { launchPreviewRuntimeSession } from './preview/service.ts'; +import { buildRuntimeSessionUrl, requireConfiguredRuntimeOrigin } from './runtime_origin.ts'; +import { + escapeHtml, + LAB_PATH, + labUrl, + renderCandidatePage, + renderLabCreateForm, + renderLabStatus, + renderPythonLabPage, +} from './python_lab/views.ts'; +import { + appendCookie, + hashToken, + labCookieName, + randomToken, + readCookie, + timingSafeEqual, +} from './python_lab/access.ts'; +import { + formValue, + readBoundedLabForm, + requireFormText, + requireFunctionName, + requireWorkEmail, +} from './python_lab/forms.ts'; + +const LAB_TRIAL_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; +const LAB_REQUEST_RETENTION_MS = 90 * 24 * 60 * 60 * 1000; +const IP_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; + +export function registerPythonLabRoutes(app: Hono, services: PythonLabRouteServices): void { + app.get(LAB_PATH, (context) => { + return context.html(renderPythonLabPage({ + title: 'Lantern Python Autograder Lab', + heading: 'Create a gradeable Python assignment', + body: renderLabCreateForm(readEnv('AUTOGRADER_TURNSTILE_SITE_KEY', services.env) ?? ''), + })); + }); + + app.post(`${LAB_PATH}/labs`, async (context) => { + try { + const form = await readBoundedLabForm(context.req.raw); + await services.pythonLabHumanVerifier.verify({ + token: formValue(form.get('cf-turnstile-response')), + remoteIp: context.req.header('cf-connecting-ip') ?? null, + }); + const visitorToken = readCookie(context.req.header('cookie'), 'lantern_python_lab_visitor') ?? + randomToken(); + const visitorHash = await hashToken(visitorToken); + const remoteIp = context.req.header('cf-connecting-ip') ?? null; + const requestIpHash = remoteIp === null ? null : await hashToken(remoteIp); + const now = new Date(); + const labId = randomToken(); + const accessToken = randomToken(); + const privateArtifactKey = buildPythonLabPrivateArtifactKey(labId); + const record: PythonLabRecord = { + labId, + visitorHash, + requestIpHash, + accessTokenHash: await hashToken(accessToken), + state: 'generating', + authorDraft: { + title: requireFormText(form.get('title'), 'Assignment title', 160), + prompt: requireFormText(form.get('prompt'), 'Assignment prompt', 12_000), + starterCode: requireFormText(form.get('starterCode'), 'Starter code', 32_000), + functionName: requireFunctionName(form.get('functionName')), + }, + packageVersionId: null, + privateArtifactKey, + failureMessage: null, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + LAB_TRIAL_RETENTION_MS).toISOString(), + }; + const admission = await services.pythonLabRepository.reserveLab({ + lab: record, + visitorDay: now.toISOString().slice(0, 10), + ipWindowStartedAt: requestIpHash === null ? null : new Date( + Math.floor(now.getTime() / IP_RATE_LIMIT_WINDOW_MS) * IP_RATE_LIMIT_WINDOW_MS, + ).toISOString(), + }); + + if (admission.status === 'visitor_limited') { + return context.html( + renderPythonLabPage({ + title: 'Lantern Python Autograder Lab', + heading: 'Trial already created', + body: '

    This browser can create one Lab each day.

    ', + }), + 429, + ); + } + + if (admission.status === 'ip_limited') { + return context.html( + renderPythonLabPage({ + title: 'Lantern Python Autograder Lab', + heading: 'Please wait before creating another Lab', + body: '

    Try again shortly.

    ', + }), + 429, + ); + } + + try { + await services.pythonLabArtifactStore.writePendingDraft(privateArtifactKey, { + kind: 'pending_draft', + referenceCode: requireFormText(form.get('referenceCode'), 'Reference solution', 32_000), + }); + await services.pythonLabGenerationScheduler.schedule({ labId }); + } catch { + await Promise.allSettled([ + services.pythonLabArtifactStore.delete(privateArtifactKey), + services.pythonLabRepository.deleteLab(labId), + ]); + return context.html( + renderPythonLabPage({ + title: 'Lantern Python Autograder Lab', + heading: 'Python Lab unavailable', + notice: 'Python Lab generation could not start. Please try again.', + body: renderLabCreateForm(readEnv('AUTOGRADER_TURNSTILE_SITE_KEY', services.env) ?? ''), + }), + 503, + ); + } + + const response = context.redirect(labUrl(labId), 303); + appendCookie( + response.headers, + 'lantern_python_lab_visitor', + visitorToken, + `${LAB_PATH}`, + 2 * 24 * 60 * 60, + ); + appendCookie( + response.headers, + labCookieName(labId), + accessToken, + `${LAB_PATH}/labs/${labId}`, + 7 * 24 * 60 * 60, + ); + return response; + } catch { + return context.html( + renderPythonLabPage({ + title: 'Lantern Python Autograder Lab', + heading: 'Create a gradeable Python assignment', + notice: 'Python Lab creation failed. Check the submitted fields and try again.', + body: renderLabCreateForm(readEnv('AUTOGRADER_TURNSTILE_SITE_KEY', services.env) ?? ''), + }), + 400, + ); + } + }); + + app.get(`${LAB_PATH}/labs/:labId`, async (context) => { + const resolved = await resolveAuthorizedLab(context, services); + if ('response' in resolved) return resolved.response; + const lab = resolved.lab; + return context.html(renderPythonLabPage({ + title: lab.authorDraft.title, + heading: lab.authorDraft.title, + body: renderLabStatus(lab), + })); + }); + + app.get(`${LAB_PATH}/labs/:labId/status`, async (context) => { + const resolved = await resolveAuthorizedLab(context, services, 'json'); + if ('response' in resolved) return resolved.response; + const lab = resolved.lab; + return context.json({ state: lab.state, failureMessage: lab.failureMessage }); + }); + + app.post(`${LAB_PATH}/labs/:labId/candidate-runs`, async (context) => { + const resolved = await resolveAuthorizedLab(context, services); + if ('response' in resolved) return resolved.response; + const lab = resolved.lab; + let source = lab.authorDraft.starterCode; + try { + const form = await readBoundedLabForm(context.req.raw); + source = requireFormText(form.get('source'), 'Candidate source', 32_000); + const result = await verifyPythonLabCandidate({ + lab, + source, + pythonLabArtifactStore: services.pythonLabArtifactStore, + sandboxFactory: services.pythonGraderSandboxFactory, + createId: randomToken, + }); + return context.html(renderPythonLabPage({ + title: lab.authorDraft.title, + heading: lab.authorDraft.title, + body: renderCandidatePage(lab, source, result), + })); + } catch { + return context.html( + renderPythonLabPage({ + title: lab.authorDraft.title, + heading: lab.authorDraft.title, + notice: 'Candidate verification failed. Review the code and try again.', + body: renderCandidatePage(lab, source, null), + }), + 409, + ); + } + }); + + app.post(`${LAB_PATH}/labs/:labId/preview`, async (context) => { + const resolved = await resolveAuthorizedLab(context, services); + if ('response' in resolved) return resolved.response; + const lab = resolved.lab; + if (lab.state !== 'ready' || lab.packageVersionId === null) { + throw new Error('Python Lab is not ready for learner preview.'); + } + const packageVersion = await services.getRepository().getPackageVersionById( + lab.packageVersionId, + ); + if (packageVersion === null) { + throw new Error('Python Lab package was not found.'); + } + const launched = await launchPreviewRuntimeSession({ + repository: services.getRepository(), + packageVersion, + artifactStore: services.runtimeArtifactStore, + previewOrigin: 'adminTestLaunch', + }); + + return context.redirect( + buildRuntimeSessionUrl({ + runtimeOrigin: requireConfiguredRuntimeOrigin(readEnv('APP_RUNTIME_ORIGIN', services.env)), + sessionId: launched.runtimeSession.sessionId, + token: launched.runtimeSession.sessionToken, + }), + 303, + ); + }); + + app.post(`${LAB_PATH}/labs/:labId/access-requests`, async (context) => { + const resolved = await resolveAuthorizedLab(context, services); + if ('response' in resolved) return resolved.response; + const lab = resolved.lab; + try { + const form = await readBoundedLabForm(context.req.raw); + const now = new Date(); + await services.pythonLabRepository.createAccessRequest({ + requestId: `python-lab-request-${randomToken()}`, + labId: lab.labId, + name: requireFormText(form.get('name'), 'Name', 160), + workEmail: requireWorkEmail(form.get('workEmail')), + institution: requireFormText(form.get('institution'), 'Institution', 240), + status: 'new', + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + LAB_REQUEST_RETENTION_MS).toISOString(), + }); + return context.html( + renderPythonLabPage({ + title: lab.authorDraft.title, + heading: lab.authorDraft.title, + notice: 'Canvas access request recorded.', + body: renderLabStatus(lab), + }), + 201, + ); + } catch { + return context.html( + renderPythonLabPage({ + title: lab.authorDraft.title, + heading: lab.authorDraft.title, + notice: 'Canvas access request failed. Check the submitted fields and try again.', + body: renderLabStatus(lab), + }), + 400, + ); + } + }); +} + +async function requireAuthorizedLab( + context: Context, + services: PythonLabRouteServices, +): Promise { + const labId = context.req.param('labId'); + + if (labId === undefined || labId === '') { + throw new PythonLabAccessError(404, 'Python Lab was not found.'); + } + const lab = await services.pythonLabRepository.getLabById(labId); + + if (lab === null) { + throw new PythonLabAccessError(404, 'Python Lab was not found.'); + } + const token = readCookie(context.req.header('cookie'), labCookieName(labId)); + + if (token === null || !timingSafeEqual(await hashToken(token), lab.accessTokenHash)) { + throw new PythonLabAccessError(403, 'Python Lab access was denied.'); + } + + return lab; +} + +async function resolveAuthorizedLab( + context: Context, + services: PythonLabRouteServices, + responseFormat: 'html' | 'json' = 'html', +): Promise<{ readonly lab: PythonLabRecord } | { readonly response: Response }> { + try { + return { lab: await requireAuthorizedLab(context, services) }; + } catch (error) { + if (!(error instanceof PythonLabAccessError)) { + throw error; + } + + if (responseFormat === 'json') { + return { response: context.json({ error: error.message }, error.status) }; + } + + return { + response: context.html( + renderPythonLabPage({ + title: 'Lantern Python Autograder Lab', + heading: error.status === 404 ? 'Python Lab not found' : 'Python Lab access denied', + body: `

    ${escapeHtml(error.message)}

    `, + }), + error.status, + ), + }; + } +} + +class PythonLabAccessError extends Error { + constructor(readonly status: 403 | 404, message: string) { + super(message); + } +} diff --git a/src/app_python_lab_routes_test.ts b/src/app_python_lab_routes_test.ts new file mode 100644 index 0000000..8a1f092 --- /dev/null +++ b/src/app_python_lab_routes_test.ts @@ -0,0 +1,322 @@ +import { assertEquals, assertStringIncludes } from '@std/assert'; +import { createTestApp as createApp } from './test_helpers/app.ts'; +import { + buildImportedPackageVersion, + createInMemoryPackageReviewRepository, +} from './test_helpers/package_review.ts'; +import type { + PythonLabArtifactStore, + PythonLabPendingDraftArtifact, + PythonLabPrivateArtifact, +} from './python_lab/artifact_store.ts'; +import { generatePythonLab } from './python_lab/lab_service.ts'; +import { createInMemoryPythonLabRepository } from './python_lab/repository_memory.ts'; +import type { PythonGraderSandboxFactory } from './python_lab/runner.ts'; + +Deno.test('public Python Lab creates one cookie-scoped trial, keeps it out of inventory, and captures a Canvas access request', async () => { + const packageRepository = createInMemoryPackageReviewRepository(); + const pythonLabRepository = createInMemoryPythonLabRepository(); + const artifacts = createMemoryArtifactStore(); + const sandboxFactory = functionSandboxFactory(); + const testVectorGenerator = { generate: () => Promise.resolve(generatedCases()) }; + const importPackageFromSource = () => + Promise.resolve( + buildImportedPackageVersion({ + appId: 'public-python-lab-fixture', + version: '1.0.0', + capabilities: [ + 'read_launch_context', + 'read_activity_content', + 'run_code_submission', + ], + grading: { mode: 'sandbox', rubricFile: null, maxScore: 10 }, + }), + ); + let verified = 0; + const app = createApp({ + getRepository: () => packageRepository, + pythonLabRepository, + pythonLabArtifactStore: artifacts, + pythonGraderSandboxFactory: sandboxFactory, + pythonTestVectorGenerator: testVectorGenerator, + pythonLabHumanVerifier: { + verify() { + verified += 1; + return Promise.resolve(); + }, + }, + importPackageFromSource, + pythonLabGenerationScheduler: { + async schedule({ labId }) { + const lab = await pythonLabRepository.getLabById(labId); + + if (lab === null) { + throw new Error('Python Lab was not found.'); + } + + await generatePythonLab({ + lab, + repository: packageRepository, + pythonLabRepository, + pythonLabArtifactStore: artifacts, + sandboxFactory, + testVectorGenerator, + importPackageFromSource, + createId: () => crypto.randomUUID(), + }); + return { workflowInstanceId: labId }; + }, + }, + }); + const form = new URLSearchParams({ + title: 'Add numbers', + prompt: 'Write a function that returns the sum of two integers.', + functionName: 'add', + starterCode: 'def add(left, right):\n return 0\n', + referenceCode: 'def add(left, right):\n return left + right\n', + 'cf-turnstile-response': 'verified-token', + }); + + const createResponse = await app.request('https://lantern.example/autograder/labs', { + method: 'POST', + headers: formHeaders(), + body: form, + }); + const location = createResponse.headers.get('location'); + const cookie = cookieHeader(createResponse.headers.get('set-cookie')); + + assertEquals(createResponse.status, 303); + assertEquals(verified, 1); + assertEquals(location?.startsWith('/autograder/labs/'), true); + assertEquals((await packageRepository.listPackageVersions()).length, 0); + + const labId = location?.split('/').at(-1) ?? ''; + const createdLab = await pythonLabRepository.getLabById(labId); + assertEquals(createdLab === null, false); + assertEquals('referenceCode' in (createdLab?.authorDraft ?? {}), false); + + const labPath = location ?? ''; + const unauthenticated = await app.request(`https://lantern.example${labPath}`); + const labPage = await app.request(`https://lantern.example${labPath}`, { + headers: { cookie }, + }); + const repeat = await app.request('https://lantern.example/autograder/labs', { + method: 'POST', + headers: { ...formHeaders(), cookie }, + body: form, + }); + + assertEquals(unauthenticated.status, 403); + assertEquals(labPage.status, 200); + assertStringIncludes(await labPage.text(), 'Canvas access'); + assertEquals(repeat.status, 429); + + const accessRequest = await app.request(`https://lantern.example${labPath}/access-requests`, { + method: 'POST', + headers: { ...formHeaders(), cookie }, + body: new URLSearchParams({ + name: 'Ada Instructor', + workEmail: 'ada@example.edu', + institution: 'Example University', + }), + }); + + assertEquals(accessRequest.status, 201); + assertEquals( + (await pythonLabRepository.listAccessRequests()).map((request) => ({ + name: request.name, + workEmail: request.workEmail, + institution: request.institution, + status: request.status, + })), + [{ + name: 'Ada Instructor', + workEmail: 'ada@example.edu', + institution: 'Example University', + status: 'new', + }], + ); +}); + +Deno.test('public Python Lab queues generation and exposes a meaningful generating status', async () => { + const packageRepository = createInMemoryPackageReviewRepository(); + const pythonLabRepository = createInMemoryPythonLabRepository(); + const queuedLabIds: string[] = []; + const app = createApp({ + getRepository: () => packageRepository, + pythonLabRepository, + pythonLabArtifactStore: createMemoryArtifactStore(), + pythonLabHumanVerifier: { verify: () => Promise.resolve() }, + pythonLabGenerationScheduler: { + schedule({ labId }) { + queuedLabIds.push(labId); + return Promise.resolve({ workflowInstanceId: labId }); + }, + }, + }); + + const createResponse = await app.request('https://lantern.example/autograder/labs', { + method: 'POST', + headers: formHeaders(), + body: labCreateForm(), + }); + const location = createResponse.headers.get('location'); + const cookie = cookieHeader(createResponse.headers.get('set-cookie')); + const statusResponse = await app.request(`https://lantern.example${location}/status`, { + headers: { cookie }, + }); + + assertEquals(createResponse.status, 303); + assertEquals(await statusResponse.json(), { state: 'generating', failureMessage: null }); + assertEquals(queuedLabIds.length, 1); + assertEquals((await packageRepository.listPackageVersions()).length, 0); +}); + +Deno.test('concurrent Lab creates admit only one daily visitor reservation', async () => { + const packageRepository = createInMemoryPackageReviewRepository(); + const pythonLabRepository = createInMemoryPythonLabRepository(); + const queuedLabIds: string[] = []; + const app = createApp({ + getRepository: () => packageRepository, + pythonLabRepository, + pythonLabArtifactStore: createMemoryArtifactStore(), + pythonLabHumanVerifier: { verify: () => Promise.resolve() }, + pythonLabGenerationScheduler: { + schedule({ labId }) { + queuedLabIds.push(labId); + return Promise.resolve({ workflowInstanceId: labId }); + }, + }, + }); + const headers = { + ...formHeaders(), + cookie: 'lantern_python_lab_visitor=shared-visitor', + }; + + const responses = await Promise.all([ + app.request('https://lantern.example/autograder/labs', { + method: 'POST', + headers, + body: labCreateForm(), + }), + app.request('https://lantern.example/autograder/labs', { + method: 'POST', + headers, + body: labCreateForm(), + }), + ]); + + assertEquals(responses.map((response) => response.status).sort(), [303, 429]); + assertEquals(queuedLabIds.length, 1); +}); + +function formHeaders(): Record { + return { + 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8', + origin: 'https://lantern.example', + 'cf-connecting-ip': '198.51.100.42', + }; +} + +function labCreateForm(): URLSearchParams { + return new URLSearchParams({ + title: 'Add numbers', + prompt: 'Write a function that returns the sum of two integers.', + functionName: 'add', + starterCode: 'def add(left, right):\n return 0\n', + referenceCode: 'def add(left, right):\n return left + right\n', + 'cf-turnstile-response': 'verified-token', + }); +} + +function cookieHeader(value: string | null): string { + if (value === null) { + throw new Error('Expected Lab cookies.'); + } + + return value.split(', ').map((part) => part.split(';')[0]).join('; '); +} + +function generatedCases() { + return { + cases: [ + { label: 'one plus two', visibility: 'public', args: [1, 2] }, + { label: 'zero plus four', visibility: 'public', args: [0, 4] }, + { label: 'negative plus five', visibility: 'public', args: [-1, 5] }, + { label: 'large', visibility: 'hidden', args: [500, 700] }, + { label: 'negative', visibility: 'hidden', args: [-2, -3] }, + { label: 'mixed', visibility: 'hidden', args: [-3, 9] }, + { label: 'identity', visibility: 'hidden', args: [0, 0] }, + { label: 'boundary', visibility: 'hidden', args: [999, 1] }, + ], + }; +} + +function createMemoryArtifactStore(): PythonLabArtifactStore { + const assignments = new Map(); + const pendingDrafts = new Map(); + const sources = new Map(); + + return { + writePendingDraft(key, artifact) { + pendingDrafts.set(key, structuredClone(artifact)); + return Promise.resolve(); + }, + readPendingDraft(key) { + const artifact = pendingDrafts.get(key); + if (artifact === undefined) return Promise.reject(new Error('Draft was not found.')); + return Promise.resolve(structuredClone(artifact)); + }, + writePrivateAssignment(key, artifact) { + assignments.set(key, structuredClone(artifact)); + return Promise.resolve(); + }, + readPrivateAssignment(key) { + const artifact = assignments.get(key); + if (artifact === undefined) return Promise.reject(new Error('Assignment was not found.')); + return Promise.resolve(structuredClone(artifact)); + }, + writeSubmissionSource(key, source) { + sources.set(key, source); + return Promise.resolve(); + }, + submissionSourceExists(key) { + return Promise.resolve(sources.has(key)); + }, + delete(key) { + assignments.delete(key); + pendingDrafts.delete(key); + sources.delete(key); + return Promise.resolve(); + }, + }; +} + +function functionSandboxFactory(): PythonGraderSandboxFactory { + return { + create() { + let source = ''; + let request = ''; + + return Promise.resolve({ + writeFile(path, contents) { + if (path === '/workspace/solution.py') source = contents; + if (path === '/workspace/request.json') request = contents; + return Promise.resolve(); + }, + exec() { + const args = (JSON.parse(request) as { args: number[] }).args; + const value = source.includes('return 0') ? 0 : args[0]! + args[1]!; + return Promise.resolve({ + exitCode: 0, + stdout: JSON.stringify({ ok: true, value }), + stderr: '', + }); + }, + destroy() { + return Promise.resolve(); + }, + }); + }, + }; +} diff --git a/src/app_request_broker_verification.ts b/src/app_request_broker_verification.ts deleted file mode 100644 index d804dc7..0000000 --- a/src/app_request_broker_verification.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { RecordBrokerVerificationRunInput } from './ops/repository.ts'; -import { isBrokerVerificationSupportedPath } from './ops/broker_verification_paths.ts'; -import { normalizeOptionalString, requireTrimmedFormValue } from './app_request_support.ts'; - -export function parseBrokerVerificationRunForm( - formData: FormData, -): RecordBrokerVerificationRunInput { - const source = parseBrokerVerificationSource( - requireTrimmedFormValue(formData.get('source'), 'Broker verification source is required.'), - ); - const status = parseBrokerVerificationStatus( - requireTrimmedFormValue(formData.get('status'), 'Broker verification status is required.'), - ); - const certificationState = parseBrokerCertificationState( - normalizeOptionalString(formData.get('certificationState')), - ); - const deploymentRecordId = parseOptionalDeploymentRecordId( - normalizeOptionalString(formData.get('deploymentRecordId')), - ); - const scope = parseBrokerVerificationScope( - requireTrimmedFormValue(formData.get('scope'), 'Broker verification scope is required.'), - ); - const workflowKey = parseCertificationWorkflowKey( - requireTrimmedFormValue(formData.get('workflowKey'), 'Certification workflow is required.'), - ); - - if (source !== '1edtech' && certificationState !== null) { - throw new Error('Internal verification runs cannot carry an official certification state.'); - } - - if (source !== '1edtech' && status === 'notCertified') { - throw new Error('Only official 1EdTech verification runs can use the notCertified status.'); - } - - return { - deploymentRecordId, - source, - scope, - workflowKey, - status, - certificationState, - summary: requireTrimmedFormValue( - formData.get('summary'), - 'Broker verification summary is required.', - ), - detailUrl: parseOptionalAbsoluteUrl( - normalizeOptionalString(formData.get('detailUrl')), - 'Verification detail URL must be an absolute URL.', - ), - checkedAt: parseVerificationCheckedAt( - requireTrimmedFormValue(formData.get('checkedAt'), 'Checked-at timestamp is required.'), - ), - }; -} - -function parseBrokerVerificationSource(value: string): RecordBrokerVerificationRunInput['source'] { - switch (value) { - case 'manual': - case 'ci': - case '1edtech': - return value; - default: - throw new Error('Choose one supported broker verification source.'); - } -} - -function parseBrokerVerificationScope(value: string): RecordBrokerVerificationRunInput['scope'] { - if (isBrokerVerificationSupportedPath(value)) { - return value; - } - - throw new Error('Choose one supported broker verification scope.'); -} - -function parseCertificationWorkflowKey( - value: string, -): RecordBrokerVerificationRunInput['workflowKey'] { - switch (value) { - case 'core': - case 'deepLinking': - case 'nrps': - case 'ags': - return value; - default: - throw new Error('Choose one supported certification workflow.'); - } -} - -function parseBrokerVerificationStatus(value: string): RecordBrokerVerificationRunInput['status'] { - switch (value) { - case 'passed': - case 'failed': - case 'pending': - case 'notCertified': - return value; - default: - throw new Error('Choose one supported broker verification status.'); - } -} - -function parseBrokerCertificationState( - value: string | null, -): RecordBrokerVerificationRunInput['certificationState'] { - switch (value) { - case null: - return null; - case 'ltiAdvantageCertified': - case 'ltiAdvantageComplete': - return value; - default: - throw new Error('Choose one supported official certification state.'); - } -} - -function parseOptionalAbsoluteUrl(value: string | null, message: string): string | null { - if (value === null) { - return null; - } - - try { - const url = new URL(value); - - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - throw new Error(message); - } - - return url.toString(); - } catch { - throw new Error(message); - } -} - -function parseOptionalDeploymentRecordId(value: string | null): number | null { - if (value === null) { - return null; - } - - const parsed = Number(value); - - if (!Number.isInteger(parsed) || parsed <= 0) { - throw new TypeError('Broker verification deployment record id must be a positive integer.'); - } - - return parsed; -} - -function parseVerificationCheckedAt(value: string): string { - const timestamp = new Date(value); - - if (Number.isNaN(timestamp.valueOf())) { - throw new TypeError('Checked-at timestamp must be a valid ISO-8601 value.'); - } - - return timestamp.toISOString(); -} diff --git a/src/app_request_support.ts b/src/app_request_support.ts index 59a4ba4..1d9bd1f 100644 --- a/src/app_request_support.ts +++ b/src/app_request_support.ts @@ -1,7 +1,5 @@ import type { Context } from '@hono/hono'; -import { type LtiProfileId, requireLtiProfileId } from './lti/profile.ts'; import type { LoginRequest, LoginRequestCompatibility } from './lti/login_request.ts'; -export { parseBrokerVerificationRunForm } from './app_request_broker_verification.ts'; export interface ReadLoginRequestResult { request: LoginRequest; @@ -61,18 +59,6 @@ export function formValueAsString(value: FormDataEntryValue | null): string | nu return typeof value === 'string' ? value : null; } -export function parseLanternDefaultLtiProfileForm(formData: FormData): LtiProfileId { - return requireLtiProfileId( - requireTrimmedFormValue(formData.get('defaultLtiProfile'), 'Choose one supported LTI profile.'), - ); -} - -export function parseDeploymentLtiProfileOverrideForm(formData: FormData): LtiProfileId | null { - const value = normalizeOptionalString(formData.get('ltiProfileOverride')); - - return value === null ? null : requireLtiProfileId(value); -} - export function readBearerToken(value: string | undefined): string | null { if (!value) { return null; diff --git a/src/app_runtime_evidence_test.ts b/src/app_runtime_evidence_test.ts index 4631365..2a462c4 100644 --- a/src/app_runtime_evidence_test.ts +++ b/src/app_runtime_evidence_test.ts @@ -1,8 +1,9 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; +import { createTestApp as createApp } from './test_helpers/app.ts'; import { withRuntimeOriginEnv } from './app_test_support.ts'; import { buildAttemptRecord, + buildPreviewSessionRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; import { buildRuntimeSessionRecord } from './test_helpers/lti.ts'; @@ -13,6 +14,7 @@ Deno.test('POST /runtime/sessions/:id/evidence-artifacts accepts an allowlisted const bytesByStorageKey = new Map(); const repository = createInMemoryPackageReviewRepository({ attempts: [buildAttemptRecord()], + previewSessions: [buildPreviewSessionRecord({ fakeAttemptId: 'attempt-123' })], runtimeSessions: [ buildRuntimeSessionRecord({ capabilities: ['read_launch_context', 'submit_evidence_artifact', 'finalize_attempt'], @@ -80,6 +82,7 @@ Deno.test('POST /runtime/sessions/:id/evidence-artifacts accepts screenshot_png const bytesByStorageKey = new Map(); const repository = createInMemoryPackageReviewRepository({ attempts: [buildAttemptRecord()], + previewSessions: [buildPreviewSessionRecord({ fakeAttemptId: 'attempt-123' })], runtimeSessions: [ buildRuntimeSessionRecord({ capabilities: ['read_launch_context', 'submit_evidence_artifact', 'finalize_attempt'], @@ -143,6 +146,7 @@ Deno.test('POST /runtime/sessions/:id/evidence-artifacts rejects unsupported evi await withRuntimeOriginEnv(async () => { const repository = createInMemoryPackageReviewRepository({ attempts: [buildAttemptRecord()], + previewSessions: [buildPreviewSessionRecord({ fakeAttemptId: 'attempt-123' })], runtimeSessions: [ buildRuntimeSessionRecord({ capabilities: ['read_launch_context', 'submit_evidence_artifact', 'finalize_attempt'], diff --git a/src/app_runtime_finalize_test.ts b/src/app_runtime_finalize_test.ts deleted file mode 100644 index 3b7d45f..0000000 --- a/src/app_runtime_finalize_test.ts +++ /dev/null @@ -1,634 +0,0 @@ -import { assertEquals, assertExists, assertObjectMatch } from '@std/assert'; -import { createApp } from './app.ts'; -import { - EXAMPLE_SNAPSHOT_ROOT, - restoreEnv, - withFetchStub, - withRuntimeOriginEnv, -} from './app_test_support.ts'; -import { - buildAttemptEventRecord, - buildAttemptEvidenceArtifactRecord, - buildAttemptRecord, - buildDeploymentRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from './test_helpers/package_review.ts'; -import { - buildDeploymentBinding, - buildRuntimeSessionRecord, - getTestToolPrivateJwkEnvValue, -} from './test_helpers/lti.ts'; -import { createTestLtiToolContext } from './test_helpers/lti_tool_context.ts'; - -const WEB_CHECKUP_SNAPSHOT_ROOT = 'examples/apps/web-checkup'; - -Deno.test('POST /runtime/sessions/:id/finalize finalizes the durable attempt and keeps grade publication inside the gateway boundary', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - await withRuntimeOriginEnv(async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - artifact: { - snapshotRoot: EXAMPLE_SNAPSHOT_ROOT, - manifestPath: `${EXAMPLE_SNAPSHOT_ROOT}/manifest.json`, - entrypointPath: `${EXAMPLE_SNAPSHOT_ROOT}/dist/index.html`, - digest: 'sha256:example-snapshot', - }, - }), - ], - deployments: [buildDeploymentRecord({ binding: buildDeploymentBinding() })], - attempts: [buildAttemptRecord()], - attemptEvents: [ - buildAttemptEventRecord({ - id: 1, - sequence: 1, - event: { - type: 'answer', - questionId: 'q1', - answer: 'resistance to a change in motion', - timestamp: '2026-03-24T02:30:00Z', - }, - }), - buildAttemptEventRecord({ - id: 2, - sequence: 2, - event: { - type: 'answer', - questionId: 'q2', - answer: 'speed with direction', - timestamp: '2026-03-24T02:31:00Z', - }, - }), - ], - runtimeSessions: [ - buildRuntimeSessionRecord({ - ltiToolSessionId: 'lti-tool-session-123', - expiresAt: '2099-03-26T02:45:00Z', - }), - ], - }); - const ltiToolContext = createTestLtiToolContext({ - sessionId: 'lti-tool-session-123', - }); - const app = createApp({ - getRepository: () => repository, - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - }); - - await withFetchStub( - (input) => { - const url = String(input); - - if (url === 'https://sso.canvaslms.com/login/oauth2/token') { - return new Response( - JSON.stringify({ - access_token: 'canvas-access-token', - token_type: 'bearer', - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - return new Response(null, { status: 202 }); - }, - async () => { - const firstResponse = await app.request( - 'https://runtime.lantern.example/runtime/sessions/runtime-session-123/finalize', - { - method: 'POST', - headers: { - Authorization: 'Bearer runtime-token-123', - 'content-type': 'application/json', - }, - body: JSON.stringify({ completionState: 'completed' }), - }, - ); - const secondResponse = await app.request( - 'https://runtime.lantern.example/runtime/sessions/runtime-session-123/finalize', - { - method: 'POST', - headers: { - Authorization: 'Bearer runtime-token-123', - 'content-type': 'application/json', - }, - body: JSON.stringify({ completionState: 'abandoned' }), - }, - ); - - assertEquals(firstResponse.status, 202); - assertEquals(secondResponse.status, 202); - - const firstBody = (await firstResponse.json()) as { - accepted: boolean; - alreadyFinalized: boolean; - attemptId: string; - completionState: 'completed' | 'abandoned' | null; - scoreGiven: number; - scoreMaximum: number; - gradePublished: boolean; - }; - const secondBody = (await secondResponse.json()) as typeof firstBody; - const attempt = await repository.getAttemptById('attempt-123'); - const attemptAuditEvents = await repository.listAuditEventsByEventType( - 'attempt.finalized', - ); - const gradeAuditEvents = await repository.listAuditEventsByEventType( - 'grade_publish.succeeded', - ); - const runtimeExitEvents = await repository.listAuditEventsByEventType( - 'runtime.session.exited', - ); - const failedGradeAuditEvents = await repository.listAuditEventsByEventType( - 'grade_publish.failed', - ); - const lineItemBinding = await repository.getLineItemBinding({ - deploymentRecordId: 1, - packageVersionId: 1, - contextId: 'course-42', - resourceLinkId: 'resource-link-123', - activityId: 'activity-123', - }); - const gradePublication = await repository.getGradePublicationByAttemptId('attempt-123'); - - assertEquals(firstBody.accepted, true); - assertEquals(firstBody.alreadyFinalized, false); - assertEquals(firstBody.completionState, 'completed'); - assertEquals(firstBody.scoreGiven, 100); - assertEquals(firstBody.scoreMaximum, 100); - assertEquals(firstBody.gradePublished, true); - assertEquals(secondBody.alreadyFinalized, true); - assertEquals(secondBody.completionState, 'completed'); - assertEquals(secondBody.scoreGiven, 100); - assertEquals(secondBody.gradePublished, true); - assertEquals(attempt?.status, 'completed'); - assertEquals(typeof attempt?.finalizedAt, 'string'); - assertEquals(attemptAuditEvents.length, 1); - assertEquals(gradeAuditEvents.length, 1); - assertEquals(runtimeExitEvents.length, 1); - const runtimeExitEvent = runtimeExitEvents[0]; - - assertExists(runtimeExitEvent); - assertObjectMatch(runtimeExitEvent, { - detail: { - sandboxModel: 'contained_browser_runtime', - boundary: 'app_runtime_origin', - completionState: 'completed', - }, - }); - assertEquals(failedGradeAuditEvents.length, 0); - assertEquals(lineItemBinding?.lineItemUrl.includes('/line_items/9'), true); - assertEquals(gradePublication?.status, 'published'); - }, - ); - }); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } -}); - -Deno.test('POST /runtime/sessions/:id/finalize accepts browser grader results while keeping publication inside the gateway boundary', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - await withRuntimeOriginEnv(async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - appId: 'web-checkup', - title: 'Web Checkup', - capabilities: [ - 'read_launch_context', - 'read_activity_content', - 'submit_attempt_event', - 'submit_evidence_artifact', - 'finalize_attempt', - ], - grading: { - mode: 'browser', - rubricFile: null, - maxScore: 100, - }, - manifestJson: { - app_id: 'web-checkup', - version: '0.1.0', - title: 'Web Checkup', - grading: { - mode: 'browser', - max_score: 100, - }, - authoring: { - kind: 'browser_autograder', - grader_spec_files: [ - '/grading/specs/structure.spec.js', - '/grading/specs/behavior.spec.js', - ], - evidence_example_file: '/evidence/example-output.json', - }, - }, - artifact: { - snapshotRoot: WEB_CHECKUP_SNAPSHOT_ROOT, - manifestPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/manifest.json`, - entrypointPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/dist/index.html`, - digest: 'sha256:web-checkup-snapshot', - }, - }), - ], - deployments: [ - buildDeploymentRecord({ - appId: 'web-checkup', - slug: 'web-checkup-pilot', - label: 'Web Checkup Pilot', - binding: buildDeploymentBinding(), - }), - ], - attempts: [ - buildAttemptRecord({ - appId: 'web-checkup', - deploymentSlug: 'web-checkup-pilot', - }), - ], - attemptEvidenceArtifacts: [ - buildAttemptEvidenceArtifactRecord({ - artifactId: 'artifact-001', - attemptId: 'attempt-123', - kind: 'structured_json', - fileName: 'submission.json', - }), - ], - runtimeSessions: [ - buildRuntimeSessionRecord({ - ltiToolSessionId: 'lti-tool-session-123', - appId: 'web-checkup', - deploymentSlug: 'web-checkup-pilot', - capabilities: [ - 'read_launch_context', - 'read_activity_content', - 'submit_attempt_event', - 'submit_evidence_artifact', - 'finalize_attempt', - ], - snapshotRoot: WEB_CHECKUP_SNAPSHOT_ROOT, - entrypointPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/dist/index.html`, - contentPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/content/activity.json`, - expiresAt: '2099-03-26T02:45:00Z', - }), - ], - }); - const ltiToolContext = createTestLtiToolContext({ - sessionId: 'lti-tool-session-123', - }); - const app = createApp({ - getRepository: () => repository, - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - }); - - await withFetchStub( - (input) => { - const url = String(input); - - if (url === 'https://sso.canvaslms.com/login/oauth2/token') { - return new Response( - JSON.stringify({ - access_token: 'canvas-access-token', - token_type: 'bearer', - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - return new Response(null, { status: 202 }); - }, - async () => { - const response = await app.request( - 'https://runtime.lantern.example/runtime/sessions/runtime-session-123/finalize', - { - method: 'POST', - headers: { - Authorization: 'Bearer runtime-token-123', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - completionState: 'completed', - browserGraderResult: { - scoreGiven: 100, - scoreMaximum: 100, - specResults: [ - { - source: '/grading/specs/structure.spec.js', - result: 'passed', - failures: [], - }, - { - source: '/grading/specs/behavior.spec.js', - result: 'passed', - failures: [], - }, - ], - }, - }), - }, - ); - - assertEquals(response.status, 202); - assertObjectMatch(await response.json(), { - accepted: true, - scoreGiven: 100, - scoreMaximum: 100, - gradePublished: true, - }); - const attemptAuditEvents = await repository.listAuditEventsByEventType( - 'attempt.finalized', - ); - - assertEquals(attemptAuditEvents.length, 1); - assertObjectMatch(attemptAuditEvents[0]?.detail ?? {}, { - submissionMode: 'anonymous_submission', - evidenceArtifactCount: 1, - evidenceArtifacts: [ - { - artifactId: 'artifact-001', - }, - ], - browserGraderResult: { - specResults: [ - { - source: '/grading/specs/structure.spec.js', - }, - { - source: '/grading/specs/behavior.spec.js', - }, - ], - }, - }); - }, - ); - }); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } -}); - -Deno.test('POST /runtime/sessions/:id/finalize rejects anonymous submissions that have no stored evidence artifacts', async () => { - await withRuntimeOriginEnv(async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - appId: 'web-checkup', - title: 'Web Checkup', - capabilities: [ - 'read_launch_context', - 'read_activity_content', - 'submit_attempt_event', - 'submit_evidence_artifact', - 'finalize_attempt', - ], - grading: { - mode: 'browser', - rubricFile: null, - maxScore: 100, - }, - manifestJson: { - app_id: 'web-checkup', - version: '0.1.0', - title: 'Web Checkup', - grading: { - mode: 'browser', - max_score: 100, - }, - authoring: { - kind: 'browser_autograder', - grader_spec_files: [ - '/grading/specs/structure.spec.js', - '/grading/specs/behavior.spec.js', - ], - evidence_example_file: '/evidence/example-output.json', - }, - }, - artifact: { - snapshotRoot: WEB_CHECKUP_SNAPSHOT_ROOT, - manifestPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/manifest.json`, - entrypointPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/dist/index.html`, - digest: 'sha256:web-checkup-snapshot', - }, - }), - ], - attempts: [ - buildAttemptRecord({ - appId: 'web-checkup', - deploymentSlug: 'web-checkup-pilot', - }), - ], - runtimeSessions: [ - buildRuntimeSessionRecord({ - appId: 'web-checkup', - deploymentSlug: 'web-checkup-pilot', - capabilities: [ - 'read_launch_context', - 'read_activity_content', - 'submit_attempt_event', - 'submit_evidence_artifact', - 'finalize_attempt', - ], - snapshotRoot: WEB_CHECKUP_SNAPSHOT_ROOT, - entrypointPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/dist/index.html`, - contentPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/content/activity.json`, - expiresAt: '2099-03-26T02:45:00Z', - }), - ], - }); - const app = createApp({ getRepository: () => repository }); - - const response = await app.request( - 'https://runtime.lantern.example/runtime/sessions/runtime-session-123/finalize', - { - method: 'POST', - headers: { - Authorization: 'Bearer runtime-token-123', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - completionState: 'completed', - browserGraderResult: { - scoreGiven: 100, - scoreMaximum: 100, - specResults: [ - { - source: '/grading/specs/structure.spec.js', - result: 'passed', - failures: [], - }, - { - source: '/grading/specs/behavior.spec.js', - result: 'passed', - failures: [], - }, - ], - }, - }), - }, - ); - const body = (await response.json()) as { - accepted: boolean; - denial: { - code: string; - capability: string | null; - }; - }; - - assertEquals(response.status, 409); - assertEquals(body.accepted, false); - assertEquals(body.denial.code, 'anonymous_evidence_required'); - assertEquals(body.denial.capability, 'finalize_attempt'); - }); -}); - -Deno.test('POST /runtime/sessions/:id/score-proposal accepts typed app proposals without direct grade writes', async () => { - await withRuntimeOriginEnv(async () => { - const repository = createInMemoryPackageReviewRepository({ - attempts: [buildAttemptRecord()], - runtimeSessions: [buildRuntimeSessionRecord({ expiresAt: '2099-03-26T02:45:00Z' })], - }); - const response = await createApp({ - getRepository: () => repository, - }).request( - 'https://runtime.lantern.example/runtime/sessions/runtime-session-123/score-proposal', - { - method: 'POST', - headers: { - Authorization: 'Bearer runtime-token-123', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - scoreGiven: 7, - scoreMaximum: 10, - }), - }, - ); - - assertEquals(response.status, 202); - assertObjectMatch(await response.json(), { - accepted: true, - scoreProposal: { - scoreGiven: 7, - scoreMaximum: 10, - }, - }); - assertEquals(await repository.getGradePublicationByAttemptId('attempt-123'), null); - assertEquals((await repository.getAttemptById('attempt-123'))?.finalizedAt, null); - const proposalEvents = await repository.listAuditEventsByEventType( - 'runtime.score_proposal.accepted', - ); - - assertEquals(proposalEvents.length, 1); - }); -}); - -Deno.test('POST /runtime/sessions/:id/finalize records a failed grade publish when lti-tool score publish fails', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - await withRuntimeOriginEnv(async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - artifact: { - snapshotRoot: EXAMPLE_SNAPSHOT_ROOT, - manifestPath: `${EXAMPLE_SNAPSHOT_ROOT}/manifest.json`, - entrypointPath: `${EXAMPLE_SNAPSHOT_ROOT}/dist/index.html`, - digest: 'sha256:example-snapshot', - }, - }), - ], - deployments: [buildDeploymentRecord({ binding: buildDeploymentBinding() })], - attempts: [buildAttemptRecord()], - attemptEvents: [ - buildAttemptEventRecord({ - id: 1, - sequence: 1, - event: { - type: 'answer', - questionId: 'q1', - answer: 'resistance to a change in motion', - timestamp: '2026-03-24T02:30:00Z', - }, - }), - ], - runtimeSessions: [ - buildRuntimeSessionRecord({ - ltiToolSessionId: 'lti-tool-session-123', - expiresAt: '2099-03-26T02:45:00Z', - }), - ], - }); - const ltiToolContext = createTestLtiToolContext({ - sessionId: 'lti-tool-session-123', - submitScoreSuccess: false, - submitScoreMessage: 'simulated score failure', - }); - - await withFetchStub( - () => - new Response(JSON.stringify({ error: 'invalid_client' }), { - status: 401, - headers: { 'content-type': 'application/json' }, - }), - async () => { - const response = await createApp({ - getRepository: () => repository, - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - }).request( - 'https://runtime.lantern.example/runtime/sessions/runtime-session-123/finalize', - { - method: 'POST', - headers: { - Authorization: 'Bearer runtime-token-123', - 'content-type': 'application/json', - 'x-real-ip': '203.0.113.80', - }, - body: JSON.stringify({ completionState: 'completed' }), - }, - ); - - assertEquals(response.status, 500); - - const attemptAuditEvents = await repository.listAuditEventsByEventType( - 'attempt.finalized', - ); - const failedGradeAuditEvents = await repository.listAuditEventsByEventType( - 'grade_publish.failed', - ); - const attempt = await repository.getAttemptById('attempt-123'); - - assertEquals(attempt?.status, 'completed'); - assertEquals(attemptAuditEvents.length, 1); - assertEquals(failedGradeAuditEvents.length, 1); - assertEquals(failedGradeAuditEvents[0]?.detail.code, 'score_publish_failed'); - assertObjectMatch(failedGradeAuditEvents[0]?.detail.request ?? {}, { - method: 'POST', - path: '/runtime/sessions/runtime-session-123/finalize', - bodyKeys: ['completionState'], - contentType: 'application/json', - clientIpMasked: '203.0.113.x', - }); - }, - ); - }); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } -}); diff --git a/src/app_runtime_routes.ts b/src/app_runtime_routes.ts index 0a0e0fd..26b77b6 100644 --- a/src/app_runtime_routes.ts +++ b/src/app_runtime_routes.ts @@ -1,3 +1,12 @@ +/** + * Preview/legacy runtime delivery plane. + * + * These routes serve preview and authoring runtime sessions (the + * `runtime_sessions` table via PackageReviewRepository). They are separate from + * the governed LMS launch plane in `src/app_governed_runtime_routes.ts`, which + * serves `governed_runtime_sessions` minted by `lms_kernel`. Keep LMS launches + * off this plane. + */ import type { Context, Hono } from '@hono/hono'; import type { RuntimeSessionRecord } from './lti/types.ts'; import { @@ -5,11 +14,7 @@ import { readRuntimeFileRequest, requireTrimmedString, } from './app_request_support.ts'; -import { - errorMessage, - statusForFinalizePublishError, - statusForRuntimeError, -} from './app_status_support.ts'; +import { errorMessage, statusForRuntimeError } from './app_status_support.ts'; import type { AppServices } from './app_services.ts'; import { recordRuntimeCapabilityAllowed, @@ -39,6 +44,7 @@ import type { PackageVersionRecord } from './package_review/types.ts'; import { readEnv } from './platform/env.ts'; import { buildRequestAuditEnvelope, type RequestAuditEnvelope } from './request_audit.ts'; import { requireRuntimeRequestOrigin } from './runtime_origin.ts'; +import { runRuntimeCodeSubmission } from './python_lab/runtime_submission.ts'; export function registerRuntimeRoutes(app: Hono, services: AppServices): void { app.get('/runtime/sessions/:sessionId', async (context) => { @@ -70,7 +76,7 @@ export function registerRuntimeRoutes(app: Hono, services: AppServices): void { } runtime.setReviewedPackage(reviewedPackage); - requireRuntimePackageVersionCanRun(runtime.session, reviewedPackage); + requireRuntimePackageVersionCanRun(reviewedPackage); runtime.setDeliveryForReviewedPackage(reviewedPackage); const response = context.html( @@ -103,19 +109,17 @@ export function registerRuntimeRoutes(app: Hono, services: AppServices): void { services.runtimeArtifactStore, ); - if (runtime.session.preview !== undefined) { - await runtime.repository.appendPreviewEvidence({ - previewSessionId: runtime.session.preview.previewSessionId, - eventType: 'preview.content_read', - capability: 'read_activity_content', - summary: 'Loaded the app content for this test launch.', - detail: { - attemptId: runtime.session.attemptId, - contentPath: runtime.session.contentPath, - }, - occurredAt: new Date().toISOString(), - }); - } + await runtime.repository.appendPreviewEvidence({ + previewSessionId: runtime.session.preview.previewSessionId, + eventType: 'preview.content_read', + capability: 'read_activity_content', + summary: 'Loaded the app content for this test launch.', + detail: { + attemptId: runtime.session.attemptId, + contentPath: runtime.session.contentPath, + }, + occurredAt: new Date().toISOString(), + }); return context.json(content); }); @@ -268,13 +272,6 @@ export function registerRuntimeRoutes(app: Hono, services: AppServices): void { request: runtime.request, }); - if (result.publishError !== null) { - return context.text( - result.publishError.message, - statusForFinalizePublishError(result.publishError.code), - ); - } - return context.json( { accepted: true, @@ -283,13 +280,59 @@ export function registerRuntimeRoutes(app: Hono, services: AppServices): void { completionState: result.attempt.completionState, scoreGiven: result.score.scoreGiven, scoreMaximum: result.score.scoreMaximum, - gradePublished: result.gradePublication?.status === 'published', + gradePublished: false, }, 202, ); }); }); + app.post('/runtime/sessions/:sessionId/code-runs', async (context) => { + return await withRuntimeRoute(context, services, 'code-runs', 'mutation', async (runtime) => { + authorizeRuntimeBearerToken(context, runtime.session); + const reviewedPackage = await runtime.repository.getPackageVersionById( + runtime.session.packageVersionId, + ); + if (reviewedPackage === null) { + throw new Error(`Package version ${runtime.session.packageVersionId} was not found.`); + } + runtime.setReviewedPackage(reviewedPackage); + runtime.setDeliveryForReviewedPackage(reviewedPackage); + + const payload = await context.req.json(); + runtime.setRequestBody(payload); + const result = await runRuntimeCodeSubmission({ + repository: runtime.repository, + pythonLabRepository: services.pythonLabRepository, + pythonLabArtifactStore: services.pythonLabArtifactStore, + sandboxFactory: services.pythonGraderSandboxFactory, + session: runtime.session, + payload, + getLtiToolContext: services.getLtiToolContext, + createId: () => crypto.randomUUID().replaceAll('-', ''), + }); + + await recordRuntimeCapabilityAllowed({ + repository: runtime.repository, + session: runtime.session, + capability: 'run_code_submission', + route: 'code-runs', + detail: { phase: result.phase, alreadyFinalized: result.alreadyFinalized }, + }); + + return context.json({ + accepted: true, + phase: result.phase, + publicOutcomes: result.publicOutcomes, + hiddenSummary: result.hiddenSummary, + scoreGiven: result.scoreGiven, + scoreMaximum: result.scoreMaximum, + alreadyFinalized: result.alreadyFinalized, + gradePublished: result.gradePublished, + }, 202); + }); + }); + app.get('/runtime/sessions/:sessionId/files/*', async (context) => { const repository = services.getRepository(); const sessionId = context.req.param('sessionId'); @@ -578,20 +621,30 @@ async function requireRunnableRuntimePackageVersion( }); } - requireRuntimePackageVersionCanRun(session, packageVersion); + requireRuntimePackageVersionCanRun(packageVersion); return packageVersion; } -function requireRuntimePackageVersionCanRun( - session: RuntimeSessionRecord, - packageVersion: PackageVersionRecord, -): void { +function requireRuntimePackageVersionCanRun(packageVersion: PackageVersionRecord): void { + if (packageVersion.runtime.kind !== 'browser') { + failRuntimeOutcome({ + type: 'integrity_failure', + code: 'runtime_kind_mismatch', + message: 'Browser preview sessions can run browser packages only.', + status: 409, + detail: { + packageVersionId: packageVersion.id, + runtimeKind: packageVersion.runtime.kind, + }, + }); + } + if (packageVersion.approvalStatus === 'approved') { return; } - if (session.preview !== undefined && packageVersion.approvalStatus === 'pending') { + if (packageVersion.approvalStatus === 'pending') { return; } diff --git a/src/app_runtime_security_headers_test.ts b/src/app_runtime_security_headers_test.ts index 096baa0..d8b78f2 100644 --- a/src/app_runtime_security_headers_test.ts +++ b/src/app_runtime_security_headers_test.ts @@ -1,5 +1,5 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { createApp } from './app.ts'; +import { createTestApp as createApp } from './test_helpers/app.ts'; import { createObjectEnvReader } from './platform/env.ts'; import { createR2RuntimeArtifactStore, @@ -223,6 +223,9 @@ function createStubRuntimeDelivery(input: { contentType: contentTypeForRuntimePath(assetPath), }); }, + invokeReviewedWorker() { + return Promise.reject(new Error('Stub Worker invocation was not configured.')); + }, }; } diff --git a/src/app_runtime_support.ts b/src/app_runtime_support.ts index c513e0f..737dd00 100644 --- a/src/app_runtime_support.ts +++ b/src/app_runtime_support.ts @@ -36,6 +36,16 @@ export async function requireRuntimeSession( }); } + if (session.preview.previewSessionId === '') { + failRuntimeOutcome({ + type: 'integrity_failure', + code: 'preview_session_missing', + message: `Runtime session ${sessionId} is not bound to a preview session.`, + status: 409, + detail: { sessionId }, + }); + } + return session; } @@ -262,10 +272,8 @@ async function recordRuntimeAuditEvent(input: { eventType: input.eventType, actorType: 'system', actorId: null, - deploymentRecordId: input.session.deploymentRecordId, packageVersionId: input.session.packageVersionId, attemptId: input.session.attemptId, - lineItemBindingId: null, status: input.status, summary: input.summary, detail: buildRuntimeDetailRecord({ diff --git a/src/app_runtime_test.ts b/src/app_runtime_test.ts index 285d2a7..d50f9f5 100644 --- a/src/app_runtime_test.ts +++ b/src/app_runtime_test.ts @@ -1,7 +1,7 @@ import { assertEquals, assertExists, assertObjectMatch, assertStringIncludes } from '@std/assert'; import { compactVerify, createLocalJWKSet } from 'jose'; import type { BootstrapPayload } from '../sdk/app-sdk.ts'; -import { createApp } from './app.ts'; +import { createTestApp as createApp } from './test_helpers/app.ts'; import { EXAMPLE_SNAPSHOT_ROOT } from './app_test_support.ts'; import { createObjectEnvReader } from './platform/env.ts'; import { getPublicJwkSet } from './lti/tool_key.ts'; @@ -17,6 +17,7 @@ import { import { buildAttemptRecord, buildPackageVersionRecord, + buildPreviewSessionRecord, createInMemoryPackageReviewRepository, } from './test_helpers/package_review.ts'; import { buildRuntimeSessionRecord, getTestToolPrivateJwkEnvValue } from './test_helpers/lti.ts'; @@ -117,6 +118,7 @@ Deno.test('GET /runtime/sessions/:id serves the reviewed entrypoint with a signe Deno.test('POST /runtime/sessions/:id/attempt-events enforces session auth, capability checks, and append-only event writes', async () => { const repository = createInMemoryPackageReviewRepository({ attempts: [buildAttemptRecord()], + previewSessions: [buildPreviewSessionRecord({ fakeAttemptId: 'attempt-123' })], runtimeSessions: [buildRuntimeSessionRecord({ expiresAt: '2030-03-26T02:45:00Z' })], }); const app = createRuntimeTestApp({ repository }); @@ -152,6 +154,7 @@ Deno.test('POST /runtime/sessions/:id/attempt-events enforces session auth, capa Deno.test('GET /runtime/sessions/:id/content serves reviewed activity content through the scoped runtime bridge', async () => { const response = await createRuntimeTestApp({ repository: createInMemoryPackageReviewRepository({ + previewSessions: [buildPreviewSessionRecord({ fakeAttemptId: 'attempt-123' })], runtimeSessions: [ buildRuntimeSessionRecord({ snapshotRoot: EXAMPLE_SNAPSHOT_ROOT, @@ -427,6 +430,9 @@ function createStubRuntimeDelivery(input: { contentType: contentTypeForRuntimePath(assetPath), }); }, + invokeReviewedWorker() { + return Promise.reject(new Error('Stub Worker invocation was not configured.')); + }, }; } diff --git a/src/app_services.ts b/src/app_services.ts index d6c1a36..b62eb47 100644 --- a/src/app_services.ts +++ b/src/app_services.ts @@ -35,7 +35,6 @@ import { createFileSystemPackageSource } from './package_review/package_source_f import type { PackageSnapshotStore } from './package_review/snapshot_store.ts'; import { getDefaultPackageSnapshotStore } from './package_review/snapshot_store_fs.ts'; import { type PackageReviewRepository } from './package_review/repository.ts'; -import type { OpsRepository } from './ops/repository.ts'; import type { RuntimeArtifactStore } from './runtime/artifact_store.ts'; import { getDefaultRuntimeArtifactStore } from './runtime/artifact_store_fs.ts'; import { createDirectRuntimeDelivery, type RuntimeDelivery } from './runtime/delivery.ts'; @@ -46,6 +45,31 @@ import type { AuthoringDraftRecord, PackageVersionRecord } from './package_revie import { createMemoryLtiStorage, type LanternLtiToolContext } from './lti/tool_provider.ts'; import { createCachedLtiToolContextFactory } from './lti/context_factory.ts'; import type { LTIStorage } from '@longsightgroup/lti-tool'; +import { + createUnavailablePythonLabArtifactStore, + type PythonLabArtifactStore, +} from './python_lab/artifact_store.ts'; +import { + createUnavailablePythonLabRepository, + type PythonLabRepository, +} from './python_lab/repository.ts'; +import { + createUnavailablePythonGraderSandboxFactory, + type PythonGraderSandboxFactory, +} from './python_lab/runner.ts'; +import { type PythonTestVectorGenerator } from './python_lab/service.ts'; +import { + createUnavailablePythonLabHumanVerifier, + type PythonLabHumanVerifier, +} from './python_lab/turnstile.ts'; +import { + createUnavailablePythonLabGenerationScheduler, + type PythonLabGenerationScheduler, +} from './python_lab/workflow.ts'; +import type { LmsKernelRepository } from './lms_kernel/repository.ts'; +import type { GovernedRosterService } from './lms_kernel/service.ts'; +import type { ProtectedMediaService } from './lms_kernel/media.ts'; +import type { LmsKernelAdminService } from './lms_kernel/admin_service.ts'; export interface AppServices { env: EnvReader; @@ -53,6 +77,16 @@ export interface AppServices { runtimeArtifactStore: RuntimeArtifactStore; runtimeDelivery: RuntimeDelivery; evidenceArtifactStore: EvidenceArtifactStore; + pythonLabRepository: PythonLabRepository; + pythonLabArtifactStore: PythonLabArtifactStore; + pythonGraderSandboxFactory: PythonGraderSandboxFactory; + pythonTestVectorGenerator: PythonTestVectorGenerator; + pythonLabHumanVerifier: PythonLabHumanVerifier; + pythonLabGenerationScheduler: PythonLabGenerationScheduler; + getLmsKernelRepository: () => LmsKernelRepository; + governedRosterService: GovernedRosterService; + protectedMediaService: ProtectedMediaService; + lmsKernelAdminService: LmsKernelAdminService; appWriterWorkspaceRunner: AppWriterWorkspaceRunner; appPackagePreviewer: AppPackagePreviewer; appPackageSourceCompiler: AppPackageSourceCompiler; @@ -69,7 +103,6 @@ export interface AppServices { createdAt: string; }) => Promise; getRepository: () => PackageReviewRepository; - getOpsRepository: () => OpsRepository; getLtiToolStorage: () => LTIStorage; getLtiToolContext: (appOrigin?: string) => Promise; loadCanvasJwks: (url: string) => Promise; @@ -97,7 +130,8 @@ export interface AppServices { const LOCAL_REPOSITORY_MESSAGE = 'Lantern persistence is Cloudflare D1-only. Run Lantern through the Cloudflare Worker entrypoint with a DB binding.'; -export function resolveServices(services: Partial): AppServices { +/** Builds explicit fakes for route tests. Production composition lives in app_worker_services.ts. */ +export function resolveTestServices(services: Partial): AppServices { const env = services.env ?? getDefaultEnvReader(); const getRepository = services.getRepository ?? getDefaultRepository; const snapshotStore = services.packageSnapshotStore ?? getDefaultPackageSnapshotStore(); @@ -142,18 +176,61 @@ export function resolveServices(services: Partial): AppServices { runtimeArtifactStore, runtimeDelivery: services.runtimeDelivery ?? createDirectRuntimeDelivery(runtimeArtifactStore), evidenceArtifactStore: services.evidenceArtifactStore ?? getDefaultEvidenceArtifactStore(), + pythonLabRepository: services.pythonLabRepository ?? + createUnavailablePythonLabRepository( + 'Python autograder Lab requires a Cloudflare D1 binding and private artifact storage.', + ), + pythonLabArtifactStore: services.pythonLabArtifactStore ?? + createUnavailablePythonLabArtifactStore( + 'Python autograder Lab requires private R2 artifact storage.', + ), + pythonGraderSandboxFactory: services.pythonGraderSandboxFactory ?? + createUnavailablePythonGraderSandboxFactory( + 'Python autograder Lab requires the PythonGraderSandbox binding.', + ), + pythonTestVectorGenerator: services.pythonTestVectorGenerator ?? { + generate() { + return Promise.reject( + new Error( + 'Python autograder Lab requires a configured Workers AI test-vector generator.', + ), + ); + }, + }, + pythonLabHumanVerifier: services.pythonLabHumanVerifier ?? + createUnavailablePythonLabHumanVerifier( + 'Python autograder Lab requires a configured Turnstile verifier.', + ), + pythonLabGenerationScheduler: services.pythonLabGenerationScheduler ?? + createUnavailablePythonLabGenerationScheduler( + 'Python autograder Lab requires a configured generation Workflow.', + ), + getLmsKernelRepository: services.getLmsKernelRepository ?? getDefaultLmsKernelRepository, + governedRosterService: services.governedRosterService ?? { + listRoster() { + return Promise.resolve({ + ok: false, + error: { + code: 'upstream_unavailable', + traceId: crypto.randomUUID(), + }, + }); + }, + }, + protectedMediaService: services.protectedMediaService ?? { + fetch() { + return Promise.resolve( + new Response('Governed LMS primitives require Cloudflare D1.', { status: 503 }), + ); + }, + }, + lmsKernelAdminService: services.lmsKernelAdminService ?? createUnavailableKernelAdminService(), loadBrowserAutograderDraftReferenceExamples: services.loadBrowserAutograderDraftReferenceExamples ?? loadBrowserAutograderDraftReferenceExamples, materializeDraftPreviewPackageVersion: services.materializeDraftPreviewPackageVersion ?? materializeDraftPreviewPackageVersion, getRepository, - getOpsRepository: services.getOpsRepository ?? - (() => { - const repository = getRepository(); - - return isOpsRepository(repository) ? repository : getDefaultOpsRepository(); - }), getLtiToolStorage: services.getLtiToolStorage ?? (() => ltiStorage), getLtiToolContext: services.getLtiToolContext ?? getLtiToolContext, loadCanvasJwks: services.loadCanvasJwks ?? defaultLoadCanvasJwks, @@ -170,6 +247,30 @@ export function resolveServices(services: Partial): AppServices { }; } +function createUnavailableKernelAdminService(): LmsKernelAdminService { + const unavailable = () => + Promise.resolve({ + ok: false as const, + error: { + code: 'invalid_request' as const, + message: 'Governed LMS administration requires Cloudflare D1.', + }, + }); + return { + createInstitution: unavailable, + createInvite: unavailable, + acceptInvite: unavailable, + createConnection: unavailable, + installApp: unavailable, + grantCapability: unavailable, + activateInstallation: unavailable, + revokeGrant: unavailable, + createSyntheticLaunch: unavailable, + disableInstallation: unavailable, + disableConnection: unavailable, + }; +} + function resolveReferencePackageSource(appId: string) { return createFileSystemPackageSource(getReferencePackageSourceRoot(appId)); } @@ -178,7 +279,7 @@ function getDefaultRepository(): PackageReviewRepository { throw new Error(LOCAL_REPOSITORY_MESSAGE); } -function getDefaultOpsRepository(): OpsRepository { +function getDefaultLmsKernelRepository(): LmsKernelRepository { throw new Error(LOCAL_REPOSITORY_MESSAGE); } @@ -191,15 +292,3 @@ async function defaultLoadCanvasJwks(url: string): Promise { return await response.json(); } - -function isOpsRepository( - repository: PackageReviewRepository, -): repository is PackageReviewRepository & OpsRepository { - return ( - typeof (repository as Partial).listControlPlaneDeployments === 'function' && - typeof (repository as Partial).getLatestBrokerVerificationStatus === - 'function' && - typeof (repository as Partial).recordBrokerVerificationRun === 'function' && - typeof (repository as Partial).getRetryableGradePublicationLookup === 'function' - ); -} diff --git a/src/app_status_support.ts b/src/app_status_support.ts index 172d047..e7e63f4 100644 --- a/src/app_status_support.ts +++ b/src/app_status_support.ts @@ -1,19 +1,5 @@ import { isRuntimeBrokerDenialError, isRuntimeOutcomeError } from './runtime/gateway_errors.ts'; -export function deepLinkingReturnErrorMessage(error: unknown): string { - const message = errorMessage(error); - - if ( - message.includes('APP_ORIGIN') || - message.includes('LTI_TOOL_PRIVATE_JWK') || - message.includes('LTI_STATE_SECRET') - ) { - return 'Lantern could not prepare the signed LMS return. Contact an operator and try again.'; - } - - return message; -} - export function statusForError(error: unknown): 409 | 500 { if (!(error instanceof Error)) { return 500; @@ -34,19 +20,9 @@ export function statusForError(error: unknown): 409 | 500 { error.message.includes('not found') || error.message.includes('required') || error.message.includes('Choose ') || - error.message.includes('belongs to another deployment') || error.message.includes('must send client_id') || - error.message.includes('duplicate LMS bindings') || - error.message.includes('Choose one supported Canvas environment') || - error.message.includes('Choose one supported LMS deployment') || - error.message.includes('active LTI profile') || - error.message.includes('dynamic registration') || - error.message.includes('callback route') || error.message.includes('already been used') || error.message.includes('has expired') || - error.message.includes('Canvas deployment') || - error.message.includes('Canvas sent deployment') || - error.message.includes('Canvas issuer') || error.message.includes('Login state') || error.message.includes('Launch ') || error.message.includes('Preview ') || @@ -58,45 +34,6 @@ export function statusForError(error: unknown): 409 | 500 { return 500; } -export function statusForDeepLinkingSessionError(error: unknown): 404 | 409 | 500 { - if (!(error instanceof Error)) { - return 500; - } - - if (error.message.includes('was not found')) { - return 404; - } - - if ( - error.message.includes('Deep Linking session') || - error.message.includes('Choose one reviewed resource') || - error.message.includes('selection') - ) { - return 409; - } - - return 500; -} - -export function statusForVerificationError(error: unknown): 400 | 500 { - if (!(error instanceof Error)) { - return 500; - } - - if ( - error.message.includes('required') || - error.message.includes('Only ') || - error.message.includes('Internal ') || - error.message.includes('Official ') || - error.message.includes('Choose ') || - error.message.includes('Verification ') - ) { - return 400; - } - - return 500; -} - export function statusForRuntimeError(error: unknown): 400 | 404 | 409 | 500 { if (isRuntimeBrokerDenialError(error)) { return error.status; @@ -133,128 +70,6 @@ export function statusForRuntimeError(error: unknown): 400 | 404 | 409 | 500 { return 500; } -export function statusForPlacementAuditError(error: unknown): 400 | 404 | 500 { - if (!(error instanceof Error)) { - return 500; - } - - if (error.message.includes('was not found')) { - return 404; - } - - if (error.message.includes('required')) { - return 400; - } - - return 500; -} - -export function statusForFinalizePublishError(code: string): 409 | 500 { - if ( - code === 'missing_binding' || - code === 'missing_ags_context' || - code === 'missing_ags_scope' - ) { - return 409; - } - - return 500; -} - -export function statusForNrpsError(error: unknown): 409 | 500 { - if (!(error instanceof Error)) { - return 500; - } - - if ( - error.message.includes('required') || - error.message.includes('Launch ') || - error.message.includes('Canvas deployment binding') || - error.message.includes('Import a package version') || - error.message.includes('roster access') || - error.message.includes('NRPS') - ) { - return 409; - } - - return 500; -} - -export function statusForRetryPublishError(error: unknown): 409 | 500 { - if (!(error instanceof Error)) { - return 500; - } - - if ( - error.message.includes('required') || - error.message.includes('Save the Canvas binding') || - error.message.includes('could not find a failed grade publication') || - error.message.includes('saved runtime session') || - error.message.includes('AGS service context') || - error.message.includes('saved Canvas binding') - ) { - return 409; - } - - return 500; -} - -export function normalizeLaunchRejectedCode(error: unknown): string { - const message = errorMessage(error); - - if (message.includes('signature or issuer validation failed')) { - return 'signature_validation_failed'; - } - - if (message.includes('did not match the saved login state')) { - return 'deployment_mismatch'; - } - - if (message.includes('has expired')) { - return 'login_state_expired'; - } - - if (message.includes('has already been used')) { - return 'login_state_used'; - } - - if (message.includes('was not found')) { - return 'launch_context_missing'; - } - - if (message.includes('not approved')) { - return 'package_not_approved'; - } - - return 'launch_validation_failed'; -} - -export function normalizeRetryFailureCode(error: unknown): string { - const message = errorMessage(error); - - if (message.includes('could not find a failed grade publication')) { - return 'retry_not_available'; - } - - if (message.includes('saved runtime session')) { - return 'missing_runtime_session'; - } - - if (message.includes('AGS service context')) { - return 'missing_ags_context'; - } - - if (message.includes('saved Canvas binding')) { - return 'missing_binding'; - } - - if (message.includes('token')) { - return 'token_request_failed'; - } - - return 'retry_failed'; -} - export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : 'Lantern hit an unexpected error.'; } diff --git a/src/app_test_support.ts b/src/app_test_support.ts index 96376e2..baf2486 100644 --- a/src/app_test_support.ts +++ b/src/app_test_support.ts @@ -1,10 +1,4 @@ -import { createLocalJWKSet, jwtVerify } from 'jose'; -import { getPublicJwkSet } from './lti/tool_key.ts'; -import { - buildDeploymentBinding, - getTestToolPrivateJwkEnvValue, - TEST_LTI_STATE_SECRET, -} from './test_helpers/lti.ts'; +import { getTestToolPrivateJwkEnvValue, TEST_LTI_STATE_SECRET } from './test_helpers/lti.ts'; export const REFERENCE_APP_SNAPSHOT_ROOTS = { 'chapter-4-asteroids': 'examples/apps/chapter-4-asteroids', @@ -85,27 +79,6 @@ export function extractHiddenInputValue(html: string, name: string): string { return match[1]; } -export async function verifyDeepLinkingResponseJwt(jwt: string) { - const keySet = createLocalJWKSet( - await getPublicJwkSet({ - get(name: string) { - return name === 'LTI_TOOL_PRIVATE_JWK' - ? getTestToolPrivateJwkEnvValue() - : name === 'LTI_STATE_SECRET' - ? TEST_LTI_STATE_SECRET - : undefined; - }, - }), - ); - const binding = buildDeploymentBinding(); - - return await jwtVerify(jwt, keySet, { - issuer: binding.clientId, - audience: binding.issuer, - currentDate: new Date('2026-03-24T18:31:00Z'), - }); -} - export async function withFetchStub( handler: (input: RequestInfo | URL, init?: RequestInit) => Response | Promise, run: () => Promise, diff --git a/src/app_worker_services.ts b/src/app_worker_services.ts index 42cf506..781e02b 100644 --- a/src/app_worker_services.ts +++ b/src/app_worker_services.ts @@ -29,8 +29,6 @@ import { createUnavailableBrowserAutograderDraftGenerator } from './authoring/br import type { AppServices } from './app_services.ts'; import { createBetterAdminAuth } from './admin_auth/service.ts'; import { type D1Database, isD1Database } from './db/d1.ts'; -import type { OpsRepository } from './ops/repository.ts'; -import { createD1OpsRepository } from './ops/repository_d1.ts'; import type { EnvReader } from './platform/env.ts'; import { getReferencePackageBucketSourceRoot, @@ -57,14 +55,42 @@ import { type EvidenceArtifactStore, } from './runtime/evidence_artifact_store.ts'; import type { AuthoringDraftRecord, PackageVersionRecord } from './package_review/types.ts'; -import { createD1LtiStorage } from './lti/tool_provider.ts'; +import { createD1LtiStorage, syncLtiToolConnection } from './lti/tool_provider.ts'; import { createCachedLtiToolContextFactory } from './lti/context_factory.ts'; +import { + createR2PythonLabArtifactStore, + type PythonLabArtifactBucket, +} from './python_lab/artifact_store.ts'; +import { createD1PythonLabRepository } from './python_lab/repository_d1.ts'; +import { + createUnavailablePythonGraderSandboxFactory, + type PythonGraderSandboxFactory, +} from './python_lab/runner.ts'; +import { createCloudflarePythonTestVectorGenerator } from './python_lab/generator.ts'; +import { + createTurnstilePythonLabHumanVerifier, + createUnavailablePythonLabHumanVerifier, +} from './python_lab/turnstile.ts'; +import { + createCloudflarePythonLabGenerationScheduler, + createUnavailablePythonLabGenerationScheduler, + isPythonLabGenerationWorkflowBinding, +} from './python_lab/workflow.ts'; +import { createD1LmsKernelRepository } from './lms_kernel/repository_d1.ts'; +import { + createNrpsRosterConnector, + createSyntheticRosterConnector, +} from './lms_kernel/connector.ts'; +import { createGovernedRosterService } from './lms_kernel/service.ts'; +import { createProtectedMediaService } from './lms_kernel/media.ts'; +import { createLmsKernelAdminService } from './lms_kernel/admin_service.ts'; +import { normalizePublicOrigin } from './public_origin.ts'; const WORKER_REPOSITORY_MESSAGE = 'Cloudflare Workers persistence requires a D1 binding named DB.'; const WORKER_RUNTIME_ARTIFACT_MESSAGE = 'Cloudflare Workers runtime artifact access requires an R2 binding named PACKAGE_ARTIFACTS. Bind the reviewed package snapshot bucket before serving runtime HTML, content, or files from Workers.'; const WORKER_EVIDENCE_ARTIFACT_MESSAGE = - 'Cloudflare Workers evidence artifact storage requires an R2 binding named PACKAGE_ARTIFACTS with write access. Bind the reviewed package artifact bucket before accepting anonymous evidence uploads on Workers.'; + 'Cloudflare Workers evidence artifact storage requires an R2 binding named PACKAGE_ARTIFACTS with write access.'; const WORKER_REFERENCE_PACKAGE_MESSAGE = 'Curated reference packages must be stored in PACKAGE_ARTIFACTS under reference-packages//source before Workers can import or inspect them. Run `deno task reference:sync --bucket=` during Worker bootstrap or release.'; const WORKER_RUNTIME_DELIVERY_MESSAGE = @@ -81,6 +107,8 @@ const WORKER_APP_PREVIEWER_MESSAGE = 'Cloudflare Workers generated app preview requires a platform previewer service binding named APP_WRITER_PREVIEWER.'; const WORKER_APP_WRITER_AGENT_MESSAGE = 'Cloudflare Workers app writer live sessions require a Durable Object binding named APP_WRITER_AGENT.'; +const WORKER_PYTHON_LAB_WORKFLOW_MESSAGE = + 'Cloudflare Workers Python autograder Lab generation requires a Workflow binding named PYTHON_LAB_GENERATION_WORKFLOW.'; export interface WorkerBindings extends Record { DB?: D1Database; @@ -91,10 +119,25 @@ export interface WorkerBindings extends Record { APP_WRITER_AGENT?: unknown; APP_WRITER_SOURCE_COMPILER?: unknown; APP_WRITER_PREVIEWER?: unknown; + PYTHON_GRADER_SANDBOX?: unknown; + PYTHON_LAB_GENERATION_WORKFLOW?: unknown; } -export function resolveWorkerServices(bindings: WorkerBindings, env: EnvReader): AppServices { +export type PythonGraderSandboxFactoryProvider = ( + namespace: object, +) => PythonGraderSandboxFactory; + +export function resolveWorkerServices( + bindings: WorkerBindings, + env: EnvReader, + createLmsBinding?: (identity: { + readonly appId: string; + readonly packageVersionId: number; + }) => unknown, + createPythonSandboxFactory?: PythonGraderSandboxFactoryProvider, +): AppServices { const repositories = resolveWorkerRepositories(bindings); + const lmsKernelRepository = resolveWorkerLmsKernelRepository(bindings); const snapshotStore = resolveWorkerSnapshotStore(bindings); const ltiStorage = resolveWorkerLtiStorage(bindings); const getLtiToolContext = createCachedLtiToolContextFactory({ env, storage: ltiStorage }); @@ -124,13 +167,38 @@ export function resolveWorkerServices(bindings: WorkerBindings, env: EnvReader): browserAutograderDraftGenerator: createUnavailableBrowserAutograderDraftGenerator(), adminAuth: resolveWorkerAdminAuth(bindings, env), runtimeArtifactStore: snapshotStore, - runtimeDelivery: resolveWorkerRuntimeDelivery(bindings, snapshotStore), + runtimeDelivery: resolveWorkerRuntimeDelivery(bindings, snapshotStore, createLmsBinding), evidenceArtifactStore: resolveWorkerEvidenceArtifactStore(bindings), + pythonLabRepository: resolveWorkerPythonLabRepository(bindings), + pythonLabArtifactStore: resolveWorkerPythonLabArtifactStore(bindings), + pythonGraderSandboxFactory: resolveWorkerPythonGraderSandboxFactory( + bindings, + createPythonSandboxFactory, + ), + pythonTestVectorGenerator: resolveWorkerPythonTestVectorGenerator(bindings, env), + pythonLabHumanVerifier: resolveWorkerPythonLabHumanVerifier(env), + pythonLabGenerationScheduler: resolveWorkerPythonLabGenerationScheduler(bindings), + getLmsKernelRepository: () => lmsKernelRepository, + governedRosterService: createGovernedRosterService({ + repository: lmsKernelRepository, + nrpsConnector: createNrpsRosterConnector(getLtiToolContext), + syntheticConnector: createSyntheticRosterConnector(), + env, + publicOrigin: resolveWorkerPublicOrigin(env), + }), + protectedMediaService: createProtectedMediaService({ + repository: lmsKernelRepository, + }), + lmsKernelAdminService: createLmsKernelAdminService({ + repository: lmsKernelRepository, + packageRepository: repositories.repository, + syncLtiToolRegistration: (connection) => + syncLtiToolConnection({ storage: ltiStorage, connection }), + }), loadBrowserAutograderDraftReferenceExamples: createUnsupportedBrowserAutograderDraftReferenceLoader(), materializeDraftPreviewPackageVersion: createUnsupportedDraftPreviewMaterializer(), getRepository: () => repositories.repository, - getOpsRepository: () => repositories.opsRepository, getLtiToolStorage: () => ltiStorage, getLtiToolContext, loadCanvasJwks: defaultLoadCanvasJwks, @@ -145,6 +213,110 @@ export function resolveWorkerServices(bindings: WorkerBindings, env: EnvReader): }; } +function resolveWorkerLmsKernelRepository(bindings: WorkerBindings) { + if (!isD1Database(bindings.DB)) { + throw new Error(WORKER_REPOSITORY_MESSAGE); + } + return createD1LmsKernelRepository(bindings.DB); +} + +function resolveWorkerPublicOrigin(env: EnvReader): string { + const value = env.get('APP_ORIGIN')?.trim(); + if (!value) { + throw new Error('Governed LMS primitives require APP_ORIGIN.'); + } + return normalizePublicOrigin(value); +} + +function resolveWorkerPythonLabRepository(bindings: WorkerBindings) { + if (!isD1Database(bindings.DB)) { + throw new Error(WORKER_REPOSITORY_MESSAGE); + } + + return createD1PythonLabRepository(bindings.DB); +} + +function resolveWorkerPythonLabArtifactStore(bindings: WorkerBindings) { + const bucket = bindings.PACKAGE_ARTIFACTS; + + if (!isPythonLabArtifactBucket(bucket)) { + throw new Error(WORKER_RUNTIME_ARTIFACT_MESSAGE); + } + + return createR2PythonLabArtifactStore(bucket); +} + +function isPythonLabArtifactBucket(value: unknown): value is PythonLabArtifactBucket { + return ( + typeof value === 'object' && value !== null && + typeof (value as Partial).get === 'function' && + typeof (value as Partial).put === 'function' && + typeof (value as Partial).delete === 'function' + ); +} + +function resolveWorkerPythonGraderSandboxFactory( + bindings: WorkerBindings, + createFactory: PythonGraderSandboxFactoryProvider | undefined, +) { + if ( + isPythonGraderSandboxNamespace(bindings.PYTHON_GRADER_SANDBOX) && createFactory !== undefined + ) { + return createFactory(bindings.PYTHON_GRADER_SANDBOX); + } + + return createUnavailablePythonGraderSandboxFactory( + 'Cloudflare Workers Python grading requires a Durable Object binding named PYTHON_GRADER_SANDBOX.', + ); +} + +function resolveWorkerPythonTestVectorGenerator(bindings: WorkerBindings, env: EnvReader) { + const model = env.get('APP_WRITER_MODEL')?.trim(); + + if (!isCloudflareAiBinding(bindings.AI) || !model) { + return { + generate() { + return Promise.reject( + new Error('Python autograder Lab requires AI and APP_WRITER_MODEL bindings.'), + ); + }, + }; + } + + return createCloudflarePythonTestVectorGenerator({ ai: bindings.AI, model }); +} + +function resolveWorkerPythonLabHumanVerifier(env: EnvReader) { + const secret = env.get('AUTOGRADER_TURNSTILE_SECRET')?.trim(); + + return secret + ? createTurnstilePythonLabHumanVerifier(secret) + : createUnavailablePythonLabHumanVerifier( + 'Python autograder Lab requires AUTOGRADER_TURNSTILE_SECRET.', + ); +} + +function resolveWorkerPythonLabGenerationScheduler(bindings: WorkerBindings) { + return isPythonLabGenerationWorkflowBinding(bindings.PYTHON_LAB_GENERATION_WORKFLOW) + ? createCloudflarePythonLabGenerationScheduler(bindings.PYTHON_LAB_GENERATION_WORKFLOW) + : createUnavailablePythonLabGenerationScheduler(WORKER_PYTHON_LAB_WORKFLOW_MESSAGE); +} + +function isCloudflareAiBinding( + value: unknown, +): value is import('./app_writer/agent_types.ts').CloudflareAiBinding { + return ( + typeof value === 'object' && value !== null && + typeof (value as { run?: unknown }).run === 'function' + ); +} + +function isPythonGraderSandboxNamespace( + value: unknown, +): value is object { + return typeof value === 'object' && value !== null; +} + function resolveWorkerAdminAuth(bindings: WorkerBindings, env: EnvReader) { if (isD1Database(bindings.DB)) { return createBetterAdminAuth({ db: bindings.DB, env }); @@ -217,6 +389,10 @@ function resolveWorkerAppWriterWorkspaceRunner(bindings: WorkerBindings) { function resolveWorkerRuntimeDelivery( bindings: WorkerBindings, snapshotStore: PackageSnapshotStore, + createLmsBinding?: (identity: { + readonly appId: string; + readonly packageVersionId: number; + }) => unknown, ) { const loader = bindings.LOADER; @@ -224,6 +400,7 @@ function resolveWorkerRuntimeDelivery( return createDynamicWorkerRuntimeDelivery({ loader, snapshotStore, + ...(createLmsBinding === undefined ? {} : { createLmsBinding }), }); } @@ -232,14 +409,12 @@ function resolveWorkerRuntimeDelivery( function resolveWorkerRepositories(bindings: WorkerBindings): { repository: PackageReviewRepository; - opsRepository: OpsRepository; } { if (isD1Database(bindings.DB)) { const repository = createD1PackageReviewRepository(bindings.DB); return { repository, - opsRepository: createD1OpsRepository(bindings.DB, repository), }; } @@ -258,11 +433,9 @@ function resolveWorkerSnapshotStore(bindings: WorkerBindings): PackageSnapshotSt function resolveWorkerEvidenceArtifactStore(bindings: WorkerBindings): EvidenceArtifactStore { const bucket = bindings.PACKAGE_ARTIFACTS; - if (isRuntimeArtifactBucket(bucket) && typeof bucket.put === 'function') { return createR2EvidenceArtifactStore(bucket); } - return createUnsupportedEvidenceArtifactStore(WORKER_EVIDENCE_ARTIFACT_MESSAGE); } @@ -337,21 +510,17 @@ function createR2EvidenceArtifactStore(bucket: RuntimeArtifactBucket): EvidenceA return { async writeBytes(storageKey, bytes) { const safeStorageKey = assertEvidenceArtifactStorageKey(storageKey); - if (typeof bucket.put !== 'function') { throw new TypeError(WORKER_EVIDENCE_ARTIFACT_MESSAGE); } - await bucket.put(safeStorageKey, bytes); }, async readBytes(storageKey) { const safeStorageKey = assertEvidenceArtifactStorageKey(storageKey); const object = await bucket.get(safeStorageKey); - if (object === null) { throw new Error(`Evidence artifact ${safeStorageKey} was not found.`); } - return new Uint8Array(await object.arrayBuffer()); }, }; diff --git a/src/app_worker_services_test.ts b/src/app_worker_services_test.ts index 1f525ff..a12fb6e 100644 --- a/src/app_worker_services_test.ts +++ b/src/app_worker_services_test.ts @@ -8,6 +8,7 @@ import { getReferencePackageBucketSourceRoot } from './package_review/intake.ts' import { createMemoryPackageSource } from './package_review/package_source.ts'; import { verifyReviewedRuntimeContractSignature } from './package_review/runtime_contract.ts'; import type { RuntimeArtifactBucket } from './runtime/artifact_store.ts'; +import type { PythonLabArtifactBucket } from './python_lab/artifact_store.ts'; import { getTestToolPrivateJwkEnvValue } from './test_helpers/lti.ts'; import { buildPackageVersionRecord, @@ -18,7 +19,7 @@ const DEMO_BUCKET_SOURCE_ROOT = getReferencePackageBucketSourceRoot('chapter-4-a const DEMO_SNAPSHOT_ROOT = 'var/packages/chapter-4-asteroids/0.1.0'; const DEMO_REFERENCE_SOURCE_FILES = { 'manifest.json': `{ - "schema_version": "1", + "schema_version": "2", "app_id": "chapter-4-asteroids", "version": "0.1.0", "title": "Chapter 4 Asteroids", @@ -27,7 +28,7 @@ const DEMO_REFERENCE_SOURCE_FILES = { "type": "user", "id": "instructor_123" }, - "entrypoint": "/dist/index.html", + "runtime": { "kind": "browser", "entrypoint": "/dist/index.html" }, "roles": ["learner", "instructor"], "install_scope": "course", "capabilities": [ @@ -64,7 +65,7 @@ const DEMO_REFERENCE_SOURCE_FILES = { } as const; const QUICK_STUDY_SOURCE_FILES = { 'manifest.json': `{ - "schema_version": "1", + "schema_version": "2", "app_id": "quick-study", "version": "0.1.0", "title": "Quick Study", @@ -73,7 +74,7 @@ const QUICK_STUDY_SOURCE_FILES = { "type": "user", "id": "instructor_123" }, - "entrypoint": "/dist/index.html", + "runtime": { "kind": "browser", "entrypoint": "/dist/index.html" }, "roles": ["learner", "instructor"], "install_scope": "course", "capabilities": [ @@ -255,10 +256,9 @@ Deno.test('worker services expose Dynamic Worker runtime delivery only when LOAD Deno.test('worker services route D1 persistence through ported repository slices', async () => { const env = createWorkerTestEnv(); - const services = resolveWorkerServices({ DB: createStubD1Database([]) }, env); + const services = resolveWorkerServices(withRequiredDb({}), env); assertEquals(await services.getRepository().listPackageVersions(), []); - assertEquals(await services.getOpsRepository().listControlPlaneDeployments(), []); }); Deno.test('worker services schedule app generation through APP_GENERATION_WORKFLOW when bound', async () => { @@ -623,7 +623,7 @@ function createSeededArtifactBucket( function createInMemoryArtifactBucket( files: Record, -): RuntimeArtifactBucket { +): RuntimeArtifactBucket & PythonLabArtifactBucket { const storedFiles = new Map( Object.entries(files).map(([key, value]) => [key, toUint8Array(value)]), ); @@ -647,6 +647,10 @@ function createInMemoryArtifactBucket( return Promise.resolve(); }, + delete(key) { + storedFiles.delete(key); + return Promise.resolve(); + }, list(options) { return Promise.resolve({ objects: [...storedFiles.keys()] @@ -698,10 +702,13 @@ function createStubD1Database(results: Array>): D1Databa function withRequiredDb>( bindings: T, -): T & { DB: D1Database } { +): T & { DB: D1Database; PACKAGE_ARTIFACTS: RuntimeArtifactBucket & PythonLabArtifactBucket } { return { ...bindings, DB: createStubD1Database([]), + PACKAGE_ARTIFACTS: 'PACKAGE_ARTIFACTS' in bindings + ? bindings.PACKAGE_ARTIFACTS as RuntimeArtifactBucket & PythonLabArtifactBucket + : createInMemoryArtifactBucket({}), }; } diff --git a/src/app_writer/background_execution.ts b/src/app_writer/background_execution.ts index 6b9623b..d0030ac 100644 --- a/src/app_writer/background_execution.ts +++ b/src/app_writer/background_execution.ts @@ -12,10 +12,8 @@ export async function recordAppWriterWorkflowQueuedEvent(input: { eventType: 'app_generation.generating', actorType: 'user', actorId: input.run.ownerId, - deploymentRecordId: null, packageVersionId: input.run.packageVersionId, attemptId: null, - lineItemBindingId: null, status: 'accepted', summary: input.summary, detail: { diff --git a/src/app_writer/evaluation_runner_test.ts b/src/app_writer/evaluation_runner_test.ts index daad10c..271f0e3 100644 --- a/src/app_writer/evaluation_runner_test.ts +++ b/src/app_writer/evaluation_runner_test.ts @@ -150,12 +150,12 @@ function buildValidBrowserAutograderFiles(): AppPackageGenerationResult['files'] { path: 'manifest.json', contents: JSON.stringify({ - schema_version: '1', + schema_version: '2', app_id: 'web-check', version: '0.1.0', title: 'Web Check', owner: { type: 'user', id: 'evaluation' }, - entrypoint: '/dist/index.html', + runtime: { kind: 'browser', entrypoint: '/dist/index.html' }, roles: ['learner', 'instructor'], capabilities: ['read_activity_content', 'submit_evidence_artifact', 'finalize_attempt'], grading: { mode: 'browser', max_score: 100 }, diff --git a/src/app_writer/revision_authoring_prompt.ts b/src/app_writer/revision_authoring_prompt.ts index 3e42675..425889c 100644 --- a/src/app_writer/revision_authoring_prompt.ts +++ b/src/app_writer/revision_authoring_prompt.ts @@ -11,7 +11,7 @@ export function buildRevisionAuthoringPrompt(input: { targetVersion: input.targetVersion, title: packageVersion.title, description: packageVersion.description, - entrypoint: packageVersion.entrypoint, + entrypoint: packageVersion.runtime.entrypoint, roles: packageVersion.roles, installScope: packageVersion.installScope, grading: packageVersion.grading, diff --git a/src/app_writer/service_audit.ts b/src/app_writer/service_audit.ts index d197d2f..28a7942 100644 --- a/src/app_writer/service_audit.ts +++ b/src/app_writer/service_audit.ts @@ -16,10 +16,8 @@ export async function recordGenerationActivity(input: { eventType: input.eventType, actorType: 'user', actorId: input.run.ownerId, - deploymentRecordId: null, packageVersionId: input.packageVersionId ?? input.run.packageVersionId, attemptId: null, - lineItemBindingId: null, status: input.status, summary: input.summary, detail: { diff --git a/src/app_writer/starter_workspace.ts b/src/app_writer/starter_workspace.ts index 1671d8f..2c364ed 100644 --- a/src/app_writer/starter_workspace.ts +++ b/src/app_writer/starter_workspace.ts @@ -336,7 +336,7 @@ function buildSimpleActivityStarterFiles(): AppWriterWorkspaceFile[] { { path: 'manifest.json', contents: formatJson({ - schema_version: '1', + schema_version: '2', app_id: 'starter-simple-activity', version: '0.1.0', title: 'Starter Simple Activity', @@ -344,7 +344,7 @@ function buildSimpleActivityStarterFiles(): AppWriterWorkspaceFile[] { type: 'user', id: 'instructor', }, - entrypoint: '/dist/index.html', + runtime: { kind: 'browser', entrypoint: '/dist/index.html' }, roles: ['learner', 'instructor'], install_scope: 'course', capabilities: ['read_activity_content'], @@ -422,7 +422,7 @@ function buildBrowserAutograderStarterFiles(): AppWriterWorkspaceFile[] { { path: 'manifest.json', contents: formatJson({ - schema_version: '1', + schema_version: '2', app_id: 'starter-browser-autograder', version: '0.1.0', title: 'Starter Browser Autograder', @@ -430,7 +430,7 @@ function buildBrowserAutograderStarterFiles(): AppWriterWorkspaceFile[] { type: 'user', id: 'instructor', }, - entrypoint: '/dist/index.html', + runtime: { kind: 'browser', entrypoint: '/dist/index.html' }, roles: ['learner', 'instructor'], install_scope: 'assignment', capabilities: ['read_activity_content', 'submit_evidence_artifact', 'finalize_attempt'], diff --git a/src/audit/repository_test.ts b/src/audit/repository_test.ts deleted file mode 100644 index cccc3e7..0000000 --- a/src/audit/repository_test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { assertEquals } from '@std/assert'; -import { - buildAttemptRecord, - buildAuditEventRecord, - buildDeploymentRecord, - buildLineItemBindingRecord, - buildPackageVersionRecord, -} from '../test_helpers/package_review.ts'; - -Deno.test.ignore( - 'audit repository records append-only events for approval decisions, deployment changes, launches, attempt submissions, and grade publishes', - async () => { - const modulePath = `./${'repository.ts'}`; - const auditRepositoryModule = await import(modulePath); - const repository = auditRepositoryModule.createAuditRepository?.({ - database: 'test-double', - }); - - await repository?.record(buildAuditEventRecord()); - - const history = await repository?.listByAttemptId?.('attempt-123'); - assertEquals(Array.isArray(history), true); - }, -); - -Deno.test.ignore( - 'audit repository preserves structured references without mutating prior rows', - async () => { - const modulePath = `./${'repository.ts'}`; - const auditRepositoryModule = await import(modulePath); - const repository = auditRepositoryModule.createAuditRepository?.({ - database: 'test-double', - }); - - await repository?.record( - buildAuditEventRecord({ - eventType: 'grade.publish.succeeded', - detail: { - deployment: buildDeploymentRecord().slug, - packageVersion: buildPackageVersionRecord().version, - attempt: buildAttemptRecord().attemptId, - lineItem: buildLineItemBindingRecord().lineItemUrl, - }, - }), - ); - - const history = await repository?.listByEventType?.('grade.publish.succeeded'); - assertEquals(Array.isArray(history), true); - }, -); diff --git a/src/authoring/draft_snapshot.ts b/src/authoring/draft_snapshot.ts index 452eabe..2e55684 100644 --- a/src/authoring/draft_snapshot.ts +++ b/src/authoring/draft_snapshot.ts @@ -31,7 +31,7 @@ export async function materializeDraftPreviewPackageVersion(input: { manifestPath: joinSnapshotPath(snapshotRoot, 'manifest.json', DRAFT_SNAPSHOT_OUTSIDE_MESSAGE), entrypointPath: joinSnapshotPath( snapshotRoot, - trimLeadingSlash(input.packageVersion.entrypoint), + trimLeadingSlash(input.packageVersion.runtime.entrypoint), DRAFT_SNAPSHOT_OUTSIDE_MESSAGE, ), digest: `sha256:authoring-draft-${input.draft.draftId}-${ diff --git a/src/authoring/draft_snapshot_test.ts b/src/authoring/draft_snapshot_test.ts index 7051d80..c9d8fa1 100644 --- a/src/authoring/draft_snapshot_test.ts +++ b/src/authoring/draft_snapshot_test.ts @@ -24,7 +24,6 @@ Deno.test('materializeDraftPreviewPackageVersion overlays saved draft files into version: '0.1.0', title: 'Template App', approvalStatus: 'approved', - entrypoint: '/dist/index.html', manifestJson: { app_id: 'template-app', version: '0.1.0', diff --git a/src/authoring/local_app.ts b/src/authoring/local_app.ts index 6ece353..c3aca39 100644 --- a/src/authoring/local_app.ts +++ b/src/authoring/local_app.ts @@ -125,14 +125,15 @@ export async function preflightLocalAppPackageSource( const entrypointHtml = await captureDiagnostic( () => - loadTextFileFromSource(source, manifest.entrypoint, { + loadTextFileFromSource(source, manifest.runtime.entrypoint, { code: 'entrypoint_unreadable', severity: 'error', field: '/entrypoint', - file: manifest.entrypoint, - message: `Entrypoint HTML ${manifest.entrypoint} could not be read from the package.`, + file: manifest.runtime.entrypoint, + message: + `Runtime entrypoint ${manifest.runtime.entrypoint} could not be read from the package.`, fix: - `Add ${manifest.entrypoint} to the reviewed package and keep /entrypoint pointed at that HTML file.`, + `Add ${manifest.runtime.entrypoint} to the reviewed package and keep /runtime/entrypoint pointed at that file.`, }), diagnostics, ); diff --git a/src/authoring/local_app_test.ts b/src/authoring/local_app_test.ts index 5698797..93e9e8d 100644 --- a/src/authoring/local_app_test.ts +++ b/src/authoring/local_app_test.ts @@ -221,7 +221,7 @@ function buildPackageFiles( function buildManifest(): Record { return { - schema_version: '1', + schema_version: '2', app_id: 'uploaded-quiz', version: '0.1.0', title: 'Uploaded Quiz', @@ -230,7 +230,7 @@ function buildManifest(): Record { type: 'user', id: 'instructor_123', }, - entrypoint: '/dist/index.html', + runtime: { kind: 'browser', entrypoint: '/dist/index.html' }, roles: ['learner', 'instructor'], install_scope: 'course', capabilities: [ diff --git a/src/authoring/local_dev.ts b/src/authoring/local_dev.ts index 7166667..ac47d0d 100644 --- a/src/authoring/local_dev.ts +++ b/src/authoring/local_dev.ts @@ -181,7 +181,7 @@ export function serializeLocalDevStatus(input: { appId: input.state.appPackage.reviewData.appId, version: input.state.appPackage.reviewData.version, title: input.state.appPackage.reviewData.title, - entrypoint: input.state.appPackage.manifest.entrypoint, + entrypoint: input.state.appPackage.manifest.runtime.entrypoint, capabilities: input.state.appPackage.reviewData.capabilities, previewTestCount: input.state.appPackage.previewTests.length, }, @@ -223,7 +223,7 @@ export function renderLocalDevStateSummary(input: { 'Lantern authoring dev loop ready.', `- Preview URL: ${previewUrl}`, `- App ID: ${input.state.appPackage.reviewData.appId}`, - `- Entrypoint: ${input.state.appPackage.manifest.entrypoint}`, + `- Entrypoint: ${input.state.appPackage.manifest.runtime.entrypoint}`, `- Preview checks: ${ input.state.previewCheck.kind === 'passed' ? 'passing' diff --git a/src/authoring/local_preview.ts b/src/authoring/local_preview.ts index ae641d3..ce8dae4 100644 --- a/src/authoring/local_preview.ts +++ b/src/authoring/local_preview.ts @@ -77,14 +77,14 @@ export function createLocalPreviewHarness(input: { return { bootstrap, - entrypointPath: input.appPackage.manifest.entrypoint, + entrypointPath: input.appPackage.manifest.runtime.entrypoint, previewSessionId, async handle(request) { const url = new URL(request.url); if (url.pathname === '/' || url.pathname === '') { return Response.redirect( - new URL(input.appPackage.manifest.entrypoint, url).toString(), + new URL(input.appPackage.manifest.runtime.entrypoint, url).toString(), 302, ); } @@ -376,7 +376,7 @@ async function serveStaticFile(input: { ); try { - if (url.pathname === input.appPackage.manifest.entrypoint) { + if (url.pathname === input.appPackage.manifest.runtime.entrypoint) { const runtimeBaseUrl = new URL(input.runtimeBasePath, url).toString(); const injectedHtml = injectPreviewRuntimeBridge({ html: input.appPackage.entrypointHtml, diff --git a/src/db/d1.ts b/src/db/d1.ts index ca91938..5ff6968 100644 --- a/src/db/d1.ts +++ b/src/db/d1.ts @@ -208,6 +208,10 @@ export function splitSqlStatements(sql: string): string[] { } if (char === ';') { + if (isTriggerStatement(current) && currentLine(current).toUpperCase() !== 'END') { + current += char; + continue; + } pushStatement(); continue; } @@ -230,6 +234,14 @@ export function splitSqlStatements(sql: string): string[] { } } +function isTriggerStatement(sql: string): boolean { + return /^CREATE\s+TRIGGER\b/i.test(sql.trimStart()); +} + +function currentLine(sql: string): string { + return sql.slice(sql.lastIndexOf('\n') + 1).trim(); +} + function assertD1Success(result: D1Result): void { if (!result.success) { throw new Error(result.error ?? 'D1 query failed.'); diff --git a/src/db/d1_migrations/0001_initial_schema.sql b/src/db/d1_migrations/0001_initial_schema.sql index 80a056b..347cb90 100644 --- a/src/db/d1_migrations/0001_initial_schema.sql +++ b/src/db/d1_migrations/0001_initial_schema.sql @@ -15,6 +15,12 @@ CREATE TABLE IF NOT EXISTS package_versions ( ), grading_rubric_file TEXT, grading_max_score INTEGER, + sandbox_grading_enabled INTEGER NOT NULL DEFAULT 0 CHECK ( + sandbox_grading_enabled IN (0, 1) + ), + catalog_visibility TEXT NOT NULL DEFAULT 'catalog' CHECK ( + catalog_visibility IN ('catalog', 'python_lab_trial') + ), approval_status TEXT NOT NULL CHECK ( approval_status IN ('pending', 'approved', 'rejected') ), @@ -33,51 +39,8 @@ CREATE TABLE IF NOT EXISTS package_versions ( UNIQUE (app_id, version) ); -CREATE TABLE IF NOT EXISTS deployments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - slug TEXT NOT NULL UNIQUE, - label TEXT NOT NULL, - app_id TEXT NOT NULL, - enabled_package_version_id INTEGER REFERENCES package_versions (id), - lms_type TEXT NOT NULL DEFAULT 'canvas' CHECK ( - lms_type IN ('canvas', 'moodle', 'sakai', 'preview') - ), - canvas_environment TEXT CHECK ( - canvas_environment IS NULL OR canvas_environment IN ('production', 'beta', 'test') - ), - issuer TEXT, - client_id TEXT, - deployment_id TEXT, - authorization_endpoint TEXT, - access_token_url TEXT, - jwks_url TEXT, - lti_profile_override TEXT CHECK ( - lti_profile_override IS NULL OR - lti_profile_override IN ('certification', 'governedCompatibility') - ), - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE UNIQUE INDEX IF NOT EXISTS deployments_lti_binding_unique - ON deployments (lms_type, issuer, client_id, deployment_id) - WHERE issuer IS NOT NULL - AND client_id IS NOT NULL - AND deployment_id IS NOT NULL; - -CREATE UNIQUE INDEX IF NOT EXISTS deployments_app_lms_slot_unique - ON deployments (app_id, lms_type); - -CREATE TABLE IF NOT EXISTS dynamic_registration_states ( - state TEXT PRIMARY KEY, - app_id TEXT NOT NULL, - lms_type TEXT NOT NULL CHECK (lms_type IN ('canvas', 'moodle', 'sakai')), - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - used_at TEXT -); - -CREATE INDEX IF NOT EXISTS dynamic_registration_states_expires_at_idx - ON dynamic_registration_states (expires_at); +CREATE INDEX IF NOT EXISTS package_versions_catalog_visibility_idx + ON package_versions (catalog_visibility, app_id, imported_at DESC); CREATE TABLE IF NOT EXISTS lti_clients ( id TEXT PRIMARY KEY NOT NULL, @@ -135,12 +98,14 @@ CREATE TABLE IF NOT EXISTS lti_sessions ( CREATE INDEX IF NOT EXISTS lti_sessions_expires_at_idx ON lti_sessions (expires_at); +-- Preview/authoring runtime sessions only. This table backs preview and admin +-- test launches. LMS launches use governed_runtime_sessions (see migration +-- 0004_governed_lms_primitive_kernel.sql), which is the LMS launch plane. CREATE TABLE IF NOT EXISTS runtime_sessions ( session_id TEXT PRIMARY KEY, session_token TEXT NOT NULL UNIQUE, + lti_tool_session_id TEXT REFERENCES lti_sessions (id) ON DELETE SET NULL, attempt_id TEXT REFERENCES attempts (attempt_id) ON DELETE SET NULL, - deployment_record_id INTEGER NOT NULL REFERENCES deployments (id) ON DELETE CASCADE, - deployment_slug TEXT NOT NULL, app_id TEXT NOT NULL, package_version_id INTEGER NOT NULL REFERENCES package_versions (id), package_version TEXT NOT NULL, @@ -157,7 +122,7 @@ CREATE TABLE IF NOT EXISTS runtime_sessions ( launch_course_id TEXT NOT NULL, launch_assignment_id TEXT, launch_activity_id TEXT NOT NULL, - preview_session_id TEXT REFERENCES preview_sessions (session_id) ON DELETE SET NULL, + preview_session_id TEXT NOT NULL REFERENCES preview_sessions (session_id) ON DELETE CASCADE, created_at TEXT NOT NULL, expires_at TEXT NOT NULL ); @@ -168,14 +133,10 @@ CREATE INDEX IF NOT EXISTS runtime_sessions_expires_at_idx CREATE INDEX IF NOT EXISTS runtime_sessions_attempt_lookup_idx ON runtime_sessions (attempt_id, created_at DESC, session_id DESC); -CREATE INDEX IF NOT EXISTS runtime_sessions_preview_session_idx - ON runtime_sessions (preview_session_id); - CREATE TABLE IF NOT EXISTS attempts ( id INTEGER PRIMARY KEY AUTOINCREMENT, attempt_id TEXT NOT NULL UNIQUE, - deployment_record_id INTEGER NOT NULL REFERENCES deployments (id) ON DELETE CASCADE, - deployment_slug TEXT NOT NULL, + preview_session_id TEXT NOT NULL REFERENCES preview_sessions (session_id) ON DELETE CASCADE, app_id TEXT NOT NULL, package_version_id INTEGER NOT NULL REFERENCES package_versions (id), package_version TEXT NOT NULL, @@ -198,17 +159,18 @@ CREATE TABLE IF NOT EXISTS attempts ( finalized_at TEXT ); -CREATE INDEX IF NOT EXISTS attempts_deployment_record_id_idx - ON attempts (deployment_record_id); - -CREATE INDEX IF NOT EXISTS attempts_deployment_started_at_idx - ON attempts (deployment_record_id, started_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS attempts_preview_started_at_idx + ON attempts (preview_session_id, started_at DESC, id DESC); CREATE TABLE IF NOT EXISTS attempt_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, attempt_id TEXT NOT NULL REFERENCES attempts (attempt_id) ON DELETE CASCADE, sequence INTEGER NOT NULL, event_type TEXT NOT NULL, + learning_verb TEXT NOT NULL, + object_id TEXT NOT NULL, + object_type TEXT NOT NULL, + result TEXT CHECK (result IS NULL OR json_valid(result)), event TEXT NOT NULL CHECK (json_valid(event)), received_at TEXT NOT NULL, UNIQUE (attempt_id, sequence) @@ -231,65 +193,13 @@ CREATE TABLE IF NOT EXISTS attempt_evidence_artifacts ( CREATE INDEX IF NOT EXISTS attempt_evidence_artifacts_attempt_sequence_idx ON attempt_evidence_artifacts (attempt_id, sequence ASC); -CREATE TABLE IF NOT EXISTS line_item_bindings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - deployment_record_id INTEGER NOT NULL REFERENCES deployments (id) ON DELETE CASCADE, - package_version_id INTEGER NOT NULL REFERENCES package_versions (id), - context_id TEXT NOT NULL, - resource_link_id TEXT NOT NULL, - activity_id TEXT NOT NULL, - line_items_url TEXT NOT NULL, - line_item_url TEXT NOT NULL UNIQUE, - resource_id TEXT NOT NULL, - tag TEXT NOT NULL, - label TEXT NOT NULL, - score_maximum REAL NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - UNIQUE ( - deployment_record_id, - package_version_id, - context_id, - resource_link_id, - activity_id - ) -); - -CREATE TABLE IF NOT EXISTS grade_publications ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - attempt_id TEXT NOT NULL REFERENCES attempts (attempt_id) ON DELETE CASCADE, - line_item_binding_id INTEGER NOT NULL REFERENCES line_item_bindings (id) ON DELETE CASCADE, - line_item_url TEXT NOT NULL, - platform_user_id TEXT NOT NULL, - score_given REAL NOT NULL, - score_maximum REAL NOT NULL, - activity_progress TEXT NOT NULL CHECK ( - activity_progress IN ('Completed', 'InProgress', 'Initialized') - ), - grading_progress TEXT NOT NULL CHECK ( - grading_progress IN ('Pending', 'PendingManual', 'FullyGraded', 'Failed') - ), - status TEXT NOT NULL CHECK (status IN ('pending', 'published', 'failed')), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - published_at TEXT, - error_code TEXT, - error_detail TEXT CHECK (error_detail IS NULL OR json_valid(error_detail)), - UNIQUE (attempt_id) -); - -CREATE INDEX IF NOT EXISTS grade_publications_status_updated_at_idx - ON grade_publications (status, updated_at DESC, id DESC); - CREATE TABLE IF NOT EXISTS audit_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_type TEXT NOT NULL, actor_type TEXT NOT NULL CHECK (actor_type IN ('user', 'system', 'platform')), actor_id TEXT, - deployment_record_id INTEGER REFERENCES deployments (id) ON DELETE SET NULL, package_version_id INTEGER REFERENCES package_versions (id) ON DELETE SET NULL, attempt_id TEXT REFERENCES attempts (attempt_id) ON DELETE SET NULL, - line_item_binding_id INTEGER REFERENCES line_item_bindings (id) ON DELETE SET NULL, status TEXT NOT NULL CHECK (status IN ('accepted', 'succeeded', 'failed')), summary TEXT NOT NULL, detail TEXT NOT NULL CHECK (json_valid(detail)), @@ -299,87 +209,6 @@ CREATE TABLE IF NOT EXISTS audit_events ( CREATE INDEX IF NOT EXISTS audit_events_attempt_id_occurred_at_idx ON audit_events (attempt_id, occurred_at, id); -CREATE INDEX IF NOT EXISTS audit_events_deployment_event_occurred_at_idx - ON audit_events (deployment_record_id, event_type, occurred_at DESC, id DESC); - -CREATE TABLE IF NOT EXISTS broker_verification_runs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - deployment_record_id INTEGER REFERENCES deployments (id) ON DELETE SET NULL, - scope TEXT NOT NULL, - workflow_key TEXT CHECK ( - workflow_key IS NULL OR workflow_key IN ('core', 'deepLinking', 'nrps', 'ags') - ), - source TEXT NOT NULL CHECK (source IN ('manual', 'ci', '1edtech')), - status TEXT NOT NULL CHECK (status IN ('passed', 'failed', 'pending', 'notCertified')), - summary TEXT NOT NULL, - detail_url TEXT, - certification_state TEXT CHECK ( - certification_state IS NULL OR - certification_state IN ('ltiAdvantageCertified', 'ltiAdvantageComplete') - ), - checked_at TEXT NOT NULL -); - -CREATE INDEX IF NOT EXISTS broker_verification_runs_scope_source_checked_at_idx - ON broker_verification_runs (scope, source, checked_at DESC, id DESC); - -CREATE INDEX IF NOT EXISTS broker_verification_runs_workflow_key_source_checked_at_idx - ON broker_verification_runs (workflow_key, source, checked_at DESC, id DESC); - -CREATE TABLE IF NOT EXISTS deep_linking_sessions ( - session_id TEXT PRIMARY KEY, - session_token TEXT NOT NULL UNIQUE, - deployment_record_id INTEGER NOT NULL REFERENCES deployments (id) ON DELETE CASCADE, - deployment_slug TEXT NOT NULL, - app_id TEXT NOT NULL, - user_id TEXT, - user_role TEXT NOT NULL CHECK (user_role IN ('learner', 'instructor')), - context_id TEXT, - context_title TEXT, - deep_link_return_url TEXT NOT NULL, - data TEXT, - placement TEXT NOT NULL CHECK (placement IN ('assignment_selection', 'resource_selection')), - accept_types TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(accept_types)), - accept_multiple INTEGER NOT NULL DEFAULT 0 CHECK (accept_multiple IN (0, 1)), - accept_presentation_document_targets TEXT NOT NULL DEFAULT '[]' CHECK ( - json_valid(accept_presentation_document_targets) - ), - accept_line_item INTEGER NOT NULL DEFAULT 0 CHECK (accept_line_item IN (0, 1)), - selected_package_version_id INTEGER REFERENCES package_versions (id) ON DELETE SET NULL, - selected_package_version TEXT, - selected_activity_id TEXT, - selected_content_path TEXT, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - used_at TEXT -); - -CREATE INDEX IF NOT EXISTS deep_linking_sessions_expires_at_idx - ON deep_linking_sessions (expires_at); - -CREATE TABLE IF NOT EXISTS reviewed_placements ( - placement_id TEXT PRIMARY KEY, - deployment_record_id INTEGER NOT NULL REFERENCES deployments (id) ON DELETE CASCADE, - deployment_slug TEXT NOT NULL, - app_id TEXT NOT NULL, - context_id TEXT, - context_title TEXT, - package_version_id INTEGER NOT NULL REFERENCES package_versions (id), - package_version TEXT NOT NULL, - package_title TEXT NOT NULL, - activity_id TEXT NOT NULL, - content_path TEXT NOT NULL, - content_title TEXT, - created_by_user_id TEXT, - resource_link_id TEXT, - created_at TEXT NOT NULL, - bound_at TEXT, - UNIQUE (deployment_record_id, resource_link_id) -); - -CREATE INDEX IF NOT EXISTS reviewed_placements_deployment_context_idx - ON reviewed_placements (deployment_record_id, context_id); - CREATE TABLE IF NOT EXISTS preview_sessions ( session_id TEXT PRIMARY KEY, package_version_id INTEGER NOT NULL REFERENCES package_versions (id) ON DELETE CASCADE, @@ -387,10 +216,9 @@ CREATE TABLE IF NOT EXISTS preview_sessions ( package_version TEXT NOT NULL, package_title TEXT NOT NULL, origin TEXT NOT NULL CHECK ( - origin IN ('adminTestLaunch', 'deepLinkingAuthoring', 'adminAuthoringDraft') + origin IN ('adminTestLaunch', 'adminAuthoringDraft') ), content_path TEXT NOT NULL, - deep_linking_session_id TEXT REFERENCES deep_linking_sessions (session_id) ON DELETE SET NULL, capabilities TEXT NOT NULL CHECK (json_valid(capabilities)), snapshot_root TEXT NOT NULL, entrypoint_path TEXT NOT NULL, @@ -427,23 +255,6 @@ CREATE TABLE IF NOT EXISTS preview_evidence ( CREATE INDEX IF NOT EXISTS preview_evidence_session_occurred_idx ON preview_evidence (preview_session_id, occurred_at ASC, id ASC); -CREATE TABLE IF NOT EXISTS lantern_settings ( - singleton INTEGER PRIMARY KEY DEFAULT 1 CHECK (singleton = 1), - default_lti_profile TEXT NOT NULL CHECK ( - default_lti_profile IN ('certification', 'governedCompatibility') - ), - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -INSERT INTO lantern_settings ( - singleton, - default_lti_profile -) VALUES ( - 1, - 'governedCompatibility' -) -ON CONFLICT (singleton) DO NOTHING; - CREATE TABLE IF NOT EXISTS authoring_drafts ( draft_id TEXT PRIMARY KEY, package_version_id INTEGER NOT NULL UNIQUE REFERENCES package_versions (id) ON DELETE CASCADE, diff --git a/src/db/d1_migrations/0002_app_generation_runs.sql b/src/db/d1_migrations/0002_app_generation_runs.sql deleted file mode 100644 index 1d06a8f..0000000 --- a/src/db/d1_migrations/0002_app_generation_runs.sql +++ /dev/null @@ -1,44 +0,0 @@ -CREATE TABLE IF NOT EXISTS app_generation_runs ( - generation_id TEXT PRIMARY KEY, - owner_id TEXT NOT NULL, - status TEXT NOT NULL CHECK ( - status IN ( - 'started', - 'initializing', - 'normalizing', - 'planning', - 'generating_package', - 'validating', - 'repairing', - 'previewing', - 'saved_pending_version', - 'failed' - ) - ), - requested_app_id TEXT, - generated_app_id TEXT, - generated_version TEXT, - package_version_id INTEGER REFERENCES package_versions (id) ON DELETE SET NULL, - prompt_text TEXT NOT NULL, - normalized_request_json TEXT CHECK ( - normalized_request_json IS NULL OR json_valid(normalized_request_json) - ), - app_plan_json TEXT CHECK (app_plan_json IS NULL OR json_valid(app_plan_json)), - selected_starter_id TEXT CHECK ( - selected_starter_id IS NULL OR selected_starter_id IN ('simple-activity', 'browser-autograder') - ), - selected_context_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(selected_context_json)), - model_request_metadata_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(model_request_metadata_json)), - generation_notes_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(generation_notes_json)), - validation_findings_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(validation_findings_json)), - repair_attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (repair_attempt_count >= 0), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE INDEX IF NOT EXISTS app_generation_runs_owner_updated_idx - ON app_generation_runs (owner_id, updated_at DESC, generation_id DESC); - -CREATE INDEX IF NOT EXISTS app_generation_runs_package_version_idx - ON app_generation_runs (package_version_id) - WHERE package_version_id IS NOT NULL; diff --git a/src/db/d1_migrations/0010_better_auth_tables.sql b/src/db/d1_migrations/0002_auth.sql similarity index 100% rename from src/db/d1_migrations/0010_better_auth_tables.sql rename to src/db/d1_migrations/0002_auth.sql diff --git a/src/db/d1_migrations/0003_app_generation_workspaces.sql b/src/db/d1_migrations/0003_app_generation_workspaces.sql deleted file mode 100644 index b0cc2a3..0000000 --- a/src/db/d1_migrations/0003_app_generation_workspaces.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE IF NOT EXISTS app_generation_workspaces ( - generation_id TEXT PRIMARY KEY REFERENCES app_generation_runs (generation_id) ON DELETE CASCADE, - selected_starter_id TEXT NOT NULL CHECK ( - selected_starter_id IN ('simple-activity', 'browser-autograder') - ), - files_json TEXT NOT NULL CHECK (json_valid(files_json)), - validation_findings_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(validation_findings_json)), - repair_attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (repair_attempt_count >= 0), - updated_at TEXT NOT NULL -); diff --git a/src/db/d1_migrations/0003_python_autograder_lab.sql b/src/db/d1_migrations/0003_python_autograder_lab.sql new file mode 100644 index 0000000..50f2046 --- /dev/null +++ b/src/db/d1_migrations/0003_python_autograder_lab.sql @@ -0,0 +1,76 @@ +CREATE TABLE IF NOT EXISTS python_lab_trials ( + lab_id TEXT PRIMARY KEY, + visitor_hash TEXT NOT NULL, + visitor_day TEXT NOT NULL, + request_ip_hash TEXT, + access_token_hash TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('generating', 'ready', 'failed', 'expired')), + author_draft TEXT NOT NULL CHECK (json_valid(author_draft)), + package_version_id INTEGER REFERENCES package_versions (id) ON DELETE SET NULL, + private_artifact_key TEXT, + failure_message TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + UNIQUE (visitor_hash, visitor_day) +); + +CREATE INDEX IF NOT EXISTS python_lab_trials_visitor_created_idx + ON python_lab_trials (visitor_hash, created_at DESC); + +CREATE TABLE IF NOT EXISTS python_lab_rate_limits ( + request_ip_hash TEXT NOT NULL, + window_started_at TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (request_ip_hash, window_started_at) +); + +CREATE INDEX IF NOT EXISTS python_lab_rate_limits_created_idx + ON python_lab_rate_limits (created_at); + +CREATE INDEX IF NOT EXISTS python_lab_trials_ip_created_idx + ON python_lab_trials (request_ip_hash, created_at DESC) + WHERE request_ip_hash IS NOT NULL; + +CREATE INDEX IF NOT EXISTS python_lab_trials_expires_idx + ON python_lab_trials (expires_at); + +CREATE TABLE IF NOT EXISTS code_assignment_definitions ( + package_version_id INTEGER PRIMARY KEY REFERENCES package_versions (id) ON DELETE CASCADE, + function_name TEXT NOT NULL, + private_artifact_key TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS code_submission_runs ( + run_id TEXT PRIMARY KEY, + attempt_id TEXT NOT NULL REFERENCES attempts (attempt_id) ON DELETE CASCADE, + source_artifact_key TEXT NOT NULL UNIQUE, + phase TEXT NOT NULL CHECK (phase = 'final'), + public_outcomes TEXT NOT NULL CHECK (json_valid(public_outcomes)), + hidden_summary TEXT NOT NULL CHECK (json_valid(hidden_summary)), + score_given REAL NOT NULL CHECK (score_given >= 0 AND score_given <= 10), + score_maximum REAL NOT NULL CHECK (score_maximum = 10), + created_at TEXT NOT NULL, + source_persisted_at TEXT, + finalized_at TEXT NOT NULL, + UNIQUE (attempt_id, phase) +); + +CREATE TABLE IF NOT EXISTS python_lab_access_requests ( + request_id TEXT PRIMARY KEY, + lab_id TEXT NOT NULL, + name TEXT NOT NULL, + work_email TEXT NOT NULL, + institution TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('new', 'contacted', 'closed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS python_lab_access_requests_status_created_idx + ON python_lab_access_requests (status, created_at DESC); + +CREATE INDEX IF NOT EXISTS python_lab_access_requests_expires_idx + ON python_lab_access_requests (expires_at); diff --git a/src/db/d1_migrations/0004_app_generation_initialization_plan.sql b/src/db/d1_migrations/0004_app_generation_initialization_plan.sql deleted file mode 100644 index d77c07c..0000000 --- a/src/db/d1_migrations/0004_app_generation_initialization_plan.sql +++ /dev/null @@ -1,95 +0,0 @@ -PRAGMA foreign_keys = off; - -CREATE TABLE app_generation_runs_new ( - generation_id TEXT PRIMARY KEY, - owner_id TEXT NOT NULL, - status TEXT NOT NULL CHECK ( - status IN ( - 'started', - 'initializing', - 'normalizing', - 'planning', - 'generating_package', - 'validating', - 'repairing', - 'previewing', - 'saved_pending_version', - 'failed' - ) - ), - requested_app_id TEXT, - generated_app_id TEXT, - generated_version TEXT, - package_version_id INTEGER REFERENCES package_versions (id) ON DELETE SET NULL, - prompt_text TEXT NOT NULL, - normalized_request_json TEXT CHECK ( - normalized_request_json IS NULL OR json_valid(normalized_request_json) - ), - app_plan_json TEXT CHECK (app_plan_json IS NULL OR json_valid(app_plan_json)), - selected_starter_id TEXT CHECK ( - selected_starter_id IS NULL OR selected_starter_id IN ('simple-activity', 'browser-autograder') - ), - selected_context_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(selected_context_json)), - model_request_metadata_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(model_request_metadata_json)), - generation_notes_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(generation_notes_json)), - validation_findings_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(validation_findings_json)), - repair_attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (repair_attempt_count >= 0), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -INSERT INTO app_generation_runs_new ( - generation_id, - owner_id, - status, - requested_app_id, - generated_app_id, - generated_version, - package_version_id, - prompt_text, - normalized_request_json, - app_plan_json, - selected_starter_id, - selected_context_json, - model_request_metadata_json, - generation_notes_json, - validation_findings_json, - repair_attempt_count, - created_at, - updated_at -) -SELECT - generation_id, - owner_id, - status, - requested_app_id, - generated_app_id, - generated_version, - package_version_id, - prompt_text, - normalized_request_json, - app_plan_json, - selected_starter_id, - selected_context_json, - model_request_metadata_json, - generation_notes_json, - validation_findings_json, - repair_attempt_count, - created_at, - updated_at -FROM app_generation_runs; - -DROP TABLE app_generation_runs; -ALTER TABLE app_generation_runs_new RENAME TO app_generation_runs; - -CREATE INDEX IF NOT EXISTS app_generation_runs_owner_updated_idx - ON app_generation_runs (owner_id, updated_at DESC, generation_id DESC); - -CREATE INDEX IF NOT EXISTS app_generation_runs_package_version_idx - ON app_generation_runs (package_version_id) - WHERE package_version_id IS NOT NULL; - -ALTER TABLE app_generation_workspaces - ADD COLUMN generation_plan_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(generation_plan_json)); - -PRAGMA foreign_keys = on; diff --git a/src/db/d1_migrations/0004_governed_lms_primitive_kernel.sql b/src/db/d1_migrations/0004_governed_lms_primitive_kernel.sql new file mode 100644 index 0000000..027e019 --- /dev/null +++ b/src/db/d1_migrations/0004_governed_lms_primitive_kernel.sql @@ -0,0 +1,337 @@ +CREATE TABLE IF NOT EXISTS institutions ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'disabled')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS institution_memberships ( + institution_id TEXT NOT NULL REFERENCES institutions (id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES auth_users (id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'auditor')), + created_at TEXT NOT NULL, + PRIMARY KEY (institution_id, user_id), + UNIQUE (user_id) +); + +CREATE INDEX IF NOT EXISTS institution_memberships_user_idx + ON institution_memberships (user_id, institution_id); + +CREATE TABLE IF NOT EXISTS institution_invites ( + id TEXT PRIMARY KEY, + institution_id TEXT NOT NULL REFERENCES institutions (id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + role TEXT NOT NULL CHECK (role IN ('admin', 'auditor')), + created_by_user_id TEXT NOT NULL REFERENCES auth_users (id), + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + accepted_by_user_id TEXT REFERENCES auth_users (id), + accepted_at TEXT, + acceptance_trace_id TEXT UNIQUE +); + +CREATE INDEX IF NOT EXISTS institution_invites_expiry_idx + ON institution_invites (expires_at); + +CREATE TABLE IF NOT EXISTS lms_connections ( + id TEXT PRIMARY KEY, + institution_id TEXT NOT NULL REFERENCES institutions (id) ON DELETE CASCADE, + label TEXT NOT NULL, + platform_type TEXT NOT NULL CHECK ( + platform_type IN ('canvas', 'moodle', 'brightspace', 'sakai', 'synthetic') + ), + issuer TEXT NOT NULL, + client_id TEXT NOT NULL, + deployment_id TEXT NOT NULL, + authorization_endpoint TEXT NOT NULL, + access_token_url TEXT NOT NULL, + jwks_url TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'active', 'disabled')), + validated_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (institution_id, issuer, client_id, deployment_id), + UNIQUE (issuer, client_id, deployment_id) +); + +CREATE INDEX IF NOT EXISTS lms_connections_institution_idx + ON lms_connections (institution_id, status, label); + +CREATE TABLE IF NOT EXISTS lms_contexts ( + id TEXT PRIMARY KEY, + institution_id TEXT NOT NULL REFERENCES institutions (id) ON DELETE CASCADE, + lms_connection_id TEXT NOT NULL REFERENCES lms_connections (id) ON DELETE CASCADE, + provider_context_id TEXT NOT NULL, + label TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (lms_connection_id, provider_context_id) +); + +CREATE INDEX IF NOT EXISTS lms_contexts_institution_idx + ON lms_contexts (institution_id, lms_connection_id); + +CREATE TABLE IF NOT EXISTS app_installations ( + id TEXT PRIMARY KEY, + institution_id TEXT NOT NULL REFERENCES institutions (id) ON DELETE CASCADE, + app_id TEXT NOT NULL, + package_version_id INTEGER NOT NULL REFERENCES package_versions (id), + status TEXT NOT NULL CHECK (status IN ('pending_approval', 'active', 'disabled')), + installed_by_user_id TEXT NOT NULL REFERENCES auth_users (id), + installed_at TEXT NOT NULL, + activated_at TEXT, + updated_at TEXT NOT NULL, + UNIQUE (institution_id, app_id) +); + +CREATE INDEX IF NOT EXISTS app_installations_institution_idx + ON app_installations (institution_id, status, app_id); + +CREATE TABLE IF NOT EXISTS capability_grants ( + id TEXT PRIMARY KEY, + institution_id TEXT NOT NULL REFERENCES institutions (id) ON DELETE CASCADE, + app_installation_id TEXT NOT NULL REFERENCES app_installations (id) ON DELETE CASCADE, + package_version_id INTEGER NOT NULL REFERENCES package_versions (id), + capability TEXT NOT NULL CHECK ( + capability IN ( + 'lms.roster.members.read', + 'lms.roster.contact.read', + 'lms.roster.profile_photo.read' + ) + ), + scope_kind TEXT NOT NULL CHECK (scope_kind IN ('institution', 'context')), + lms_context_id TEXT REFERENCES lms_contexts (id) ON DELETE CASCADE, + approved_by_user_id TEXT NOT NULL REFERENCES auth_users (id), + approved_at TEXT NOT NULL, + revoked_by_user_id TEXT REFERENCES auth_users (id), + revoked_at TEXT, + CHECK ( + (scope_kind = 'institution' AND lms_context_id IS NULL) OR + (scope_kind = 'context' AND lms_context_id IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS capability_grants_unique_idx + ON capability_grants ( + app_installation_id, + package_version_id, + capability, + scope_kind, + IFNULL(lms_context_id, '') + ); + +CREATE INDEX IF NOT EXISTS capability_grants_authorization_idx + ON capability_grants ( + institution_id, + app_installation_id, + package_version_id, + capability, + revoked_at + ); + +-- LMS launch plane. governed_runtime_sessions is the ONLY runtime session store +-- for governed LMS launches (minted by lms_kernel). The preview/authoring plane +-- uses runtime_sessions in 0001_initial_schema.sql; do not mix the two. +CREATE TABLE IF NOT EXISTS governed_runtime_sessions ( + runtime_session_id TEXT PRIMARY KEY, + runtime_session_token TEXT NOT NULL UNIQUE, + launch_trace_id TEXT NOT NULL, + runtime_expires_at TEXT NOT NULL, + institution_id TEXT NOT NULL REFERENCES institutions (id) ON DELETE CASCADE, + lms_connection_id TEXT NOT NULL REFERENCES lms_connections (id) ON DELETE CASCADE, + lms_context_id TEXT NOT NULL REFERENCES lms_contexts (id) ON DELETE CASCADE, + app_installation_id TEXT NOT NULL REFERENCES app_installations (id) ON DELETE CASCADE, + app_id TEXT NOT NULL, + package_version_id INTEGER NOT NULL REFERENCES package_versions (id), + package_version TEXT NOT NULL, + package_capabilities TEXT NOT NULL CHECK (json_valid(package_capabilities)), + actor_subject_id TEXT NOT NULL, + actor_type TEXT NOT NULL CHECK (actor_type IN ('instructor', 'administrator')), + lti_tool_session_id TEXT, + nrps_context_memberships_url TEXT, + nrps_service_versions TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(nrps_service_versions)), + connector_kind TEXT NOT NULL CHECK (connector_kind IN ('nrps', 'synthetic')), + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS governed_runtime_sessions_authorization_idx + ON governed_runtime_sessions ( + institution_id, + app_installation_id, + lms_context_id, + runtime_session_id + ); + +CREATE TABLE IF NOT EXISTS subject_identities ( + id TEXT PRIMARY KEY, + institution_id TEXT NOT NULL REFERENCES institutions (id) ON DELETE CASCADE, + lms_connection_id TEXT NOT NULL REFERENCES lms_connections (id) ON DELETE CASCADE, + provider_subject_id TEXT NOT NULL, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + UNIQUE (lms_connection_id, provider_subject_id) +); + +CREATE TABLE IF NOT EXISTS roster_continuations ( + id TEXT PRIMARY KEY, + runtime_session_id TEXT NOT NULL REFERENCES governed_runtime_sessions (runtime_session_id) + ON DELETE CASCADE, + app_installation_id TEXT NOT NULL REFERENCES app_installations (id) ON DELETE CASCADE, + package_version_id INTEGER NOT NULL REFERENCES package_versions (id), + connector_kind TEXT NOT NULL CHECK (connector_kind IN ('nrps', 'synthetic')), + continuation_value TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS roster_continuations_expiry_idx + ON roster_continuations (expires_at); + +CREATE TABLE IF NOT EXISTS protected_media_refs ( + id TEXT PRIMARY KEY, + trace_id TEXT NOT NULL, + institution_id TEXT NOT NULL REFERENCES institutions (id) ON DELETE CASCADE, + lms_connection_id TEXT NOT NULL REFERENCES lms_connections (id) ON DELETE CASCADE, + lms_context_id TEXT NOT NULL REFERENCES lms_contexts (id) ON DELETE CASCADE, + runtime_session_id TEXT NOT NULL REFERENCES governed_runtime_sessions (runtime_session_id) + ON DELETE CASCADE, + app_installation_id TEXT NOT NULL REFERENCES app_installations (id) ON DELETE CASCADE, + package_version_id INTEGER NOT NULL REFERENCES package_versions (id), + subject_identity_id TEXT NOT NULL REFERENCES subject_identities (id) ON DELETE CASCADE, + actor_subject_id TEXT NOT NULL, + actor_type TEXT NOT NULL CHECK (actor_type IN ('instructor', 'administrator')), + connector_kind TEXT NOT NULL CHECK (connector_kind IN ('nrps', 'synthetic')), + source_url TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS protected_media_refs_expiry_idx + ON protected_media_refs (expires_at); + +CREATE TABLE IF NOT EXISTS governance_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trace_id TEXT NOT NULL, + event_type TEXT NOT NULL, + institution_id TEXT REFERENCES institutions (id) ON DELETE SET NULL, + lms_connection_id TEXT REFERENCES lms_connections (id) ON DELETE SET NULL, + lms_context_id TEXT REFERENCES lms_contexts (id) ON DELETE SET NULL, + app_installation_id TEXT REFERENCES app_installations (id) ON DELETE SET NULL, + app_id TEXT, + package_version_id INTEGER REFERENCES package_versions (id) ON DELETE SET NULL, + runtime_session_id TEXT REFERENCES governed_runtime_sessions (runtime_session_id) + ON DELETE SET NULL, + actor_type TEXT NOT NULL CHECK ( + actor_type IN ('user', 'instructor', 'administrator', 'system', 'app') + ), + actor_id TEXT, + capability TEXT, + capability_grant_id TEXT REFERENCES capability_grants (id) ON DELETE SET NULL, + decision TEXT CHECK (decision IS NULL OR decision IN ('allowed', 'denied')), + outcome TEXT NOT NULL CHECK (outcome IN ('completed', 'failed', 'limited')), + connector_kind TEXT CHECK ( + connector_kind IS NULL OR connector_kind IN ('nrps', 'synthetic') + ), + object_count INTEGER CHECK (object_count IS NULL OR object_count >= 0), + latency_ms INTEGER CHECK (latency_ms IS NULL OR latency_ms >= 0), + error_code TEXT, + request_metadata TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(request_metadata)), + occurred_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS governance_events_trace_idx + ON governance_events (trace_id, occurred_at, id); + +CREATE INDEX IF NOT EXISTS governance_events_search_idx + ON governance_events ( + institution_id, + app_installation_id, + capability, + decision, + outcome, + occurred_at DESC + ); + +CREATE TRIGGER IF NOT EXISTS lms_contexts_tenant_guard +BEFORE INSERT ON lms_contexts +WHEN NOT EXISTS ( + SELECT 1 FROM lms_connections + WHERE id = NEW.lms_connection_id + AND institution_id = NEW.institution_id +) +BEGIN + SELECT RAISE(ABORT, 'lms_context tenant mismatch'); +END; + +CREATE TRIGGER IF NOT EXISTS capability_grants_boundary_guard +BEFORE INSERT ON capability_grants +WHEN NOT EXISTS ( + SELECT 1 FROM app_installations + WHERE id = NEW.app_installation_id + AND institution_id = NEW.institution_id + AND package_version_id = NEW.package_version_id +) OR ( + NEW.scope_kind = 'context' AND NOT EXISTS ( + SELECT 1 FROM lms_contexts + WHERE id = NEW.lms_context_id + AND institution_id = NEW.institution_id + ) +) +BEGIN + SELECT RAISE(ABORT, 'capability grant boundary mismatch'); +END; + +CREATE TRIGGER IF NOT EXISTS governed_runtime_sessions_boundary_guard +BEFORE INSERT ON governed_runtime_sessions +WHEN NOT EXISTS ( + SELECT 1 + FROM lms_connections + JOIN lms_contexts + ON lms_contexts.lms_connection_id = lms_connections.id + JOIN app_installations + ON app_installations.institution_id = lms_connections.institution_id + WHERE lms_connections.id = NEW.lms_connection_id + AND lms_connections.institution_id = NEW.institution_id + AND lms_contexts.id = NEW.lms_context_id + AND lms_contexts.institution_id = NEW.institution_id + AND app_installations.id = NEW.app_installation_id + AND app_installations.app_id = NEW.app_id + AND app_installations.package_version_id = NEW.package_version_id +) +BEGIN + SELECT RAISE(ABORT, 'governed runtime session boundary mismatch'); +END; + +CREATE TRIGGER IF NOT EXISTS protected_media_refs_boundary_guard +BEFORE INSERT ON protected_media_refs +WHEN NOT EXISTS ( + SELECT 1 + FROM governed_runtime_sessions + JOIN subject_identities + ON subject_identities.id = NEW.subject_identity_id + WHERE governed_runtime_sessions.runtime_session_id = NEW.runtime_session_id + AND governed_runtime_sessions.institution_id = NEW.institution_id + AND governed_runtime_sessions.lms_connection_id = NEW.lms_connection_id + AND governed_runtime_sessions.lms_context_id = NEW.lms_context_id + AND governed_runtime_sessions.app_installation_id = NEW.app_installation_id + AND governed_runtime_sessions.package_version_id = NEW.package_version_id + AND subject_identities.institution_id = NEW.institution_id + AND subject_identities.lms_connection_id = NEW.lms_connection_id +) +BEGIN + SELECT RAISE(ABORT, 'protected media boundary mismatch'); +END; + +CREATE TRIGGER IF NOT EXISTS governance_events_no_update +BEFORE UPDATE ON governance_events +BEGIN + SELECT RAISE(ABORT, 'governance_events is append-only'); +END; + +CREATE TRIGGER IF NOT EXISTS governance_events_no_delete +BEFORE DELETE ON governance_events +BEGIN + SELECT RAISE(ABORT, 'governance_events is append-only'); +END; diff --git a/src/db/d1_migrations/0005_runtime_session_preview_marker.sql b/src/db/d1_migrations/0005_runtime_session_preview_marker.sql deleted file mode 100644 index 24f1886..0000000 --- a/src/db/d1_migrations/0005_runtime_session_preview_marker.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE runtime_sessions ADD COLUMN preview_session_id TEXT REFERENCES preview_sessions (session_id) ON DELETE SET NULL; - -CREATE INDEX IF NOT EXISTS runtime_sessions_preview_session_idx - ON runtime_sessions (preview_session_id); diff --git a/src/db/d1_migrations/0006_attempt_event_normalization.sql b/src/db/d1_migrations/0006_attempt_event_normalization.sql deleted file mode 100644 index 98a6bf6..0000000 --- a/src/db/d1_migrations/0006_attempt_event_normalization.sql +++ /dev/null @@ -1,54 +0,0 @@ -ALTER TABLE attempt_events - ADD COLUMN learning_verb TEXT NOT NULL DEFAULT 'completed'; - -ALTER TABLE attempt_events - ADD COLUMN object_id TEXT NOT NULL DEFAULT 'activity'; - -ALTER TABLE attempt_events - ADD COLUMN object_type TEXT NOT NULL DEFAULT 'activity'; - -ALTER TABLE attempt_events - ADD COLUMN result TEXT NOT NULL DEFAULT '{"completionState":"completed"}' - CHECK (json_valid(result)); - -UPDATE attempt_events -SET - learning_verb = CASE event_type - WHEN 'answer' THEN 'answered' - WHEN 'progress' THEN 'progressed' - ELSE 'completed' - END, - object_id = CASE event_type - WHEN 'answer' THEN COALESCE(json_extract(event, '$.questionId'), 'question') - WHEN 'progress' THEN COALESCE(json_extract(event, '$.checkpoint'), 'checkpoint') - ELSE 'activity' - END, - object_type = CASE event_type - WHEN 'answer' THEN 'question' - WHEN 'progress' THEN 'checkpoint' - ELSE 'activity' - END, - result = CASE event_type - WHEN 'answer' THEN json_object( - 'response', - json_extract(event, '$.answer'), - 'success', - CASE json_type(event, '$.correct') - WHEN 'true' THEN json('true') - WHEN 'false' THEN json('false') - ELSE NULL - END, - 'scoreGiven', - json_extract(event, '$.scoreGiven'), - 'scoreMaximum', - json_extract(event, '$.scoreMaximum') - ) - WHEN 'progress' THEN json_object( - 'value', - json_extract(event, '$.value') - ) - ELSE json_object( - 'completionState', - 'completed' - ) - END; diff --git a/src/db/d1_migrations/0007_lti_tool_tables.sql b/src/db/d1_migrations/0007_lti_tool_tables.sql deleted file mode 100644 index cbda347..0000000 --- a/src/db/d1_migrations/0007_lti_tool_tables.sql +++ /dev/null @@ -1,55 +0,0 @@ -CREATE TABLE IF NOT EXISTS lti_clients ( - id TEXT PRIMARY KEY NOT NULL, - platform_name TEXT NOT NULL, - iss TEXT NOT NULL, - client_id TEXT NOT NULL, - auth_url TEXT NOT NULL, - token_url TEXT NOT NULL, - jwks_url TEXT NOT NULL -); - -CREATE INDEX IF NOT EXISTS lti_clients_issuer_client_idx - ON lti_clients (client_id, iss); - -CREATE UNIQUE INDEX IF NOT EXISTS lti_clients_iss_client_id_uniq - ON lti_clients (iss, client_id); - -CREATE TABLE IF NOT EXISTS lti_deployments ( - id TEXT PRIMARY KEY NOT NULL, - client_id TEXT NOT NULL REFERENCES lti_clients (id) ON DELETE CASCADE, - deployment_id TEXT NOT NULL, - deployment_name TEXT, - deployment_description TEXT -); - -CREATE INDEX IF NOT EXISTS lti_deployments_deployment_id_idx - ON lti_deployments (deployment_id); - -CREATE UNIQUE INDEX IF NOT EXISTS lti_deployments_client_deployment_uniq - ON lti_deployments (client_id, deployment_id); - -CREATE TABLE IF NOT EXISTS lti_nonces ( - nonce TEXT PRIMARY KEY NOT NULL, - expires_at INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS lti_nonces_expires_at_idx - ON lti_nonces (expires_at); - -CREATE TABLE IF NOT EXISTS lti_registration_sessions ( - id TEXT PRIMARY KEY NOT NULL, - payload TEXT NOT NULL, - expires_at INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS lti_registration_sessions_expires_at_idx - ON lti_registration_sessions (expires_at); - -CREATE TABLE IF NOT EXISTS lti_sessions ( - id TEXT PRIMARY KEY NOT NULL, - payload TEXT NOT NULL, - expires_at INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS lti_sessions_expires_at_idx - ON lti_sessions (expires_at); diff --git a/src/db/d1_migrations/0008_runtime_session_lti_tool_session.sql b/src/db/d1_migrations/0008_runtime_session_lti_tool_session.sql deleted file mode 100644 index fc29495..0000000 --- a/src/db/d1_migrations/0008_runtime_session_lti_tool_session.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE runtime_sessions ADD COLUMN lti_tool_session_id TEXT REFERENCES lti_sessions (id) ON DELETE SET NULL; diff --git a/src/db/d1_migrations/0009_deep_linking_session_lti_tool_session.sql b/src/db/d1_migrations/0009_deep_linking_session_lti_tool_session.sql deleted file mode 100644 index 068bac1..0000000 --- a/src/db/d1_migrations/0009_deep_linking_session_lti_tool_session.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE deep_linking_sessions - ADD COLUMN lti_tool_session_id TEXT REFERENCES lti_sessions (id) ON DELETE SET NULL; diff --git a/src/db/d1_migrations/README.md b/src/db/d1_migrations/README.md index bfb65b2..621b1de 100644 --- a/src/db/d1_migrations/README.md +++ b/src/db/d1_migrations/README.md @@ -2,6 +2,13 @@ Wrangler-managed D1 migrations belong here. -This is Lantern's only checked-in D1 migration path. D1 uses SQLite, so -schema changes should preserve Lantern's trust boundary with intentional -SQLite-compatible SQL. +This is Lantern's only checked-in D1 migration path. The numbered files are +the current product baseline, not a compatibility history: + +1. reviewed packages and browser previews +2. administrator authentication +3. the Python autograder lab +4. the governed LMS primitive kernel + +There is no production data to migrate. Rewrite this baseline when the domain +changes instead of preserving obsolete tables or adding compatibility shims. diff --git a/src/grading/attempt_scoring.ts b/src/grading/attempt_scoring.ts index 31c969d..16e496b 100644 --- a/src/grading/attempt_scoring.ts +++ b/src/grading/attempt_scoring.ts @@ -11,6 +11,10 @@ export function scoreAttempt(input: ScoreAttemptInput): AttemptScoreResult { throw new Error( 'Browser grading must be finalized through the reviewed runtime browser grader result.', ); + case 'sandbox': + throw new Error( + 'Sandbox grading must be finalized through Lantern server-side code evaluation.', + ); case 'manual': throw new Error('Manual grading cannot be finalized automatically in Phase 3.'); } diff --git a/src/interop_audit.ts b/src/interop_audit.ts deleted file mode 100644 index c74574a..0000000 --- a/src/interop_audit.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { buildResolvedLtiProfileDetail } from './lti/profile_resolution.ts'; -import type { ResolvedLtiProfile } from './lti/profile.ts'; -import type { PackageReviewRepository } from './package_review/repository.ts'; -import type { AuditActorType } from './package_review/types.ts'; - -export async function recordInteropPathUsed(input: { - repository: Pick; - scope: string; - path: string; - actorType: AuditActorType; - actorId?: string | null; - deploymentRecordId?: number | null; - packageVersionId?: number | null; - attemptId?: string | null; - lineItemBindingId?: number | null; - summary?: string; - detail?: Record; - ltiProfile?: ResolvedLtiProfile | null; - occurredAt?: string; -}): Promise { - await input.repository.recordAuditEvent({ - eventType: 'interop.path_used', - actorType: input.actorType, - actorId: input.actorId ?? null, - deploymentRecordId: input.deploymentRecordId ?? null, - packageVersionId: input.packageVersionId ?? null, - attemptId: input.attemptId ?? null, - lineItemBindingId: input.lineItemBindingId ?? null, - status: 'accepted', - summary: input.summary ?? 'Lantern used an LTI interoperability path.', - detail: { - scope: input.scope, - path: input.path, - ...input.detail, - ...(input.ltiProfile === null || input.ltiProfile === undefined - ? {} - : buildResolvedLtiProfileDetail(input.ltiProfile)), - }, - occurredAt: input.occurredAt ?? new Date().toISOString(), - }); -} diff --git a/src/lms_kernel/admin_connection.ts b/src/lms_kernel/admin_connection.ts new file mode 100644 index 0000000..925f858 --- /dev/null +++ b/src/lms_kernel/admin_connection.ts @@ -0,0 +1,126 @@ +import type { InstitutionMembershipRecord, LmsConnectionRecord, LmsPlatformType } from './types.ts'; +import { failure, type KernelAdminResult } from './admin_result.ts'; +import { canGovern, type KernelAdminDeps, parseConnectionFields } from './admin_support.ts'; +import { adminGovernanceEvent } from './governance_events.ts'; + +export function createConnection( + deps: KernelAdminDeps, + command: { + readonly membership: InstitutionMembershipRecord; + readonly label: string; + readonly platformType: LmsPlatformType; + readonly issuer?: string; + readonly clientId?: string; + readonly deploymentId?: string; + readonly authorizationEndpoint?: string; + readonly accessTokenUrl?: string; + readonly jwksUrl?: string; + }, +): Promise> { + return (async () => { + if (!canGovern(command.membership)) { + return failure( + 'permission_denied', + 'Only institution owners and admins can create LMS connections.', + ); + } + const label = command.label.trim(); + if (label.length === 0 || label.length > 120) { + return failure('invalid_request', 'Connection label is required.'); + } + const connectionFields = parseConnectionFields(command); + if (!connectionFields.ok) { + return connectionFields; + } + const createdAt = deps.now().toISOString(); + const connectionId = deps.createId(); + const connection = await deps.repository.createLmsConnection({ + id: connectionId, + institutionId: command.membership.institutionId, + label, + platformType: command.platformType, + ...connectionFields.value, + now: createdAt, + event: adminGovernanceEvent({ + traceId: deps.createId(), + eventType: 'lms_connection.created', + institutionId: command.membership.institutionId, + lmsConnectionId: connectionId, + actorId: command.membership.userId, + occurredAt: createdAt, + }), + }); + if (command.platformType === 'synthetic') { + const active = await deps.repository.activateLmsConnection({ + id: connection.id, + validatedAt: createdAt, + event: adminGovernanceEvent({ + traceId: deps.createId(), + eventType: 'lms_connection.validated', + institutionId: connection.institutionId, + lmsConnectionId: connection.id, + actorId: command.membership.userId, + occurredAt: createdAt, + }), + }); + if (active === null) { + return failure('not_found', 'Synthetic connection was not found after creation.'); + } + await deps.repository.getOrCreateLmsContext({ + id: deps.createId(), + institutionId: active.institutionId, + lmsConnectionId: active.id, + providerContextId: 'synthetic-course', + label: 'Synthetic course', + now: createdAt, + }); + return { ok: true, value: active }; + } + // Real platforms carry an LTI registration in the lti-tool protocol cache. + // `lms_connections` stays authoritative; project it into that cache here so + // launches can complete without waiting for the login-time sync. + if (deps.syncLtiToolRegistration !== undefined) { + await deps.syncLtiToolRegistration(connection); + } + return { ok: true, value: connection }; + })(); +} + +export function disableConnection( + deps: KernelAdminDeps, + command: { + readonly membership: InstitutionMembershipRecord; + readonly connectionId: string; + }, +): Promise> { + return (async () => { + if (!canGovern(command.membership)) { + return failure( + 'permission_denied', + 'Only institution owners and admins can disable LMS connections.', + ); + } + const existing = await deps.repository.getLmsConnectionById(command.connectionId); + if (existing === null || existing.institutionId !== command.membership.institutionId) { + return failure('not_found', 'LMS connection was not found.'); + } + const disabledAt = deps.now().toISOString(); + const connection = await deps.repository.disableLmsConnection({ + id: command.connectionId, + institutionId: command.membership.institutionId, + disabledAt, + event: adminGovernanceEvent({ + traceId: deps.createId(), + eventType: 'lms_connection.disabled', + institutionId: command.membership.institutionId, + lmsConnectionId: command.connectionId, + actorId: command.membership.userId, + occurredAt: disabledAt, + }), + }); + if (connection === null) { + return failure('not_found', 'LMS connection was not found.'); + } + return { ok: true, value: connection }; + })(); +} diff --git a/src/lms_kernel/admin_context.ts b/src/lms_kernel/admin_context.ts new file mode 100644 index 0000000..fbe3eea --- /dev/null +++ b/src/lms_kernel/admin_context.ts @@ -0,0 +1,26 @@ +import type { Context } from '@hono/hono'; +import type { InstitutionMembershipRecord, InstitutionRecord } from './types.ts'; + +const KERNEL_ADMIN_CONTEXT = 'lmsKernelAdminContext'; + +export interface KernelAdminContext { + readonly institution: InstitutionRecord; + readonly membership: InstitutionMembershipRecord; +} + +export function setKernelAdminContext(context: Context, value: KernelAdminContext): void { + context.set(KERNEL_ADMIN_CONTEXT, value); +} + +export function getKernelAdminContext(context: Context): KernelAdminContext { + const value = context.get(KERNEL_ADMIN_CONTEXT); + if (!isKernelAdminContext(value)) { + throw new Error('Institution-scoped admin route reached without institution context.'); + } + return value; +} + +function isKernelAdminContext(value: unknown): value is KernelAdminContext { + return typeof value === 'object' && value !== null && + 'institution' in value && 'membership' in value; +} diff --git a/src/lms_kernel/admin_installation.ts b/src/lms_kernel/admin_installation.ts new file mode 100644 index 0000000..fa3270e --- /dev/null +++ b/src/lms_kernel/admin_installation.ts @@ -0,0 +1,388 @@ +import type { RosterCapability } from '../../sdk/worker-app-sdk.ts'; +import type { + AppInstallationRecord, + CapabilityGrantRecord, + InstitutionMembershipRecord, +} from './types.ts'; +import { failure, type KernelAdminResult } from './admin_result.ts'; +import { canGovern, type KernelAdminDeps } from './admin_support.ts'; +import { isRosterCapability } from './capabilities.ts'; +import { adminGovernanceEvent } from './governance_events.ts'; +import { mintGovernedRuntimeSession } from './launch_service.ts'; + +export function installApp( + deps: KernelAdminDeps, + command: { + readonly membership: InstitutionMembershipRecord; + readonly packageVersionId: number; + }, +): Promise> { + return (async () => { + if (!canGovern(command.membership)) { + return failure('permission_denied', 'Only institution owners and admins can install apps.'); + } + const packageVersion = await deps.packageRepository.getPackageVersionById( + command.packageVersionId, + ); + if (packageVersion === null || packageVersion.approvalStatus !== 'approved') { + return failure('not_found', 'Only an approved package version can be installed.'); + } + if ( + packageVersion.runtime.kind !== 'worker' || + packageVersion.capabilities.some((capability) => !isRosterCapability(capability)) + ) { + return failure( + 'invalid_request', + 'Governed LMS installations currently require a reviewed Worker app using roster capabilities only.', + ); + } + const installedAt = deps.now().toISOString(); + const existing = (await deps.repository.listInstallations( + command.membership.institutionId, + )).find((installation) => installation.appId === packageVersion.appId); + const installationId = existing?.id ?? deps.createId(); + const event = adminGovernanceEvent({ + traceId: deps.createId(), + eventType: existing === undefined + ? 'app_installation.created' + : 'app_installation.version_replaced', + institutionId: command.membership.institutionId, + appInstallationId: installationId, + appId: packageVersion.appId, + packageVersionId: packageVersion.id, + actorId: command.membership.userId, + occurredAt: installedAt, + }); + const installation = existing === undefined + ? await deps.repository.createInstallation({ + id: installationId, + institutionId: command.membership.institutionId, + appId: packageVersion.appId, + packageVersionId: packageVersion.id, + installedByUserId: command.membership.userId, + now: installedAt, + event, + }) + : await deps.repository.replaceInstallationPackageVersion({ + id: existing.id, + institutionId: command.membership.institutionId, + packageVersionId: packageVersion.id, + installedByUserId: command.membership.userId, + now: installedAt, + event, + }); + if (installation === null) { + return failure('not_found', 'App installation was not found.'); + } + return { ok: true, value: installation }; + })(); +} + +export function grantCapability( + deps: KernelAdminDeps, + command: { + readonly membership: InstitutionMembershipRecord; + readonly installationId: string; + readonly capability: RosterCapability; + readonly lmsContextId: string | null; + }, +): Promise> { + return (async () => { + if (!canGovern(command.membership)) { + return failure( + 'permission_denied', + 'Only institution owners and admins can approve data access.', + ); + } + const installation = await deps.repository.getInstallationById(command.installationId); + if ( + installation === null || + installation.institutionId !== command.membership.institutionId + ) { + return failure('not_found', 'App installation was not found.'); + } + const packageVersion = await deps.packageRepository.getPackageVersionById( + installation.packageVersionId, + ); + if (packageVersion === null || !packageVersion.capabilities.includes(command.capability)) { + return failure( + 'invalid_request', + 'The reviewed app version did not request this capability.', + ); + } + if (command.lmsContextId !== null) { + const context = await deps.repository.getLmsContextById(command.lmsContextId); + if ( + context === null || + context.institutionId !== command.membership.institutionId + ) { + return failure('not_found', 'LMS context was not found.'); + } + } + const scopeKind = command.lmsContextId === null ? 'institution' : 'context'; + const existingGrant = (await deps.repository.listCapabilityGrants(installation.id)).find( + (grant) => + grant.packageVersionId === installation.packageVersionId && + grant.capability === command.capability && + grant.scope.kind === scopeKind && + (grant.scope.kind === 'institution' || + grant.scope.lmsContextId === command.lmsContextId), + ); + const grantId = existingGrant?.id ?? deps.createId(); + const approvedAt = deps.now().toISOString(); + const grant = await deps.repository.createCapabilityGrant({ + id: grantId, + institutionId: installation.institutionId, + appInstallationId: installation.id, + packageVersionId: installation.packageVersionId, + capability: command.capability, + scope: command.lmsContextId === null + ? { kind: 'institution' } + : { kind: 'context', lmsContextId: command.lmsContextId }, + approvedByUserId: command.membership.userId, + approvedAt, + event: adminGovernanceEvent({ + traceId: deps.createId(), + eventType: 'capability_grant.approved', + institutionId: installation.institutionId, + appInstallationId: installation.id, + appId: installation.appId, + packageVersionId: installation.packageVersionId, + actorId: command.membership.userId, + capability: command.capability, + capabilityGrantId: grantId, + occurredAt: approvedAt, + }), + }); + return { ok: true, value: grant }; + })(); +} + +export function activateInstallation( + deps: KernelAdminDeps, + command: { + readonly membership: InstitutionMembershipRecord; + readonly installationId: string; + }, +): Promise> { + return (async () => { + if (!canGovern(command.membership)) { + return failure( + 'permission_denied', + 'Only institution owners and admins can activate apps.', + ); + } + const installation = await deps.repository.getInstallationById(command.installationId); + if ( + installation === null || + installation.institutionId !== command.membership.institutionId + ) { + return failure('not_found', 'App installation was not found.'); + } + const packageVersion = await deps.packageRepository.getPackageVersionById( + installation.packageVersionId, + ); + if (packageVersion === null) { + return failure('not_found', 'Reviewed package version was not found.'); + } + const required = packageVersion.capabilities.filter(isRosterCapability); + const grants = await deps.repository.listCapabilityGrants(installation.id); + const missing = required.filter((capability) => + !grants.some((grant) => + grant.capability === capability && + grant.packageVersionId === installation.packageVersionId && + grant.revokedAt === null + ) + ); + if (missing.length > 0) { + return failure( + 'approval_incomplete', + `Approve every requested capability before activation: ${missing.join(', ')}.`, + ); + } + const activatedAt = deps.now().toISOString(); + const activated = await deps.repository.activateInstallation({ + id: installation.id, + activatedAt, + event: adminGovernanceEvent({ + traceId: deps.createId(), + eventType: 'app_installation.activated', + institutionId: installation.institutionId, + appInstallationId: installation.id, + appId: installation.appId, + packageVersionId: installation.packageVersionId, + actorId: command.membership.userId, + occurredAt: activatedAt, + }), + }); + return activated === null + ? failure('not_found', 'App installation was not found.') + : { ok: true, value: activated }; + })(); +} + +export function revokeGrant( + deps: KernelAdminDeps, + command: { + readonly membership: InstitutionMembershipRecord; + readonly grantId: string; + }, +): Promise> { + return (async () => { + if (!canGovern(command.membership)) { + return failure( + 'permission_denied', + 'Only institution owners and admins can revoke access.', + ); + } + const existingGrant = (await deps.repository.listCapabilityGrantsForInstitution( + command.membership.institutionId, + )).find((grant) => grant.id === command.grantId && grant.revokedAt === null); + if (existingGrant === undefined) { + return failure('not_found', 'Capability grant was not found.'); + } + const revokedAt = deps.now().toISOString(); + const grant = await deps.repository.revokeCapabilityGrant({ + id: command.grantId, + institutionId: command.membership.institutionId, + revokedByUserId: command.membership.userId, + revokedAt, + event: adminGovernanceEvent({ + traceId: deps.createId(), + eventType: 'capability_grant.revoked', + institutionId: existingGrant.institutionId, + appInstallationId: existingGrant.appInstallationId, + packageVersionId: existingGrant.packageVersionId, + actorId: command.membership.userId, + capability: existingGrant.capability, + capabilityGrantId: existingGrant.id, + occurredAt: revokedAt, + }), + }); + if (grant === null) { + return failure('not_found', 'Capability grant was not found.'); + } + return { ok: true, value: grant }; + })(); +} + +export function createSyntheticLaunch( + deps: KernelAdminDeps, + command: { + readonly membership: InstitutionMembershipRecord; + readonly installationId: string; + }, +): Promise< + KernelAdminResult<{ + readonly runtimeSessionId: string; + readonly runtimeSessionToken: string; + readonly expiresAt: string; + }> +> { + return (async () => { + if (!canGovern(command.membership)) { + return failure( + 'permission_denied', + 'Only institution owners and admins can create a synthetic launch.', + ); + } + const installation = await deps.repository.getInstallationById(command.installationId); + if ( + installation === null || + installation.institutionId !== command.membership.institutionId || + installation.status !== 'active' + ) { + return failure('not_found', 'An active app installation was not found.'); + } + const packageVersion = await deps.packageRepository.getPackageVersionById( + installation.packageVersionId, + ); + if ( + packageVersion === null || + packageVersion.approvalStatus !== 'approved' || + packageVersion.runtime.kind !== 'worker' || + packageVersion.appId !== installation.appId + ) { + return failure('not_found', 'The installation package is not runnable.'); + } + const connection = (await deps.repository.listLmsConnections( + command.membership.institutionId, + )).find((item) => item.platformType === 'synthetic' && item.status === 'active'); + if (connection === undefined) { + return failure('not_found', 'Create an active synthetic LMS connection first.'); + } + const context = (await deps.repository.listLmsContexts( + command.membership.institutionId, + )).find((item) => item.lmsConnectionId === connection.id); + if (context === undefined) { + return failure('not_found', 'The synthetic LMS context was not found.'); + } + const minted = await mintGovernedRuntimeSession({ + repository: deps.repository, + connection, + installation, + packageVersion: { + appId: packageVersion.appId, + id: packageVersion.id, + version: packageVersion.version, + capabilities: packageVersion.capabilities, + }, + providerContextId: context.providerContextId, + contextLabel: context.label, + providerSubjectId: `synthetic-admin:${command.membership.userId}`, + actorType: 'administrator', + connectorKind: 'synthetic', + ltiToolSessionId: null, + nrpsContextMembershipsUrl: null, + nrpsServiceVersions: [], + createId: deps.createId, + now: deps.now(), + }); + return { + ok: true, + value: { + runtimeSessionId: minted.runtimeSessionId, + runtimeSessionToken: minted.runtimeSessionToken, + expiresAt: minted.expiresAt, + }, + }; + })(); +} + +export function disableInstallation( + deps: KernelAdminDeps, + command: { + readonly membership: InstitutionMembershipRecord; + readonly installationId: string; + }, +): Promise> { + return (async () => { + if (!canGovern(command.membership)) { + return failure('permission_denied', 'Only institution owners and admins can disable apps.'); + } + const existing = await deps.repository.getInstallationById(command.installationId); + if (existing === null || existing.institutionId !== command.membership.institutionId) { + return failure('not_found', 'App installation was not found.'); + } + const disabledAt = deps.now().toISOString(); + const installation = await deps.repository.disableInstallation({ + id: command.installationId, + institutionId: command.membership.institutionId, + disabledAt, + event: adminGovernanceEvent({ + traceId: deps.createId(), + eventType: 'app_installation.disabled', + institutionId: command.membership.institutionId, + appInstallationId: command.installationId, + appId: existing.appId, + packageVersionId: existing.packageVersionId, + actorId: command.membership.userId, + occurredAt: disabledAt, + }), + }); + if (installation === null) { + return failure('not_found', 'App installation was not found.'); + } + return { ok: true, value: installation }; + })(); +} diff --git a/src/lms_kernel/admin_institution.ts b/src/lms_kernel/admin_institution.ts new file mode 100644 index 0000000..06b6f99 --- /dev/null +++ b/src/lms_kernel/admin_institution.ts @@ -0,0 +1,89 @@ +import type { InstitutionMembershipRecord, InstitutionRecord } from './types.ts'; +import { failure, type KernelAdminResult } from './admin_result.ts'; +import { canGovern, institutionSlug, type KernelAdminDeps, sha256 } from './admin_support.ts'; +import { adminGovernanceEvent } from './governance_events.ts'; + +export function createInstitution( + deps: KernelAdminDeps, + command: { readonly userId: string; readonly name: string }, +): Promise> { + return (async () => { + if ((await deps.repository.listMembershipsForUser(command.userId)).length > 0) { + return failure('invalid_request', 'This administrator already belongs to an institution.'); + } + const name = command.name.trim(); + if (name.length < 2 || name.length > 120) { + return failure('invalid_request', 'Institution name must be between 2 and 120 characters.'); + } + const createdAt = deps.now().toISOString(); + const institutionId = deps.createId(); + const institution = await deps.repository.createInstitutionWithOwner({ + id: institutionId, + slug: institutionSlug(name, deps.createId()), + name, + ownerUserId: command.userId, + now: createdAt, + event: adminGovernanceEvent({ + traceId: deps.createId(), + eventType: 'institution.created', + institutionId, + actorId: command.userId, + occurredAt: createdAt, + }), + }); + return { ok: true, value: institution }; + })(); +} + +export function createInvite( + deps: KernelAdminDeps, + command: { + readonly membership: InstitutionMembershipRecord; + readonly role: 'admin' | 'auditor'; + }, +): Promise> { + return (async () => { + if (!canGovern(command.membership)) { + return failure('permission_denied', 'Only institution owners and admins can invite users.'); + } + const token = deps.createSecret(); + const createdAt = deps.now(); + const expiresAt = new Date(createdAt.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(); + const inviteId = deps.createId(); + await deps.repository.createInvite({ + id: inviteId, + institutionId: command.membership.institutionId, + tokenHash: await sha256(token), + role: command.role, + createdByUserId: command.membership.userId, + createdAt: createdAt.toISOString(), + expiresAt, + event: adminGovernanceEvent({ + traceId: deps.createId(), + eventType: 'institution_invite.created', + institutionId: command.membership.institutionId, + actorId: command.membership.userId, + occurredAt: createdAt.toISOString(), + }), + }); + return { ok: true, value: { token, expiresAt } }; + })(); +} + +export function acceptInvite( + deps: KernelAdminDeps, + command: { readonly userId: string; readonly token: string }, +): Promise> { + return (async () => { + const membership = await deps.repository.acceptInvite({ + tokenHash: await sha256(command.token), + userId: command.userId, + acceptedAt: deps.now().toISOString(), + traceId: deps.createId(), + }); + if (membership === null) { + return failure('not_found', 'This invitation is invalid, expired, or already used.'); + } + return { ok: true, value: membership }; + })(); +} diff --git a/src/lms_kernel/admin_middleware.ts b/src/lms_kernel/admin_middleware.ts new file mode 100644 index 0000000..c817bdc --- /dev/null +++ b/src/lms_kernel/admin_middleware.ts @@ -0,0 +1,44 @@ +import type { MiddlewareHandler } from '@hono/hono'; +import type { AppServices } from '../app_services.ts'; +import { getAdminSession } from '../admin_auth/context.ts'; +import { setKernelAdminContext } from './admin_context.ts'; + +type KernelAdminContextServices = Pick; + +export function createKernelAdminContextMiddleware( + services: KernelAdminContextServices, +): MiddlewareHandler { + return async (context, next) => { + const session = getAdminSession(context); + const repository = services.getLmsKernelRepository(); + const memberships = await repository.listMembershipsForUser(session.user.id); + const path = new URL(context.req.url).pathname; + + if (memberships.length === 0) { + if (path === '/admin/onboarding' || path.startsWith('/admin/invites/')) { + await next(); + return; + } + return context.redirect('/admin/onboarding', 303); + } + if (memberships.length !== 1) { + return context.text( + 'Institution selection is ambiguous. An administrator must belong to exactly one institution.', + 409, + ); + } + const membership = memberships[0]; + if (membership === undefined) { + return context.text('Institution membership could not be resolved.', 409); + } + const institution = await repository.getInstitutionById(membership.institutionId); + if (institution === null) { + return context.text('Institution membership is invalid.', 409); + } + setKernelAdminContext(context, { institution, membership }); + if (path === '/admin/onboarding') { + return context.redirect('/admin/governance', 303); + } + await next(); + }; +} diff --git a/src/lms_kernel/admin_result.ts b/src/lms_kernel/admin_result.ts new file mode 100644 index 0000000..420c23c --- /dev/null +++ b/src/lms_kernel/admin_result.ts @@ -0,0 +1,22 @@ +export type KernelAdminFailureCode = + | 'invalid_request' + | 'permission_denied' + | 'not_found' + | 'approval_incomplete'; + +export type KernelAdminResult = + | { readonly ok: true; readonly value: T } + | { + readonly ok: false; + readonly error: { + readonly code: KernelAdminFailureCode; + readonly message: string; + }; + }; + +export function failure( + code: KernelAdminFailureCode, + message: string, +): KernelAdminResult { + return { ok: false, error: { code, message } }; +} diff --git a/src/lms_kernel/admin_service.ts b/src/lms_kernel/admin_service.ts new file mode 100644 index 0000000..615dabc --- /dev/null +++ b/src/lms_kernel/admin_service.ts @@ -0,0 +1,122 @@ +import type { RosterCapability } from '../../sdk/worker-app-sdk.ts'; +import type { PackageReviewRepository } from '../package_review/repository.ts'; +import type { LmsKernelRepository } from './repository.ts'; +import type { + AppInstallationRecord, + CapabilityGrantRecord, + InstitutionMembershipRecord, + InstitutionRecord, + LmsConnectionRecord, + LmsPlatformType, +} from './types.ts'; +import type { KernelAdminResult } from './admin_result.ts'; +import type { KernelAdminDeps } from './admin_support.ts'; +import { acceptInvite, createInstitution, createInvite } from './admin_institution.ts'; +import { createConnection, disableConnection } from './admin_connection.ts'; +import { + activateInstallation, + createSyntheticLaunch, + disableInstallation, + grantCapability, + installApp, + revokeGrant, +} from './admin_installation.ts'; + +export type { KernelAdminFailureCode, KernelAdminResult } from './admin_result.ts'; + +export interface LmsKernelAdminService { + createInstitution(input: { + readonly userId: string; + readonly name: string; + }): Promise>; + createInvite(input: { + readonly membership: InstitutionMembershipRecord; + readonly role: 'admin' | 'auditor'; + }): Promise>; + acceptInvite(input: { + readonly userId: string; + readonly token: string; + }): Promise>; + createConnection(input: { + readonly membership: InstitutionMembershipRecord; + readonly label: string; + readonly platformType: LmsPlatformType; + readonly issuer?: string; + readonly clientId?: string; + readonly deploymentId?: string; + readonly authorizationEndpoint?: string; + readonly accessTokenUrl?: string; + readonly jwksUrl?: string; + }): Promise>; + installApp(input: { + readonly membership: InstitutionMembershipRecord; + readonly packageVersionId: number; + }): Promise>; + grantCapability(input: { + readonly membership: InstitutionMembershipRecord; + readonly installationId: string; + readonly capability: RosterCapability; + readonly lmsContextId: string | null; + }): Promise>; + activateInstallation(input: { + readonly membership: InstitutionMembershipRecord; + readonly installationId: string; + }): Promise>; + revokeGrant(input: { + readonly membership: InstitutionMembershipRecord; + readonly grantId: string; + }): Promise>; + createSyntheticLaunch(input: { + readonly membership: InstitutionMembershipRecord; + readonly installationId: string; + }): Promise< + KernelAdminResult<{ + readonly runtimeSessionId: string; + readonly runtimeSessionToken: string; + readonly expiresAt: string; + }> + >; + disableInstallation(input: { + readonly membership: InstitutionMembershipRecord; + readonly installationId: string; + }): Promise>; + disableConnection(input: { + readonly membership: InstitutionMembershipRecord; + readonly connectionId: string; + }): Promise>; +} + +export function createLmsKernelAdminService(input: { + readonly repository: LmsKernelRepository; + readonly packageRepository: PackageReviewRepository; + readonly createId?: () => string; + readonly createSecret?: () => string; + readonly now?: () => Date; + readonly syncLtiToolRegistration?: (connection: LmsConnectionRecord) => Promise; +}): LmsKernelAdminService { + const deps: KernelAdminDeps = { + repository: input.repository, + packageRepository: input.packageRepository, + createId: input.createId ?? (() => crypto.randomUUID()), + createSecret: input.createSecret ?? + (() => `${crypto.randomUUID()}${crypto.randomUUID()}`.replaceAll('-', '')), + now: input.now ?? (() => new Date()), + ...(input.syncLtiToolRegistration === undefined + ? {} + : { syncLtiToolRegistration: input.syncLtiToolRegistration }), + }; + + return { + createInstitution: (command) => createInstitution(deps, command), + createInvite: (command) => createInvite(deps, command), + acceptInvite: (command) => acceptInvite(deps, command), + createConnection: (command) => createConnection(deps, command), + installApp: (command) => installApp(deps, command), + grantCapability: (command) => grantCapability(deps, command), + activateInstallation: (command) => activateInstallation(deps, command), + revokeGrant: (command) => revokeGrant(deps, command), + createSyntheticLaunch: (command) => createSyntheticLaunch(deps, command), + disableInstallation: (command) => disableInstallation(deps, command), + disableConnection: (command) => disableConnection(deps, command), + }; +} diff --git a/src/lms_kernel/admin_service_test.ts b/src/lms_kernel/admin_service_test.ts new file mode 100644 index 0000000..5a18392 --- /dev/null +++ b/src/lms_kernel/admin_service_test.ts @@ -0,0 +1,312 @@ +import { assertEquals } from '@std/assert'; +import type { RosterCapability } from '../../sdk/worker-app-sdk.ts'; +import { + buildPackageVersionRecord, + createInMemoryPackageReviewRepository, +} from '../test_helpers/package_review.ts'; +import { createLmsKernelAdminService } from './admin_service.ts'; +import { + buildGovernedSyntheticSession, + MemoryLmsKernelRepository, +} from './repository_memory_test_support.ts'; +import type { GovernedWrite } from './repository.ts'; +import type { + AppInstallationRecord, + CapabilityGrantRecord, + InstitutionMembershipRecord, + LmsContextRecord, +} from './types.ts'; + +const NOW = '2026-07-20T15:00:00.000Z'; + +Deno.test('kernel administration requires explicit approval for every exact app version', async () => { + const repository = new AdminMemoryLmsKernelRepository(); + const packageRepository = createInMemoryPackageReviewRepository({ + packageVersions: [ + buildPhotoRosterVersion(1, '0.1.0'), + buildPhotoRosterVersion(2, '0.2.0'), + ], + }); + const service = createLmsKernelAdminService({ + repository, + packageRepository, + createId: sequentialIds(), + createSecret: () => 'test-secret', + now: () => new Date(NOW), + }); + + const installed = await service.installApp({ + membership: ownerMembership(), + packageVersionId: 1, + }); + assertEquals(installed.ok, true); + if (!installed.ok) return; + + const incomplete = await service.activateInstallation({ + membership: ownerMembership(), + installationId: installed.value.id, + }); + assertEquals(incomplete, { + ok: false, + error: { + code: 'approval_incomplete', + message: + 'Approve every requested capability before activation: lms.roster.members.read, lms.roster.profile_photo.read.', + }, + }); + + for ( + const capability of [ + 'lms.roster.members.read', + 'lms.roster.profile_photo.read', + ] as const + ) { + const granted = await service.grantCapability({ + membership: ownerMembership(), + installationId: installed.value.id, + capability, + lmsContextId: null, + }); + assertEquals(granted.ok, true); + } + + const activated = await service.activateInstallation({ + membership: ownerMembership(), + installationId: installed.value.id, + }); + assertEquals(activated.ok && activated.value.status, 'active'); + + const upgraded = await service.installApp({ + membership: ownerMembership(), + packageVersionId: 2, + }); + assertEquals(upgraded.ok, true); + if (!upgraded.ok) return; + assertEquals(upgraded.value.status, 'pending_approval'); + + const oldGrantsDoNotCarry = await service.activateInstallation({ + membership: ownerMembership(), + installationId: upgraded.value.id, + }); + assertEquals(oldGrantsDoNotCarry.ok, false); + if (!oldGrantsDoNotCarry.ok) { + assertEquals(oldGrantsDoNotCarry.error.code, 'approval_incomplete'); + } + assertEquals( + repository.events.map((event) => event.eventType), + [ + 'app_installation.created', + 'capability_grant.approved', + 'capability_grant.approved', + 'app_installation.activated', + 'app_installation.version_replaced', + ], + ); +}); + +Deno.test('auditors cannot mutate governance and context grants cannot cross institutions', async () => { + const repository = new AdminMemoryLmsKernelRepository(); + repository.contexts.push({ + id: 'foreign-context', + institutionId: 'institution-2', + lmsConnectionId: 'connection-2', + providerContextId: 'course-2', + label: 'Foreign course', + createdAt: NOW, + updatedAt: NOW, + }); + const service = createLmsKernelAdminService({ + repository, + packageRepository: createInMemoryPackageReviewRepository({ + packageVersions: [buildPhotoRosterVersion(1, '0.1.0')], + }), + createId: sequentialIds(), + now: () => new Date(NOW), + }); + + const denied = await service.installApp({ + membership: { ...ownerMembership(), role: 'auditor' }, + packageVersionId: 1, + }); + assertEquals(denied.ok, false); + if (!denied.ok) assertEquals(denied.error.code, 'permission_denied'); + + const installed = await service.installApp({ + membership: ownerMembership(), + packageVersionId: 1, + }); + if (!installed.ok) throw new Error(installed.error.message); + + const crossTenantGrant = await service.grantCapability({ + membership: ownerMembership(), + installationId: installed.value.id, + capability: 'lms.roster.members.read', + lmsContextId: 'foreign-context', + }); + assertEquals(crossTenantGrant.ok, false); + if (!crossTenantGrant.ok) assertEquals(crossTenantGrant.error.code, 'not_found'); +}); + +class AdminMemoryLmsKernelRepository extends MemoryLmsKernelRepository { + readonly installations: AppInstallationRecord[] = []; + readonly contexts: LmsContextRecord[] = []; + + constructor() { + super({ session: buildGovernedSyntheticSession(), grants: [] }); + } + + override getLmsContextById(id: string): Promise { + return Promise.resolve(this.contexts.find((context) => context.id === id) ?? null); + } + + override createInstallation( + input: GovernedWrite & { + readonly id: string; + readonly institutionId: string; + readonly appId: string; + readonly packageVersionId: number; + readonly installedByUserId: string; + readonly now: string; + }, + ): Promise { + const installation: AppInstallationRecord = { + id: input.id, + institutionId: input.institutionId, + appId: input.appId, + packageVersionId: input.packageVersionId, + status: 'pending_approval', + installedByUserId: input.installedByUserId, + installedAt: input.now, + activatedAt: null, + updatedAt: input.now, + }; + this.installations.push(installation); + this.recordGovernanceEvent(input.event); + return Promise.resolve(installation); + } + + override replaceInstallationPackageVersion( + input: GovernedWrite & { + readonly id: string; + readonly institutionId: string; + readonly packageVersionId: number; + readonly installedByUserId: string; + readonly now: string; + }, + ): Promise { + const installation = this.updateInstallation(input.id, input.institutionId, (current) => ({ + ...current, + packageVersionId: input.packageVersionId, + status: 'pending_approval', + installedByUserId: input.installedByUserId, + installedAt: input.now, + activatedAt: null, + updatedAt: input.now, + })); + if (installation !== null) this.recordGovernanceEvent(input.event); + return Promise.resolve(installation); + } + + override getInstallationById(id: string): Promise { + return Promise.resolve( + this.installations.find((installation) => installation.id === id) ?? null, + ); + } + + override listInstallations(institutionId: string): Promise { + return Promise.resolve( + this.installations.filter((installation) => installation.institutionId === institutionId), + ); + } + + override activateInstallation( + input: GovernedWrite & { + readonly id: string; + readonly activatedAt: string; + }, + ): Promise { + const installation = this.updateInstallation(input.id, null, (current) => ({ + ...current, + status: 'active', + activatedAt: input.activatedAt, + updatedAt: input.activatedAt, + })); + if (installation !== null) this.recordGovernanceEvent(input.event); + return Promise.resolve(installation); + } + + override createCapabilityGrant( + input: GovernedWrite & { + readonly id: string; + readonly institutionId: string; + readonly appInstallationId: string; + readonly packageVersionId: number; + readonly capability: RosterCapability; + readonly scope: + | { readonly kind: 'institution' } + | { readonly kind: 'context'; readonly lmsContextId: string }; + readonly approvedByUserId: string; + readonly approvedAt: string; + }, + ): Promise { + const grant: CapabilityGrantRecord = { + id: input.id, + institutionId: input.institutionId, + appInstallationId: input.appInstallationId, + packageVersionId: input.packageVersionId, + capability: input.capability, + scope: input.scope, + approvedByUserId: input.approvedByUserId, + approvedAt: input.approvedAt, + revokedByUserId: null, + revokedAt: null, + }; + this.grants.push(grant); + this.recordGovernanceEvent(input.event); + return Promise.resolve(grant); + } + + private updateInstallation( + id: string, + institutionId: string | null, + update: (current: AppInstallationRecord) => AppInstallationRecord, + ): AppInstallationRecord | null { + const index = this.installations.findIndex((installation) => + installation.id === id && + (institutionId === null || installation.institutionId === institutionId) + ); + if (index < 0) return null; + const next = update(this.installations[index]!); + this.installations[index] = next; + return next; + } +} + +function buildPhotoRosterVersion(id: number, version: string) { + return buildPackageVersionRecord({ + id, + appId: 'photo-roster', + version, + approvalStatus: 'approved', + reviewedAt: NOW, + runtime: { kind: 'worker', entrypoint: '/dist/worker.js' }, + capabilities: [ + 'lms.roster.members.read', + 'lms.roster.profile_photo.read', + ], + }); +} + +function ownerMembership(): InstitutionMembershipRecord { + return { + institutionId: 'institution-1', + userId: 'owner-1', + role: 'owner', + createdAt: NOW, + }; +} + +function sequentialIds(): () => string { + let next = 0; + return () => `id-${++next}`; +} diff --git a/src/lms_kernel/admin_support.ts b/src/lms_kernel/admin_support.ts new file mode 100644 index 0000000..8da9ba0 --- /dev/null +++ b/src/lms_kernel/admin_support.ts @@ -0,0 +1,89 @@ +import type { PackageReviewRepository } from '../package_review/repository.ts'; +import type { LmsKernelRepository } from './repository.ts'; +import type { InstitutionMembershipRecord, LmsConnectionRecord, LmsPlatformType } from './types.ts'; +import { failure, type KernelAdminResult } from './admin_result.ts'; + +export interface KernelAdminDeps { + readonly repository: LmsKernelRepository; + readonly packageRepository: PackageReviewRepository; + readonly createId: () => string; + readonly createSecret: () => string; + readonly now: () => Date; + readonly syncLtiToolRegistration?: (connection: LmsConnectionRecord) => Promise; +} + +export function canGovern(membership: InstitutionMembershipRecord): boolean { + return membership.role === 'owner' || membership.role === 'admin'; +} + +export function isHttpsUrl(value: string): boolean { + try { + return new URL(value).protocol === 'https:'; + } catch { + return false; + } +} + +export function institutionSlug(name: string, suffix: string): string { + const base = name.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '').slice(0, 48) || 'institution'; + return `${base}-${suffix.replaceAll('-', '').slice(0, 8)}`; +} + +export async function sha256(value: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +export function parseConnectionFields(input: { + readonly platformType: LmsPlatformType; + readonly issuer?: string; + readonly clientId?: string; + readonly deploymentId?: string; + readonly authorizationEndpoint?: string; + readonly accessTokenUrl?: string; + readonly jwksUrl?: string; +}): KernelAdminResult<{ + readonly issuer: string; + readonly clientId: string; + readonly deploymentId: string; + readonly authorizationEndpoint: string; + readonly accessTokenUrl: string; + readonly jwksUrl: string; +}> { + if (input.platformType === 'synthetic') { + return { + ok: true, + value: { + issuer: 'https://synthetic.lantern.invalid', + clientId: `synthetic-${crypto.randomUUID()}`, + deploymentId: `synthetic-${crypto.randomUUID()}`, + authorizationEndpoint: 'https://synthetic.lantern.invalid/authorize', + accessTokenUrl: 'https://synthetic.lantern.invalid/token', + jwksUrl: 'https://synthetic.lantern.invalid/jwks', + }, + }; + } + const values = { + issuer: input.issuer?.trim() ?? '', + clientId: input.clientId?.trim() ?? '', + deploymentId: input.deploymentId?.trim() ?? '', + authorizationEndpoint: input.authorizationEndpoint?.trim() ?? '', + accessTokenUrl: input.accessTokenUrl?.trim() ?? '', + jwksUrl: input.jwksUrl?.trim() ?? '', + }; + if ( + values.clientId === '' || + values.deploymentId === '' || + !isHttpsUrl(values.issuer) || + !isHttpsUrl(values.authorizationEndpoint) || + !isHttpsUrl(values.accessTokenUrl) || + !isHttpsUrl(values.jwksUrl) + ) { + return failure( + 'invalid_request', + 'Real LMS connections require an issuer, client ID, deployment ID, and HTTPS authorization, token, and JWKS URLs.', + ); + } + return { ok: true, value: values }; +} diff --git a/src/lms_kernel/capabilities.ts b/src/lms_kernel/capabilities.ts new file mode 100644 index 0000000..4e3171a --- /dev/null +++ b/src/lms_kernel/capabilities.ts @@ -0,0 +1,22 @@ +import type { RosterCapability } from '../../sdk/worker-app-sdk.ts'; + +export const ROSTER_CAPABILITIES = [ + 'lms.roster.members.read', + 'lms.roster.contact.read', + 'lms.roster.profile_photo.read', +] as const satisfies readonly RosterCapability[]; + +export function isRosterCapability(value: string): value is RosterCapability { + return (ROSTER_CAPABILITIES as readonly string[]).includes(value); +} + +export function parseRosterCapability(value: string): RosterCapability | null { + return isRosterCapability(value) ? value : null; +} + +export function requireRosterCapability(value: unknown, field = 'capability'): RosterCapability { + if (typeof value !== 'string' || !isRosterCapability(value)) { + throw new Error(`${field} used an unsupported value.`); + } + return value; +} diff --git a/src/lms_kernel/connector.ts b/src/lms_kernel/connector.ts new file mode 100644 index 0000000..927e2fe --- /dev/null +++ b/src/lms_kernel/connector.ts @@ -0,0 +1,172 @@ +import type { PrimitiveErrorCode, RosterRole } from '../../sdk/worker-app-sdk.ts'; +import type { LanternLtiToolContext } from '../lti/tool_provider.ts'; +import { requireVerifiedLtiSessionWithContext } from '../lti/session_boundary.ts'; +import type { + GovernedRuntimeSession, + RosterConnectorMember, + RosterConnectorPage, +} from './types.ts'; + +export type RosterConnectorResult = + | { readonly ok: true; readonly page: RosterConnectorPage } + | { + readonly ok: false; + readonly errorCode: Extract; + }; + +export interface RosterConnector { + listPage(input: { + readonly session: GovernedRuntimeSession; + readonly continuation: string | null; + }): Promise; +} + +export function createNrpsRosterConnector( + getContext: () => Promise, +): RosterConnector { + return { + async listPage(input) { + if (input.session.ltiToolSessionId === null) { + return { ok: false, errorCode: 'upstream_unavailable' }; + } + + try { + const verified = await requireVerifiedLtiSessionWithContext({ + getContext, + sessionId: input.session.ltiToolSessionId, + missingMessage: 'Verified LTI session is required for roster access.', + notFoundMessage: 'Verified LTI session for roster access was not found.', + }); + const verifiedNrps = verified.session.services?.nrps; + if ( + verifiedNrps === undefined || + verifiedNrps.membershipUrl !== input.session.nrpsContextMembershipsUrl || + !sameStrings(verifiedNrps.versions, input.session.nrpsServiceVersions) + ) { + return { ok: false, errorCode: 'upstream_unavailable' }; + } + const result = await verified.context.tool + .createAdvantage(verified.session) + .getMembersPage(input.continuation ?? undefined); + + if (!result.success) { + const message = result.error.message.toLowerCase(); + return { + ok: false, + errorCode: message.includes('429') || message.includes('rate') + ? 'rate_limited' + : 'upstream_unavailable', + }; + } + + return { + ok: true, + page: { + members: result.data.members.map((member) => ({ + providerSubjectId: member.userId, + displayName: member.name.trim() || 'Name unavailable', + roles: normalizeRoles(member.roles), + status: normalizeStatus(member.status), + email: member.email ?? null, + pictureUrl: member.picture ?? null, + })), + nextContinuation: result.data.nextUrl ?? null, + }, + }; + } catch { + return { ok: false, errorCode: 'upstream_unavailable' }; + } + }, + }; +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && + [...left].sort().every((value, index) => value === [...right].sort()[index]); +} + +export function createSyntheticRosterConnector(): RosterConnector { + const members = buildSyntheticMembers(); + const pageSize = 24; + + return { + listPage(input) { + const offset = parseSyntheticOffset(input.continuation); + if (offset === null) { + return Promise.resolve({ ok: false, errorCode: 'upstream_unavailable' }); + } + const page = members.slice(offset, offset + pageSize); + const nextOffset = offset + page.length; + return Promise.resolve({ + ok: true, + page: { + members: page, + nextContinuation: nextOffset < members.length ? String(nextOffset) : null, + }, + }); + }, + }; +} + +function normalizeRoles(roles: readonly string[]): RosterRole[] { + const normalized = new Set(); + for (const role of roles) { + const value = role.toLowerCase(); + if (value.includes('administrator')) { + normalized.add('administrator'); + } else if (value.includes('instructor') || value.includes('teachingassistant')) { + normalized.add('instructor'); + } else if (value.includes('learner') || value.includes('student')) { + normalized.add('learner'); + } else { + normalized.add('other'); + } + } + return normalized.size === 0 ? ['other'] : [...normalized]; +} + +function normalizeStatus(value: string): RosterConnectorMember['status'] { + const status = value.toLowerCase(); + if (status === 'active') { + return 'active'; + } + if (status === 'inactive' || status === 'deleted') { + return 'inactive'; + } + return 'unknown'; +} + +function parseSyntheticOffset(value: string | null): number | null { + if (value === null) { + return 0; + } + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null; +} + +function buildSyntheticMembers(): RosterConnectorMember[] { + const firstNames = [ + 'Avery', + 'Jordan', + 'Morgan', + 'Riley', + 'Cameron', + 'Taylor', + 'Casey', + 'Quinn', + ]; + const lastNames = ['Allen', 'Brooks', 'Chen', 'Diaz', 'Evans', 'Foster', 'Gupta', 'Hall']; + return Array.from({ length: 57 }, (_, index) => { + const firstName = firstNames[index % firstNames.length]!; + const lastName = lastNames[Math.floor(index / firstNames.length) % lastNames.length]!; + const ordinal = index + 1; + return { + providerSubjectId: `synthetic-user-${ordinal}`, + displayName: `${firstName} ${lastName}`, + roles: index === 0 ? ['instructor'] : ['learner'], + status: 'active', + email: `${firstName}.${lastName}.${ordinal}@example.edu`.toLowerCase(), + pictureUrl: null, + }; + }); +} diff --git a/src/lms_kernel/connector_test.ts b/src/lms_kernel/connector_test.ts new file mode 100644 index 0000000..636edc7 --- /dev/null +++ b/src/lms_kernel/connector_test.ts @@ -0,0 +1,54 @@ +import { assertEquals } from '@std/assert'; +import { createSyntheticRosterConnector } from './connector.ts'; +import type { GovernedRuntimeSession } from './types.ts'; + +Deno.test('synthetic roster connector follows the same opaque page seam as NRPS', async () => { + const connector = createSyntheticRosterConnector(); + const first = await connector.listPage({ + session: buildSyntheticSession(), + continuation: null, + }); + + assertEquals(first.ok, true); + if (!first.ok) { + throw new Error('Expected synthetic roster page.'); + } + assertEquals(first.page.members.length, 24); + assertEquals(first.page.members[0]?.roles, ['instructor']); + assertEquals(first.page.nextContinuation, '24'); + + const second = await connector.listPage({ + session: buildSyntheticSession(), + continuation: first.page.nextContinuation, + }); + assertEquals(second.ok, true); + if (second.ok) { + assertEquals(second.page.members[0]?.providerSubjectId, 'synthetic-user-25'); + } +}); + +function buildSyntheticSession(): GovernedRuntimeSession { + return { + runtimeSessionId: 'runtime-1', + runtimeSessionToken: 'token', + launchTraceId: 'trace-1', + runtimeExpiresAt: '2026-07-19T17:00:00.000Z', + institutionId: 'institution-1', + lmsConnectionId: 'synthetic-1', + lmsContextId: 'context-1', + appInstallationId: 'installation-1', + appId: 'photo-roster', + packageVersionId: 1, + packageVersion: '0.1.0', + packageCapabilities: ['lms.roster.members.read'], + actorSubjectId: 'instructor-1', + actorType: 'instructor', + ltiToolSessionId: null, + nrpsContextMembershipsUrl: null, + nrpsServiceVersions: [], + connectionStatus: 'active', + institutionStatus: 'active', + installationStatus: 'active', + connectorKind: 'synthetic', + }; +} diff --git a/src/lms_kernel/cursor.ts b/src/lms_kernel/cursor.ts new file mode 100644 index 0000000..7cca961 --- /dev/null +++ b/src/lms_kernel/cursor.ts @@ -0,0 +1,113 @@ +import { CompactSign, compactVerify, createLocalJWKSet } from 'jose'; +import { getPublicJwkSet, loadToolSigningKey } from '../lti/tool_key.ts'; +import type { EnvReader } from '../platform/env.ts'; +import type { ConnectorKind } from './types.ts'; + +const CURSOR_TYPE = 'application/lantern-roster-cursor+jws'; +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export interface RosterCursorPayload { + readonly continuationId: string; + readonly runtimeSessionId: string; + readonly lmsContextId: string; + readonly appInstallationId: string; + readonly packageVersionId: number; + readonly connectorKind: ConnectorKind; + readonly expiresAt: string; +} + +export type CursorVerificationResult = + | { readonly ok: true; readonly payload: RosterCursorPayload } + | { readonly ok: false }; + +export async function signRosterCursor(input: { + readonly payload: RosterCursorPayload; + readonly env: EnvReader; +}): Promise { + const signingKey = await loadToolSigningKey(input.env); + return await new CompactSign(encoder.encode(serializeCursor(input.payload))) + .setProtectedHeader({ + alg: signingKey.alg, + kid: signingKey.keyId, + typ: CURSOR_TYPE, + }) + .sign(signingKey.privateKey); +} + +export async function verifyRosterCursor(input: { + readonly cursor: string; + readonly env: EnvReader; + readonly now: Date; +}): Promise { + try { + const verified = await compactVerify( + input.cursor, + createLocalJWKSet(await getPublicJwkSet(input.env)), + ); + if (verified.protectedHeader.typ !== CURSOR_TYPE) { + return { ok: false }; + } + const payload = parseCursor(JSON.parse(decoder.decode(verified.payload))); + if (Date.parse(payload.expiresAt) <= input.now.getTime()) { + return { ok: false }; + } + return { ok: true, payload }; + } catch { + return { ok: false }; + } +} + +function serializeCursor(payload: RosterCursorPayload): string { + return JSON.stringify({ + continuationId: payload.continuationId, + runtimeSessionId: payload.runtimeSessionId, + lmsContextId: payload.lmsContextId, + appInstallationId: payload.appInstallationId, + packageVersionId: payload.packageVersionId, + connectorKind: payload.connectorKind, + expiresAt: payload.expiresAt, + }); +} + +function parseCursor(value: unknown): RosterCursorPayload { + if (!isRecord(value)) { + throw new Error('Roster cursor payload must be an object.'); + } + return { + continuationId: requireString(value, 'continuationId'), + runtimeSessionId: requireString(value, 'runtimeSessionId'), + lmsContextId: requireString(value, 'lmsContextId'), + appInstallationId: requireString(value, 'appInstallationId'), + packageVersionId: requireInteger(value, 'packageVersionId'), + connectorKind: requireConnectorKind(value.connectorKind), + expiresAt: requireString(value, 'expiresAt'), + }; +} + +function requireConnectorKind(value: unknown): ConnectorKind { + if (value !== 'nrps' && value !== 'synthetic') { + throw new Error('Roster cursor connectorKind is unsupported.'); + } + return value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requireString(value: Record, field: string): string { + const item = value[field]; + if (typeof item !== 'string' || item.length === 0) { + throw new Error(`Roster cursor ${field} must be a non-empty string.`); + } + return item; +} + +function requireInteger(value: Record, field: string): number { + const item = value[field]; + if (typeof item !== 'number' || !Number.isSafeInteger(item)) { + throw new Error(`Roster cursor ${field} must be an integer.`); + } + return item; +} diff --git a/src/lms_kernel/governance_events.ts b/src/lms_kernel/governance_events.ts new file mode 100644 index 0000000..466c6ad --- /dev/null +++ b/src/lms_kernel/governance_events.ts @@ -0,0 +1,142 @@ +import type { ConnectorKind, GovernanceEventRecord } from './types.ts'; + +export type GovernanceEventInput = Omit; + +type RequiredKeys = 'traceId' | 'eventType' | 'actorType' | 'outcome' | 'occurredAt'; + +export type GovernanceEventBaseInput = + & Pick + & Partial>; + +export function governanceEventBase(input: GovernanceEventBaseInput): GovernanceEventInput { + return { + traceId: input.traceId, + eventType: input.eventType, + institutionId: input.institutionId ?? null, + lmsConnectionId: input.lmsConnectionId ?? null, + lmsContextId: input.lmsContextId ?? null, + appInstallationId: input.appInstallationId ?? null, + appId: input.appId ?? null, + packageVersionId: input.packageVersionId ?? null, + runtimeSessionId: input.runtimeSessionId ?? null, + actorType: input.actorType, + actorId: input.actorId ?? null, + capability: input.capability ?? null, + capabilityGrantId: input.capabilityGrantId ?? null, + decision: input.decision ?? null, + outcome: input.outcome, + connectorKind: input.connectorKind ?? null, + objectCount: input.objectCount ?? null, + latencyMs: input.latencyMs ?? null, + errorCode: input.errorCode ?? null, + requestMetadata: input.requestMetadata ?? {}, + occurredAt: input.occurredAt, + }; +} + +export function adminGovernanceEvent(input: { + readonly traceId: string; + readonly eventType: string; + readonly institutionId: string; + readonly lmsConnectionId?: string | null; + readonly lmsContextId?: string | null; + readonly appInstallationId?: string | null; + readonly appId?: string | null; + readonly packageVersionId?: number | null; + readonly runtimeSessionId?: string | null; + readonly actorId: string; + readonly capability?: string | null; + readonly capabilityGrantId?: string | null; + readonly occurredAt: string; +}): GovernanceEventInput { + return governanceEventBase({ + ...input, + actorType: 'user', + decision: 'allowed', + outcome: 'completed', + }); +} + +export function launchAcceptedEvent(input: { + readonly traceId: string; + readonly institutionId: string; + readonly lmsConnectionId: string; + readonly lmsContextId: string; + readonly appInstallationId: string; + readonly appId: string; + readonly packageVersionId: number; + readonly runtimeSessionId: string; + readonly actorId: string; + readonly actorType: 'instructor' | 'administrator'; + readonly connectorKind: ConnectorKind; + readonly occurredAt: string; + readonly requestMetadata?: Readonly>; +}): GovernanceEventInput { + return governanceEventBase({ + ...input, + eventType: 'launch.accepted', + decision: 'allowed', + outcome: 'completed', + }); +} + +export function launchDeniedEvent(input: { + readonly traceId: string; + readonly institutionId: string; + readonly lmsConnectionId: string; + readonly errorCode: string; + readonly occurredAt: string; + readonly requestMetadata?: Readonly>; +}): GovernanceEventInput { + return governanceEventBase({ + ...input, + eventType: 'launch.denied', + actorType: 'system', + decision: 'denied', + outcome: 'failed', + connectorKind: 'nrps', + }); +} + +export function connectionValidatedEvent(input: { + readonly traceId: string; + readonly institutionId: string; + readonly lmsConnectionId: string; + readonly lmsContextId?: string | null; + readonly appInstallationId?: string | null; + readonly appId?: string | null; + readonly packageVersionId?: number | null; + readonly occurredAt: string; +}): GovernanceEventInput { + return governanceEventBase({ + ...input, + eventType: 'lms_connection.validated', + actorType: 'system', + decision: 'allowed', + outcome: 'completed', + connectorKind: 'nrps', + }); +} + +export function runtimeResponseEvent(input: { + readonly traceId: string; + readonly institutionId: string; + readonly lmsConnectionId: string; + readonly lmsContextId: string; + readonly appInstallationId: string; + readonly appId: string; + readonly packageVersionId: number; + readonly runtimeSessionId: string; + readonly actorType: GovernanceEventInput['actorType']; + readonly actorId: string; + readonly outcome: GovernanceEventInput['outcome']; + readonly connectorKind: ConnectorKind; + readonly errorCode?: string | null; + readonly occurredAt: string; +}): GovernanceEventInput { + return governanceEventBase({ + ...input, + eventType: 'runtime.response', + decision: input.outcome === 'completed' ? 'allowed' : 'denied', + }); +} diff --git a/src/lms_kernel/launch_service.ts b/src/lms_kernel/launch_service.ts new file mode 100644 index 0000000..4cad636 --- /dev/null +++ b/src/lms_kernel/launch_service.ts @@ -0,0 +1,222 @@ +import type { LmsKernelRepository } from './repository.ts'; +import type { AppInstallationRecord, ConnectorKind, LmsConnectionRecord } from './types.ts'; +import { connectionValidatedEvent, launchAcceptedEvent } from './governance_events.ts'; + +/** + * Governed launch service. + * + * This module owns the single trusted path that turns an authorized LMS launch + * into a governed runtime session. All LMS launches (LTI resource links and + * synthetic admin launches) mint sessions here so the boundary logic stays in + * one place instead of being duplicated across routes and the admin service. + */ + +export interface GovernedLaunchPackage { + readonly appId: string; + readonly id: number; + readonly version: string; + readonly capabilities: readonly string[]; +} + +export interface MintGovernedSessionInput { + readonly repository: LmsKernelRepository; + readonly connection: LmsConnectionRecord; + readonly installation: AppInstallationRecord; + readonly packageVersion: GovernedLaunchPackage; + readonly providerContextId: string; + readonly contextLabel: string | null; + readonly providerSubjectId: string; + readonly actorType: 'instructor' | 'administrator'; + readonly connectorKind: ConnectorKind; + readonly ltiToolSessionId: string | null; + readonly nrpsContextMembershipsUrl: string | null; + readonly nrpsServiceVersions: readonly string[]; + readonly createId: () => string; + readonly now?: Date; + readonly traceId?: string; +} + +export interface MintGovernedSessionResult { + readonly runtimeSessionId: string; + readonly runtimeSessionToken: string; + readonly expiresAt: string; + readonly launchTraceId: string; +} + +const RUNTIME_SESSION_TTL_MS = 10 * 60 * 1000; + +export function resolveGovernedLaunchActorType( + roles: readonly string[], +): 'instructor' | 'administrator' | null { + if (roles.some((role) => role.toLowerCase().includes('administrator'))) { + return 'administrator'; + } + if ( + roles.some((role) => { + const normalized = role.toLowerCase(); + return normalized.includes('instructor') || normalized.includes('teachingassistant'); + }) + ) { + return 'instructor'; + } + return null; +} + +export async function mintGovernedRuntimeSession( + input: MintGovernedSessionInput, +): Promise { + const now = input.now ?? new Date(); + const createdAt = now.toISOString(); + const traceId = input.traceId ?? input.createId(); + const { repository, connection, installation, packageVersion } = input; + + if (connection.status !== 'active') { + const activated = await repository.activateLmsConnection({ + id: connection.id, + validatedAt: createdAt, + event: connectionValidatedEvent({ + traceId, + institutionId: connection.institutionId, + lmsConnectionId: connection.id, + appInstallationId: installation.id, + appId: packageVersion.appId, + packageVersionId: packageVersion.id, + occurredAt: createdAt, + }), + }); + if (activated === null) { + throw new Error('LMS connection could not be activated.'); + } + } + + const lmsContext = await repository.getOrCreateLmsContext({ + id: input.createId(), + institutionId: connection.institutionId, + lmsConnectionId: connection.id, + providerContextId: input.providerContextId, + label: input.contextLabel, + now: createdAt, + }); + const actorSubjectId = await repository.getOrCreateSubjectIdentity({ + id: input.createId(), + institutionId: connection.institutionId, + lmsConnectionId: connection.id, + providerSubjectId: input.providerSubjectId, + now: createdAt, + }); + const runtimeSessionId = input.createId(); + const runtimeSessionToken = input.createId(); + const expiresAt = new Date(now.getTime() + RUNTIME_SESSION_TTL_MS).toISOString(); + + await repository.createGovernedRuntimeSession({ + session: { + runtimeSessionId, + runtimeSessionToken, + launchTraceId: traceId, + runtimeExpiresAt: expiresAt, + institutionId: connection.institutionId, + lmsConnectionId: connection.id, + lmsContextId: lmsContext.id, + appInstallationId: installation.id, + appId: packageVersion.appId, + packageVersionId: packageVersion.id, + packageVersion: packageVersion.version, + packageCapabilities: packageVersion.capabilities, + actorSubjectId, + actorType: input.actorType, + ltiToolSessionId: input.ltiToolSessionId, + nrpsContextMembershipsUrl: input.nrpsContextMembershipsUrl, + nrpsServiceVersions: input.nrpsServiceVersions, + connectorKind: input.connectorKind, + }, + createdAt, + event: launchAcceptedEvent({ + traceId, + institutionId: connection.institutionId, + lmsConnectionId: connection.id, + lmsContextId: lmsContext.id, + appInstallationId: installation.id, + appId: packageVersion.appId, + packageVersionId: packageVersion.id, + runtimeSessionId, + actorId: actorSubjectId, + actorType: input.actorType, + connectorKind: input.connectorKind, + occurredAt: createdAt, + }), + }); + + return { runtimeSessionId, runtimeSessionToken, expiresAt, launchTraceId: traceId }; +} + +export interface AcceptResourceLinkLaunchInput { + readonly repository: LmsKernelRepository; + readonly connection: LmsConnectionRecord; + readonly installationId: string; + readonly providerContextId: string; + readonly contextLabel: string | null; + readonly providerSubjectId: string; + readonly roles: readonly string[]; + readonly ltiToolSessionId: string | null; + readonly nrpsContextMembershipsUrl: string | null; + readonly nrpsServiceVersions: readonly string[]; + readonly loadPackageVersion: ( + input: { readonly packageVersionId: number; readonly appId: string }, + ) => Promise; + readonly createId: () => string; + readonly now?: Date; + readonly traceId?: string; +} + +export async function acceptResourceLinkLaunch( + input: AcceptResourceLinkLaunchInput, +): Promise { + const installationId = input.installationId.trim(); + const installation = installationId === '' + ? null + : await input.repository.getInstallationById(installationId); + if ( + installation === null || + installation.institutionId !== input.connection.institutionId || + installation.status !== 'active' + ) { + throw new Error('The governed app installation is not active for this LMS connection.'); + } + const packageVersion = await input.loadPackageVersion({ + packageVersionId: installation.packageVersionId, + appId: installation.appId, + }); + if (packageVersion === null) { + throw new Error('The governed app package is not runnable.'); + } + const providerContextId = input.providerContextId.trim(); + if (providerContextId === '') { + throw new Error('LTI launch context.id is required.'); + } + const actorType = resolveGovernedLaunchActorType(input.roles); + if (actorType === null) { + throw new Error('Governed apps require an instructor or administrator launch role.'); + } + const providerSubjectId = input.providerSubjectId.trim(); + if (providerSubjectId === '') { + throw new Error('LTI launch subject is required.'); + } + + return await mintGovernedRuntimeSession({ + repository: input.repository, + connection: input.connection, + installation, + packageVersion, + providerContextId, + contextLabel: input.contextLabel, + providerSubjectId, + actorType, + connectorKind: 'nrps', + ltiToolSessionId: input.ltiToolSessionId, + nrpsContextMembershipsUrl: input.nrpsContextMembershipsUrl, + nrpsServiceVersions: input.nrpsServiceVersions, + createId: input.createId, + ...(input.now === undefined ? {} : { now: input.now }), + ...(input.traceId === undefined ? {} : { traceId: input.traceId }), + }); +} diff --git a/src/lms_kernel/media.ts b/src/lms_kernel/media.ts new file mode 100644 index 0000000..c004597 --- /dev/null +++ b/src/lms_kernel/media.ts @@ -0,0 +1,109 @@ +import type { LmsKernelRepository, ProtectedMediaReference } from './repository.ts'; + +export interface ProtectedMediaService { + fetch(input: { + readonly mediaId: string; + readonly now: Date; + }): Promise; +} + +export function createProtectedMediaService(input: { + readonly repository: LmsKernelRepository; + readonly fetchUpstream?: typeof fetch; +}): ProtectedMediaService { + const fetchUpstream = input.fetchUpstream ?? fetch; + return { + async fetch(command) { + const reference = await input.repository.getAuthorizedProtectedMediaReference({ + id: command.mediaId, + now: command.now.toISOString(), + }); + if (reference === null) { + return mediaResponse('Profile photo link expired.', 404); + } + + let source: URL; + try { + source = new URL(reference.sourceUrl); + } catch { + await recordMediaEvent(input.repository, reference, 'failed', 'upstream_unavailable'); + return mediaResponse('Profile photo is unavailable.', 502); + } + if (source.protocol !== 'https:') { + return mediaResponse('Profile photo is unavailable.', 502); + } + + try { + const upstream = await fetchUpstream(source, { + redirect: 'follow', + headers: { accept: 'image/*' }, + }); + if (!upstream.ok || !isImageContentType(upstream.headers.get('content-type'))) { + await recordMediaEvent(input.repository, reference, 'failed', 'upstream_unavailable'); + return mediaResponse('Profile photo is unavailable.', 502); + } + await recordMediaEvent(input.repository, reference, 'completed', null); + return new Response(upstream.body, { + status: 200, + headers: mediaHeaders(upstream.headers.get('content-type')!), + }); + } catch { + await recordMediaEvent(input.repository, reference, 'failed', 'upstream_unavailable'); + return mediaResponse('Profile photo is unavailable.', 502); + } + }, + }; +} + +async function recordMediaEvent( + repository: LmsKernelRepository, + reference: ProtectedMediaReference, + outcome: 'completed' | 'failed', + errorCode: string | null, +): Promise { + await repository.appendGovernanceEvent({ + traceId: reference.traceId, + eventType: 'protected_media.fetch', + institutionId: reference.institutionId, + lmsConnectionId: reference.lmsConnectionId, + lmsContextId: reference.lmsContextId, + appInstallationId: reference.appInstallationId, + appId: null, + packageVersionId: reference.packageVersionId, + runtimeSessionId: reference.runtimeSessionId, + actorType: reference.actorType, + actorId: reference.actorSubjectId, + capability: 'lms.roster.profile_photo.read', + capabilityGrantId: null, + decision: outcome === 'completed' ? 'allowed' : 'denied', + outcome, + connectorKind: reference.connectorKind, + objectCount: outcome === 'completed' ? 1 : null, + latencyMs: null, + errorCode, + requestMetadata: {}, + occurredAt: new Date().toISOString(), + }); +} + +function mediaResponse(message: string, status: number): Response { + return new Response(message, { + status, + headers: { + ...mediaHeaders('text/plain; charset=UTF-8'), + 'x-content-type-options': 'nosniff', + }, + }); +} + +function mediaHeaders(contentType: string): Record { + return { + 'content-type': contentType, + 'cache-control': 'private, no-store', + 'referrer-policy': 'no-referrer', + }; +} + +function isImageContentType(value: string | null): value is string { + return value !== null && value.toLowerCase().startsWith('image/'); +} diff --git a/src/lms_kernel/media_test.ts b/src/lms_kernel/media_test.ts new file mode 100644 index 0000000..b97bfe2 --- /dev/null +++ b/src/lms_kernel/media_test.ts @@ -0,0 +1,93 @@ +import { assertEquals } from '@std/assert'; +import { createProtectedMediaService } from './media.ts'; +import { + buildGovernedSyntheticSession, + buildKernelGrant, + MemoryLmsKernelRepository, +} from './repository_memory_test_support.ts'; + +const NOW = new Date('2026-07-19T16:00:00.000Z'); + +Deno.test('protected media revalidates the photo grant and records the shared trace', async () => { + const session = buildGovernedSyntheticSession(); + const repository = new MemoryLmsKernelRepository({ + session, + grants: [buildKernelGrant('photo-grant', 'lms.roster.profile_photo.read')], + }); + await repository.saveProtectedMediaReference({ + id: 'media-1', + traceId: session.launchTraceId, + institutionId: session.institutionId, + lmsConnectionId: session.lmsConnectionId, + lmsContextId: session.lmsContextId, + runtimeSessionId: session.runtimeSessionId, + appInstallationId: session.appInstallationId, + packageVersionId: session.packageVersionId, + subjectIdentityId: 'subject-1', + actorSubjectId: session.actorSubjectId, + actorType: session.actorType, + connectorKind: session.connectorKind, + sourceUrl: 'https://images.lms.example/profile.png', + expiresAt: '2026-07-19T16:05:00.000Z', + }); + const service = createProtectedMediaService({ + repository, + fetchUpstream() { + return Promise.resolve( + new Response(new Uint8Array([1, 2, 3]), { + headers: { 'content-type': 'image/png' }, + }), + ); + }, + }); + + const response = await service.fetch({ mediaId: 'media-1', now: NOW }); + + assertEquals(response.status, 200); + assertEquals(response.headers.get('cache-control'), 'private, no-store'); + assertEquals(response.headers.get('referrer-policy'), 'no-referrer'); + assertEquals(repository.events.length, 1); + assertEquals(repository.events[0]?.traceId, session.launchTraceId); + assertEquals(repository.events[0]?.capability, 'lms.roster.profile_photo.read'); +}); + +Deno.test('protected media denies a reference after photo permission is revoked', async () => { + const session = buildGovernedSyntheticSession(); + const repository = new MemoryLmsKernelRepository({ + session, + grants: [{ + ...buildKernelGrant('photo-grant', 'lms.roster.profile_photo.read'), + revokedByUserId: 'admin-1', + revokedAt: '2026-07-19T15:59:00.000Z', + }], + }); + await repository.saveProtectedMediaReference({ + id: 'media-1', + traceId: session.launchTraceId, + institutionId: session.institutionId, + lmsConnectionId: session.lmsConnectionId, + lmsContextId: session.lmsContextId, + runtimeSessionId: session.runtimeSessionId, + appInstallationId: session.appInstallationId, + packageVersionId: session.packageVersionId, + subjectIdentityId: 'subject-1', + actorSubjectId: session.actorSubjectId, + actorType: session.actorType, + connectorKind: session.connectorKind, + sourceUrl: 'https://images.lms.example/profile.png', + expiresAt: '2026-07-19T16:05:00.000Z', + }); + let called = false; + const service = createProtectedMediaService({ + repository, + fetchUpstream() { + called = true; + return Promise.resolve(new Response()); + }, + }); + + const response = await service.fetch({ mediaId: 'media-1', now: NOW }); + + assertEquals(response.status, 404); + assertEquals(called, false); +}); diff --git a/src/lms_kernel/parsing.ts b/src/lms_kernel/parsing.ts new file mode 100644 index 0000000..18b2206 --- /dev/null +++ b/src/lms_kernel/parsing.ts @@ -0,0 +1,65 @@ +import type { RosterListInput } from '../../sdk/worker-app-sdk.ts'; + +export type RosterListInputParseResult = + | { readonly ok: true; readonly value: RosterListInput } + | { readonly ok: false }; + +export function parseRosterListInput(value: unknown): RosterListInputParseResult { + if (!isRecord(value) || !Array.isArray(value.fields)) { + return { ok: false }; + } + + const fields: Array<'email' | 'photo'> = []; + for (const field of value.fields) { + if (field !== 'email' && field !== 'photo') { + return { ok: false }; + } + if (!fields.includes(field)) { + fields.push(field); + } + } + + if ( + value.cursor !== undefined && (typeof value.cursor !== 'string' || value.cursor.length === 0) + ) { + return { ok: false }; + } + + const allowedKeys = new Set(['fields', 'cursor']); + if (Object.keys(value).some((key) => !allowedKeys.has(key))) { + return { ok: false }; + } + + return { + ok: true, + value: value.cursor === undefined ? { fields } : { fields, cursor: value.cursor }, + }; +} + +export function parseStringArray(value: unknown, fieldName: string): string[] { + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw new Error(`${fieldName} must be an array of strings.`); + } + + return [...value]; +} + +export function parseJsonObject(value: unknown, fieldName: string): Record { + if (!isRecord(value)) { + throw new Error(`${fieldName} must be an object.`); + } + + const parsed: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (typeof item !== 'string') { + throw new Error(`${fieldName} values must be strings.`); + } + parsed[key] = item; + } + + return parsed; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/lms_kernel/parsing_test.ts b/src/lms_kernel/parsing_test.ts new file mode 100644 index 0000000..665de20 --- /dev/null +++ b/src/lms_kernel/parsing_test.ts @@ -0,0 +1,18 @@ +import { assertEquals } from '@std/assert'; +import { parseRosterListInput } from './parsing.ts'; + +Deno.test('roster input parser returns a narrowed immutable command', () => { + assertEquals(parseRosterListInput({ fields: ['photo', 'email', 'photo'], cursor: 'next' }), { + ok: true, + value: { + fields: ['photo', 'email'], + cursor: 'next', + }, + }); +}); + +Deno.test('roster input parser rejects unknown fields, keys, and malformed cursors', () => { + assertEquals(parseRosterListInput({ fields: ['pronouns'] }), { ok: false }); + assertEquals(parseRosterListInput({ fields: [], pageSize: 100 }), { ok: false }); + assertEquals(parseRosterListInput({ fields: [], cursor: '' }), { ok: false }); +}); diff --git a/src/lms_kernel/policy.ts b/src/lms_kernel/policy.ts new file mode 100644 index 0000000..58d066c --- /dev/null +++ b/src/lms_kernel/policy.ts @@ -0,0 +1,85 @@ +import type { RosterCapability } from '../../sdk/worker-app-sdk.ts'; +import type { CapabilityGrantRecord, GovernedRuntimeSession } from './types.ts'; + +export type PrimitiveAuthorizationResult = + | { + readonly allowed: true; + readonly grants: readonly CapabilityGrantRecord[]; + } + | { + readonly allowed: false; + readonly reason: + | 'session_expired' + | 'institution_inactive' + | 'connection_inactive' + | 'installation_inactive' + | 'actor_not_authorized' + | 'service_unavailable' + | 'manifest_capability_missing' + | 'grant_missing'; + }; + +export function authorizeRosterRead(input: { + readonly session: GovernedRuntimeSession; + readonly grants: readonly CapabilityGrantRecord[]; + readonly requestedCapabilities: readonly RosterCapability[]; + readonly now: Date; +}): PrimitiveAuthorizationResult { + if (Date.parse(input.session.runtimeExpiresAt) <= input.now.getTime()) { + return { allowed: false, reason: 'session_expired' }; + } + if (input.session.institutionStatus !== 'active') { + return { allowed: false, reason: 'institution_inactive' }; + } + if (input.session.connectionStatus !== 'active') { + return { allowed: false, reason: 'connection_inactive' }; + } + if (input.session.installationStatus !== 'active') { + return { allowed: false, reason: 'installation_inactive' }; + } + if (input.session.actorType !== 'instructor' && input.session.actorType !== 'administrator') { + return { allowed: false, reason: 'actor_not_authorized' }; + } + if ( + input.session.connectorKind === 'nrps' && + (input.session.nrpsContextMembershipsUrl === null || + input.session.nrpsServiceVersions.length === 0) + ) { + return { allowed: false, reason: 'service_unavailable' }; + } + + const matchingGrants: CapabilityGrantRecord[] = []; + for (const capability of input.requestedCapabilities) { + if (!input.session.packageCapabilities.includes(capability)) { + return { allowed: false, reason: 'manifest_capability_missing' }; + } + + const grant = input.grants.find((candidate) => + candidate.capability === capability && + candidate.packageVersionId === input.session.packageVersionId && + candidate.revokedAt === null && + (candidate.scope.kind === 'institution' || + candidate.scope.lmsContextId === input.session.lmsContextId) + ); + + if (grant === undefined) { + return { allowed: false, reason: 'grant_missing' }; + } + matchingGrants.push(grant); + } + + return { allowed: true, grants: matchingGrants }; +} + +export function requiredRosterCapabilities( + fields: readonly ('email' | 'photo')[], +): RosterCapability[] { + const capabilities: RosterCapability[] = ['lms.roster.members.read']; + if (fields.includes('email')) { + capabilities.push('lms.roster.contact.read'); + } + if (fields.includes('photo')) { + capabilities.push('lms.roster.profile_photo.read'); + } + return capabilities; +} diff --git a/src/lms_kernel/policy_test.ts b/src/lms_kernel/policy_test.ts new file mode 100644 index 0000000..a9644b4 --- /dev/null +++ b/src/lms_kernel/policy_test.ts @@ -0,0 +1,128 @@ +import { assertEquals } from '@std/assert'; +import type { CapabilityGrantRecord, GovernedRuntimeSession } from './types.ts'; +import { authorizeRosterRead, requiredRosterCapabilities } from './policy.ts'; + +const NOW = new Date('2026-07-19T16:00:00.000Z'); + +Deno.test('roster policy requires the base capability plus separately requested fields', () => { + assertEquals(requiredRosterCapabilities([]), ['lms.roster.members.read']); + assertEquals(requiredRosterCapabilities(['email', 'photo']), [ + 'lms.roster.members.read', + 'lms.roster.contact.read', + 'lms.roster.profile_photo.read', + ]); +}); + +Deno.test('roster policy allows exact-version grants scoped to the current context', () => { + const result = authorizeRosterRead({ + session: buildSession(), + grants: [ + buildGrant('members', 'lms.roster.members.read'), + buildGrant('photo', 'lms.roster.profile_photo.read'), + ], + requestedCapabilities: [ + 'lms.roster.members.read', + 'lms.roster.profile_photo.read', + ], + now: NOW, + }); + + assertEquals(result.allowed, true); +}); + +Deno.test('roster policy denies a grant from another package version or context', () => { + const wrongVersion = authorizeRosterRead({ + session: buildSession(), + grants: [ + { ...buildGrant('members', 'lms.roster.members.read'), packageVersionId: 99 }, + ], + requestedCapabilities: ['lms.roster.members.read'], + now: NOW, + }); + const wrongContext = authorizeRosterRead({ + session: buildSession(), + grants: [ + { + ...buildGrant('members', 'lms.roster.members.read'), + scope: { kind: 'context', lmsContextId: 'context-other' }, + }, + ], + requestedCapabilities: ['lms.roster.members.read'], + now: NOW, + }); + + assertEquals(wrongVersion, { allowed: false, reason: 'grant_missing' }); + assertEquals(wrongContext, { allowed: false, reason: 'grant_missing' }); +}); + +Deno.test('roster policy fails closed for missing NRPS claims and expired sessions', () => { + const missingNrps = authorizeRosterRead({ + session: { + ...buildSession(), + nrpsContextMembershipsUrl: null, + }, + grants: [buildGrant('members', 'lms.roster.members.read')], + requestedCapabilities: ['lms.roster.members.read'], + now: NOW, + }); + const expired = authorizeRosterRead({ + session: { + ...buildSession(), + runtimeExpiresAt: '2026-07-19T15:59:59.000Z', + }, + grants: [buildGrant('members', 'lms.roster.members.read')], + requestedCapabilities: ['lms.roster.members.read'], + now: NOW, + }); + + assertEquals(missingNrps, { allowed: false, reason: 'service_unavailable' }); + assertEquals(expired, { allowed: false, reason: 'session_expired' }); +}); + +function buildSession(): GovernedRuntimeSession { + return { + runtimeSessionId: 'runtime-1', + runtimeSessionToken: 'runtime-token', + launchTraceId: 'trace-1', + runtimeExpiresAt: '2026-07-19T17:00:00.000Z', + institutionId: 'institution-1', + lmsConnectionId: 'connection-1', + lmsContextId: 'context-1', + appInstallationId: 'installation-1', + appId: 'photo-roster', + packageVersionId: 1, + packageVersion: '0.1.0', + packageCapabilities: [ + 'lms.roster.members.read', + 'lms.roster.contact.read', + 'lms.roster.profile_photo.read', + ], + actorSubjectId: 'instructor-1', + actorType: 'instructor', + ltiToolSessionId: 'lti-session-1', + nrpsContextMembershipsUrl: 'https://lms.example/nrps/context-1', + nrpsServiceVersions: ['2.0'], + connectionStatus: 'active', + institutionStatus: 'active', + installationStatus: 'active', + connectorKind: 'nrps', + }; +} + +function buildGrant( + id: string, + capability: CapabilityGrantRecord['capability'], +): CapabilityGrantRecord { + return { + id, + institutionId: 'institution-1', + appInstallationId: 'installation-1', + packageVersionId: 1, + capability, + scope: { kind: 'context', lmsContextId: 'context-1' }, + approvedByUserId: 'admin-1', + approvedAt: '2026-07-19T15:00:00.000Z', + revokedByUserId: null, + revokedAt: null, + }; +} diff --git a/src/lms_kernel/repository.ts b/src/lms_kernel/repository.ts new file mode 100644 index 0000000..4d21fdf --- /dev/null +++ b/src/lms_kernel/repository.ts @@ -0,0 +1,235 @@ +import type { RosterCapability } from '../../sdk/worker-app-sdk.ts'; +import type { + AppInstallationRecord, + CapabilityGrantRecord, + ConnectorKind, + GovernanceEventFilter, + GovernanceEventRecord, + GovernedRuntimeSession, + GovernedRuntimeSessionWrite, + InstitutionMembershipRecord, + InstitutionRecord, + InstitutionRole, + LmsConnectionRecord, + LmsContextRecord, + LmsPlatformType, +} from './types.ts'; + +export interface ProtectedMediaReference { + readonly id: string; + readonly traceId: string; + readonly institutionId: string; + readonly lmsConnectionId: string; + readonly lmsContextId: string; + readonly runtimeSessionId: string; + readonly appInstallationId: string; + readonly packageVersionId: number; + readonly subjectIdentityId: string; + readonly actorSubjectId: string; + readonly actorType: 'instructor' | 'administrator'; + readonly connectorKind: ConnectorKind; + readonly sourceUrl: string; + readonly expiresAt: string; +} + +export interface RosterContinuation { + readonly id: string; + readonly runtimeSessionId: string; + readonly appInstallationId: string; + readonly packageVersionId: number; + readonly connectorKind: ConnectorKind; + readonly continuationValue: string; + readonly expiresAt: string; +} + +export interface GovernedWrite { + readonly event: Omit; +} + +export interface InstitutionRepository { + createInstitutionWithOwner( + input: GovernedWrite & { + readonly id: string; + readonly slug: string; + readonly name: string; + readonly ownerUserId: string; + readonly now: string; + }, + ): Promise; + getInstitutionById(id: string): Promise; + listMembershipsForUser(userId: string): Promise; + getMembership( + institutionId: string, + userId: string, + ): Promise; + createInvite( + input: GovernedWrite & { + readonly id: string; + readonly institutionId: string; + readonly tokenHash: string; + readonly role: Exclude; + readonly createdByUserId: string; + readonly createdAt: string; + readonly expiresAt: string; + }, + ): Promise; + acceptInvite(input: { + readonly tokenHash: string; + readonly userId: string; + readonly acceptedAt: string; + readonly traceId: string; + }): Promise; +} + +export interface LmsConnectionRepository { + createLmsConnection( + input: GovernedWrite & { + readonly id: string; + readonly institutionId: string; + readonly label: string; + readonly platformType: LmsPlatformType; + readonly issuer: string; + readonly clientId: string; + readonly deploymentId: string; + readonly authorizationEndpoint: string; + readonly accessTokenUrl: string; + readonly jwksUrl: string; + readonly now: string; + }, + ): Promise; + getLmsConnectionById(id: string): Promise; + getLmsConnectionByPlatformIdentity(input: { + readonly issuer: string; + readonly clientId: string; + readonly deploymentId: string; + }): Promise; + listLmsConnections(institutionId: string): Promise; + activateLmsConnection( + input: GovernedWrite & { + readonly id: string; + readonly validatedAt: string; + }, + ): Promise; + disableLmsConnection( + input: GovernedWrite & { + readonly id: string; + readonly institutionId: string; + readonly disabledAt: string; + }, + ): Promise; + getOrCreateLmsContext(input: { + readonly id: string; + readonly institutionId: string; + readonly lmsConnectionId: string; + readonly providerContextId: string; + readonly label: string | null; + readonly now: string; + }): Promise; + getLmsContextById(id: string): Promise; + listLmsContexts(institutionId: string): Promise; +} + +export interface AppInstallationRepository { + createInstallation( + input: GovernedWrite & { + readonly id: string; + readonly institutionId: string; + readonly appId: string; + readonly packageVersionId: number; + readonly installedByUserId: string; + readonly now: string; + }, + ): Promise; + replaceInstallationPackageVersion( + input: GovernedWrite & { + readonly id: string; + readonly institutionId: string; + readonly packageVersionId: number; + readonly installedByUserId: string; + readonly now: string; + }, + ): Promise; + getInstallationById(id: string): Promise; + listInstallations(institutionId: string): Promise; + activateInstallation( + input: GovernedWrite & { + readonly id: string; + readonly activatedAt: string; + }, + ): Promise; + disableInstallation( + input: GovernedWrite & { + readonly id: string; + readonly institutionId: string; + readonly disabledAt: string; + }, + ): Promise; + createCapabilityGrant( + input: GovernedWrite & { + readonly id: string; + readonly institutionId: string; + readonly appInstallationId: string; + readonly packageVersionId: number; + readonly capability: RosterCapability; + readonly scope: + | { readonly kind: 'institution' } + | { readonly kind: 'context'; readonly lmsContextId: string }; + readonly approvedByUserId: string; + readonly approvedAt: string; + }, + ): Promise; + revokeCapabilityGrant( + input: GovernedWrite & { + readonly id: string; + readonly institutionId: string; + readonly revokedByUserId: string; + readonly revokedAt: string; + }, + ): Promise; + listCapabilityGrants(appInstallationId: string): Promise; + listCapabilityGrantsForInstitution(institutionId: string): Promise; +} + +export interface GovernedRuntimeRepository { + getGovernedRuntimeSession(input: { + readonly runtimeSessionId: string; + readonly runtimeSessionToken: string; + }): Promise; + createGovernedRuntimeSession( + input: GovernedWrite & { + readonly session: GovernedRuntimeSessionWrite; + readonly createdAt: string; + }, + ): Promise; + getOrCreateSubjectIdentity(input: { + readonly id: string; + readonly institutionId: string; + readonly lmsConnectionId: string; + readonly providerSubjectId: string; + readonly now: string; + }): Promise; + saveRosterContinuation(input: RosterContinuation): Promise; + getRosterContinuation(id: string): Promise; + saveProtectedMediaReference(input: ProtectedMediaReference): Promise; + getAuthorizedProtectedMediaReference(input: { + readonly id: string; + readonly now: string; + }): Promise; +} + +export interface GovernanceEventRepository { + appendGovernanceEvents( + events: readonly Omit[], + ): Promise; + appendGovernanceEvent( + event: Omit, + ): Promise; + listGovernanceEvents(filter: GovernanceEventFilter): Promise; +} + +export type LmsKernelRepository = + & InstitutionRepository + & LmsConnectionRepository + & AppInstallationRepository + & GovernedRuntimeRepository + & GovernanceEventRepository; diff --git a/src/lms_kernel/repository_d1.ts b/src/lms_kernel/repository_d1.ts new file mode 100644 index 0000000..7430876 --- /dev/null +++ b/src/lms_kernel/repository_d1.ts @@ -0,0 +1,17 @@ +import type { D1Database } from '../db/d1.ts'; +import type { LmsKernelRepository } from './repository.ts'; +import { createD1LmsConnectionRepository } from './repository_d1_connections.ts'; +import { createD1GovernanceEventRepository } from './repository_d1_governance.ts'; +import { createD1AppInstallationRepository } from './repository_d1_installations.ts'; +import { createD1InstitutionRepository } from './repository_d1_institutions.ts'; +import { createD1GovernedRuntimeRepository } from './repository_d1_runtime.ts'; + +export function createD1LmsKernelRepository(db: D1Database): LmsKernelRepository { + return { + ...createD1InstitutionRepository(db), + ...createD1LmsConnectionRepository(db), + ...createD1AppInstallationRepository(db), + ...createD1GovernedRuntimeRepository(db), + ...createD1GovernanceEventRepository(db), + }; +} diff --git a/src/lms_kernel/repository_d1_connections.ts b/src/lms_kernel/repository_d1_connections.ts new file mode 100644 index 0000000..38c9542 --- /dev/null +++ b/src/lms_kernel/repository_d1_connections.ts @@ -0,0 +1,177 @@ +import { type D1Database, queryD1First, queryD1Objects, runD1, runD1Batch } from '../db/d1.ts'; +import type { LmsConnectionRepository } from './repository.ts'; +import { governanceEventStatement } from './repository_d1_governance.ts'; +import { + type D1Row, + mapConnection, + mapLmsContext, + requireConnection, +} from './repository_d1_row.ts'; + +export function createD1LmsConnectionRepository(db: D1Database): LmsConnectionRepository { + const repository: LmsConnectionRepository = { + async createLmsConnection(input) { + await runD1Batch(db, [{ + sql: ` + INSERT INTO lms_connections ( + id, institution_id, label, platform_type, issuer, client_id, + deployment_id, authorization_endpoint, access_token_url, jwks_url, + status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?) + `, + parameters: [ + input.id, + input.institutionId, + input.label, + input.platformType, + input.issuer, + input.clientId, + input.deploymentId, + input.authorizationEndpoint, + input.accessTokenUrl, + input.jwksUrl, + input.now, + input.now, + ], + }, governanceEventStatement(input.event)]); + return requireConnection(await repository.getLmsConnectionById(input.id), input.id); + }, + + async getLmsConnectionById(id) { + const row = await queryD1First( + db, + `SELECT * FROM lms_connections WHERE id = ?`, + [id], + ); + return row === null ? null : mapConnection(row); + }, + + async getLmsConnectionByPlatformIdentity(input) { + const row = await queryD1First( + db, + ` + SELECT * FROM lms_connections + WHERE issuer = ? AND client_id = ? AND deployment_id = ? + `, + [input.issuer, input.clientId, input.deploymentId], + ); + return row === null ? null : mapConnection(row); + }, + + async listLmsConnections(institutionId) { + const rows = await queryD1Objects( + db, + ` + SELECT * FROM lms_connections + WHERE institution_id = ? + ORDER BY label, id + `, + [institutionId], + ); + return rows.map(mapConnection); + }, + + async activateLmsConnection(input) { + await runD1Batch(db, [ + { + sql: ` + UPDATE lms_connections + SET status = 'active', validated_at = ?, updated_at = ? + WHERE id = ? + `, + parameters: [input.validatedAt, input.validatedAt, input.id], + }, + governanceEventStatement(input.event, { + sql: `EXISTS ( + SELECT 1 FROM lms_connections + WHERE id = ? AND status = 'active' AND validated_at = ? + )`, + parameters: [input.id, input.validatedAt], + }), + ]); + return await repository.getLmsConnectionById(input.id); + }, + + async disableLmsConnection(input) { + await runD1Batch(db, [ + { + sql: ` + UPDATE lms_connections + SET status = 'disabled', updated_at = ? + WHERE id = ? AND institution_id = ? + `, + parameters: [input.disabledAt, input.id, input.institutionId], + }, + governanceEventStatement(input.event, { + sql: `EXISTS ( + SELECT 1 FROM lms_connections + WHERE id = ? AND institution_id = ? AND status = 'disabled' AND updated_at = ? + )`, + parameters: [input.id, input.institutionId, input.disabledAt], + }), + ]); + const connection = await repository.getLmsConnectionById(input.id); + return connection?.institutionId === input.institutionId ? connection : null; + }, + + async getOrCreateLmsContext(input) { + await runD1( + db, + ` + INSERT INTO lms_contexts ( + id, institution_id, lms_connection_id, provider_context_id, + label, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (lms_connection_id, provider_context_id) DO UPDATE SET + label = excluded.label, + updated_at = excluded.updated_at + `, + [ + input.id, + input.institutionId, + input.lmsConnectionId, + input.providerContextId, + input.label, + input.now, + input.now, + ], + ); + const row = await queryD1First( + db, + ` + SELECT * FROM lms_contexts + WHERE lms_connection_id = ? AND provider_context_id = ? + `, + [input.lmsConnectionId, input.providerContextId], + ); + if (row === null) { + throw new Error('LMS context was not persisted.'); + } + return mapLmsContext(row); + }, + + async listLmsContexts(institutionId) { + const rows = await queryD1Objects( + db, + ` + SELECT * FROM lms_contexts + WHERE institution_id = ? + ORDER BY label, id + `, + [institutionId], + ); + return rows.map(mapLmsContext); + }, + + async getLmsContextById(id) { + const row = await queryD1First( + db, + `SELECT * FROM lms_contexts WHERE id = ?`, + [id], + ); + return row === null ? null : mapLmsContext(row); + }, + }; + + return repository; +} diff --git a/src/lms_kernel/repository_d1_governance.ts b/src/lms_kernel/repository_d1_governance.ts new file mode 100644 index 0000000..b04a15c --- /dev/null +++ b/src/lms_kernel/repository_d1_governance.ts @@ -0,0 +1,114 @@ +import { type D1Database, queryD1First, queryD1Objects, runD1, runD1Batch } from '../db/d1.ts'; +import type { GovernanceEventRepository } from './repository.ts'; +import { addOptionalClause, type D1Row, mapGovernanceEvent } from './repository_d1_row.ts'; +import type { GovernanceEventRecord } from './types.ts'; + +export interface GovernanceEventStatement { + readonly sql: string; + readonly parameters: readonly unknown[]; +} + +export function governanceEventStatement( + event: Omit, + guard?: { readonly sql: string; readonly parameters: readonly unknown[] }, +): GovernanceEventStatement { + const placeholders = Array.from({ length: 21 }, () => '?').join(', '); + return { + sql: ` + INSERT INTO governance_events ( + trace_id, event_type, institution_id, lms_connection_id, + lms_context_id, app_installation_id, app_id, package_version_id, + runtime_session_id, actor_type, actor_id, capability, + capability_grant_id, decision, outcome, connector_kind, + object_count, latency_ms, error_code, request_metadata, occurred_at + ) SELECT ${placeholders}${guard === undefined ? '' : ` WHERE ${guard.sql}`} + `, + parameters: [ + event.traceId, + event.eventType, + event.institutionId, + event.lmsConnectionId, + event.lmsContextId, + event.appInstallationId, + event.appId, + event.packageVersionId, + event.runtimeSessionId, + event.actorType, + event.actorId, + event.capability, + event.capabilityGrantId, + event.decision, + event.outcome, + event.connectorKind, + event.objectCount, + event.latencyMs, + event.errorCode, + event.requestMetadata, + event.occurredAt, + ...(guard?.parameters ?? []), + ], + }; +} + +export function createD1GovernanceEventRepository(db: D1Database): GovernanceEventRepository { + return { + async appendGovernanceEvents(events) { + if (events.length === 0) return; + await runD1Batch(db, events.map((event) => governanceEventStatement(event))); + }, + + async appendGovernanceEvent(event) { + const statement = governanceEventStatement(event); + await runD1(db, statement.sql, statement.parameters); + const row = await queryD1First( + db, + ` + SELECT * FROM governance_events + WHERE trace_id = ? + ORDER BY id DESC + LIMIT 1 + `, + [event.traceId], + ); + if (row === null) { + throw new Error('Governance event was not persisted.'); + } + return mapGovernanceEvent(row); + }, + + async listGovernanceEvents(filter) { + const clauses = ['institution_id = ?']; + const parameters: unknown[] = [filter.institutionId]; + addOptionalClause(clauses, parameters, 'trace_id', filter.traceId); + addOptionalClause(clauses, parameters, 'lms_connection_id', filter.lmsConnectionId); + addOptionalClause(clauses, parameters, 'app_installation_id', filter.appInstallationId); + addOptionalClause(clauses, parameters, 'capability', filter.capability); + addOptionalClause(clauses, parameters, 'decision', filter.decision); + addOptionalClause(clauses, parameters, 'outcome', filter.outcome); + if (filter.occurredAfter !== undefined) { + clauses.push('occurred_at >= ?'); + parameters.push(filter.occurredAfter); + } + if (filter.occurredBefore !== undefined) { + clauses.push('occurred_at < ?'); + parameters.push(filter.occurredBefore); + } + const limit = Math.max(1, Math.min(filter.limit ?? 100, 200)); + parameters.push(limit); + + const rows = await queryD1Objects( + db, + ` + SELECT * FROM governance_events + WHERE ${clauses.join(' AND ')} + ORDER BY ${ + filter.traceId === undefined ? 'occurred_at DESC, id DESC' : 'occurred_at ASC, id ASC' + } + LIMIT ? + `, + parameters, + ); + return rows.map(mapGovernanceEvent); + }, + }; +} diff --git a/src/lms_kernel/repository_d1_installations.ts b/src/lms_kernel/repository_d1_installations.ts new file mode 100644 index 0000000..7e06422 --- /dev/null +++ b/src/lms_kernel/repository_d1_installations.ts @@ -0,0 +1,241 @@ +import { type D1Database, queryD1First, queryD1Objects, runD1Batch } from '../db/d1.ts'; +import type { AppInstallationRepository } from './repository.ts'; +import { governanceEventStatement } from './repository_d1_governance.ts'; +import { + type D1Row, + mapCapabilityGrant, + mapInstallation, + requireInstallation, +} from './repository_d1_row.ts'; + +export function createD1AppInstallationRepository(db: D1Database): AppInstallationRepository { + const repository: AppInstallationRepository = { + async createInstallation(input) { + await runD1Batch(db, [{ + sql: ` + INSERT INTO app_installations ( + id, institution_id, app_id, package_version_id, status, + installed_by_user_id, installed_at, updated_at + ) VALUES (?, ?, ?, ?, 'pending_approval', ?, ?, ?) + `, + parameters: [ + input.id, + input.institutionId, + input.appId, + input.packageVersionId, + input.installedByUserId, + input.now, + input.now, + ], + }, governanceEventStatement(input.event)]); + return requireInstallation(await repository.getInstallationById(input.id), input.id); + }, + + async replaceInstallationPackageVersion(input) { + await runD1Batch(db, [ + { + sql: ` + UPDATE app_installations + SET package_version_id = ?, + status = 'pending_approval', + installed_by_user_id = ?, + installed_at = ?, + activated_at = NULL, + updated_at = ? + WHERE id = ? AND institution_id = ? + `, + parameters: [ + input.packageVersionId, + input.installedByUserId, + input.now, + input.now, + input.id, + input.institutionId, + ], + }, + governanceEventStatement(input.event, { + sql: `EXISTS ( + SELECT 1 FROM app_installations + WHERE id = ? AND institution_id = ? AND package_version_id = ? AND updated_at = ? + )`, + parameters: [input.id, input.institutionId, input.packageVersionId, input.now], + }), + ]); + const row = await queryD1First( + db, + `SELECT * FROM app_installations WHERE id = ? AND institution_id = ?`, + [input.id, input.institutionId], + ); + return row === null ? null : mapInstallation(row); + }, + + async getInstallationById(id) { + const row = await queryD1First( + db, + `SELECT * FROM app_installations WHERE id = ?`, + [id], + ); + return row === null ? null : mapInstallation(row); + }, + + async listInstallations(institutionId) { + const rows = await queryD1Objects( + db, + ` + SELECT * FROM app_installations + WHERE institution_id = ? + ORDER BY installed_at DESC, id DESC + `, + [institutionId], + ); + return rows.map(mapInstallation); + }, + + async activateInstallation(input) { + await runD1Batch(db, [ + { + sql: ` + UPDATE app_installations + SET status = 'active', activated_at = ?, updated_at = ? + WHERE id = ? + `, + parameters: [input.activatedAt, input.activatedAt, input.id], + }, + governanceEventStatement(input.event, { + sql: `EXISTS ( + SELECT 1 FROM app_installations + WHERE id = ? AND status = 'active' AND activated_at = ? + )`, + parameters: [input.id, input.activatedAt], + }), + ]); + return await repository.getInstallationById(input.id); + }, + + async disableInstallation(input) { + await runD1Batch(db, [ + { + sql: ` + UPDATE app_installations + SET status = 'disabled', updated_at = ? + WHERE id = ? AND institution_id = ? + `, + parameters: [input.disabledAt, input.id, input.institutionId], + }, + governanceEventStatement(input.event, { + sql: `EXISTS ( + SELECT 1 FROM app_installations + WHERE id = ? AND institution_id = ? AND status = 'disabled' AND updated_at = ? + )`, + parameters: [input.id, input.institutionId, input.disabledAt], + }), + ]); + const installation = await repository.getInstallationById(input.id); + return installation?.institutionId === input.institutionId ? installation : null; + }, + + async createCapabilityGrant(input) { + const lmsContextId = input.scope.kind === 'context' ? input.scope.lmsContextId : null; + await runD1Batch(db, [{ + sql: ` + INSERT INTO capability_grants ( + id, institution_id, app_installation_id, package_version_id, + capability, scope_kind, lms_context_id, approved_by_user_id, approved_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO UPDATE SET + approved_by_user_id = excluded.approved_by_user_id, + approved_at = excluded.approved_at, + revoked_by_user_id = NULL, + revoked_at = NULL + `, + parameters: [ + input.id, + input.institutionId, + input.appInstallationId, + input.packageVersionId, + input.capability, + input.scope.kind, + lmsContextId, + input.approvedByUserId, + input.approvedAt, + ], + }, governanceEventStatement(input.event)]); + const row = await queryD1First( + db, + ` + SELECT * FROM capability_grants + WHERE app_installation_id = ? + AND package_version_id = ? + AND capability = ? + AND scope_kind = ? + AND lms_context_id IS ? + `, + [ + input.appInstallationId, + input.packageVersionId, + input.capability, + input.scope.kind, + lmsContextId, + ], + ); + if (row === null) { + throw new Error('Capability grant was not persisted.'); + } + return mapCapabilityGrant(row); + }, + + async revokeCapabilityGrant(input) { + await runD1Batch(db, [ + { + sql: ` + UPDATE capability_grants + SET revoked_by_user_id = ?, revoked_at = ? + WHERE id = ? AND institution_id = ? AND revoked_at IS NULL + `, + parameters: [input.revokedByUserId, input.revokedAt, input.id, input.institutionId], + }, + governanceEventStatement(input.event, { + sql: `EXISTS ( + SELECT 1 FROM capability_grants + WHERE id = ? AND institution_id = ? AND revoked_at = ? + )`, + parameters: [input.id, input.institutionId, input.revokedAt], + }), + ]); + const row = await queryD1First( + db, + `SELECT * FROM capability_grants WHERE id = ? AND institution_id = ?`, + [input.id, input.institutionId], + ); + return row === null ? null : mapCapabilityGrant(row); + }, + + async listCapabilityGrants(appInstallationId) { + const rows = await queryD1Objects( + db, + ` + SELECT * FROM capability_grants + WHERE app_installation_id = ? + ORDER BY approved_at, id + `, + [appInstallationId], + ); + return rows.map(mapCapabilityGrant); + }, + + async listCapabilityGrantsForInstitution(institutionId) { + const rows = await queryD1Objects( + db, + ` + SELECT * FROM capability_grants + WHERE institution_id = ? + ORDER BY approved_at, id + `, + [institutionId], + ); + return rows.map(mapCapabilityGrant); + }, + }; + + return repository; +} diff --git a/src/lms_kernel/repository_d1_institutions.ts b/src/lms_kernel/repository_d1_institutions.ts new file mode 100644 index 0000000..69313f5 --- /dev/null +++ b/src/lms_kernel/repository_d1_institutions.ts @@ -0,0 +1,185 @@ +import { type D1Database, queryD1First, queryD1Objects, runD1Batch } from '../db/d1.ts'; +import type { InstitutionRepository } from './repository.ts'; +import { governanceEventStatement } from './repository_d1_governance.ts'; +import { + type D1Row, + mapInstitution, + mapMembership, + requireInstitution, +} from './repository_d1_row.ts'; + +export function createD1InstitutionRepository(db: D1Database): InstitutionRepository { + const repository: InstitutionRepository = { + async createInstitutionWithOwner(input) { + await runD1Batch(db, [ + { + sql: ` + INSERT INTO institutions (id, slug, name, status, created_at, updated_at) + VALUES (?, ?, ?, 'active', ?, ?) + `, + parameters: [input.id, input.slug, input.name, input.now, input.now], + }, + { + sql: ` + INSERT INTO institution_memberships ( + institution_id, user_id, role, created_at + ) VALUES (?, ?, 'owner', ?) + `, + parameters: [input.id, input.ownerUserId, input.now], + }, + governanceEventStatement(input.event), + ]); + + return requireInstitution(await repository.getInstitutionById(input.id), input.id); + }, + + async getInstitutionById(id) { + const row = await queryD1First( + db, + `SELECT * FROM institutions WHERE id = ?`, + [id], + ); + return row === null ? null : mapInstitution(row); + }, + + async listMembershipsForUser(userId) { + const rows = await queryD1Objects( + db, + ` + SELECT institution_id, user_id, role, created_at + FROM institution_memberships + WHERE user_id = ? + ORDER BY created_at, institution_id + `, + [userId], + ); + return rows.map(mapMembership); + }, + + async getMembership(institutionId, userId) { + const row = await queryD1First( + db, + ` + SELECT institution_id, user_id, role, created_at + FROM institution_memberships + WHERE institution_id = ? AND user_id = ? + `, + [institutionId, userId], + ); + return row === null ? null : mapMembership(row); + }, + + async createInvite(input) { + await runD1Batch(db, [{ + sql: ` + INSERT INTO institution_invites ( + id, institution_id, token_hash, role, created_by_user_id, + created_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, + parameters: [ + input.id, + input.institutionId, + input.tokenHash, + input.role, + input.createdByUserId, + input.createdAt, + input.expiresAt, + ], + }, governanceEventStatement(input.event)]); + }, + + async acceptInvite(input) { + const results = await runD1Batch(db, [ + { + sql: ` + INSERT INTO institution_memberships ( + institution_id, user_id, role, created_at + ) + SELECT institution_id, ?, role, ? + FROM institution_invites + WHERE token_hash = ? + AND accepted_at IS NULL + AND expires_at > ? + ON CONFLICT (user_id) DO NOTHING + `, + parameters: [input.userId, input.acceptedAt, input.tokenHash, input.acceptedAt], + }, + { + sql: ` + UPDATE institution_invites + SET accepted_by_user_id = ?, accepted_at = ?, acceptance_trace_id = ? + WHERE token_hash = ? + AND accepted_at IS NULL + AND expires_at > ? + AND EXISTS ( + SELECT 1 + FROM institution_memberships membership + WHERE membership.institution_id = institution_invites.institution_id + AND membership.user_id = ? + ) + `, + parameters: [ + input.userId, + input.acceptedAt, + input.traceId, + input.tokenHash, + input.acceptedAt, + input.userId, + ], + }, + { + sql: ` + INSERT INTO governance_events ( + trace_id, event_type, institution_id, actor_type, actor_id, + outcome, request_metadata, occurred_at + ) + SELECT ?, 'institution_invite.accepted', institution_id, + 'user', ?, 'completed', '{}', ? + FROM institution_invites + WHERE token_hash = ? + AND accepted_by_user_id = ? + AND accepted_at = ? + AND acceptance_trace_id = ? + `, + parameters: [ + input.traceId, + input.userId, + input.acceptedAt, + input.tokenHash, + input.userId, + input.acceptedAt, + input.traceId, + ], + }, + ]); + if (readD1Changes(results[1]?.meta) !== 1) { + return null; + } + + const row = await queryD1First( + db, + ` + SELECT membership.institution_id, membership.user_id, + membership.role, membership.created_at + FROM institution_memberships membership + JOIN institution_invites invite + ON invite.institution_id = membership.institution_id + WHERE invite.token_hash = ? + AND invite.accepted_by_user_id = ? + AND invite.accepted_at = ? + AND invite.acceptance_trace_id = ? + `, + [input.tokenHash, input.userId, input.acceptedAt, input.traceId], + ); + return row === null ? null : mapMembership(row); + }, + }; + + return repository; +} + +function readD1Changes(meta: Record | undefined): number { + const changes = meta?.changes; + return typeof changes === 'number' ? changes : 0; +} diff --git a/src/lms_kernel/repository_d1_integration_test.ts b/src/lms_kernel/repository_d1_integration_test.ts new file mode 100644 index 0000000..3830c2c --- /dev/null +++ b/src/lms_kernel/repository_d1_integration_test.ts @@ -0,0 +1,144 @@ +import { assertEquals, assertRejects } from '@std/assert'; +import { Miniflare } from 'miniflare'; +import type { D1Database } from '../db/d1.ts'; +import { runD1, runD1Statements } from '../db/d1.ts'; +import { adminGovernanceEvent } from './governance_events.ts'; +import { createD1LmsKernelRepository } from './repository_d1.ts'; + +const NOW = '2026-07-20T15:00:00.000Z'; + +Deno.test('D1 rolls back a governed mutation when its audit event cannot commit', async () => { + await withKernelD1(async (db) => { + await seedAuthUser(db, 'owner-1'); + const repository = createD1LmsKernelRepository(db); + + await assertRejects(() => + repository.createInstitutionWithOwner({ + id: 'institution-rollback', + slug: 'rollback-university', + name: 'Rollback University', + ownerUserId: 'owner-1', + now: NOW, + event: adminGovernanceEvent({ + traceId: 'trace-rollback', + eventType: 'institution.created', + institutionId: 'institution-rollback', + packageVersionId: 999_999, + actorId: 'owner-1', + occurredAt: NOW, + }), + }) + ); + + assertEquals(await repository.getInstitutionById('institution-rollback'), null); + assertEquals(await repository.listMembershipsForUser('owner-1'), []); + }); +}); + +Deno.test('D1 invitation acceptance is one-time and appends its audit event atomically', async () => { + await withKernelD1(async (db) => { + await Promise.all([seedAuthUser(db, 'owner-1'), seedAuthUser(db, 'admin-1')]); + const repository = createD1LmsKernelRepository(db); + await repository.createInstitutionWithOwner({ + id: 'institution-1', + slug: 'example-university', + name: 'Example University', + ownerUserId: 'owner-1', + now: NOW, + event: adminGovernanceEvent({ + traceId: 'trace-institution', + eventType: 'institution.created', + institutionId: 'institution-1', + actorId: 'owner-1', + occurredAt: NOW, + }), + }); + await repository.createInvite({ + id: 'invite-1', + institutionId: 'institution-1', + tokenHash: 'hashed-token', + role: 'admin', + createdByUserId: 'owner-1', + createdAt: NOW, + expiresAt: '2026-07-27T15:00:00.000Z', + event: adminGovernanceEvent({ + traceId: 'trace-invite', + eventType: 'institution_invite.created', + institutionId: 'institution-1', + actorId: 'owner-1', + occurredAt: NOW, + }), + }); + + const accepted = await repository.acceptInvite({ + tokenHash: 'hashed-token', + userId: 'admin-1', + acceptedAt: '2026-07-20T15:01:00.000Z', + traceId: 'trace-accepted', + }); + const replay = await repository.acceptInvite({ + tokenHash: 'hashed-token', + userId: 'admin-1', + acceptedAt: '2026-07-20T15:01:00.000Z', + traceId: 'trace-replay', + }); + + assertEquals(accepted?.role, 'admin'); + assertEquals(replay, null); + assertEquals( + (await repository.listGovernanceEvents({ + institutionId: 'institution-1', + traceId: 'trace-accepted', + })).length, + 1, + ); + await assertRejects( + () => + runD1(db, `UPDATE governance_events SET outcome = 'failed' WHERE trace_id = ?`, [ + 'trace-accepted', + ]), + Error, + 'governance_events is append-only', + ); + await assertRejects( + () => runD1(db, `DELETE FROM governance_events WHERE trace_id = ?`, ['trace-accepted']), + Error, + 'governance_events is append-only', + ); + }); +}); + +async function withKernelD1(run: (db: D1Database) => Promise): Promise { + const miniflare = new Miniflare({ + modules: true, + script: 'export default { fetch() { return new Response("ok"); } };', + d1Databases: { DB: crypto.randomUUID() }, + }); + try { + const db: D1Database = await miniflare.getD1Database('DB'); + for ( + const migration of [ + 'src/db/d1_migrations/0001_initial_schema.sql', + 'src/db/d1_migrations/0002_auth.sql', + 'src/db/d1_migrations/0003_python_autograder_lab.sql', + 'src/db/d1_migrations/0004_governed_lms_primitive_kernel.sql', + ] + ) { + await runD1Statements(db, await Deno.readTextFile(migration)); + } + await run(db); + } finally { + await miniflare.dispose(); + } +} + +function seedAuthUser(db: D1Database, id: string): Promise { + return runD1( + db, + ` + INSERT INTO auth_users (id, name, email, emailVerified, createdAt, updatedAt) + VALUES (?, ?, ?, 1, ?, ?) + `, + [id, id, `${id}@example.edu`, NOW, NOW], + ); +} diff --git a/src/lms_kernel/repository_d1_row.ts b/src/lms_kernel/repository_d1_row.ts new file mode 100644 index 0000000..142c626 --- /dev/null +++ b/src/lms_kernel/repository_d1_row.ts @@ -0,0 +1,374 @@ +import { requireRosterCapability as requireCapability } from './capabilities.ts'; +import { parseJsonObject, parseStringArray } from './parsing.ts'; +import type { ProtectedMediaReference, RosterContinuation } from './repository.ts'; +import type { + AppInstallationRecord, + CapabilityGrantRecord, + GovernanceEventRecord, + GovernedRuntimeSession, + InstitutionMembershipRecord, + InstitutionRecord, + LmsConnectionRecord, + LmsContextRecord, +} from './types.ts'; + +export type D1Row = Record; + +export function requireString(row: D1Row, field: string): string { + const value = row[field]; + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${field} must be a non-empty string.`); + } + return value; +} + +export function optionalString(row: D1Row, field: string): string | null { + const value = row[field]; + if (value === null || value === undefined) { + return null; + } + if (typeof value !== 'string') { + throw new Error(`${field} must be a string or null.`); + } + return value; +} + +export function requireInteger(row: D1Row, field: string): number { + const value = row[field]; + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw new Error(`${field} must be an integer.`); + } + return value; +} + +export function optionalInteger(row: D1Row, field: string): number | null { + const value = row[field]; + if (value === null || value === undefined) { + return null; + } + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw new Error(`${field} must be an integer or null.`); + } + return value; +} + +export function requireEnum( + value: unknown, + values: readonly T[], + field: string, +): T { + if (typeof value !== 'string' || !values.includes(value as T)) { + throw new Error(`${field} used an unsupported value.`); + } + return value as T; +} + +export function nullableEnum( + value: unknown, + values: readonly T[], + field: string, +): T | null { + return value === null || value === undefined ? null : requireEnum(value, values, field); +} + +export function parseJson(value: unknown, field: string): unknown { + if (typeof value !== 'string') { + throw new Error(`${field} must be JSON text.`); + } + try { + return JSON.parse(value); + } catch { + throw new Error(`${field} must contain valid JSON.`); + } +} + +export function requireMembershipRole(value: unknown): InstitutionMembershipRecord['role'] { + return requireEnum(value, ['owner', 'admin', 'auditor'], 'institution_memberships.role'); +} + +export function requireRosterCapability(value: unknown): CapabilityGrantRecord['capability'] { + return requireCapability(value, 'capability_grants.capability'); +} + +export function addOptionalClause( + clauses: string[], + parameters: unknown[], + column: string, + value: string | undefined, +): void { + if (value !== undefined) { + clauses.push(`${column} = ?`); + parameters.push(value); + } +} + +export function mapInstitution(row: D1Row): InstitutionRecord { + return { + id: requireString(row, 'id'), + slug: requireString(row, 'slug'), + name: requireString(row, 'name'), + status: requireEnum(row.status, ['active', 'disabled'], 'institutions.status'), + createdAt: requireString(row, 'created_at'), + updatedAt: requireString(row, 'updated_at'), + }; +} + +export function mapMembership(row: D1Row): InstitutionMembershipRecord { + return { + institutionId: requireString(row, 'institution_id'), + userId: requireString(row, 'user_id'), + role: requireMembershipRole(row.role), + createdAt: requireString(row, 'created_at'), + }; +} + +export function mapConnection(row: D1Row): LmsConnectionRecord { + return { + id: requireString(row, 'id'), + institutionId: requireString(row, 'institution_id'), + label: requireString(row, 'label'), + platformType: requireEnum( + row.platform_type, + ['canvas', 'moodle', 'brightspace', 'sakai', 'synthetic'], + 'lms_connections.platform_type', + ), + issuer: requireString(row, 'issuer'), + clientId: requireString(row, 'client_id'), + deploymentId: requireString(row, 'deployment_id'), + authorizationEndpoint: requireString(row, 'authorization_endpoint'), + accessTokenUrl: requireString(row, 'access_token_url'), + jwksUrl: requireString(row, 'jwks_url'), + status: requireEnum( + row.status, + ['pending', 'active', 'disabled'], + 'lms_connections.status', + ), + validatedAt: optionalString(row, 'validated_at'), + createdAt: requireString(row, 'created_at'), + updatedAt: requireString(row, 'updated_at'), + }; +} + +export function mapLmsContext(row: D1Row): LmsContextRecord { + return { + id: requireString(row, 'id'), + institutionId: requireString(row, 'institution_id'), + lmsConnectionId: requireString(row, 'lms_connection_id'), + providerContextId: requireString(row, 'provider_context_id'), + label: optionalString(row, 'label'), + createdAt: requireString(row, 'created_at'), + updatedAt: requireString(row, 'updated_at'), + }; +} + +export function mapInstallation(row: D1Row): AppInstallationRecord { + return { + id: requireString(row, 'id'), + institutionId: requireString(row, 'institution_id'), + appId: requireString(row, 'app_id'), + packageVersionId: requireInteger(row, 'package_version_id'), + status: requireEnum( + row.status, + ['pending_approval', 'active', 'disabled'], + 'app_installations.status', + ), + installedByUserId: requireString(row, 'installed_by_user_id'), + installedAt: requireString(row, 'installed_at'), + activatedAt: optionalString(row, 'activated_at'), + updatedAt: requireString(row, 'updated_at'), + }; +} + +export function mapCapabilityGrant(row: D1Row): CapabilityGrantRecord { + const scopeKind = requireEnum( + row.scope_kind, + ['institution', 'context'], + 'capability_grants.scope_kind', + ); + const contextId = optionalString(row, 'lms_context_id'); + if (scopeKind === 'context' && contextId === null) { + throw new Error('Context capability grant is missing lms_context_id.'); + } + + return { + id: requireString(row, 'id'), + institutionId: requireString(row, 'institution_id'), + appInstallationId: requireString(row, 'app_installation_id'), + packageVersionId: requireInteger(row, 'package_version_id'), + capability: requireRosterCapability(row.capability), + scope: scopeKind === 'institution' + ? { kind: 'institution' } + : { kind: 'context', lmsContextId: contextId! }, + approvedByUserId: requireString(row, 'approved_by_user_id'), + approvedAt: requireString(row, 'approved_at'), + revokedByUserId: optionalString(row, 'revoked_by_user_id'), + revokedAt: optionalString(row, 'revoked_at'), + }; +} + +export function mapGovernedRuntimeSession(row: D1Row): GovernedRuntimeSession { + return { + runtimeSessionId: requireString(row, 'runtime_session_id'), + runtimeSessionToken: requireString(row, 'runtime_session_token'), + launchTraceId: requireString(row, 'launch_trace_id'), + runtimeExpiresAt: requireString(row, 'runtime_expires_at'), + institutionId: requireString(row, 'institution_id'), + lmsConnectionId: requireString(row, 'lms_connection_id'), + lmsContextId: requireString(row, 'lms_context_id'), + appInstallationId: requireString(row, 'app_installation_id'), + appId: requireString(row, 'app_id'), + packageVersionId: requireInteger(row, 'package_version_id'), + packageVersion: requireString(row, 'package_version'), + packageCapabilities: parseStringArray( + parseJson(row.package_capabilities, 'governed_runtime_sessions.package_capabilities'), + 'governed_runtime_sessions.package_capabilities', + ), + actorSubjectId: requireString(row, 'actor_subject_id'), + actorType: requireEnum( + row.actor_type, + ['instructor', 'administrator'], + 'governed_runtime_sessions.actor_type', + ), + ltiToolSessionId: optionalString(row, 'lti_tool_session_id'), + nrpsContextMembershipsUrl: optionalString(row, 'nrps_context_memberships_url'), + nrpsServiceVersions: parseStringArray( + parseJson(row.nrps_service_versions, 'runtime_sessions.nrps_service_versions'), + 'runtime_sessions.nrps_service_versions', + ), + connectionStatus: requireEnum( + row.connection_status, + ['pending', 'active', 'disabled'], + 'lms_connections.status', + ), + institutionStatus: requireEnum( + row.institution_status, + ['active', 'disabled'], + 'institutions.status', + ), + installationStatus: requireEnum( + row.installation_status, + ['pending_approval', 'active', 'disabled'], + 'app_installations.status', + ), + connectorKind: requireEnum( + row.connector_kind, + ['nrps', 'synthetic'], + 'governed_runtime_sessions.connector_kind', + ), + }; +} + +export function mapContinuation(row: D1Row): RosterContinuation { + return { + id: requireString(row, 'id'), + runtimeSessionId: requireString(row, 'runtime_session_id'), + appInstallationId: requireString(row, 'app_installation_id'), + packageVersionId: requireInteger(row, 'package_version_id'), + connectorKind: requireEnum( + row.connector_kind, + ['nrps', 'synthetic'], + 'roster_continuations.connector_kind', + ), + continuationValue: requireString(row, 'continuation_value'), + expiresAt: requireString(row, 'expires_at'), + }; +} + +export function mapProtectedMediaReference(row: D1Row): ProtectedMediaReference { + return { + id: requireString(row, 'id'), + traceId: requireString(row, 'trace_id'), + institutionId: requireString(row, 'institution_id'), + lmsConnectionId: requireString(row, 'lms_connection_id'), + lmsContextId: requireString(row, 'lms_context_id'), + runtimeSessionId: requireString(row, 'runtime_session_id'), + appInstallationId: requireString(row, 'app_installation_id'), + packageVersionId: requireInteger(row, 'package_version_id'), + subjectIdentityId: requireString(row, 'subject_identity_id'), + actorSubjectId: requireString(row, 'actor_subject_id'), + actorType: requireEnum( + row.actor_type, + ['instructor', 'administrator'], + 'protected_media_refs.actor_type', + ), + connectorKind: requireEnum( + row.connector_kind, + ['nrps', 'synthetic'], + 'protected_media_refs.connector_kind', + ), + sourceUrl: requireString(row, 'source_url'), + expiresAt: requireString(row, 'expires_at'), + }; +} + +export function mapGovernanceEvent(row: D1Row): GovernanceEventRecord { + return { + id: requireInteger(row, 'id'), + traceId: requireString(row, 'trace_id'), + eventType: requireString(row, 'event_type'), + institutionId: optionalString(row, 'institution_id'), + lmsConnectionId: optionalString(row, 'lms_connection_id'), + lmsContextId: optionalString(row, 'lms_context_id'), + appInstallationId: optionalString(row, 'app_installation_id'), + appId: optionalString(row, 'app_id'), + packageVersionId: optionalInteger(row, 'package_version_id'), + runtimeSessionId: optionalString(row, 'runtime_session_id'), + actorType: requireEnum( + row.actor_type, + ['user', 'instructor', 'administrator', 'system', 'app'], + 'governance_events.actor_type', + ), + actorId: optionalString(row, 'actor_id'), + capability: optionalString(row, 'capability'), + capabilityGrantId: optionalString(row, 'capability_grant_id'), + decision: nullableEnum(row.decision, ['allowed', 'denied'], 'governance_events.decision'), + outcome: requireEnum( + row.outcome, + ['completed', 'failed', 'limited'], + 'governance_events.outcome', + ), + connectorKind: nullableEnum( + row.connector_kind, + ['nrps', 'synthetic'], + 'governance_events.connector_kind', + ), + objectCount: optionalInteger(row, 'object_count'), + latencyMs: optionalInteger(row, 'latency_ms'), + errorCode: optionalString(row, 'error_code'), + requestMetadata: parseJsonObject( + parseJson(row.request_metadata, 'governance_events.request_metadata'), + 'governance_events.request_metadata', + ), + occurredAt: requireString(row, 'occurred_at'), + }; +} + +export function requireInstitution( + value: InstitutionRecord | null, + id: string, +): InstitutionRecord { + if (value === null) { + throw new Error(`Institution ${id} was not found after persistence.`); + } + return value; +} + +export function requireConnection( + value: LmsConnectionRecord | null, + id: string, +): LmsConnectionRecord { + if (value === null) { + throw new Error(`LMS connection ${id} was not found after persistence.`); + } + return value; +} + +export function requireInstallation( + value: AppInstallationRecord | null, + id: string, +): AppInstallationRecord { + if (value === null) { + throw new Error(`App installation ${id} was not found after persistence.`); + } + return value; +} diff --git a/src/lms_kernel/repository_d1_runtime.ts b/src/lms_kernel/repository_d1_runtime.ts new file mode 100644 index 0000000..d25e64a --- /dev/null +++ b/src/lms_kernel/repository_d1_runtime.ts @@ -0,0 +1,240 @@ +import { type D1Database, queryD1First, runD1, runD1Batch } from '../db/d1.ts'; +import type { GovernedRuntimeRepository } from './repository.ts'; +import { governanceEventStatement } from './repository_d1_governance.ts'; +import { + type D1Row, + mapContinuation, + mapGovernedRuntimeSession, + mapProtectedMediaReference, + requireString, +} from './repository_d1_row.ts'; + +export function createD1GovernedRuntimeRepository(db: D1Database): GovernedRuntimeRepository { + const repository: GovernedRuntimeRepository = { + async getGovernedRuntimeSession(input) { + const row = await queryD1First( + db, + ` + SELECT + governed_runtime_sessions.runtime_session_id, + governed_runtime_sessions.runtime_session_token, + governed_runtime_sessions.launch_trace_id, + governed_runtime_sessions.runtime_expires_at, + governed_runtime_sessions.app_id, + governed_runtime_sessions.package_version_id, + governed_runtime_sessions.package_version, + governed_runtime_sessions.package_capabilities, + governed_runtime_sessions.lti_tool_session_id, + governed_runtime_sessions.nrps_context_memberships_url, + governed_runtime_sessions.nrps_service_versions, + governed_runtime_sessions.connector_kind, + governed_runtime_sessions.institution_id, + governed_runtime_sessions.lms_connection_id, + governed_runtime_sessions.lms_context_id, + governed_runtime_sessions.app_installation_id, + governed_runtime_sessions.actor_subject_id, + governed_runtime_sessions.actor_type, + institutions.status AS institution_status, + lms_connections.status AS connection_status, + lms_connections.platform_type, + app_installations.status AS installation_status + FROM governed_runtime_sessions + JOIN institutions + ON institutions.id = governed_runtime_sessions.institution_id + JOIN lms_connections + ON lms_connections.id = governed_runtime_sessions.lms_connection_id + JOIN app_installations + ON app_installations.id = governed_runtime_sessions.app_installation_id + WHERE governed_runtime_sessions.runtime_session_id = ? + AND governed_runtime_sessions.runtime_session_token = ? + `, + [input.runtimeSessionId, input.runtimeSessionToken], + ); + return row === null ? null : mapGovernedRuntimeSession(row); + }, + + async createGovernedRuntimeSession(input) { + const session = input.session; + await runD1Batch(db, [{ + sql: ` + INSERT INTO governed_runtime_sessions ( + runtime_session_id, runtime_session_token, launch_trace_id, runtime_expires_at, + institution_id, lms_connection_id, lms_context_id, app_installation_id, + app_id, package_version_id, package_version, package_capabilities, + actor_subject_id, actor_type, lti_tool_session_id, + nrps_context_memberships_url, nrps_service_versions, connector_kind, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + parameters: [ + session.runtimeSessionId, + session.runtimeSessionToken, + session.launchTraceId, + session.runtimeExpiresAt, + session.institutionId, + session.lmsConnectionId, + session.lmsContextId, + session.appInstallationId, + session.appId, + session.packageVersionId, + session.packageVersion, + session.packageCapabilities, + session.actorSubjectId, + session.actorType, + session.ltiToolSessionId, + session.nrpsContextMembershipsUrl, + session.nrpsServiceVersions, + session.connectorKind, + input.createdAt, + ], + }, governanceEventStatement(input.event)]); + const stored = await repository.getGovernedRuntimeSession({ + runtimeSessionId: session.runtimeSessionId, + runtimeSessionToken: session.runtimeSessionToken, + }); + if (stored === null) { + throw new Error('Governed runtime session was not persisted.'); + } + return stored; + }, + + async getOrCreateSubjectIdentity(input) { + await runD1( + db, + ` + INSERT INTO subject_identities ( + id, institution_id, lms_connection_id, provider_subject_id, + created_at, last_seen_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (lms_connection_id, provider_subject_id) DO UPDATE SET + last_seen_at = excluded.last_seen_at + `, + [ + input.id, + input.institutionId, + input.lmsConnectionId, + input.providerSubjectId, + input.now, + input.now, + ], + ); + const row = await queryD1First( + db, + ` + SELECT id FROM subject_identities + WHERE lms_connection_id = ? AND provider_subject_id = ? + `, + [input.lmsConnectionId, input.providerSubjectId], + ); + if (row === null) { + throw new Error('Subject identity was not persisted.'); + } + return requireString(row, 'id'); + }, + + async saveRosterContinuation(input) { + await runD1( + db, + ` + INSERT INTO roster_continuations ( + id, runtime_session_id, app_installation_id, package_version_id, + connector_kind, continuation_value, created_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + input.id, + input.runtimeSessionId, + input.appInstallationId, + input.packageVersionId, + input.connectorKind, + input.continuationValue, + new Date().toISOString(), + input.expiresAt, + ], + ); + }, + + async getRosterContinuation(id) { + const row = await queryD1First( + db, + `SELECT * FROM roster_continuations WHERE id = ?`, + [id], + ); + return row === null ? null : mapContinuation(row); + }, + + async saveProtectedMediaReference(input) { + await runD1( + db, + ` + INSERT INTO protected_media_refs ( + id, trace_id, institution_id, lms_connection_id, lms_context_id, + runtime_session_id, app_installation_id, package_version_id, + subject_identity_id, actor_subject_id, actor_type, connector_kind, + source_url, created_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + input.id, + input.traceId, + input.institutionId, + input.lmsConnectionId, + input.lmsContextId, + input.runtimeSessionId, + input.appInstallationId, + input.packageVersionId, + input.subjectIdentityId, + input.actorSubjectId, + input.actorType, + input.connectorKind, + input.sourceUrl, + new Date().toISOString(), + input.expiresAt, + ], + ); + }, + + async getAuthorizedProtectedMediaReference(input) { + const row = await queryD1First( + db, + ` + SELECT protected_media_refs.* + FROM protected_media_refs + JOIN governed_runtime_sessions + ON governed_runtime_sessions.runtime_session_id = + protected_media_refs.runtime_session_id + JOIN institutions + ON institutions.id = protected_media_refs.institution_id + JOIN lms_connections + ON lms_connections.id = governed_runtime_sessions.lms_connection_id + JOIN app_installations + ON app_installations.id = protected_media_refs.app_installation_id + WHERE protected_media_refs.id = ? + AND protected_media_refs.expires_at > ? + AND governed_runtime_sessions.runtime_expires_at > ? + AND institutions.status = 'active' + AND lms_connections.status = 'active' + AND app_installations.status = 'active' + AND EXISTS ( + SELECT 1 + FROM capability_grants + WHERE capability_grants.app_installation_id = + protected_media_refs.app_installation_id + AND capability_grants.package_version_id = + protected_media_refs.package_version_id + AND capability_grants.capability = 'lms.roster.profile_photo.read' + AND capability_grants.revoked_at IS NULL + AND ( + capability_grants.scope_kind = 'institution' + OR capability_grants.lms_context_id = + governed_runtime_sessions.lms_context_id + ) + ) + `, + [input.id, input.now, input.now], + ); + return row === null ? null : mapProtectedMediaReference(row); + }, + }; + + return repository; +} diff --git a/src/lms_kernel/repository_memory_test_support.ts b/src/lms_kernel/repository_memory_test_support.ts new file mode 100644 index 0000000..0907946 --- /dev/null +++ b/src/lms_kernel/repository_memory_test_support.ts @@ -0,0 +1,349 @@ +import type { RosterCapability } from '../../sdk/worker-app-sdk.ts'; +import type { + GovernedWrite, + LmsKernelRepository, + ProtectedMediaReference, + RosterContinuation, +} from './repository.ts'; +import type { + AppInstallationRecord, + CapabilityGrantRecord, + GovernanceEventFilter, + GovernanceEventRecord, + GovernedRuntimeSession, + GovernedRuntimeSessionWrite, + InstitutionMembershipRecord, + InstitutionRecord, + LmsConnectionRecord, + LmsContextRecord, +} from './types.ts'; + +export class MemoryLmsKernelRepository implements LmsKernelRepository { + readonly grants: CapabilityGrantRecord[]; + readonly events: GovernanceEventRecord[] = []; + readonly continuations = new Map(); + readonly media = new Map(); + readonly subjectIds = new Map(); + session: GovernedRuntimeSession | null; + + constructor(input: { + readonly session: GovernedRuntimeSession; + readonly grants: readonly CapabilityGrantRecord[]; + }) { + this.session = input.session; + this.grants = [...input.grants]; + } + + createInstitutionWithOwner(): Promise { + return unsupported(); + } + + getInstitutionById(): Promise { + return unsupported(); + } + + listMembershipsForUser(): Promise { + return unsupported(); + } + + getMembership(): Promise { + return unsupported(); + } + + createInvite(): Promise { + return unsupported(); + } + + acceptInvite(): Promise { + return unsupported(); + } + + createLmsConnection(): Promise { + return unsupported(); + } + + getLmsConnectionById(): Promise { + return unsupported(); + } + + getLmsConnectionByPlatformIdentity(): Promise { + return unsupported(); + } + + listLmsConnections(): Promise { + return unsupported(); + } + + activateLmsConnection(): Promise { + return unsupported(); + } + + disableLmsConnection(): Promise { + return unsupported(); + } + + getOrCreateLmsContext(): Promise { + return unsupported(); + } + + getLmsContextById(_id: string): Promise { + return unsupported(); + } + + listLmsContexts(): Promise { + return unsupported(); + } + + createInstallation(_input: { + readonly id: string; + readonly institutionId: string; + readonly appId: string; + readonly packageVersionId: number; + readonly installedByUserId: string; + readonly now: string; + }): Promise { + return unsupported(); + } + + replaceInstallationPackageVersion(_input: { + readonly id: string; + readonly institutionId: string; + readonly packageVersionId: number; + readonly installedByUserId: string; + readonly now: string; + }): Promise { + return unsupported(); + } + + getInstallationById(_id: string): Promise { + return unsupported(); + } + + listInstallations(_institutionId: string): Promise { + return unsupported(); + } + + activateInstallation(_input: { + readonly id: string; + readonly activatedAt: string; + }): Promise { + return unsupported(); + } + + disableInstallation(): Promise { + return unsupported(); + } + + createCapabilityGrant(_input: { + readonly id: string; + readonly institutionId: string; + readonly appInstallationId: string; + readonly packageVersionId: number; + readonly capability: RosterCapability; + readonly scope: + | { readonly kind: 'institution' } + | { readonly kind: 'context'; readonly lmsContextId: string }; + readonly approvedByUserId: string; + readonly approvedAt: string; + }): Promise { + return unsupported(); + } + + revokeCapabilityGrant(input: { + readonly id: string; + readonly institutionId: string; + readonly revokedByUserId: string; + readonly revokedAt: string; + }): Promise { + const index = this.grants.findIndex((grant) => + grant.id === input.id && grant.institutionId === input.institutionId + ); + if (index < 0) { + return Promise.resolve(null); + } + const revoked = { + ...this.grants[index]!, + revokedByUserId: input.revokedByUserId, + revokedAt: input.revokedAt, + }; + this.grants[index] = revoked; + return Promise.resolve(revoked); + } + + listCapabilityGrants(appInstallationId: string): Promise { + return Promise.resolve( + this.grants.filter((grant) => grant.appInstallationId === appInstallationId), + ); + } + + listCapabilityGrantsForInstitution(institutionId: string): Promise { + return Promise.resolve( + this.grants.filter((grant) => grant.institutionId === institutionId), + ); + } + + getGovernedRuntimeSession(input: { + readonly runtimeSessionId: string; + readonly runtimeSessionToken: string; + }): Promise { + if ( + this.session === null || + input.runtimeSessionId !== this.session.runtimeSessionId || + input.runtimeSessionToken !== this.session.runtimeSessionToken + ) { + return Promise.resolve(null); + } + return Promise.resolve(this.session); + } + + createGovernedRuntimeSession( + input: GovernedWrite & { + readonly session: GovernedRuntimeSessionWrite; + readonly createdAt: string; + }, + ): Promise { + const session: GovernedRuntimeSession = { + ...input.session, + connectionStatus: 'active', + institutionStatus: 'active', + installationStatus: 'active', + }; + this.session = session; + this.recordGovernanceEvent(input.event); + return Promise.resolve(session); + } + + getOrCreateSubjectIdentity(input: { + readonly id: string; + readonly institutionId: string; + readonly lmsConnectionId: string; + readonly providerSubjectId: string; + readonly now: string; + }): Promise { + const key = `${input.lmsConnectionId}:${input.providerSubjectId}`; + const existing = this.subjectIds.get(key); + if (existing !== undefined) { + return Promise.resolve(existing); + } + this.subjectIds.set(key, input.id); + return Promise.resolve(input.id); + } + + saveRosterContinuation(input: RosterContinuation): Promise { + this.continuations.set(input.id, input); + return Promise.resolve(); + } + + getRosterContinuation(id: string): Promise { + return Promise.resolve(this.continuations.get(id) ?? null); + } + + saveProtectedMediaReference(input: ProtectedMediaReference): Promise { + this.media.set(input.id, input); + return Promise.resolve(); + } + + getAuthorizedProtectedMediaReference(input: { + readonly id: string; + readonly now: string; + }): Promise { + const reference = this.media.get(input.id); + const photoGrant = this.grants.find((grant) => + grant.capability === 'lms.roster.profile_photo.read' && + grant.revokedAt === null + ); + if ( + reference === undefined || + Date.parse(reference.expiresAt) <= Date.parse(input.now) || + photoGrant === undefined + ) { + return Promise.resolve(null); + } + return Promise.resolve(reference); + } + + appendGovernanceEvent( + event: Omit, + ): Promise { + return Promise.resolve(this.recordGovernanceEvent(event)); + } + + appendGovernanceEvents( + events: readonly Omit[], + ): Promise { + for (const event of events) this.recordGovernanceEvent(event); + return Promise.resolve(); + } + + listGovernanceEvents(filter: GovernanceEventFilter): Promise { + return Promise.resolve( + this.events.filter((event) => + event.institutionId === filter.institutionId && + (filter.traceId === undefined || event.traceId === filter.traceId) && + (filter.capability === undefined || event.capability === filter.capability) && + (filter.decision === undefined || event.decision === filter.decision) && + (filter.outcome === undefined || event.outcome === filter.outcome) + ), + ); + } + + protected recordGovernanceEvent( + event: Omit, + ): GovernanceEventRecord { + const record = { id: this.events.length + 1, ...event }; + this.events.push(record); + return record; + } +} + +export function buildKernelGrant( + id: string, + capability: RosterCapability, +): CapabilityGrantRecord { + return { + id, + institutionId: 'institution-1', + appInstallationId: 'installation-1', + packageVersionId: 1, + capability, + scope: { kind: 'context', lmsContextId: 'context-1' }, + approvedByUserId: 'admin-1', + approvedAt: '2026-07-19T15:00:00.000Z', + revokedByUserId: null, + revokedAt: null, + }; +} + +export function buildGovernedSyntheticSession(): GovernedRuntimeSession { + return { + runtimeSessionId: 'runtime-1', + runtimeSessionToken: 'runtime-token', + launchTraceId: 'trace-1', + runtimeExpiresAt: '2026-07-19T17:00:00.000Z', + institutionId: 'institution-1', + lmsConnectionId: 'connection-1', + lmsContextId: 'context-1', + appInstallationId: 'installation-1', + appId: 'photo-roster', + packageVersionId: 1, + packageVersion: '0.1.0', + packageCapabilities: [ + 'lms.roster.members.read', + 'lms.roster.contact.read', + 'lms.roster.profile_photo.read', + ], + actorSubjectId: 'instructor-1', + actorType: 'instructor', + ltiToolSessionId: null, + nrpsContextMembershipsUrl: null, + nrpsServiceVersions: [], + connectionStatus: 'active', + institutionStatus: 'active', + installationStatus: 'active', + connectorKind: 'synthetic', + }; +} + +function unsupported(): Promise { + return Promise.reject(new Error('This repository operation is outside the test seam.')); +} diff --git a/src/lms_kernel/service.ts b/src/lms_kernel/service.ts new file mode 100644 index 0000000..29b5c54 --- /dev/null +++ b/src/lms_kernel/service.ts @@ -0,0 +1,431 @@ +import type { + PrimitiveErrorCode, + PrimitiveResult, + RosterListInput, + RosterMember, + RosterPage, +} from '../../sdk/worker-app-sdk.ts'; +import type { EnvReader } from '../platform/env.ts'; +import { signRosterCursor, verifyRosterCursor } from './cursor.ts'; +import type { RosterConnector } from './connector.ts'; +import { parseRosterListInput } from './parsing.ts'; +import { authorizeRosterRead, requiredRosterCapabilities } from './policy.ts'; +import type { LmsKernelRepository } from './repository.ts'; +import type { + CapabilityGrantRecord, + GovernedRuntimeSession, + RosterConnectorMember, +} from './types.ts'; + +const CONTINUATION_TTL_MS = 10 * 60 * 1000; +const MEDIA_TTL_MS = 5 * 60 * 1000; + +export interface GovernedRosterService { + listRoster(input: { + readonly runtimeSessionId: string; + readonly runtimeSessionToken: string; + readonly traceId?: string; + readonly request: unknown; + readonly bindingIdentity?: { + readonly appId: string; + readonly packageVersionId: number; + }; + readonly requestMetadata?: Readonly>; + }): Promise>; +} + +export function createGovernedRosterService(input: { + readonly repository: LmsKernelRepository; + readonly nrpsConnector: RosterConnector; + readonly syntheticConnector: RosterConnector; + readonly env: EnvReader; + readonly publicOrigin: string; + readonly createId?: () => string; + readonly now?: () => Date; +}): GovernedRosterService { + const createId = input.createId ?? (() => crypto.randomUUID()); + const now = input.now ?? (() => new Date()); + + return { + async listRoster(command) { + const traceId = command.traceId ?? createId(); + const parsed = parseRosterListInput(command.request); + if (!parsed.ok) { + return primitiveFailure('invalid_request', traceId); + } + + const observedAt = now(); + const session = await input.repository.getGovernedRuntimeSession({ + runtimeSessionId: command.runtimeSessionId, + runtimeSessionToken: command.runtimeSessionToken, + }); + if (session === null) { + return primitiveFailure('session_expired', traceId); + } + if ( + command.bindingIdentity !== undefined && + (command.bindingIdentity.appId !== session.appId || + command.bindingIdentity.packageVersionId !== session.packageVersionId) + ) { + await recordDecision({ + repository: input.repository, + session, + traceId, + grants: [], + capabilities: requiredRosterCapabilities(parsed.value.fields), + decision: 'denied', + outcome: 'failed', + errorCode: 'permission_denied', + requestMetadata: command.requestMetadata ?? {}, + occurredAt: observedAt.toISOString(), + }); + return primitiveFailure('permission_denied', traceId); + } + + const grants = await input.repository.listCapabilityGrants(session.appInstallationId); + const capabilities = requiredRosterCapabilities(parsed.value.fields); + const authorization = authorizeRosterRead({ + session, + grants, + requestedCapabilities: capabilities, + now: observedAt, + }); + if (!authorization.allowed) { + const code = authorization.reason === 'session_expired' + ? 'session_expired' + : 'permission_denied'; + await recordDecision({ + repository: input.repository, + session, + traceId, + grants: [], + capabilities, + decision: 'denied', + outcome: 'failed', + errorCode: code, + requestMetadata: command.requestMetadata ?? {}, + occurredAt: observedAt.toISOString(), + }); + return primitiveFailure(code, traceId); + } + + await recordDecision({ + repository: input.repository, + session, + traceId, + grants: authorization.grants, + capabilities, + decision: 'allowed', + outcome: 'completed', + errorCode: null, + requestMetadata: command.requestMetadata ?? {}, + occurredAt: observedAt.toISOString(), + }); + + const continuation = await resolveContinuation({ + repository: input.repository, + env: input.env, + session, + cursor: parsed.value.cursor, + now: observedAt, + }); + if (!continuation.ok) { + await recordCompletion({ + repository: input.repository, + session, + traceId, + outcome: 'failed', + objectCount: null, + latencyMs: null, + errorCode: 'invalid_request', + occurredAt: now().toISOString(), + }); + return primitiveFailure('invalid_request', traceId); + } + + const connector = session.connectorKind === 'synthetic' + ? input.syntheticConnector + : input.nrpsConnector; + const startedAt = performance.now(); + const connectorResult = await connector.listPage({ + session, + continuation: continuation.value, + }); + if (!connectorResult.ok) { + await recordCompletion({ + repository: input.repository, + session, + traceId, + outcome: connectorResult.errorCode === 'rate_limited' ? 'limited' : 'failed', + objectCount: null, + latencyMs: elapsedMilliseconds(startedAt), + errorCode: connectorResult.errorCode, + occurredAt: now().toISOString(), + }); + return primitiveFailure(connectorResult.errorCode, traceId); + } + + const expiresAt = new Date(observedAt.getTime() + MEDIA_TTL_MS).toISOString(); + const members: RosterMember[] = []; + for (const member of connectorResult.page.members) { + members.push( + await materializeMember({ + repository: input.repository, + session, + member, + fields: parsed.value.fields, + publicOrigin: input.publicOrigin, + expiresAt, + now: observedAt.toISOString(), + traceId, + createId, + }), + ); + } + + const nextCursor = connectorResult.page.nextContinuation === null + ? undefined + : await persistContinuation({ + repository: input.repository, + env: input.env, + session, + continuation: connectorResult.page.nextContinuation, + now: observedAt, + createId, + }); + + await recordCompletion({ + repository: input.repository, + session, + traceId, + outcome: 'completed', + objectCount: members.length, + latencyMs: elapsedMilliseconds(startedAt), + errorCode: null, + occurredAt: now().toISOString(), + }); + + const value: RosterPage = { + members, + observedAt: observedAt.toISOString(), + ...(nextCursor === undefined ? {} : { nextCursor }), + }; + return { ok: true, value }; + }, + }; +} + +async function resolveContinuation(input: { + readonly repository: LmsKernelRepository; + readonly env: EnvReader; + readonly session: GovernedRuntimeSession; + readonly cursor: string | undefined; + readonly now: Date; +}): Promise<{ readonly ok: true; readonly value: string | null } | { readonly ok: false }> { + if (input.cursor === undefined) { + return { ok: true, value: null }; + } + const verified = await verifyRosterCursor({ + cursor: input.cursor, + env: input.env, + now: input.now, + }); + if ( + !verified.ok || + verified.payload.runtimeSessionId !== input.session.runtimeSessionId || + verified.payload.lmsContextId !== input.session.lmsContextId || + verified.payload.appInstallationId !== input.session.appInstallationId || + verified.payload.packageVersionId !== input.session.packageVersionId || + verified.payload.connectorKind !== input.session.connectorKind + ) { + return { ok: false }; + } + const continuation = await input.repository.getRosterContinuation( + verified.payload.continuationId, + ); + if ( + continuation === null || + Date.parse(continuation.expiresAt) <= input.now.getTime() || + continuation.runtimeSessionId !== input.session.runtimeSessionId || + continuation.appInstallationId !== input.session.appInstallationId || + continuation.packageVersionId !== input.session.packageVersionId || + continuation.connectorKind !== input.session.connectorKind + ) { + return { ok: false }; + } + return { ok: true, value: continuation.continuationValue }; +} + +async function persistContinuation(input: { + readonly repository: LmsKernelRepository; + readonly env: EnvReader; + readonly session: GovernedRuntimeSession; + readonly continuation: string; + readonly now: Date; + readonly createId: () => string; +}): Promise { + const id = input.createId(); + const expiresAt = new Date(input.now.getTime() + CONTINUATION_TTL_MS).toISOString(); + await input.repository.saveRosterContinuation({ + id, + runtimeSessionId: input.session.runtimeSessionId, + appInstallationId: input.session.appInstallationId, + packageVersionId: input.session.packageVersionId, + connectorKind: input.session.connectorKind, + continuationValue: input.continuation, + expiresAt, + }); + return await signRosterCursor({ + payload: { + continuationId: id, + runtimeSessionId: input.session.runtimeSessionId, + lmsContextId: input.session.lmsContextId, + appInstallationId: input.session.appInstallationId, + packageVersionId: input.session.packageVersionId, + connectorKind: input.session.connectorKind, + expiresAt, + }, + env: input.env, + }); +} + +async function materializeMember(input: { + readonly repository: LmsKernelRepository; + readonly session: GovernedRuntimeSession; + readonly member: RosterConnectorMember; + readonly fields: RosterListInput['fields']; + readonly publicOrigin: string; + readonly expiresAt: string; + readonly now: string; + readonly traceId: string; + readonly createId: () => string; +}): Promise { + const subjectId = await input.repository.getOrCreateSubjectIdentity({ + id: input.createId(), + institutionId: input.session.institutionId, + lmsConnectionId: input.session.lmsConnectionId, + providerSubjectId: input.member.providerSubjectId, + now: input.now, + }); + let photo: RosterMember['photo']; + if (input.fields.includes('photo') && input.member.pictureUrl !== null) { + const mediaId = input.createId(); + await input.repository.saveProtectedMediaReference({ + id: mediaId, + traceId: input.traceId, + institutionId: input.session.institutionId, + lmsConnectionId: input.session.lmsConnectionId, + lmsContextId: input.session.lmsContextId, + runtimeSessionId: input.session.runtimeSessionId, + appInstallationId: input.session.appInstallationId, + packageVersionId: input.session.packageVersionId, + subjectIdentityId: subjectId, + actorSubjectId: input.session.actorSubjectId, + actorType: input.session.actorType, + connectorKind: input.session.connectorKind, + sourceUrl: input.member.pictureUrl, + expiresAt: input.expiresAt, + }); + photo = { + url: `${input.publicOrigin}/runtime/media/${encodeURIComponent(mediaId)}`, + expiresAt: input.expiresAt, + }; + } + + return { + subjectId, + displayName: input.member.displayName, + roles: input.member.roles, + status: input.member.status, + ...(input.fields.includes('email') && input.member.email !== null + ? { email: input.member.email } + : {}), + ...(photo === undefined ? {} : { photo }), + }; +} + +async function recordDecision(input: { + readonly repository: LmsKernelRepository; + readonly session: GovernedRuntimeSession; + readonly traceId: string; + readonly grants: readonly CapabilityGrantRecord[]; + readonly capabilities: readonly string[]; + readonly decision: 'allowed' | 'denied'; + readonly outcome: 'completed' | 'failed'; + readonly errorCode: PrimitiveErrorCode | null; + readonly requestMetadata: Readonly>; + readonly occurredAt: string; +}): Promise { + await input.repository.appendGovernanceEvents(input.capabilities.map((capability) => { + const grant = input.grants.find((candidate) => candidate.capability === capability); + return { + traceId: input.traceId, + eventType: 'primitive.authorization', + institutionId: input.session.institutionId, + lmsConnectionId: input.session.lmsConnectionId, + lmsContextId: input.session.lmsContextId, + appInstallationId: input.session.appInstallationId, + appId: input.session.appId, + packageVersionId: input.session.packageVersionId, + runtimeSessionId: input.session.runtimeSessionId, + actorType: input.session.actorType, + actorId: input.session.actorSubjectId, + capability, + capabilityGrantId: grant?.id ?? null, + decision: input.decision, + outcome: input.outcome, + connectorKind: input.session.connectorKind, + objectCount: null, + latencyMs: null, + errorCode: input.errorCode, + requestMetadata: input.requestMetadata, + occurredAt: input.occurredAt, + }; + })); +} + +async function recordCompletion(input: { + readonly repository: LmsKernelRepository; + readonly session: GovernedRuntimeSession; + readonly traceId: string; + readonly outcome: 'completed' | 'failed' | 'limited'; + readonly objectCount: number | null; + readonly latencyMs: number | null; + readonly errorCode: PrimitiveErrorCode | null; + readonly occurredAt: string; +}): Promise { + await input.repository.appendGovernanceEvent({ + traceId: input.traceId, + eventType: 'primitive.roster.list', + institutionId: input.session.institutionId, + lmsConnectionId: input.session.lmsConnectionId, + lmsContextId: input.session.lmsContextId, + appInstallationId: input.session.appInstallationId, + appId: input.session.appId, + packageVersionId: input.session.packageVersionId, + runtimeSessionId: input.session.runtimeSessionId, + actorType: 'app', + actorId: input.session.appId, + capability: 'lms.roster.members.read', + capabilityGrantId: null, + decision: 'allowed', + outcome: input.outcome, + connectorKind: input.session.connectorKind, + objectCount: input.objectCount, + latencyMs: input.latencyMs, + errorCode: input.errorCode, + requestMetadata: {}, + occurredAt: input.occurredAt, + }); +} + +function primitiveFailure( + code: PrimitiveErrorCode, + traceId: string, +): PrimitiveResult { + return { ok: false, error: { code, traceId } }; +} + +function elapsedMilliseconds(startedAt: number): number { + return Math.max(0, Math.round(performance.now() - startedAt)); +} diff --git a/src/lms_kernel/service_test.ts b/src/lms_kernel/service_test.ts new file mode 100644 index 0000000..fd1bdc0 --- /dev/null +++ b/src/lms_kernel/service_test.ts @@ -0,0 +1,237 @@ +import { assert, assertEquals, assertNotEquals, assertStringIncludes } from '@std/assert'; +import { createObjectEnvReader } from '../platform/env.ts'; +import { getTestToolPrivateJwkEnvValue } from '../test_helpers/lti.ts'; +import type { RosterConnector } from './connector.ts'; +import { createGovernedRosterService } from './service.ts'; +import { + buildGovernedSyntheticSession, + buildKernelGrant, + MemoryLmsKernelRepository, +} from './repository_memory_test_support.ts'; + +const NOW = new Date('2026-07-19T16:00:00.000Z'); + +Deno.test('governed roster service returns opaque members, protected media, pagination, and one audit trace', async () => { + const repository = new MemoryLmsKernelRepository({ + session: buildGovernedSyntheticSession(), + grants: [ + buildKernelGrant('grant-members', 'lms.roster.members.read'), + buildKernelGrant('grant-email', 'lms.roster.contact.read'), + buildKernelGrant('grant-photo', 'lms.roster.profile_photo.read'), + ], + }); + const ids = idSequence(); + const service = createGovernedRosterService({ + repository, + nrpsConnector: unavailableConnector(), + syntheticConnector: pagedConnector(), + env: testEnv(), + publicOrigin: 'https://runtime.lantern.example', + createId: ids, + now: () => NOW, + }); + + const first = await service.listRoster({ + runtimeSessionId: 'runtime-1', + runtimeSessionToken: 'runtime-token', + request: { fields: ['email', 'photo'] }, + }); + + assert(first.ok); + assertEquals(first.value.members.length, 1); + const member = first.value.members[0]!; + assertNotEquals(member.subjectId, 'provider-user-1'); + assertEquals(member.displayName, 'Avery Learner'); + assertEquals(member.email, 'avery@example.edu'); + assert(member.photo !== undefined); + assertStringIncludes(member.photo.url, 'https://runtime.lantern.example/runtime/media/'); + assert(first.value.nextCursor !== undefined); + assertEquals(first.value.nextCursor.includes('opaque-next-url'), false); + assertEquals(repository.events.length, 4); + assertEquals(new Set(repository.events.map((event) => event.traceId)).size, 1); + assertEquals(repository.events.at(-1)?.objectCount, 1); + const ledger = JSON.stringify(repository.events); + assertEquals(ledger.includes('Avery Learner'), false); + assertEquals(ledger.includes('avery@example.edu'), false); + assertEquals(ledger.includes('provider-user-1'), false); + assertEquals(ledger.includes('profile.example'), false); + + const second = await service.listRoster({ + runtimeSessionId: 'runtime-1', + runtimeSessionToken: 'runtime-token', + request: { + fields: ['email', 'photo'], + cursor: first.value.nextCursor, + }, + }); + assert(second.ok); + assertEquals(second.value.members[0]?.displayName, 'Jordan Learner'); + assertEquals(second.value.nextCursor, undefined); +}); + +Deno.test('governed roster service rejects a binding from another immutable app version', async () => { + const repository = new MemoryLmsKernelRepository({ + session: buildGovernedSyntheticSession(), + grants: [buildKernelGrant('grant-members', 'lms.roster.members.read')], + }); + let connectorCalled = false; + const connector: RosterConnector = { + listPage() { + connectorCalled = true; + return Promise.resolve({ + ok: true, + page: { members: [], nextContinuation: null }, + }); + }, + }; + const service = createGovernedRosterService({ + repository, + nrpsConnector: connector, + syntheticConnector: connector, + env: testEnv(), + publicOrigin: 'https://runtime.lantern.example', + createId: idSequence(), + now: () => NOW, + }); + + const result = await service.listRoster({ + runtimeSessionId: 'runtime-1', + runtimeSessionToken: 'runtime-token', + bindingIdentity: { appId: 'photo-roster', packageVersionId: 99 }, + request: { fields: [] }, + }); + + assertEquals(result.ok, false); + assertEquals(connectorCalled, false); +}); + +Deno.test('governed roster service denies the complete request after photo permission is revoked', async () => { + const repository = new MemoryLmsKernelRepository({ + session: buildGovernedSyntheticSession(), + grants: [ + buildKernelGrant('grant-members', 'lms.roster.members.read'), + { + ...buildKernelGrant('grant-photo', 'lms.roster.profile_photo.read'), + revokedByUserId: 'admin-1', + revokedAt: '2026-07-19T15:30:00.000Z', + }, + ], + }); + let connectorCalled = false; + const connector: RosterConnector = { + listPage() { + connectorCalled = true; + return Promise.resolve({ + ok: true, + page: { members: [], nextContinuation: null }, + }); + }, + }; + const service = createGovernedRosterService({ + repository, + nrpsConnector: connector, + syntheticConnector: connector, + env: testEnv(), + publicOrigin: 'https://runtime.lantern.example', + createId: idSequence(), + now: () => NOW, + }); + + const result = await service.listRoster({ + runtimeSessionId: 'runtime-1', + runtimeSessionToken: 'runtime-token', + request: { fields: ['photo'] }, + }); + + assertEquals(result.ok, false); + if (!result.ok) { + assertEquals(result.error.code, 'permission_denied'); + } + assertEquals(connectorCalled, false); + assertEquals(repository.events.map((event) => event.decision), ['denied', 'denied']); +}); + +Deno.test('governed roster service rejects a cursor replayed by another package version', async () => { + const repository = new MemoryLmsKernelRepository({ + session: buildGovernedSyntheticSession(), + grants: [buildKernelGrant('grant-members', 'lms.roster.members.read')], + }); + const service = createGovernedRosterService({ + repository, + nrpsConnector: unavailableConnector(), + syntheticConnector: pagedConnector(), + env: testEnv(), + publicOrigin: 'https://runtime.lantern.example', + createId: idSequence(), + now: () => NOW, + }); + const first = await service.listRoster({ + runtimeSessionId: 'runtime-1', + runtimeSessionToken: 'runtime-token', + request: { fields: [] }, + }); + assert(first.ok); + assert(first.value.nextCursor !== undefined); + + repository.session = { + ...buildGovernedSyntheticSession(), + packageVersionId: 2, + }; + repository.grants[0] = { + ...repository.grants[0]!, + packageVersionId: 2, + }; + const replay = await service.listRoster({ + runtimeSessionId: 'runtime-1', + runtimeSessionToken: 'runtime-token', + request: { fields: [], cursor: first.value.nextCursor }, + }); + + assertEquals(replay.ok, false); + if (!replay.ok) { + assertEquals(replay.error.code, 'invalid_request'); + } +}); + +function pagedConnector(): RosterConnector { + return { + listPage(input) { + const second = input.continuation === 'opaque-next-url'; + return Promise.resolve({ + ok: true, + page: { + members: [ + { + providerSubjectId: second ? 'provider-user-2' : 'provider-user-1', + displayName: second ? 'Jordan Learner' : 'Avery Learner', + roles: ['learner'], + status: 'active', + email: second ? 'jordan@example.edu' : 'avery@example.edu', + pictureUrl: 'https://lms.example/profile.jpg', + }, + ], + nextContinuation: second ? null : 'opaque-next-url', + }, + }); + }, + }; +} + +function unavailableConnector(): RosterConnector { + return { + listPage() { + return Promise.resolve({ ok: false, errorCode: 'upstream_unavailable' }); + }, + }; +} + +function testEnv() { + return createObjectEnvReader({ + LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), + }); +} + +function idSequence(): () => string { + let next = 0; + return () => `id-${++next}`; +} diff --git a/src/lms_kernel/types.ts b/src/lms_kernel/types.ts new file mode 100644 index 0000000..09d9340 --- /dev/null +++ b/src/lms_kernel/types.ts @@ -0,0 +1,155 @@ +import type { PrimitiveErrorCode, RosterCapability, RosterRole } from '../../sdk/worker-app-sdk.ts'; + +export type InstitutionRole = 'owner' | 'admin' | 'auditor'; +export type LmsPlatformType = 'canvas' | 'moodle' | 'brightspace' | 'sakai' | 'synthetic'; +export type ConnectorKind = 'nrps' | 'synthetic'; +export type GrantScope = + | { readonly kind: 'institution' } + | { readonly kind: 'context'; readonly lmsContextId: string }; + +export interface InstitutionRecord { + readonly id: string; + readonly slug: string; + readonly name: string; + readonly status: 'active' | 'disabled'; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface InstitutionMembershipRecord { + readonly institutionId: string; + readonly userId: string; + readonly role: InstitutionRole; + readonly createdAt: string; +} + +export interface LmsConnectionRecord { + readonly id: string; + readonly institutionId: string; + readonly label: string; + readonly platformType: LmsPlatformType; + readonly issuer: string; + readonly clientId: string; + readonly deploymentId: string; + readonly authorizationEndpoint: string; + readonly accessTokenUrl: string; + readonly jwksUrl: string; + readonly status: 'pending' | 'active' | 'disabled'; + readonly validatedAt: string | null; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface LmsContextRecord { + readonly id: string; + readonly institutionId: string; + readonly lmsConnectionId: string; + readonly providerContextId: string; + readonly label: string | null; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface AppInstallationRecord { + readonly id: string; + readonly institutionId: string; + readonly appId: string; + readonly packageVersionId: number; + readonly status: 'pending_approval' | 'active' | 'disabled'; + readonly installedByUserId: string; + readonly installedAt: string; + readonly activatedAt: string | null; + readonly updatedAt: string; +} + +export interface CapabilityGrantRecord { + readonly id: string; + readonly institutionId: string; + readonly appInstallationId: string; + readonly packageVersionId: number; + readonly capability: RosterCapability; + readonly scope: GrantScope; + readonly approvedByUserId: string; + readonly approvedAt: string; + readonly revokedByUserId: string | null; + readonly revokedAt: string | null; +} + +export interface GovernedRuntimeSessionWrite { + readonly runtimeSessionId: string; + readonly runtimeSessionToken: string; + readonly launchTraceId: string; + readonly runtimeExpiresAt: string; + readonly institutionId: string; + readonly lmsConnectionId: string; + readonly lmsContextId: string; + readonly appInstallationId: string; + readonly appId: string; + readonly packageVersionId: number; + readonly packageVersion: string; + readonly packageCapabilities: readonly string[]; + readonly actorSubjectId: string; + readonly actorType: 'instructor' | 'administrator'; + readonly ltiToolSessionId: string | null; + readonly nrpsContextMembershipsUrl: string | null; + readonly nrpsServiceVersions: readonly string[]; + readonly connectorKind: ConnectorKind; +} + +export interface GovernedRuntimeSession extends GovernedRuntimeSessionWrite { + readonly connectionStatus: LmsConnectionRecord['status']; + readonly institutionStatus: InstitutionRecord['status']; + readonly installationStatus: AppInstallationRecord['status']; +} + +export interface RosterConnectorMember { + readonly providerSubjectId: string; + readonly displayName: string; + readonly roles: readonly RosterRole[]; + readonly status: 'active' | 'inactive' | 'unknown'; + readonly email: string | null; + readonly pictureUrl: string | null; +} + +export interface RosterConnectorPage { + readonly members: readonly RosterConnectorMember[]; + readonly nextContinuation: string | null; +} + +export interface GovernanceEventRecord { + readonly id: number; + readonly traceId: string; + readonly eventType: string; + readonly institutionId: string | null; + readonly lmsConnectionId: string | null; + readonly lmsContextId: string | null; + readonly appInstallationId: string | null; + readonly appId: string | null; + readonly packageVersionId: number | null; + readonly runtimeSessionId: string | null; + readonly actorType: 'user' | 'instructor' | 'administrator' | 'system' | 'app'; + readonly actorId: string | null; + readonly capability: string | null; + readonly capabilityGrantId: string | null; + readonly decision: 'allowed' | 'denied' | null; + readonly outcome: 'completed' | 'failed' | 'limited'; + readonly connectorKind: ConnectorKind | null; + readonly objectCount: number | null; + readonly latencyMs: number | null; + readonly errorCode: PrimitiveErrorCode | string | null; + readonly requestMetadata: Readonly>; + readonly occurredAt: string; +} + +export interface GovernanceEventFilter { + readonly institutionId: string; + readonly traceId?: string; + readonly lmsConnectionId?: string; + readonly appInstallationId?: string; + readonly capability?: string; + readonly decision?: 'allowed' | 'denied'; + readonly outcome?: 'completed' | 'failed' | 'limited'; + readonly occurredAfter?: string; + readonly occurredBefore?: string; + readonly limit?: number; +} diff --git a/src/lti/canvas_platform.ts b/src/lti/canvas_platform.ts deleted file mode 100644 index 37e48ff..0000000 --- a/src/lti/canvas_platform.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { resolveCanvasPlatformBinding } from '@longsightgroup/lti-tool'; -import type { CanvasEnvironment, CanvasPlatformConfig } from './types.ts'; - -const CANVAS_PLATFORMS: Record = { - production: buildCanvasPlatformConfig('production'), - beta: buildCanvasPlatformConfig('beta'), - test: buildCanvasPlatformConfig('test'), -}; - -export function listCanvasPlatforms(): CanvasPlatformConfig[] { - return [CANVAS_PLATFORMS.production, CANVAS_PLATFORMS.beta, CANVAS_PLATFORMS.test]; -} - -export function resolveCanvasPlatform(value: CanvasEnvironment | string): CanvasPlatformConfig { - if (value === 'production' || value === 'beta' || value === 'test') { - return CANVAS_PLATFORMS[value]; - } - - const byIssuer = listCanvasPlatforms().find((platform) => platform.issuer === value); - - if (byIssuer) { - return byIssuer; - } - - throw new Error(`Canvas issuer ${value} is not supported by Lantern.`); -} - -function buildCanvasPlatformConfig(environment: CanvasEnvironment): CanvasPlatformConfig { - const binding = resolveCanvasPlatformBinding(environment); - - return { - environment, - issuer: binding.iss, - authorizationEndpoint: binding.authUrl, - jwksUrl: binding.jwksUrl, - }; -} diff --git a/src/lti/config.ts b/src/lti/config.ts index 473751b..6134f04 100644 --- a/src/lti/config.ts +++ b/src/lti/config.ts @@ -1,107 +1,13 @@ -import { - buildCanvasStaticRegistrationConfig, - type CanvasStaticRegistrationConfig, - type DynamicRegistrationConfig, -} from '@longsightgroup/lti-tool'; -import type { CanvasEnvironment } from './types.ts'; -import { listCanvasPlatforms, resolveCanvasPlatform } from './canvas_platform.ts'; import { type EnvReader, readEnv } from '../platform/env.ts'; import { normalizePublicOrigin } from '../public_origin.ts'; -import { buildDynamicRegistrationConfig } from './dynamic_registration_config.ts'; -import { getPublicJwkSet } from './tool_key.ts'; - -export interface CanvasEnvironmentOption { - id: CanvasEnvironment; - label: string; - issuer: string; -} - -export type CanvasConfigDocument = CanvasStaticRegistrationConfig; const APP_ORIGIN_ENV = 'APP_ORIGIN'; +/** Resolves Lantern's configured public origin for LTI protocol URLs. */ export function requireAppOrigin(env?: EnvReader): string { const appOrigin = readEnv(APP_ORIGIN_ENV, env)?.trim(); - if (!appOrigin) { - throw new Error( - `${APP_ORIGIN_ENV} is required to publish Lantern's Canvas config and launch URLs.`, - ); + throw new Error(`${APP_ORIGIN_ENV} is required to publish Lantern's LTI URLs.`); } - return normalizePublicOrigin(appOrigin); } - -export function listCanvasEnvironments(): CanvasEnvironmentOption[] { - return listCanvasPlatforms().map((platform) => ({ - id: platform.environment, - label: platform.environment === 'production' - ? 'Production Canvas' - : platform.environment === 'beta' - ? 'Beta Canvas' - : 'Test Canvas', - issuer: platform.issuer, - })); -} - -export function resolveCanvasIssuer(environment: CanvasEnvironment): string { - return resolveCanvasPlatform(environment).issuer; -} - -export function buildCanvasConfigUrl(appOrigin = requireAppOrigin()): string { - return `${normalizePublicOrigin(appOrigin)}/lti/canvas/config.json`; -} - -export function buildCanvasJwksUrl(appOrigin = requireAppOrigin()): string { - return `${normalizePublicOrigin(appOrigin)}/lti/jwks.json`; -} - -export function buildCanvasLaunchUrl(appOrigin = requireAppOrigin()): string { - return `${normalizePublicOrigin(appOrigin)}/lti/launch`; -} - -export function buildCanvasDeepLinkingUrl(appOrigin = requireAppOrigin()): string { - return `${normalizePublicOrigin(appOrigin)}/lti/deep-linking`; -} - -export async function buildCanvasConfigDocument( - appOrigin: string, - env: EnvReader, -): Promise { - const normalizedOrigin = normalizePublicOrigin(appOrigin); - - await getPublicJwkSet(env); - - return buildCanvasStaticRegistrationDocument(normalizedOrigin); -} - -function buildCanvasStaticRegistrationDocument( - normalizedOrigin: string, -): CanvasConfigDocument { - const registrationConfig = buildDynamicRegistrationConfig(normalizedOrigin); - - return buildCanvasStaticRegistrationConfig({ - config: buildCanvasStaticRegistrationConfigShape(registrationConfig, normalizedOrigin), - selectedServices: ['ags', 'nrps', 'deep_linking'], - }); -} - -function buildCanvasStaticRegistrationConfigShape( - registrationConfig: DynamicRegistrationConfig, - normalizedOrigin: string, -): DynamicRegistrationConfig { - return { - ...registrationConfig, - url: buildCanvasLaunchUrl(normalizedOrigin), - }; -} - -export function parseCanvasEnvironment(value: FormDataEntryValue | null): CanvasEnvironment { - if (value === 'production' || value === 'beta' || value === 'test') { - return value; - } - - throw new Error( - 'Choose one supported Canvas environment before you save the deployment binding.', - ); -} diff --git a/src/lti/config_test.ts b/src/lti/config_test.ts deleted file mode 100644 index 1f2213b..0000000 --- a/src/lti/config_test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { assertEquals, assertRejects, assertStringIncludes } from '@std/assert'; -import { getTestToolPrivateJwkEnvValue, TEST_LTI_STATE_SECRET } from '../test_helpers/lti.ts'; - -const EXPECTED_CANVAS_STATIC_SCOPES = [ - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', - 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', - 'https://purl.imsglobal.org/spec/lti-ags/scope/score', - 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly', -]; - -const TEST_CONFIG_ENV = { - get(name: string): string | undefined { - return name === 'APP_ORIGIN' - ? 'http://localhost:8417' - : name === 'LTI_TOOL_PRIVATE_JWK' - ? getTestToolPrivateJwkEnvValue() - : name === 'LTI_STATE_SECRET' - ? TEST_LTI_STATE_SECRET - : undefined; - }, -}; - -Deno.test('GET /lti/canvas/config.json publishes one supported Canvas config document', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - const previousJwk = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const previousStateSecret = Deno.env.get('LTI_STATE_SECRET'); - - Deno.env.set('APP_ORIGIN', 'http://localhost:8417'); - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - Deno.env.set('LTI_STATE_SECRET', TEST_LTI_STATE_SECRET); - - try { - const { createApp } = await import('../app.ts'); - const response = await createApp().request('http://localhost/lti/canvas/config.json'); - - assertEquals(response.status, 200); - - const body = (await response.json()) as { - oidc_initiation_url: string; - target_link_uri: string; - public_jwk_url: string; - scopes: string[]; - extensions: unknown[]; - }; - - assertEquals(typeof body.oidc_initiation_url, 'string'); - assertEquals(typeof body.target_link_uri, 'string'); - assertEquals(typeof body.public_jwk_url, 'string'); - assertEquals(body.scopes, EXPECTED_CANVAS_STATIC_SCOPES); - assertEquals(Array.isArray(body.extensions), true); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousJwk); - restoreEnv('LTI_STATE_SECRET', previousStateSecret); - } -}); - -Deno.test('GET /lti/jwks.json exposes only the public Lantern tool JWK', async () => { - const previousJwk = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const previousStateSecret = Deno.env.get('LTI_STATE_SECRET'); - - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - Deno.env.set('LTI_STATE_SECRET', TEST_LTI_STATE_SECRET); - - try { - const { createApp } = await import('../app.ts'); - const response = await createApp().request('http://localhost/lti/jwks.json'); - - assertEquals(response.status, 200); - - const body = await response.text(); - - assertStringIncludes(body, '"keys"'); - assertEquals(body.includes('"d"'), false); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousJwk); - restoreEnv('LTI_STATE_SECRET', previousStateSecret); - } -}); - -Deno.test('config document advertises exactly the Phase 3 AGS and NRPS scopes', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - const previousJwk = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - - Deno.env.set('APP_ORIGIN', 'http://localhost:8417'); - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - const { buildCanvasConfigDocument } = await import('./config.ts'); - const document = await buildCanvasConfigDocument('http://localhost:8417', TEST_CONFIG_ENV); - - assertEquals(Array.isArray(document.extensions), true); - assertEquals(document.scopes, EXPECTED_CANVAS_STATIC_SCOPES); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousJwk); - } -}); - -Deno.test('config document fails before publishing when the tool JWK is missing', async () => { - const { buildCanvasConfigDocument } = await import('./config.ts'); - - await assertRejects( - () => - buildCanvasConfigDocument('http://localhost:8417', { - get: () => undefined, - }), - Error, - 'Missing LTI_TOOL_PRIVATE_JWK', - ); -}); - -Deno.test('config document advertises assignment-selection Deep Linking on a dedicated route', async () => { - const previousOrigin = Deno.env.get('APP_ORIGIN'); - const previousJwk = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - - Deno.env.set('APP_ORIGIN', 'http://localhost:8417'); - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - const { buildCanvasConfigDocument } = await import('./config.ts'); - const document = await buildCanvasConfigDocument('http://localhost:8417', TEST_CONFIG_ENV); - const placements = document.extensions.flatMap((extension) => extension.settings.placements); - const assignmentSelection = placements.find( - (placement) => placement.placement === 'assignment_selection', - ); - - assertEquals(assignmentSelection?.message_type, 'LtiDeepLinkingRequest'); - assertEquals(assignmentSelection?.target_link_uri, 'http://localhost:8417/lti/deep-linking'); - const resourceSelection = placements.find( - (placement) => placement.placement === 'resource_selection', - ); - - assertEquals(resourceSelection?.message_type, 'LtiDeepLinkingRequest'); - assertEquals( - resourceSelection?.target_link_uri, - 'http://localhost:8417/lti/deep-linking?placement=resource_selection', - ); - } finally { - restoreEnv('APP_ORIGIN', previousOrigin); - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousJwk); - } -}); - -function restoreEnv(name: string, value: string | undefined): void { - if (value === undefined) { - Deno.env.delete(name); - return; - } - - Deno.env.set(name, value); -} diff --git a/src/lti/deep_linking.ts b/src/lti/deep_linking.ts deleted file mode 100644 index 9553fad..0000000 --- a/src/lti/deep_linking.ts +++ /dev/null @@ -1,13 +0,0 @@ -export { - authorizeDeepLinkingSession, - createDeepLinkingSession, - createReviewedPlacementFromDeepLinkingSession, - listDeepLinkingResources, - requireAuthorizedDeepLinkingSession, - saveDeepLinkingSessionSelection, -} from './deep_linking_session.ts'; -export { - buildDeepLinkingSelectionValue, - normalizeDeepLinkingSelectionInput, - resolveDeepLinkingSelection, -} from './deep_linking_selection.ts'; diff --git a/src/lti/deep_linking_content_item.ts b/src/lti/deep_linking_content_item.ts deleted file mode 100644 index 60f2985..0000000 --- a/src/lti/deep_linking_content_item.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { createLtiResourceLinkContentItem } from '@longsightgroup/lti-tool'; -import { buildCanvasLaunchUrl } from './config.ts'; -import { - buildLtiActivityResourceId, - type DeepLinkingPresentationDocumentTarget, - type DeepLinkingResponseContentItem, - type DeepLinkingSessionRecord, - LANTERN_PLACEMENT_CUSTOM_KEY, -} from './types.ts'; -import type { PackageVersionRecord, ReviewedPlacementRecord } from '../package_review/types.ts'; - -const FINAL_GRADE_TAG = 'final-grade'; - -export function buildDeepLinkingResourceLinkContentItem(input: { - session: DeepLinkingSessionRecord; - placement: ReviewedPlacementRecord; - packageVersion: PackageVersionRecord; - appOrigin?: string; -}): DeepLinkingResponseContentItem { - const title = buildPlacementTitle(input.placement); - const baseContentItem = { - title, - text: 'Launches one reviewed Lantern activity through the governed broker.', - url: buildCanvasLaunchUrl(input.appOrigin), - custom: { - [LANTERN_PLACEMENT_CUSTOM_KEY]: input.placement.placementId, - }, - ...buildOptionalPresentation(input.session.acceptPresentationDocumentTargets), - ...buildOptionalLineItem({ - session: input.session, - placement: input.placement, - packageVersion: input.packageVersion, - title, - }), - }; - const validated = createLtiResourceLinkContentItem(baseContentItem); - - return validated; -} - -function buildPlacementTitle(placement: ReviewedPlacementRecord): string { - return placement.contentTitle ?? `${placement.packageTitle} ${placement.packageVersion}`; -} - -function buildOptionalLineItem(input: { - session: DeepLinkingSessionRecord; - placement: ReviewedPlacementRecord; - packageVersion: PackageVersionRecord; - title: string; -}): { - lineItem?: { - label: string; - scoreMaximum: number; - resourceId: string; - tag: string; - }; -} { - if ( - !input.session.acceptLineItem || input.session.placement !== 'assignment_selection' || - input.packageVersion.grading.maxScore === null - ) { - return {}; - } - - return { - lineItem: { - label: input.title, - scoreMaximum: input.packageVersion.grading.maxScore, - resourceId: buildReviewedPlacementLineItemResourceId(input.placement), - tag: FINAL_GRADE_TAG, - }, - }; -} - -function buildOptionalPresentation( - targets: DeepLinkingSessionRecord['acceptPresentationDocumentTargets'], -): { - presentation?: { - documentTarget: DeepLinkingPresentationDocumentTarget; - }; -} { - if (targets.length === 0) { - return {}; - } - - return { - presentation: { - documentTarget: targets.includes('iframe') ? 'iframe' : (targets[0] ?? 'iframe'), - }, - }; -} - -export function buildReviewedPlacementLineItemResourceId( - placement: ReviewedPlacementRecord, -): string { - return buildLtiActivityResourceId({ - appId: placement.appId, - packageVersion: placement.packageVersion, - activityId: placement.activityId, - }); -} diff --git a/src/lti/deep_linking_response_tool.ts b/src/lti/deep_linking_response_tool.ts deleted file mode 100644 index 34c1d70..0000000 --- a/src/lti/deep_linking_response_tool.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { buildDeepLinkingResourceLinkContentItem } from './deep_linking_content_item.ts'; -import type { DeepLinkingSessionRecord } from './types.ts'; -import type { LanternLtiToolContext } from './tool_provider.ts'; -import type { PackageVersionRecord, ReviewedPlacementRecord } from '../package_review/types.ts'; -import { requireVerifiedLtiSession } from './session_boundary.ts'; - -export async function createDeepLinkingReturnResponseWithLtiTool(input: { - ltiContext: LanternLtiToolContext; - session: DeepLinkingSessionRecord; - placement: ReviewedPlacementRecord; - packageVersion: PackageVersionRecord; - appOrigin?: string; -}): Promise { - const ltiSession = await requireVerifiedLtiSession({ - storage: input.ltiContext.storage, - sessionId: input.session.ltiToolSessionId, - missingMessage: 'Deep Linking return requires a verified lti-tool session.', - notFoundMessage: 'The verified LTI session for this Deep Linking return was not found.', - }); - - const result = await input.ltiContext.tool.createAdvantage(ltiSession) - .createDeepLinkingHtmlResponse([ - buildDeepLinkingResourceLinkContentItem({ - session: input.session, - placement: input.placement, - packageVersion: input.packageVersion, - ...(input.appOrigin === undefined ? {} : { appOrigin: input.appOrigin }), - }), - ]); - - if (!result.success) { - throw new Error(result.error.message); - } - - return result.data; -} diff --git a/src/lti/deep_linking_selection.ts b/src/lti/deep_linking_selection.ts deleted file mode 100644 index 10ee677..0000000 --- a/src/lti/deep_linking_selection.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { - DeepLinkingResourceOption, - DeepLinkingResourceSelection, -} from '../package_review/types.ts'; -import type { DeepLinkingSessionRecord } from './types.ts'; -import { requireStringClaim, requireTrimmedValue } from './claim_support.ts'; - -export function buildDeepLinkingSelectionValue(input: { - packageVersionId: number; - contentPath: string; -}): string { - return JSON.stringify({ - packageVersionId: input.packageVersionId, - contentPath: normalizeContentPath(input.contentPath), - }); -} - -export function resolveDeepLinkingSelection(input: { - session: DeepLinkingSessionRecord; - resources: DeepLinkingResourceOption[]; -}): DeepLinkingResourceSelection | null { - const sessionSelection = input.session.selection; - - if (sessionSelection === null) { - return null; - } - - const resource = input.resources.find( - (candidate) => - candidate.packageVersionId === sessionSelection.packageVersionId && - candidate.contentPath === sessionSelection.contentPath, - ); - - if (!resource) { - return null; - } - - return { - packageVersionId: resource.packageVersionId, - packageVersion: resource.packageVersion, - packageTitle: resource.packageTitle, - activityId: resource.activityId, - contentPath: resource.contentPath, - contentTitle: resource.contentTitle, - }; -} - -export function normalizeDeepLinkingSelectionInput(input: { - selectionValue: string; - resources: DeepLinkingResourceOption[]; -}): DeepLinkingResourceSelection { - const selectionValue = requireTrimmedValue( - input.selectionValue, - 'Choose one reviewed resource before continuing.', - ); - let payload: unknown; - - try { - payload = JSON.parse(selectionValue); - } catch { - throw new Error('Deep Linking selection payload was invalid.'); - } - - const record = requireSelectionRecord(payload); - const packageVersionId = parseSelectionPackageVersionId(record.packageVersionId); - const contentPath = normalizeContentPath( - requireStringClaim(record.contentPath, 'Deep Linking selection content path is required.'), - ); - const resource = input.resources.find( - (candidate) => - candidate.packageVersionId === packageVersionId && candidate.contentPath === contentPath, - ); - - if (!resource) { - throw new Error( - `Deep Linking selection ${packageVersionId} ${contentPath} is not approved for this app.`, - ); - } - - return { - packageVersionId: resource.packageVersionId, - packageVersion: resource.packageVersion, - packageTitle: resource.packageTitle, - activityId: resource.activityId, - contentPath: resource.contentPath, - contentTitle: resource.contentTitle, - }; -} - -function requireSelectionRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('Deep Linking selection payload was invalid.'); - } - - return value as Record; -} - -function parseSelectionPackageVersionId(value: unknown): number { - if (typeof value === 'number' && Number.isInteger(value) && value > 0) { - return value; - } - - if (typeof value === 'string' && /^\d+$/.test(value.trim())) { - return Number(value.trim()); - } - - throw new Error('Deep Linking selection package version is required.'); -} - -function normalizeContentPath(value: string): string { - return value.startsWith('/') ? value : `/${value}`; -} diff --git a/src/lti/deep_linking_session.ts b/src/lti/deep_linking_session.ts deleted file mode 100644 index 46b513b..0000000 --- a/src/lti/deep_linking_session.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { - DeepLinkingResourceOption, - DeepLinkingResourceSelection, - ReviewedPlacementRecord, -} from '../package_review/types.ts'; -import type { PackageReviewRepository } from '../package_review/repository.ts'; -import { createOpaqueToken } from './token_support.ts'; -import { - normalizeDeepLinkingSelectionInput, - resolveDeepLinkingSelection, -} from './deep_linking_selection.ts'; -import type { DeepLinkingSessionRecord, ValidatedDeepLinkingRequest } from './types.ts'; -import { requireTrimmedValue } from './claim_support.ts'; - -const DEEP_LINKING_SESSION_TTL_MS = 10 * 60 * 1000; - -export async function createDeepLinkingSession(input: { - repository: PackageReviewRepository; - request: ValidatedDeepLinkingRequest; - now?: () => Date; - createOpaqueToken?: () => string; -}): Promise { - const now = input.now ?? (() => new Date()); - const nextOpaqueToken = input.createOpaqueToken ?? createOpaqueToken; - const createdAt = now(); - - return await input.repository.createDeepLinkingSession({ - sessionId: nextOpaqueToken(), - sessionToken: nextOpaqueToken(), - ltiToolSessionId: input.request.ltiToolSessionId, - deploymentRecordId: input.request.internalDeploymentId, - deploymentSlug: input.request.internalDeploymentSlug, - appId: input.request.appId, - userId: input.request.userId, - userRole: input.request.userRole, - contextId: input.request.contextId, - contextTitle: input.request.contextTitle, - deepLinkReturnUrl: input.request.deepLinkReturnUrl, - data: input.request.data, - placement: input.request.placement, - acceptTypes: [...input.request.settings.acceptTypes], - acceptMultiple: input.request.settings.acceptMultiple, - acceptPresentationDocumentTargets: [ - ...input.request.settings.acceptPresentationDocumentTargets, - ], - acceptLineItem: input.request.settings.acceptLineItem, - selection: null, - createdAt: createdAt.toISOString(), - expiresAt: new Date(createdAt.getTime() + DEEP_LINKING_SESSION_TTL_MS).toISOString(), - usedAt: null, - }); -} - -export async function requireAuthorizedDeepLinkingSession(input: { - repository: PackageReviewRepository; - sessionId: string; - token: string; - now?: () => Date; -}): Promise { - const sessionId = requireTrimmedValue(input.sessionId, 'Deep Linking session id is required.'); - const token = requireTrimmedValue(input.token, 'Deep Linking session token is required.'); - const session = await input.repository.getDeepLinkingSessionById(sessionId); - - if (!session) { - throw new Error(`Deep Linking session ${sessionId} was not found.`); - } - - authorizeDeepLinkingSession({ - token, - expected: session, - ...(input.now === undefined ? {} : { now: input.now }), - }); - - return session; -} - -export function authorizeDeepLinkingSession(input: { - token: string; - expected: DeepLinkingSessionRecord; - now?: () => Date; -}): void { - const now = input.now ?? (() => new Date()); - const token = requireTrimmedValue(input.token, 'Deep Linking session token is required.'); - - if (Date.parse(input.expected.expiresAt) <= now().getTime()) { - throw new Error(`Deep Linking session ${input.expected.sessionId} has expired.`); - } - - if (input.expected.usedAt !== null) { - throw new Error(`Deep Linking session ${input.expected.sessionId} has already been used.`); - } - - if (token !== input.expected.sessionToken) { - throw new Error(`Deep Linking session token did not match ${input.expected.sessionId}.`); - } -} - -export async function listDeepLinkingResources(input: { - repository: PackageReviewRepository; - session: DeepLinkingSessionRecord; -}): Promise { - return await input.repository.listDeepLinkingResourceOptions( - input.session.appId, - input.session.placement, - ); -} - -export async function saveDeepLinkingSessionSelection(input: { - repository: PackageReviewRepository; - session: DeepLinkingSessionRecord; - selectionValue: string; -}): Promise<{ - session: DeepLinkingSessionRecord; - selection: DeepLinkingResourceSelection; -}> { - const resources = await listDeepLinkingResources({ - repository: input.repository, - session: input.session, - }); - const selection = normalizeDeepLinkingSelectionInput({ - selectionValue: input.selectionValue, - resources, - }); - const session = await input.repository.updateDeepLinkingSessionSelection({ - sessionId: input.session.sessionId, - selection: { - packageVersionId: selection.packageVersionId, - packageVersion: selection.packageVersion, - activityId: selection.activityId, - contentPath: selection.contentPath, - }, - }); - - return { - session, - selection, - }; -} - -export async function createReviewedPlacementFromDeepLinkingSession(input: { - repository: PackageReviewRepository; - session: DeepLinkingSessionRecord; - now?: () => Date; - createPlacementId?: () => string; -}): Promise<{ - placement: ReviewedPlacementRecord; - selection: DeepLinkingResourceSelection; -}> { - const now = input.now ?? (() => new Date()); - const createPlacementId = input.createPlacementId ?? createOpaqueToken; - const resources = await listDeepLinkingResources({ - repository: input.repository, - session: input.session, - }); - const selection = resolveDeepLinkingSelection({ - session: input.session, - resources, - }); - - if (selection === null) { - throw new Error( - `Deep Linking session ${input.session.sessionId} does not have a valid reviewed selection.`, - ); - } - - return { - placement: await input.repository.createReviewedPlacement({ - placementId: createPlacementId(), - deploymentRecordId: input.session.deploymentRecordId, - deploymentSlug: input.session.deploymentSlug, - appId: input.session.appId, - contextId: input.session.contextId, - contextTitle: input.session.contextTitle, - packageVersionId: selection.packageVersionId, - packageVersion: selection.packageVersion, - packageTitle: selection.packageTitle, - activityId: selection.activityId, - contentPath: selection.contentPath, - contentTitle: selection.contentTitle, - createdByUserId: input.session.userId, - resourceLinkId: null, - createdAt: now().toISOString(), - boundAt: null, - }), - selection, - }; -} diff --git a/src/lti/deep_linking_session_test.ts b/src/lti/deep_linking_session_test.ts deleted file mode 100644 index a2e79e0..0000000 --- a/src/lti/deep_linking_session_test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { assertEquals, assertExists } from '@std/assert'; -import { - buildDeepLinkingSelectionValue, - createDeepLinkingSession, - createReviewedPlacementFromDeepLinkingSession, - listDeepLinkingResources, - requireAuthorizedDeepLinkingSession, - resolveDeepLinkingSelection, - saveDeepLinkingSessionSelection, -} from './deep_linking.ts'; -import { - buildDeepLinkingSessionRecord, - buildDeploymentBinding, - buildValidatedDeepLinkingRequest, -} from '../test_helpers/lti.ts'; -import { - buildDeepLinkingResourceOption, - buildDeepLinkingResourceSelection, - buildDeploymentRecord, - createInMemoryPackageReviewRepository, -} from '../test_helpers/package_review.ts'; - -Deno.test('deep linking in-memory fixtures preserve reviewed resources and explicit session selection', async () => { - const repository = createInMemoryPackageReviewRepository({ - deepLinkingSessions: [buildDeepLinkingSessionRecord()], - deepLinkingResourceOptions: [ - buildDeepLinkingResourceOption(), - buildDeepLinkingResourceOption({ - packageVersionId: 2, - packageVersion: '0.2.0', - activityId: '/content/bonus.json', - contentPath: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - ], - }); - const initialSession = await repository.getDeepLinkingSessionById('deep-linking-session-123'); - const options = await repository.listDeepLinkingResourceOptions( - 'chapter-4-asteroids', - 'assignment_selection', - ); - const updatedSession = await repository.updateDeepLinkingSessionSelection({ - sessionId: 'deep-linking-session-123', - selection: { - ...buildDeepLinkingResourceSelection({ - packageVersionId: 2, - packageVersion: '0.2.0', - activityId: '/content/bonus.json', - contentPath: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - }, - }); - - assertEquals(initialSession?.selection, null); - assertEquals(options.length, 2); - assertEquals(options[1]?.contentPath, '/content/bonus.json'); - assertEquals(updatedSession.selection?.packageVersionId, 2); - assertEquals(updatedSession.selection?.contentPath, '/content/bonus.json'); -}); - -Deno.test('createDeepLinkingSession persists a short-lived authoring session without creating runtime state', async () => { - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - id: 7, - appId: 'chapter-4-asteroids', - binding: buildDeploymentBinding(), - }), - ], - }); - const tokens = ['deep-linking-session-456', 'deep-linking-token-456']; - const session = await createDeepLinkingSession({ - repository, - request: buildValidatedDeepLinkingRequest({ - internalDeploymentId: 7, - internalDeploymentSlug: 'chapter-4-asteroids-pilot', - placement: 'resource_selection', - }), - now: () => new Date('2026-03-24T16:20:00Z'), - createOpaqueToken: () => { - const next = tokens.shift(); - - if (!next) { - throw new Error('Expected another deterministic deep linking token.'); - } - - return next; - }, - }); - const saved = await repository.getDeepLinkingSessionById('deep-linking-session-456'); - const runtimeSession = await repository.getLatestRuntimeSessionByDeploymentId(7); - const attempt = await repository.getAttemptById('attempt-123'); - - assertEquals(session.sessionId, 'deep-linking-session-456'); - assertEquals(session.sessionToken, 'deep-linking-token-456'); - assertEquals(session.deepLinkReturnUrl.includes('deep_link_return'), true); - assertEquals(session.placement, 'resource_selection'); - assertEquals(saved?.selection, null); - assertEquals(saved?.placement, 'resource_selection'); - assertEquals(runtimeSession, null); - assertEquals(attempt, null); -}); - -Deno.test('deep linking helpers authorize the picker session and store one explicit reviewed selection', async () => { - const repository = createInMemoryPackageReviewRepository({ - deepLinkingSessions: [ - buildDeepLinkingSessionRecord({ - sessionId: 'deep-linking-session-picker', - sessionToken: 'deep-linking-token-picker', - }), - ], - deepLinkingResourceOptions: [ - buildDeepLinkingResourceOption(), - buildDeepLinkingResourceOption({ - packageVersionId: 2, - packageVersion: '0.2.0', - contentPath: '/content/bonus.json', - activityId: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - buildDeepLinkingResourceOption({ - appId: 'other-app', - packageTitle: 'Other App', - }), - ], - }); - const session = await requireAuthorizedDeepLinkingSession({ - repository, - sessionId: 'deep-linking-session-picker', - token: 'deep-linking-token-picker', - now: () => new Date('2026-03-23T22:46:00Z'), - }); - const saved = await saveDeepLinkingSessionSelection({ - repository, - session, - selectionValue: buildDeepLinkingSelectionValue({ - packageVersionId: 2, - contentPath: '/content/bonus.json', - }), - }); - const resources = await repository.listDeepLinkingResourceOptions( - session.appId, - session.placement, - ); - const selection = resolveDeepLinkingSelection({ - session: saved.session, - resources, - }); - - assertEquals(resources.length, 2); - assertEquals(saved.session.selection?.packageVersionId, 2); - assertEquals(saved.session.selection?.contentPath, '/content/bonus.json'); - assertEquals(saved.selection.packageTitle, 'Chapter 4 Asteroids'); - assertEquals(selection?.contentTitle, 'Bonus Activity'); -}); - -Deno.test('deep linking helpers list course-scoped resources for resource_selection placement', async () => { - const repository = createInMemoryPackageReviewRepository({ - deepLinkingSessions: [ - buildDeepLinkingSessionRecord({ - sessionId: 'deep-linking-session-course-scope', - placement: 'resource_selection', - }), - ], - deepLinkingResourceOptions: [ - buildDeepLinkingResourceOption({ - installScope: 'course', - packageVersion: '0.3.0', - activityId: '/content/course.json', - contentPath: '/content/course.json', - contentTitle: 'Course Activity', - }), - buildDeepLinkingResourceOption({ - installScope: 'assignment', - packageVersion: '0.2.0', - activityId: '/content/bonus.json', - contentPath: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - ], - }); - const session = await repository.getDeepLinkingSessionById('deep-linking-session-course-scope'); - - assertExists(session); - - const resources = await listDeepLinkingResources({ - repository, - session, - }); - - assertEquals(resources.length, 1); - assertEquals(resources[0]?.installScope, 'course'); - assertEquals(resources[0]?.contentTitle, 'Course Activity'); -}); - -Deno.test('deep linking helpers keep the same governed list-and-save flow for assignment and resource selection', async () => { - const repository = createInMemoryPackageReviewRepository({ - deepLinkingSessions: [ - buildDeepLinkingSessionRecord({ - sessionId: 'deep-linking-session-assignment', - sessionToken: 'deep-linking-token-assignment', - placement: 'assignment_selection', - }), - buildDeepLinkingSessionRecord({ - sessionId: 'deep-linking-session-resource', - sessionToken: 'deep-linking-token-resource', - placement: 'resource_selection', - }), - ], - deepLinkingResourceOptions: [ - buildDeepLinkingResourceOption({ - packageVersionId: 2, - packageVersion: '0.2.0', - installScope: 'assignment', - activityId: '/content/assignment.json', - contentPath: '/content/assignment.json', - contentTitle: 'Assignment Activity', - }), - buildDeepLinkingResourceOption({ - packageVersionId: 3, - packageVersion: '0.3.0', - installScope: 'course', - activityId: '/content/course.json', - contentPath: '/content/course.json', - contentTitle: 'Course Activity', - }), - ], - }); - - const assignmentSession = await requireAuthorizedDeepLinkingSession({ - repository, - sessionId: 'deep-linking-session-assignment', - token: 'deep-linking-token-assignment', - now: () => new Date('2026-03-23T22:46:00Z'), - }); - const resourceSession = await requireAuthorizedDeepLinkingSession({ - repository, - sessionId: 'deep-linking-session-resource', - token: 'deep-linking-token-resource', - now: () => new Date('2026-03-23T22:46:00Z'), - }); - const assignmentSaved = await saveDeepLinkingSessionSelection({ - repository, - session: assignmentSession, - selectionValue: buildDeepLinkingSelectionValue({ - packageVersionId: 2, - contentPath: '/content/assignment.json', - }), - }); - const resourceSaved = await saveDeepLinkingSessionSelection({ - repository, - session: resourceSession, - selectionValue: buildDeepLinkingSelectionValue({ - packageVersionId: 3, - contentPath: '/content/course.json', - }), - }); - - const assignmentResources = await listDeepLinkingResources({ - repository, - session: assignmentSaved.session, - }); - const resourceResources = await listDeepLinkingResources({ - repository, - session: resourceSaved.session, - }); - - assertEquals( - assignmentResources.map((resource) => resource.contentPath), - ['/content/assignment.json'], - ); - assertEquals( - resourceResources.map((resource) => resource.contentPath), - ['/content/course.json'], - ); - assertEquals(assignmentSaved.selection.contentTitle, 'Assignment Activity'); - assertEquals(resourceSaved.selection.contentTitle, 'Course Activity'); - assertEquals(assignmentSaved.session.selection?.packageVersionId, 2); - assertEquals(resourceSaved.session.selection?.packageVersionId, 3); -}); - -Deno.test('deep linking helpers create one durable reviewed placement from the saved reviewed selection', async () => { - const repository = createInMemoryPackageReviewRepository({ - deepLinkingSessions: [ - buildDeepLinkingSessionRecord({ - sessionId: 'deep-linking-session-placement', - selection: { - packageVersionId: 2, - packageVersion: '0.2.0', - activityId: '/content/bonus.json', - contentPath: '/content/bonus.json', - }, - }), - ], - deepLinkingResourceOptions: [ - buildDeepLinkingResourceOption({ - packageVersionId: 2, - packageVersion: '0.2.0', - activityId: '/content/bonus.json', - contentPath: '/content/bonus.json', - contentTitle: 'Bonus Activity', - }), - ], - }); - const session = await repository.getDeepLinkingSessionById('deep-linking-session-placement'); - - assertExists(session); - - const created = await createReviewedPlacementFromDeepLinkingSession({ - repository, - session, - now: () => new Date('2026-03-24T17:00:00Z'), - createPlacementId: () => 'placement-123', - }); - const saved = await repository.getReviewedPlacementById('placement-123'); - - assertEquals(created.selection.contentTitle, 'Bonus Activity'); - assertEquals(created.placement.placementId, 'placement-123'); - assertEquals(created.placement.deploymentRecordId, session.deploymentRecordId); - assertEquals(created.placement.contextId, 'course-42'); - assertEquals(created.placement.packageVersionId, 2); - assertEquals(created.placement.packageVersion, '0.2.0'); - assertEquals(created.placement.packageTitle, 'Chapter 4 Asteroids'); - assertEquals(created.placement.activityId, '/content/bonus.json'); - assertEquals(created.placement.contentPath, '/content/bonus.json'); - assertEquals(created.placement.contentTitle, 'Bonus Activity'); - assertEquals(created.placement.resourceLinkId, null); - assertEquals(saved?.packageVersionId, 2); - assertEquals(saved?.contentPath, '/content/bonus.json'); -}); diff --git a/src/lti/dynamic_registration_config.ts b/src/lti/dynamic_registration_config.ts deleted file mode 100644 index 7dab027..0000000 --- a/src/lti/dynamic_registration_config.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { DynamicRegistrationConfig } from '@longsightgroup/lti-tool'; - -export function buildDynamicRegistrationConfig(appOrigin: string): DynamicRegistrationConfig { - return { - url: appOrigin, - name: 'Lantern', - description: 'Launch approved Lantern learning activities through the governed runtime.', - loginUri: `${appOrigin}/lti/login`, - launchUri: `${appOrigin}/lti/launch`, - jwksUri: `${appOrigin}/lti/jwks.json`, - deepLinkingUri: `${appOrigin}/lti/deep-linking`, - redirectUris: [ - `${appOrigin}/lti/launch`, - `${appOrigin}/lti/deep-linking`, - `${appOrigin}/lti/deep-linking?placement=resource_selection`, - ], - platforms: { - canvas: { - privacyLevel: 'public', - toolId: 'lantern-demo-broker', - resourceLinkPlacements: ['course_navigation'], - deepLinkPlacements: ['assignment_selection', 'resource_selection'], - }, - moodle: { - deepLinkPlacements: ['assignment_selection'], - }, - sakai: { - resourceLinkPlacements: ['course_navigation'], - deepLinkPlacements: ['assignment_selection'], - }, - }, - customizeMessages: (_context, messages) => - messages.map((message) => - message.type === 'LtiDeepLinkingRequest' && - message.placements?.includes('resource_selection') - ? { - ...message, - target_link_uri: `${appOrigin}/lti/deep-linking?placement=resource_selection`, - } - : message - ), - }; -} diff --git a/src/lti/dynamic_registration_state.ts b/src/lti/dynamic_registration_state.ts deleted file mode 100644 index d1f6245..0000000 --- a/src/lti/dynamic_registration_state.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { PackageReviewRepository } from '../package_review/repository.ts'; -import { createOpaqueToken } from './token_support.ts'; -import type { DynamicRegistrationStateRecord, LmsType } from './types.ts'; - -const DYNAMIC_REGISTRATION_STATE_TTL_MS = 30 * 60 * 1000; - -export async function createDynamicRegistrationState(input: { - repository: PackageReviewRepository; - appId: string; - lms: LmsType; - now?: () => Date; - createOpaqueToken?: () => string; -}): Promise { - const now = input.now ?? (() => new Date()); - const nextOpaqueToken = input.createOpaqueToken ?? createOpaqueToken; - const createdAt = now(); - - return await input.repository.createDynamicRegistrationState({ - state: nextOpaqueToken(), - appId: input.appId, - lms: input.lms, - createdAt: createdAt.toISOString(), - expiresAt: new Date(createdAt.getTime() + DYNAMIC_REGISTRATION_STATE_TTL_MS).toISOString(), - usedAt: null, - }); -} - -export async function consumeDynamicRegistrationState(input: { - repository: PackageReviewRepository; - state: string; - appId: string; - lms: LmsType; - now?: () => Date; -}): Promise { - const now = input.now ?? (() => new Date()); - const state = requireTrimmedValue( - input.state, - 'Dynamic registration state is required. Start again from Lantern app settings.', - ); - const existing = await input.repository.getDynamicRegistrationStateByState(state); - - if (!existing) { - throw new Error( - 'Lantern could not verify this dynamic registration link. Start again from Lantern app settings.', - ); - } - - if (existing.appId !== input.appId || existing.lms !== input.lms) { - throw new Error( - 'This dynamic registration link does not match the requested LMS setup. Start again from Lantern app settings.', - ); - } - - if (existing.usedAt !== null) { - throw new Error( - 'This dynamic registration link has already been used. Start again from Lantern app settings.', - ); - } - - if (Date.parse(existing.expiresAt) <= now().getTime()) { - throw new Error( - 'This dynamic registration link has expired. Start again from Lantern app settings.', - ); - } - - return await input.repository.consumeDynamicRegistrationState({ - state, - usedAt: now().toISOString(), - }); -} - -function requireTrimmedValue(value: string, message: string): string { - const trimmed = value.trim(); - - if (trimmed === '') { - throw new Error(message); - } - - return trimmed; -} diff --git a/src/lti/dynamic_registration_support.ts b/src/lti/dynamic_registration_support.ts deleted file mode 100644 index f9423f4..0000000 --- a/src/lti/dynamic_registration_support.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { resolveCanvasPlatform } from './canvas_platform.ts'; - -export function assertCanvasDynamicRegistrationMetadata(input: { - openidConfigurationUrl: string; - issuer: string; - authorizationEndpoint: string; - registrationEndpoint: string; -}): void { - const openidConfigurationUrl = requireHttpsUrl( - input.openidConfigurationUrl, - 'Canvas openid_configuration URL', - ); - const issuer = requireHttpsUrl(input.issuer, 'Canvas issuer'); - const authorizationEndpoint = requireHttpsUrl( - input.authorizationEndpoint, - 'Canvas authorization endpoint', - ); - const registrationEndpoint = requireHttpsUrl( - input.registrationEndpoint, - 'Canvas registration endpoint', - ); - const platform = resolveCanvasPlatform(input.issuer); - - if (openidConfigurationUrl.host !== issuer.host) { - throw new Error('Canvas openid_configuration host did not match the Canvas issuer host.'); - } - - if (registrationEndpoint.host !== issuer.host) { - throw new Error('Canvas registration endpoint host did not match the Canvas issuer host.'); - } - - if (authorizationEndpoint.toString() !== platform.authorizationEndpoint) { - throw new Error( - 'Canvas authorization endpoint did not match the supported Canvas platform metadata.', - ); - } -} - -export function assertHostedDynamicRegistrationMetadata(input: { - platformLabel: 'Moodle' | 'Sakai'; - openidConfigurationUrl: string; - issuer: string; - authorizationEndpoint: string; - tokenEndpoint: string; - jwksUrl: string; - registrationEndpoint: string; -}): void { - const openidConfigurationUrl = requireHttpsUrl( - input.openidConfigurationUrl, - `${input.platformLabel} openid_configuration URL`, - ); - const issuer = requireHttpsUrl(input.issuer, `${input.platformLabel} issuer`); - const authorizationEndpoint = requireHttpsUrl( - input.authorizationEndpoint, - `${input.platformLabel} authorization endpoint`, - ); - const tokenEndpoint = requireHttpsUrl( - input.tokenEndpoint, - `${input.platformLabel} token endpoint`, - ); - const jwksUrl = requireHttpsUrl(input.jwksUrl, `${input.platformLabel} JWKS URL`); - const registrationEndpoint = requireHttpsUrl( - input.registrationEndpoint, - `${input.platformLabel} registration endpoint`, - ); - const hosts = [ - openidConfigurationUrl.host, - issuer.host, - authorizationEndpoint.host, - tokenEndpoint.host, - jwksUrl.host, - registrationEndpoint.host, - ]; - - if (new Set(hosts).size !== 1) { - throw new Error( - `${input.platformLabel} registration metadata must stay on one HTTPS host anchored at the issuer.`, - ); - } -} - -function requireHttpsUrl(value: string, subject: string): URL { - let url: URL; - - try { - url = new URL(value); - } catch { - throw new Error(`${subject} must be an absolute HTTPS URL.`); - } - - if (url.protocol !== 'https:') { - throw new Error(`${subject} must be an absolute HTTPS URL.`); - } - - return url; -} diff --git a/src/lti/dynamic_registration_tool.ts b/src/lti/dynamic_registration_tool.ts deleted file mode 100644 index a87e9a0..0000000 --- a/src/lti/dynamic_registration_tool.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { - DynamicRegistrationForm, - LtiDynamicRegistrationCompletionResult, - RegistrationRequest, -} from '@longsightgroup/lti-tool'; -import { resolveCanvasPlatform } from './canvas_platform.ts'; -import { requireAppOrigin } from './config.ts'; -import type { DeploymentBinding } from './types.ts'; -import type { LanternLtiToolContext } from './tool_provider.ts'; - -export type DynamicRegistrationLms = 'canvas' | 'moodle' | 'sakai'; - -export interface LanternDynamicRegistrationAppState { - appId: string; - lms: DynamicRegistrationLms; -} - -export interface LanternDynamicRegistrationCompletion { - appState: LanternDynamicRegistrationAppState; - launchConfig: LtiDynamicRegistrationCompletionResult['launchConfig']; - canvasEnvironment: 'production' | 'beta' | 'test' | null; - binding: DeploymentBinding | null; - usedDefaultDeploymentId: boolean; -} - -const DEFAULT_DYNAMIC_REGISTRATION_SERVICES: DynamicRegistrationForm['services'] = [ - 'ags', - 'nrps', - 'deep_linking', -]; - -export function buildDynamicRegistrationUrl( - appId: string, - lms: DynamicRegistrationLms, - state: string | null = null, - appOrigin = requireAppOrigin(), -): string { - const url = new URL( - `${appOrigin}/admin/packages/${encodeURIComponent(appId)}/deployment/register/${lms}`, - ); - - if (state !== null) { - url.searchParams.set('state', state); - } - - return url.toString(); -} - -export async function completeLanternDynamicRegistrationWithLtiTool(input: { - ltiContext: LanternLtiToolContext; - appId: string; - lms: DynamicRegistrationLms; - requestPath: string; - openidConfigurationUrl: string; - registrationToken: string | null; -}): Promise { - const registrationRequest: RegistrationRequest = { - openid_configuration: input.openidConfigurationUrl, - ...(input.registrationToken === null ? {} : { registration_token: input.registrationToken }), - }; - const initiated = await input.ltiContext.dynamicRegistration.initiateDynamicRegistration( - registrationRequest, - input.requestPath, - { - appState: { - appId: input.appId, - lms: input.lms, - }, - }, - ); - - if (!initiated.success) { - throw new Error(initiated.error.message); - } - - const completed = await input.ltiContext.dynamicRegistration.completeDynamicRegistration({ - services: DEFAULT_DYNAMIC_REGISTRATION_SERVICES, - sessionToken: initiated.data.sessionToken, - }); - - if (!completed.success) { - throw new Error(completed.error.message); - } - - const appState = readLanternDynamicRegistrationAppState(completed.data.appState); - - if (appState.appId !== input.appId || appState.lms !== input.lms) { - throw new Error('lti-tool dynamic registration returned mismatched Lantern app state.'); - } - - const canvasEnvironment = input.lms === 'canvas' - ? resolveCanvasPlatform(completed.data.launchConfig.iss).environment - : null; - const binding = input.lms === 'canvas' - ? null - : buildDeploymentBinding(input.lms, completed.data.launchConfig); - - return { - appState, - launchConfig: completed.data.launchConfig, - canvasEnvironment, - binding, - usedDefaultDeploymentId: completed.data.launchConfig.deploymentId === 'default', - }; -} - -function buildDeploymentBinding( - lms: Exclude, - launchConfig: LtiDynamicRegistrationCompletionResult['launchConfig'], -): DeploymentBinding { - return { - lms, - issuer: launchConfig.iss, - clientId: launchConfig.clientId, - deploymentId: launchConfig.deploymentId, - authorizationEndpoint: launchConfig.authUrl, - accessTokenUrl: launchConfig.tokenUrl, - jwksUrl: launchConfig.jwksUrl, - }; -} - -function readLanternDynamicRegistrationAppState( - value: LtiDynamicRegistrationCompletionResult['appState'], -): LanternDynamicRegistrationAppState { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new Error('lti-tool dynamic registration did not return Lantern app state.'); - } - - const record = value as Record; - - if (typeof record.appId !== 'string' || record.appId.trim() === '') { - throw new Error('lti-tool dynamic registration app state is missing appId.'); - } - - if (record.lms !== 'canvas' && record.lms !== 'moodle' && record.lms !== 'sakai') { - throw new Error('lti-tool dynamic registration app state is missing lms.'); - } - - return { - appId: record.appId, - lms: record.lms, - }; -} diff --git a/src/lti/launch_audit.ts b/src/lti/launch_audit.ts deleted file mode 100644 index 0bcd5d4..0000000 --- a/src/lti/launch_audit.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { Context } from '@hono/hono'; -import type { LtiLaunchVerificationError } from '@longsightgroup/lti-tool'; -import type { PackageReviewRepository } from '../package_review/repository.ts'; -import type { DeploymentRecord } from '../package_review/types.ts'; -import type { ResolvedLtiProfile } from './profile.ts'; -import { buildResolvedLtiProfileDetail } from './profile_resolution.ts'; -import { isLtiBoundaryDenialError } from './lti_boundary_denial.ts'; - -export async function recordLaunchRejectedAudit(input: { - repository: Pick; - context: Context; - error: unknown; - deployment?: DeploymentRecord | null; - ltiProfile?: ResolvedLtiProfile | null; - summary?: string; -}): Promise { - const detail = buildRejectedLaunchDetail(input); - - await input.repository.recordAuditEvent({ - eventType: 'launch.rejected', - actorType: 'platform', - actorId: null, - deploymentRecordId: input.deployment?.id ?? null, - packageVersionId: input.deployment?.enabledPackageVersionId ?? null, - attemptId: null, - lineItemBindingId: null, - status: 'failed', - summary: input.summary ?? 'Rejected the governed LTI launch before runtime handoff.', - detail, - occurredAt: new Date().toISOString(), - }); -} - -function buildRejectedLaunchDetail(input: { - context: Context; - error: unknown; - deployment?: DeploymentRecord | null; - ltiProfile?: ResolvedLtiProfile | null; -}): Record { - const base = { - requestPath: new URL(input.context.req.url).pathname, - forwardedHost: input.context.req.header('x-forwarded-host') ?? null, - forwardedProto: input.context.req.header('x-forwarded-proto') ?? null, - }; - - if (isLtiBoundaryDenialError(input.error)) { - return { - ...base, - category: input.error.category, - code: input.error.code, - message: input.error.message, - ...input.error.detail, - ...(input.deployment === undefined || input.deployment === null ? {} : { - internalDeploymentSlug: input.deployment.slug, - issuer: input.deployment.binding?.issuer ?? null, - clientId: input.deployment.binding?.clientId ?? null, - deploymentId: input.deployment.binding?.deploymentId ?? null, - }), - ...(input.ltiProfile === undefined || input.ltiProfile === null - ? {} - : buildResolvedLtiProfileDetail(input.ltiProfile)), - }; - } - - const verificationError = readVerificationError(input.error); - - if (verificationError !== null) { - return { - ...base, - category: 'specInvalid', - code: verificationError.code, - message: verificationError.message, - }; - } - - return { - ...base, - category: 'specInvalid', - code: 'unknown_launch_error', - message: input.error instanceof Error ? input.error.message : 'Unknown launch error.', - }; -} - -function readVerificationError(error: unknown): LtiLaunchVerificationError | null { - if ( - error instanceof Error && - 'code' in error && - typeof (error as { code: unknown }).code === 'string' - ) { - return error as LtiLaunchVerificationError; - } - - return null; -} diff --git a/src/lti/launch_mapping.ts b/src/lti/launch_mapping.ts deleted file mode 100644 index ca909cb..0000000 --- a/src/lti/launch_mapping.ts +++ /dev/null @@ -1,211 +0,0 @@ -import type { - LtiAuthorizedLaunch, - LTISession, - LtiVerifiedLaunch, - LtiVerifiedLaunchAuthorizationResult, - ResolvedLtiDeepLinkingLaunchMessage, - ResolvedLtiResourceLinkLaunchMessage, -} from '@longsightgroup/lti-tool'; -import { resolveLaunchTarget } from './launch_target_resolution.ts'; -import { resolveUserRole } from './claim_support.ts'; -import { createOpaqueToken } from './token_support.ts'; -import type { - DeepLinkingAcceptType, - DeepLinkingPresentationDocumentTarget, - LaunchServiceClaims, - LmsType, - ValidatedDeepLinkingRequest, - ValidatedLaunch, -} from './types.ts'; -import type { PackageReviewRepository } from '../package_review/repository.ts'; -import type { DeploymentRecord } from '../package_review/types.ts'; - -export interface LanternLaunchAuthorization { - readonly deployment: DeploymentRecord; -} - -export async function authorizeLanternLaunch( - repository: PackageReviewRepository, - launch: LtiVerifiedLaunch, -): Promise> { - const deployment = await repository.getDeploymentByPlatformIdentity({ - issuer: launch.issuer, - clientId: launch.clientId, - deploymentId: launch.deploymentId, - }); - - if (deployment === null || deployment.binding === null) { - return { - success: false, - code: 'lantern_deployment_missing', - message: - `Lantern has no governed deployment for ${launch.issuer} / ${launch.clientId} / ${launch.deploymentId}.`, - }; - } - - return { - success: true, - data: { - deployment, - }, - }; -} - -export async function mapResourceLinkLaunch(input: { - repository: PackageReviewRepository; - verifiedLaunch: LtiAuthorizedLaunch; - session: LTISession; - message: ResolvedLtiResourceLinkLaunchMessage; -}): Promise { - const deployment = input.verifiedLaunch.authorization.deployment; - const binding = deployment.binding; - - if (binding === null) { - throw new Error(`Deployment ${deployment.slug} does not have an LMS binding.`); - } - - const contextId = requireString(input.message.context?.id, 'Launch context.id is required.'); - const resolved = await resolveLaunchTarget({ - repository: input.repository, - deployment, - resourceLinkId: input.message.resourceLink.id, - contextId, - customClaim: input.message.customParameters, - now: () => new Date(), - }); - - return { - lms: binding.lms, - canvasEnvironment: binding.lms === 'canvas' ? binding.canvasEnvironment : null, - issuer: input.verifiedLaunch.issuer, - clientId: input.verifiedLaunch.clientId, - deploymentId: input.verifiedLaunch.deploymentId, - internalDeploymentId: deployment.id, - internalDeploymentSlug: deployment.slug, - appId: resolved.packageVersion.appId, - packageVersionId: resolved.packageVersion.id, - packageVersion: resolved.packageVersion.version, - contentPath: resolved.contentPath, - attemptId: createOpaqueToken(), - userId: requireString(input.session.user.id, 'Launch user.id is required.'), - userDisplayName: input.session.user.name ?? null, - userEmail: input.session.user.email ?? null, - userLogin: readVerifiedUserLogin(input.session.jwtPayload), - userRole: resolveUserRole(input.message.roles), - resourceLinkId: input.message.resourceLink.id, - resourceLinkTitle: input.message.resourceLink.title ?? null, - contextId, - contextTitle: input.message.context?.title ?? null, - targetLinkUri: input.verifiedLaunch.targetLinkUri, - returnUrl: readLaunchPresentationReturnUrl(input.session.jwtPayload), - activityId: resolved.activityId, - services: mapLaunchServiceClaims(input.session), - issuedAt: issuedAtIso(input.session), - }; -} - -export function mapDeepLinkingLaunch(input: { - verifiedLaunch: LtiAuthorizedLaunch; - session: LTISession; - message: ResolvedLtiDeepLinkingLaunchMessage; -}): ValidatedDeepLinkingRequest { - const deployment = input.verifiedLaunch.authorization.deployment; - const binding = deployment.binding; - - if (binding === null) { - throw new Error(`Deployment ${deployment.slug} does not have an LMS binding.`); - } - - return { - ltiToolSessionId: input.session.id, - lms: binding.lms as LmsType, - canvasEnvironment: binding.lms === 'canvas' ? binding.canvasEnvironment : null, - issuer: input.verifiedLaunch.issuer, - clientId: input.verifiedLaunch.clientId, - deploymentId: input.verifiedLaunch.deploymentId, - internalDeploymentId: deployment.id, - internalDeploymentSlug: deployment.slug, - appId: deployment.appId, - userId: input.session.user.id ?? null, - userRole: resolveUserRole(input.message.roles), - contextId: input.message.context?.id ?? null, - contextTitle: input.message.context?.title ?? null, - targetLinkUri: input.verifiedLaunch.targetLinkUri, - deepLinkReturnUrl: input.message.deepLinkingSettings.returnUrl, - data: input.message.deepLinkingSettings.data ?? null, - placement: resolveDeepLinkingPlacement(input.verifiedLaunch.targetLinkUri), - settings: { - acceptTypes: input.message.deepLinkingSettings.acceptTypes - .filter((value): value is DeepLinkingAcceptType => value === 'ltiResourceLink'), - acceptMultiple: input.message.deepLinkingSettings.acceptMultiple, - acceptPresentationDocumentTargets: input.message.deepLinkingSettings - .acceptPresentationDocumentTargets.filter(( - value, - ): value is DeepLinkingPresentationDocumentTarget => - value === 'iframe' || value === 'window' || value === 'embed' - ), - acceptLineItem: input.message.deepLinkingSettings.acceptLineItem ?? false, - }, - issuedAt: issuedAtIso(input.session), - }; -} - -export function mapLaunchServiceClaims(session: LTISession): LaunchServiceClaims { - return { - ags: session.services?.ags === undefined ? null : { - scope: [...session.services.ags.scopes], - lineitemsUrl: session.services.ags.lineitems ?? null, - lineitemUrl: session.services.ags.lineitem ?? null, - }, - nrps: session.services?.nrps === undefined ? null : { - contextMembershipsUrl: session.services.nrps.membershipUrl, - serviceVersions: [...session.services.nrps.versions], - }, - }; -} - -function resolveDeepLinkingPlacement( - targetLinkUri: string, -): ValidatedDeepLinkingRequest['placement'] { - const url = new URL(targetLinkUri); - const placement = url.searchParams.get('placement'); - - if (placement === 'resource_selection') { - return 'resource_selection'; - } - - return 'assignment_selection'; -} - -function issuedAtIso(session: LTISession): string { - return typeof session.jwtPayload.iat === 'number' - ? new Date(session.jwtPayload.iat * 1000).toISOString() - : new Date().toISOString(); -} - -function readLaunchPresentationReturnUrl(payload: LTISession['jwtPayload']): string | null { - const claim = payload['https://purl.imsglobal.org/spec/lti/claim/launch_presentation']; - - if (typeof claim !== 'object' || claim === null || Array.isArray(claim)) { - return null; - } - - return readOptionalString((claim as Record).return_url); -} - -function readOptionalString(value: unknown): string | null { - return typeof value === 'string' && value.trim() !== '' ? value : null; -} - -function readVerifiedUserLogin(_payload: LTISession['jwtPayload']): string | null { - // lti-tool verifies launches through a schema that does not expose Canvas preferred_username. - return null; -} - -function requireString(value: unknown, message: string): string { - if (typeof value !== 'string' || value.trim() === '') { - throw new Error(message); - } - - return value; -} diff --git a/src/lti/launch_rejection.ts b/src/lti/launch_rejection.ts deleted file mode 100644 index 4b862c3..0000000 --- a/src/lti/launch_rejection.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { - type LtiBoundaryDenial, - type LtiBoundaryDenialCategory, - LtiBoundaryDenialError, -} from './lti_boundary_denial.ts'; - -export { - buildLaunchDetailRecord, - buildRejectionDetailRecord, - isLtiBoundaryDenialError, - type LtiBoundaryDenial, - type LtiBoundaryDenialCategory, - LtiBoundaryDenialError, -} from './lti_boundary_denial.ts'; - -export type LaunchRejectionCode = - | 'deployment_binding_missing' - | 'deployment_mismatch' - | 'launch_package_version_missing' - | 'login_state_expired' - | 'login_state_missing' - | 'login_state_used' - | 'missing_baseline_claim' - | 'missing_pinned_package_version' - | 'package_not_approved' - | 'reviewed_placement_context_mismatch' - | 'reviewed_placement_deployment_mismatch' - | 'reviewed_placement_not_found' - | 'reviewed_placement_resource_link_conflict' - | 'signature_validation_failed' - | 'unsupported_lti_version' - | 'unsupported_message_type'; - -export interface LaunchRejection extends LtiBoundaryDenial { - code: LaunchRejectionCode; -} - -type LaunchRejectionInput = Omit & { - category?: LtiBoundaryDenialCategory; -}; - -const POLICY_DENIED_LAUNCH_CODES = new Set([ - 'launch_package_version_missing', - 'missing_pinned_package_version', - 'package_not_approved', - 'reviewed_placement_context_mismatch', - 'reviewed_placement_deployment_mismatch', - 'reviewed_placement_not_found', - 'reviewed_placement_resource_link_conflict', -]); - -export class LaunchRejectionError extends LtiBoundaryDenialError { - readonly rejection: LaunchRejection; - - constructor(rejection: LaunchRejection) { - super(rejection); - this.name = 'LaunchRejectionError'; - this.rejection = rejection; - } - - override get code(): LaunchRejectionCode { - return this.rejection.code; - } -} - -export function isLaunchRejectionError(error: unknown): error is LaunchRejectionError { - return error instanceof LaunchRejectionError; -} - -export function rejectLaunch(rejection: LaunchRejectionInput): never { - throw new LaunchRejectionError({ - ...rejection, - category: rejection.category ?? categorizeLaunchRejection(rejection.code), - }); -} - -function categorizeLaunchRejection(code: LaunchRejectionCode): LtiBoundaryDenialCategory { - return POLICY_DENIED_LAUNCH_CODES.has(code) ? 'policyDenied' : 'specInvalid'; -} diff --git a/src/lti/launch_runtime_session_test.ts b/src/lti/launch_runtime_session_test.ts deleted file mode 100644 index b9838fc..0000000 --- a/src/lti/launch_runtime_session_test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { assertEquals } from '@std/assert'; -import { createRuntimeSession } from './runtime_session.ts'; -import { buildRuntimeSessionRecord, buildValidatedLaunch } from '../test_helpers/lti.ts'; -import { - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from '../test_helpers/package_review.ts'; - -Deno.test('createRuntimeSession keeps the pinned approved version instead of resolving latest', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - version: '0.1.0', - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }), - buildPackageVersionRecord({ - id: 2, - version: '0.2.0', - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:10:00Z', - }), - ], - runtimeSessions: [ - buildRuntimeSessionRecord({ - sessionId: 'existing-session', - sessionToken: 'existing-token', - }), - ], - }); - const opaqueTokens = ['runtime-session-123', 'runtime-token-123']; - const session = await createRuntimeSession({ - repository, - launch: buildValidatedLaunch(), - now: () => new Date('2026-03-23T22:45:00Z'), - createOpaqueToken: () => { - const next = opaqueTokens.shift(); - - if (!next) { - throw new Error('Expected another deterministic runtime token.'); - } - - return next; - }, - }); - const saved = await repository.getRuntimeSessionById('runtime-session-123'); - const attempt = await repository.getAttemptById('attempt-123'); - - assertEquals(session.packageVersionId, 1); - assertEquals(session.packageVersion, '0.1.0'); - assertEquals(session.attemptId, 'attempt-123'); - assertEquals(saved?.packageVersionId, 1); - assertEquals(saved?.sessionToken, 'runtime-token-123'); - assertEquals(saved?.attemptId, 'attempt-123'); - assertEquals(saved?.launch.submissionMode, 'standard'); - assertEquals(attempt?.attemptId, 'attempt-123'); - assertEquals(attempt?.status, 'in_progress'); -}); - -Deno.test('createRuntimeSession uses the reviewed placement content path instead of the package default content file', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - manifestJson: { - app_id: 'chapter-4-asteroids', - version: '0.1.0', - title: 'Chapter 4 Asteroids', - content_files: ['/content/activity.json', '/content/bonus.json'], - }, - }), - ], - }); - const opaqueTokens = ['runtime-session-reviewed', 'runtime-token-reviewed']; - const session = await createRuntimeSession({ - repository, - launch: buildValidatedLaunch({ - activityId: '/content/bonus.json', - contentPath: '/content/bonus.json', - }), - now: () => new Date('2026-03-23T22:45:00Z'), - createOpaqueToken: () => { - const next = opaqueTokens.shift(); - - if (!next) { - throw new Error('Expected another deterministic runtime token.'); - } - - return next; - }, - }); - const saved = await repository.getRuntimeSessionById('runtime-session-reviewed'); - - assertEquals(session.contentPath, 'var/packages/chapter-4-asteroids/0.1.0/content/bonus.json'); - assertEquals(saved?.contentPath, 'var/packages/chapter-4-asteroids/0.1.0/content/bonus.json'); - assertEquals(saved?.launch.activityId, '/content/bonus.json'); -}); - -Deno.test('createRuntimeSession binds the reviewed runtime contract entrypoint and capabilities into the attempt-scoped session', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - capabilities: ['read_launch_context', 'read_activity_content', 'submit_attempt_event'], - artifact: { - snapshotRoot: 'var/packages/chapter-4-asteroids/0.1.0', - manifestPath: 'var/packages/chapter-4-asteroids/0.1.0/manifest.json', - entrypointPath: 'var/packages/chapter-4-asteroids/0.1.0/dist/index.html', - digest: 'sha256:chapter-4-asteroids-0.1.0', - }, - runtimeContract: { - appId: 'chapter-4-asteroids', - packageVersion: '0.1.0', - artifactDigest: 'sha256:chapter-4-asteroids-0.1.0', - entrypoint: '/dist/reviewed-shell.html', - capabilities: ['read_launch_context', 'finalize_attempt', 'submit_evidence_artifact'], - }, - }), - ], - runtimeSessions: [ - buildRuntimeSessionRecord({ - sessionId: 'existing-session', - sessionToken: 'existing-token', - }), - ], - }); - const opaqueTokens = ['runtime-session-contract', 'runtime-token-contract']; - const session = await createRuntimeSession({ - repository, - launch: buildValidatedLaunch(), - now: () => new Date('2026-03-23T22:45:00Z'), - createOpaqueToken: () => { - const next = opaqueTokens.shift(); - - if (!next) { - throw new Error('Expected another deterministic runtime token.'); - } - - return next; - }, - }); - - assertEquals( - session.entrypointPath, - 'var/packages/chapter-4-asteroids/0.1.0/dist/reviewed-shell.html', - ); - assertEquals(session.capabilities, [ - 'read_launch_context', - 'finalize_attempt', - 'submit_evidence_artifact', - ]); - assertEquals(session.launch.submissionMode, 'anonymous_submission'); -}); diff --git a/src/lti/launch_support_matrix.ts b/src/lti/launch_support_matrix.ts deleted file mode 100644 index 8c69632..0000000 --- a/src/lti/launch_support_matrix.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { LTI_RESOURCE_LINK_REQUEST_MESSAGE_TYPE } from './types.ts'; -import { buildLaunchDetailRecord, rejectLaunch } from './launch_rejection.ts'; - -export const GOVERNED_RUNTIME_BASELINE_RULE = 'governed_runtime_baseline'; -export const SUPPORTED_LTI_LAUNCH_PATH = '/lti/launch'; -export const SUPPORTED_LTI_VERSION = '1.3.0'; - -export function assertSupportedLaunchMessageType(messageType: string): void { - if (messageType === LTI_RESOURCE_LINK_REQUEST_MESSAGE_TYPE) { - return; - } - - rejectLaunch({ - code: 'unsupported_message_type', - message: - `Launch rejected because ${SUPPORTED_LTI_LAUNCH_PATH} only accepts ${LTI_RESOURCE_LINK_REQUEST_MESSAGE_TYPE} for the governed runtime baseline.`, - detail: buildLaunchDetailRecord({ - route: SUPPORTED_LTI_LAUNCH_PATH, - rule: GOVERNED_RUNTIME_BASELINE_RULE, - messageType, - supportedMessageType: LTI_RESOURCE_LINK_REQUEST_MESSAGE_TYPE, - version: SUPPORTED_LTI_VERSION, - }), - }); -} - -export function assertSupportedLaunchVersion(version: string): void { - if (version === SUPPORTED_LTI_VERSION) { - return; - } - - rejectLaunch({ - code: 'unsupported_lti_version', - message: - `Launch rejected because ${SUPPORTED_LTI_LAUNCH_PATH} only supports LTI ${SUPPORTED_LTI_VERSION} for the governed runtime baseline.`, - detail: buildLaunchDetailRecord({ - route: SUPPORTED_LTI_LAUNCH_PATH, - rule: GOVERNED_RUNTIME_BASELINE_RULE, - version, - supportedVersion: SUPPORTED_LTI_VERSION, - }), - }); -} - -export function requireBaselineStringClaim(value: unknown, claim: string): string { - if (typeof value !== 'string' || value.trim() === '') { - rejectLaunch({ - code: 'missing_baseline_claim', - message: `Launch rejected because the governed runtime baseline requires ${claim}.`, - detail: buildLaunchDetailRecord({ - route: SUPPORTED_LTI_LAUNCH_PATH, - rule: GOVERNED_RUNTIME_BASELINE_RULE, - claim, - }), - }); - } - - return value.trim(); -} - -export function rejectLoginStateMissing(state: string): never { - rejectLaunch({ - code: 'login_state_missing', - message: - 'Lantern could not resume this LTI launch because the saved login state was missing. This usually means the LMS frame lost cookies or storage, or the launch came from an older tab. Reopen the tool from the LMS and, if needed, retry in a new top-level browser tab.', - detail: buildLaunchDetailRecord({ - state, - }), - }); -} - -export function rejectLoginStateUsed(state: string): never { - rejectLaunch({ - code: 'login_state_used', - message: - 'Lantern already used this LTI launch state. This usually happens when the LMS reposts an older launch page or the browser resubmits from history. Reopen the tool from the LMS and retry from a fresh top-level tab if the LMS stays embedded.', - detail: buildLaunchDetailRecord({ - state, - }), - }); -} - -export function rejectLoginStateExpired(state: string): never { - rejectLaunch({ - code: 'login_state_expired', - message: - 'Lantern could not resume this LTI launch because the saved login state expired before the LMS returned. Reopen the tool from the LMS and, if the launch keeps failing inside the frame, retry in a new top-level browser tab.', - detail: buildLaunchDetailRecord({ - state, - }), - }); -} - -export function rejectDeploymentBindingMissing(input: { - lmsLabel: string; - clientId: string; - deploymentId: string; - issuer: string; -}): never { - rejectLaunch({ - code: 'deployment_binding_missing', - message: - `${input.lmsLabel} deployment ${input.clientId} / ${input.deploymentId} was not found for issuer ${input.issuer}.`, - detail: buildLaunchDetailRecord({ - issuer: input.issuer, - clientId: input.clientId, - deploymentId: input.deploymentId, - }), - }); -} - -export function rejectSignatureValidationFailed(): never { - rejectLaunch({ - code: 'signature_validation_failed', - message: 'Launch id_token signature or issuer validation failed.', - detail: buildLaunchDetailRecord({}), - }); -} - -export function rejectDeploymentMismatch(input: { field: string; target: string }): never { - rejectLaunch({ - code: 'deployment_mismatch', - message: `Launch ${input.field} did not match the ${input.target}.`, - detail: buildLaunchDetailRecord({ - field: input.field, - target: input.target, - }), - }); -} - -export function rejectReviewedPlacementNotFound(placementId: string): never { - rejectLaunch({ - code: 'reviewed_placement_not_found', - message: `Reviewed placement ${placementId} was not found.`, - detail: buildLaunchDetailRecord({ - placementId, - }), - }); -} - -export function rejectReviewedPlacementDeploymentMismatch(input: { - placementId: string; - deploymentSlug: string; - placementDeploymentSlug?: string | null; -}): never { - rejectLaunch({ - code: 'reviewed_placement_deployment_mismatch', - message: - `Reviewed placement ${input.placementId} does not belong to deployment ${input.deploymentSlug}.`, - detail: buildLaunchDetailRecord({ - placementId: input.placementId, - deploymentSlug: input.deploymentSlug, - placementDeploymentSlug: input.placementDeploymentSlug, - }), - }); -} - -export function rejectReviewedPlacementContextMismatch(input: { - placementId: string; - contextId: string; - placementContextId?: string | null; -}): never { - rejectLaunch({ - code: 'reviewed_placement_context_mismatch', - message: - `Reviewed placement ${input.placementId} does not match governed launch context ${input.contextId}.`, - detail: buildLaunchDetailRecord({ - placementId: input.placementId, - contextId: input.contextId, - placementContextId: input.placementContextId, - }), - }); -} - -export function rejectReviewedPlacementResourceLinkConflict(input: { - placementId: string; - resourceLinkId: string; - existingResourceLinkId?: string | null; - deploymentSlug?: string | null; -}): never { - if (input.existingResourceLinkId) { - rejectLaunch({ - code: 'reviewed_placement_resource_link_conflict', - message: - `Reviewed placement ${input.placementId} is already bound to resource link ${input.existingResourceLinkId}.`, - detail: buildLaunchDetailRecord({ - placementId: input.placementId, - resourceLinkId: input.resourceLinkId, - existingResourceLinkId: input.existingResourceLinkId, - }), - }); - } - - rejectLaunch({ - code: 'reviewed_placement_resource_link_conflict', - message: input.deploymentSlug - ? `Resource link ${input.resourceLinkId} is already bound to another reviewed placement in deployment ${input.deploymentSlug}.` - : `Resource link ${input.resourceLinkId} conflicts with the saved reviewed placement binding.`, - detail: buildLaunchDetailRecord({ - placementId: input.placementId, - resourceLinkId: input.resourceLinkId, - deploymentSlug: input.deploymentSlug, - }), - }); -} - -export function rejectMissingPinnedPackageVersion(deploymentSlug: string): never { - rejectLaunch({ - code: 'missing_pinned_package_version', - message: - `Launch rejected because deployment ${deploymentSlug} does not have an approved pinned package version.`, - detail: buildLaunchDetailRecord({ - deploymentSlug, - }), - }); -} - -export function rejectLaunchPackageVersionMissing(packageVersionId: number): never { - rejectLaunch({ - code: 'launch_package_version_missing', - message: `Launch package version id ${packageVersionId} was not found.`, - detail: buildLaunchDetailRecord({ - packageVersionId, - }), - }); -} - -export function rejectPackageNotApproved(input: { appId: string; packageVersion: string }): never { - rejectLaunch({ - code: 'package_not_approved', - message: `Launch package version ${input.appId}@${input.packageVersion} is not approved.`, - detail: buildLaunchDetailRecord({ - appId: input.appId, - packageVersion: input.packageVersion, - }), - }); -} diff --git a/src/lti/launch_target_resolution.ts b/src/lti/launch_target_resolution.ts deleted file mode 100644 index f057e77..0000000 --- a/src/lti/launch_target_resolution.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { PackageReviewRepository } from '../package_review/repository.ts'; -import type { PackageVersionRecord } from '../package_review/types.ts'; -import { ensureLeadingSlash } from '../package_review/snapshot_path.ts'; -import { requireRecordClaim, requireStringClaim } from './claim_support.ts'; -import { - rejectLaunchPackageVersionMissing, - rejectMissingPinnedPackageVersion, - rejectPackageNotApproved, - rejectReviewedPlacementContextMismatch, - rejectReviewedPlacementDeploymentMismatch, - rejectReviewedPlacementNotFound, - rejectReviewedPlacementResourceLinkConflict, -} from './launch_support_matrix.ts'; -import { LANTERN_PLACEMENT_CUSTOM_KEY } from './types.ts'; - -export async function resolveLaunchTarget(input: { - repository: PackageReviewRepository; - deployment: { - id: number; - slug: string; - appId: string; - enabledPackageVersionId: number | null; - }; - resourceLinkId: string; - contextId: string; - customClaim: unknown; - now: () => Date; -}): Promise<{ - packageVersion: PackageVersionRecord; - activityId: string; - contentPath: string; -}> { - const placementId = readReviewedPlacementId(input.customClaim); - - if (placementId === null) { - return await resolvePinnedLaunchTarget(input); - } - - const placement = await input.repository.getReviewedPlacementById(placementId); - - if (!placement) { - rejectReviewedPlacementNotFound(placementId); - } - - if ( - placement.deploymentRecordId !== input.deployment.id || - placement.deploymentSlug !== input.deployment.slug || - placement.appId !== input.deployment.appId - ) { - rejectReviewedPlacementDeploymentMismatch({ - placementId, - deploymentSlug: input.deployment.slug, - placementDeploymentSlug: placement.deploymentSlug, - }); - } - - if (placement.contextId !== input.contextId) { - rejectReviewedPlacementContextMismatch({ - placementId, - contextId: input.contextId, - placementContextId: placement.contextId, - }); - } - - if (placement.resourceLinkId !== null && placement.resourceLinkId !== input.resourceLinkId) { - rejectReviewedPlacementResourceLinkConflict({ - placementId, - resourceLinkId: input.resourceLinkId, - existingResourceLinkId: placement.resourceLinkId, - }); - } - - let boundPlacement; - - try { - boundPlacement = await input.repository.bindReviewedPlacementResourceLink({ - placementId, - resourceLinkId: input.resourceLinkId, - boundAt: input.now().toISOString(), - }); - } catch (error) { - if ( - error instanceof Error && - error.message.includes('already bound to another reviewed placement') - ) { - rejectReviewedPlacementResourceLinkConflict({ - placementId, - resourceLinkId: input.resourceLinkId, - deploymentSlug: placement.deploymentSlug, - }); - } - - throw error; - } - const packageVersion = await requireApprovedLaunchPackageVersion({ - repository: input.repository, - packageVersionId: boundPlacement.packageVersionId, - }); - - if (packageVersion.appId !== boundPlacement.appId) { - throw new Error( - `Reviewed placement ${placementId} resolved to the wrong app package ${packageVersion.appId}.`, - ); - } - - return { - packageVersion, - activityId: boundPlacement.activityId, - contentPath: boundPlacement.contentPath, - }; -} - -async function resolvePinnedLaunchTarget(input: { - repository: PackageReviewRepository; - deployment: { - slug: string; - enabledPackageVersionId: number | null; - }; - resourceLinkId: string; -}): Promise<{ - packageVersion: PackageVersionRecord; - activityId: string; - contentPath: string; -}> { - if (input.deployment.enabledPackageVersionId === null) { - rejectMissingPinnedPackageVersion(input.deployment.slug); - } - - const packageVersion = await requireApprovedLaunchPackageVersion({ - repository: input.repository, - packageVersionId: input.deployment.enabledPackageVersionId, - }); - - return { - packageVersion, - activityId: input.resourceLinkId, - contentPath: resolveCanonicalContentPath(packageVersion), - }; -} - -async function requireApprovedLaunchPackageVersion(input: { - repository: PackageReviewRepository; - packageVersionId: number; -}): Promise { - const packageVersion = await input.repository.getPackageVersionById(input.packageVersionId); - - if (!packageVersion) { - rejectLaunchPackageVersionMissing(input.packageVersionId); - } - - if (packageVersion.approvalStatus !== 'approved') { - rejectPackageNotApproved({ - appId: packageVersion.appId, - packageVersion: packageVersion.version, - }); - } - - return packageVersion; -} - -function readReviewedPlacementId(customClaim: unknown): string | null { - const custom = customClaim === undefined - ? null - : requireRecordClaim(customClaim, 'Launch custom claim must be an object when provided.'); - - if (!custom || custom[LANTERN_PLACEMENT_CUSTOM_KEY] === undefined) { - return null; - } - - return requireStringClaim( - custom[LANTERN_PLACEMENT_CUSTOM_KEY], - `Launch custom.${LANTERN_PLACEMENT_CUSTOM_KEY} is required when provided.`, - ); -} - -function resolveCanonicalContentPath(packageVersion: PackageVersionRecord): string { - const contentFiles = Array.isArray(packageVersion.manifestJson.content_files) - ? packageVersion.manifestJson.content_files - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .filter((item) => item !== '') - : []; - const firstContentFile = contentFiles[0]; - - return ensureLeadingSlash(firstContentFile ?? '/content/activity.json'); -} diff --git a/src/lti/login_policy.ts b/src/lti/login_policy.ts deleted file mode 100644 index eef5314..0000000 --- a/src/lti/login_policy.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { getLtiProfileDefinition, type ResolvedLtiProfile } from './profile.ts'; -import { buildLanternTargetLinkUri } from './target_link_uri.ts'; -import type { DeploymentRecord } from '../package_review/types.ts'; - -export interface LoginPolicyRequest { - readonly request: { - readonly loginHint: string; - readonly ltiMessageHint: string | null; - readonly targetLinkUri: string | null; - }; - readonly compatibility: { - readonly decodedLoginHint: string | null; - readonly decodedLtiMessageHint: string | null; - }; -} - -export function resolveLoginPolicy(input: { - appOrigin: string; - binding: NonNullable; - loginRequest: LoginPolicyRequest; - ltiProfile: ResolvedLtiProfile; -}): { - loginHint: string; - ltiMessageHint?: string; - targetLinkUri: string; - compatibilityPaths: string[]; -} { - const profileBehavior = getLtiProfileDefinition(input.ltiProfile.id).behavior; - const compatibilityPaths: string[] = []; - - if ( - (input.loginRequest.compatibility.decodedLoginHint !== null || - input.loginRequest.compatibility.decodedLtiMessageHint !== null) && - !profileBehavior.decodeOpaqueHints - ) { - throw new Error('The active LTI profile does not allow opaque login hint decoding.'); - } - - const loginHint = input.loginRequest.compatibility.decodedLoginHint ?? - input.loginRequest.request.loginHint; - const ltiMessageHint = input.loginRequest.request.ltiMessageHint === null - ? undefined - : input.loginRequest.compatibility.decodedLtiMessageHint ?? - input.loginRequest.request.ltiMessageHint; - - if (input.loginRequest.compatibility.decodedLoginHint !== null) { - compatibilityPaths.push('opaque_login_hint_decode'); - } - - if (input.loginRequest.compatibility.decodedLtiMessageHint !== null) { - compatibilityPaths.push('opaque_lti_message_hint_decode'); - } - - if (input.loginRequest.request.targetLinkUri !== null) { - return { - loginHint, - ...(ltiMessageHint === undefined ? {} : { ltiMessageHint }), - targetLinkUri: input.loginRequest.request.targetLinkUri, - compatibilityPaths, - }; - } - - if (input.binding.lms === 'canvas') { - throw new Error( - 'Canvas launches use more than one Lantern callback route; target_link_uri is required.', - ); - } - - if (!profileBehavior.allowPlatformDefaultLaunchTarget) { - throw new Error('The active LTI profile requires target_link_uri in login initiation.'); - } - - compatibilityPaths.push('platform_default_launch_target'); - - return { - loginHint, - ...(ltiMessageHint === undefined ? {} : { ltiMessageHint }), - targetLinkUri: buildLanternTargetLinkUri('launch', input.appOrigin), - compatibilityPaths, - }; -} diff --git a/src/lti/platform_binding.ts b/src/lti/platform_binding.ts deleted file mode 100644 index 2ce4cee..0000000 --- a/src/lti/platform_binding.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { resolveCanvasPlatformBinding } from '@longsightgroup/lti-tool'; -import { resolveCanvasPlatform } from './canvas_platform.ts'; -import type { DeploymentBinding, LmsType } from './types.ts'; - -export function resolveAuthorizationEndpoint(binding: DeploymentBinding): string { - switch (binding.lms) { - case 'canvas': - return resolveCanvasPlatform(binding.issuer).authorizationEndpoint; - case 'moodle': - case 'sakai': - return binding.authorizationEndpoint; - } -} - -export function resolveBindingJwksUrl(binding: DeploymentBinding): string { - switch (binding.lms) { - case 'canvas': - return resolveCanvasPlatform(binding.issuer).jwksUrl; - case 'moodle': - case 'sakai': - return binding.jwksUrl; - } -} - -export function resolveServiceTokenEndpoint(binding: DeploymentBinding): string { - switch (binding.lms) { - case 'canvas': - return resolveCanvasPlatformBinding( - resolveCanvasPlatform(binding.issuer).environment, - ).tokenUrl; - case 'moodle': - case 'sakai': - return binding.accessTokenUrl; - } -} - -export function formatLmsLabel(lms: LmsType): string { - switch (lms) { - case 'canvas': - return 'Canvas'; - case 'moodle': - return 'Moodle'; - case 'sakai': - return 'Sakai'; - } -} diff --git a/src/lti/profile.ts b/src/lti/profile.ts deleted file mode 100644 index 9954d9c..0000000 --- a/src/lti/profile.ts +++ /dev/null @@ -1,82 +0,0 @@ -export const LTI_CERTIFICATION_PROFILE_ID = 'certification'; -export const LTI_GOVERNED_COMPATIBILITY_PROFILE_ID = 'governedCompatibility'; - -export type LtiProfileId = - | typeof LTI_CERTIFICATION_PROFILE_ID - | typeof LTI_GOVERNED_COMPATIBILITY_PROFILE_ID; - -export interface LtiProfileDefinition { - id: LtiProfileId; - label: string; - summary: string; - behavior: { - decodeOpaqueHints: boolean; - tolerateTargetLinkUriDrift: boolean; - allowDeepLinkingJtiNonceBridge: boolean; - retryJwksRefetchOnce: boolean; - retryServiceUnauthorizedOnce: boolean; - allowPlatformDefaultLaunchTarget: boolean; - }; -} - -export interface ResolvedLtiProfile { - id: LtiProfileId; - source: 'lanternDefault' | 'deploymentOverride'; - deploymentRecordId: number; -} - -export const DEFAULT_LTI_PROFILE_ID = LTI_GOVERNED_COMPATIBILITY_PROFILE_ID; - -export const LTI_PROFILE_DEFINITIONS: readonly LtiProfileDefinition[] = [ - { - id: LTI_CERTIFICATION_PROFILE_ID, - label: 'Certification', - summary: 'Use strict LTI behavior for diagnostics and certification runs.', - behavior: { - decodeOpaqueHints: false, - tolerateTargetLinkUriDrift: false, - allowDeepLinkingJtiNonceBridge: false, - retryJwksRefetchOnce: false, - retryServiceUnauthorizedOnce: false, - allowPlatformDefaultLaunchTarget: false, - }, - }, - { - id: LTI_GOVERNED_COMPATIBILITY_PROFILE_ID, - label: 'Governed interoperability', - summary: "Keep Lantern's bounded real-world LTI compatibility paths available.", - behavior: { - decodeOpaqueHints: true, - tolerateTargetLinkUriDrift: true, - allowDeepLinkingJtiNonceBridge: true, - retryJwksRefetchOnce: true, - retryServiceUnauthorizedOnce: true, - allowPlatformDefaultLaunchTarget: true, - }, - }, -] as const; - -export function isLtiProfileId(value: string): value is LtiProfileId { - return LTI_PROFILE_DEFINITIONS.some((profile) => profile.id === value); -} - -export function requireLtiProfileId( - value: string, - message = 'Choose one supported LTI profile.', -): LtiProfileId { - if (isLtiProfileId(value)) { - return value; - } - - throw new Error(message); -} - -export function getLtiProfileDefinition(profileId: LtiProfileId): LtiProfileDefinition { - const profile = LTI_PROFILE_DEFINITIONS.find((candidate) => candidate.id === profileId); - - if (!profile) { - throw new Error(`Unsupported LTI profile ${profileId}.`); - } - - return profile; -} diff --git a/src/lti/profile_resolution.ts b/src/lti/profile_resolution.ts deleted file mode 100644 index df1de88..0000000 --- a/src/lti/profile_resolution.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { PackageReviewRepository } from '../package_review/repository.ts'; -import type { DeploymentRecord } from '../package_review/types.ts'; -import type { LtiProfileId, ResolvedLtiProfile } from './profile.ts'; -import { getLtiProfileDefinition } from './profile.ts'; - -export async function resolveLtiProfileForDeployment(input: { - repository: Pick; - deployment: Pick; -}): Promise { - const settings = await input.repository.getLanternLtiProfileSettings(); - const source = input.deployment.ltiProfileOverride === null - ? 'lanternDefault' - : 'deploymentOverride'; - - return { - id: input.deployment.ltiProfileOverride ?? settings.defaultLtiProfile, - source, - deploymentRecordId: input.deployment.id, - }; -} - -export function buildResolvedLtiProfileDetail(profile: ResolvedLtiProfile): { - ltiProfileId: LtiProfileId; - ltiProfileSource: ResolvedLtiProfile['source']; -} { - return { - ltiProfileId: profile.id, - ltiProfileSource: profile.source, - }; -} - -export function describeResolvedLtiProfile(profile: { - id: LtiProfileId; - source: ResolvedLtiProfile['source']; -}): string { - const label = getLtiProfileDefinition(profile.id).label; - - return profile.source === 'deploymentOverride' - ? `${label} override` - : `${label} from Lantern default`; -} diff --git a/src/lti/runtime_session.ts b/src/lti/runtime_session.ts deleted file mode 100644 index af175e4..0000000 --- a/src/lti/runtime_session.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { resolveSubmissionMode } from '../../sdk/app-sdk.ts'; -import type { PackageVersionRecord } from '../package_review/types.ts'; -import type { PackageReviewRepository } from '../package_review/repository.ts'; -import { ensureLeadingSlash } from '../package_review/snapshot_path.ts'; -import { requireTrimmedValue } from './claim_support.ts'; -import { createOpaqueToken } from './token_support.ts'; -import type { RuntimeSessionRecord, ValidatedLaunch } from './types.ts'; - -const RUNTIME_SESSION_TTL_MS = 10 * 60 * 1000; - -export async function createRuntimeSession(input: { - repository: PackageReviewRepository; - launch: ValidatedLaunch; - ltiToolSessionId?: string | null; - now?: () => Date; - createOpaqueToken?: () => string; -}): Promise { - const now = input.now ?? (() => new Date()); - const nextOpaqueToken = input.createOpaqueToken ?? createOpaqueToken; - const packageVersion = await input.repository.getPackageVersionById( - input.launch.packageVersionId, - ); - - if (!packageVersion) { - throw new Error(`Launch package version id ${input.launch.packageVersionId} was not found.`); - } - - if (packageVersion.approvalStatus !== 'approved') { - throw new Error( - `Launch package version ${packageVersion.appId}@${packageVersion.version} is not approved.`, - ); - } - - const createdAt = now(); - const attempt = await input.repository.createAttempt({ - attemptId: input.launch.attemptId, - deploymentRecordId: input.launch.internalDeploymentId, - deploymentSlug: input.launch.internalDeploymentSlug, - appId: packageVersion.appId, - packageVersionId: packageVersion.id, - packageVersion: packageVersion.version, - userId: input.launch.userId, - userDisplayName: input.launch.userDisplayName, - userEmail: input.launch.userEmail, - userLogin: input.launch.userLogin, - userRole: input.launch.userRole, - contextId: requireTrimmedValue( - input.launch.contextId ?? '', - 'Launch context.id is required for the governed runtime.', - ), - resourceLinkId: input.launch.resourceLinkId, - activityId: input.launch.activityId, - status: 'in_progress', - completionState: null, - localState: null, - startedAt: createdAt.toISOString(), - finalizedAt: null, - }); - - return await input.repository.createRuntimeSession({ - sessionId: nextOpaqueToken(), - sessionToken: nextOpaqueToken(), - ltiToolSessionId: input.ltiToolSessionId ?? null, - attemptId: attempt.attemptId, - deploymentRecordId: input.launch.internalDeploymentId, - deploymentSlug: input.launch.internalDeploymentSlug, - appId: packageVersion.runtimeContract.appId, - packageVersionId: packageVersion.id, - packageVersion: packageVersion.runtimeContract.packageVersion, - capabilities: packageVersion.runtimeContract.capabilities, - snapshotRoot: packageVersion.artifact.snapshotRoot, - entrypointPath: `${packageVersion.artifact.snapshotRoot}${ - ensureLeadingSlash( - packageVersion.runtimeContract.entrypoint, - ) - }`, - contentPath: resolveRuntimeContentPath(packageVersion, input.launch.contentPath), - services: input.launch.services, - launch: { - userRole: input.launch.userRole, - courseId: requireTrimmedValue( - input.launch.contextId ?? '', - 'Launch context.id is required for the governed runtime.', - ), - activityId: input.launch.activityId, - submissionMode: resolveSubmissionMode(packageVersion.runtimeContract.capabilities), - }, - createdAt: createdAt.toISOString(), - expiresAt: new Date(createdAt.getTime() + RUNTIME_SESSION_TTL_MS).toISOString(), - }); -} - -function resolveRuntimeContentPath( - packageVersion: PackageVersionRecord, - contentPath: string, -): string { - return `${packageVersion.artifact.snapshotRoot}${ensureLeadingSlash(contentPath)}`; -} diff --git a/src/lti/target_link_uri.ts b/src/lti/target_link_uri.ts deleted file mode 100644 index fb04981..0000000 --- a/src/lti/target_link_uri.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { buildCanvasDeepLinkingUrl, buildCanvasLaunchUrl } from './config.ts'; -import { - LTI_ASSIGNMENT_SELECTION_PLACEMENT, - LTI_RESOURCE_SELECTION_PLACEMENT, - type LtiPlacement, -} from './types.ts'; - -export type LanternTargetLinkKind = 'launch' | 'deep_linking'; - -export function resolveLanternTargetLinkKind(value: string): LanternTargetLinkKind | null { - let url: URL; - - try { - url = new URL(value); - } catch { - return null; - } - - switch (normalizePathname(url.pathname)) { - case '/lti/launch': - return 'launch'; - case '/lti/deep-linking': - return 'deep_linking'; - default: - return null; - } -} - -export function targetLinkUrisMatch(input: { - expected: string; - actual: string; - allowLanternDrift?: boolean; -}): boolean { - const expected = parseAbsoluteUrl(input.expected); - const actual = parseAbsoluteUrl(input.actual); - - if (!expected || !actual) { - return false; - } - - if (input.allowLanternDrift === false) { - return expected.toString() === actual.toString(); - } - - const expectedKind = resolveLanternTargetLinkKind(expected.toString()); - const actualKind = resolveLanternTargetLinkKind(actual.toString()); - - if (expectedKind !== null && actualKind !== null) { - if (expectedKind === 'deep_linking' && actualKind === 'deep_linking') { - const expectedPlacement = resolveLanternDeepLinkingPlacement(expected.toString()); - const actualPlacement = resolveLanternDeepLinkingPlacement(actual.toString()); - - return ( - expected.hostname === actual.hostname && - expectedPlacement !== null && - actualPlacement !== null && - expectedPlacement === actualPlacement - ); - } - - return expectedKind === actualKind && expected.hostname === actual.hostname; - } - - return ( - expected.hostname === actual.hostname && - normalizePathname(expected.pathname) === normalizePathname(actual.pathname) - ); -} - -export function targetLinkUriUsesLanternDriftTolerance(input: { - expected: string; - actual: string; -}): boolean { - return ( - targetLinkUrisMatch({ - expected: input.expected, - actual: input.actual, - allowLanternDrift: true, - }) && - !targetLinkUrisMatch({ - expected: input.expected, - actual: input.actual, - allowLanternDrift: false, - }) - ); -} - -export function assertLanternTargetLinkKind(input: { - targetLinkUri: string; - kind: LanternTargetLinkKind; - message: string; -}): void { - const kind = resolveLanternTargetLinkKind(input.targetLinkUri); - - if (kind !== input.kind) { - throw new Error(input.message); - } -} - -export function resolveLanternDeepLinkingPlacement(targetLinkUri: string): LtiPlacement | null { - const url = parseAbsoluteUrl(targetLinkUri); - - if (!url || resolveLanternTargetLinkKind(url.toString()) !== 'deep_linking') { - return null; - } - - const placement = url.searchParams.get('placement'); - - if (placement === null || placement.trim() === '') { - return LTI_ASSIGNMENT_SELECTION_PLACEMENT; - } - - if ( - placement === LTI_ASSIGNMENT_SELECTION_PLACEMENT || - placement === LTI_RESOURCE_SELECTION_PLACEMENT - ) { - return placement; - } - - return null; -} - -export function buildLanternTargetLinkUri(kind: LanternTargetLinkKind, appOrigin?: string): string { - return kind === 'launch' ? buildCanvasLaunchUrl(appOrigin) : buildCanvasDeepLinkingUrl(appOrigin); -} - -function parseAbsoluteUrl(value: string): URL | null { - try { - return new URL(value); - } catch { - return null; - } -} - -function normalizePathname(pathname: string): string { - if (pathname.length > 1 && pathname.endsWith('/')) { - return pathname.slice(0, -1); - } - - return pathname; -} diff --git a/src/lti/tool_provider.ts b/src/lti/tool_provider.ts index 125df45..2f45ef1 100644 --- a/src/lti/tool_provider.ts +++ b/src/lti/tool_provider.ts @@ -1,6 +1,5 @@ import { createNoopLogger, - LtiDynamicRegistration, type LtiLogger, type LTIStorage, LTITool, @@ -12,14 +11,22 @@ import type { AnyD1Database } from 'drizzle-orm/d1'; import { type D1Database } from '../db/d1.ts'; import { type EnvReader, readEnv } from '../platform/env.ts'; import { requireAppOrigin } from './config.ts'; -import { - resolveAuthorizationEndpoint, - resolveBindingJwksUrl, - resolveServiceTokenEndpoint, -} from './platform_binding.ts'; import { loadToolSigningKey } from './tool_key.ts'; -import { buildDynamicRegistrationConfig } from './dynamic_registration_config.ts'; -import type { DeploymentBinding } from './types.ts'; +import type { LmsConnectionRecord } from '../lms_kernel/types.ts'; + +/** + * Data ownership boundary for LTI: + * + * - `lms_connections` (owned by lms_kernel) is Lantern's product identity for an + * institution's LMS platform. It is the source of truth an admin manages. + * - The `lti_*` tables written through {@link LTIStorage} are an ephemeral + * protocol registration cache consumed only by the `@longsightgroup/lti-tool` + * library to run the OIDC/launch handshake. They are derived from + * `lms_connections` and can be rebuilt from it at any time. + * + * {@link syncLtiToolConnection} projects a product `lms_connections` record into + * that protocol cache. Never treat the `lti_*` tables as authoritative. + */ const LTI_STATE_SECRET_ENV = 'LTI_STATE_SECRET'; const MIN_STATE_SECRET_BYTES = 32; @@ -27,7 +34,6 @@ const MIN_STATE_SECRET_BYTES = 32; export interface LanternLtiToolContext { readonly storage: LTIStorage; readonly tool: LTITool; - readonly dynamicRegistration: LtiDynamicRegistration; readonly logger: LtiLogger; } @@ -55,7 +61,9 @@ export async function createLanternLtiToolContext( ): Promise { const logger = input.logger ?? createNoopLogger(); const toolSigningKey = await loadToolSigningKey(input.env); - const appOrigin = input.appOrigin ?? requireAppOrigin(input.env); + if (input.appOrigin === undefined) { + requireAppOrigin(input.env); + } const config = { stateSecret: readLtiStateSecret(input.env), keyPair: toolSigningKey.keyPair, @@ -69,32 +77,33 @@ export async function createLanternLtiToolContext( cacheMaxAge: 300_000, }, }, - dynamicRegistration: buildDynamicRegistrationConfig(appOrigin), }; return { storage: input.storage, tool: new LTITool(config), - dynamicRegistration: new LtiDynamicRegistration(config), logger, }; } -export async function syncLtiToolDeploymentBinding(input: { +/** + * Projects a product `lms_connections` record into the lti-tool protocol + * registration cache. `lms_connections` remains the owner; this only refreshes + * the derived `lti_*` registration the library needs to complete launches. + */ +export async function syncLtiToolConnection(input: { readonly storage: LTIStorage; - readonly appTitle: string; - readonly deploymentSlug: string; - readonly binding: DeploymentBinding; + readonly connection: LmsConnectionRecord; }): Promise { await upsertLaunchRegistration(input.storage, { - iss: input.binding.issuer, - clientId: input.binding.clientId, - deploymentId: input.binding.deploymentId, - authUrl: resolveAuthorizationEndpoint(input.binding), - tokenUrl: resolveServiceTokenEndpoint(input.binding), - jwksUrl: resolveBindingJwksUrl(input.binding), - name: `${input.appTitle} via Lantern`, - deploymentName: input.deploymentSlug, + iss: input.connection.issuer, + clientId: input.connection.clientId, + deploymentId: input.connection.deploymentId, + authUrl: input.connection.authorizationEndpoint, + tokenUrl: input.connection.accessTokenUrl, + jwksUrl: input.connection.jwksUrl, + name: `${input.connection.label} via Lantern`, + deploymentName: input.connection.id, }); } diff --git a/src/lti/types.ts b/src/lti/types.ts index 171038e..0f64a24 100644 --- a/src/lti/types.ts +++ b/src/lti/types.ts @@ -1,5 +1,4 @@ import type { Capability, SubmissionMode, UserRole } from '../../sdk/app-sdk.ts'; -import type { DeepLinkingLtiResourceLink } from '@longsightgroup/lti-tool'; export type CanvasEnvironment = 'production' | 'beta' | 'test'; export const LTI_AGS_SCORE_SCOPE = 'https://purl.imsglobal.org/spec/lti-ags/scope/score'; @@ -7,11 +6,6 @@ export const LTI_AGS_LINEITEM_SCOPE = 'https://purl.imsglobal.org/spec/lti-ags/s export const LTI_NRPS_CONTEXT_MEMBERSHIP_SCOPE = 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly'; export const LTI_RESOURCE_LINK_REQUEST_MESSAGE_TYPE = 'LtiResourceLinkRequest'; -export const LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE = 'LtiDeepLinkingRequest'; -export const LTI_DEEP_LINKING_RESPONSE_MESSAGE_TYPE = 'LtiDeepLinkingResponse'; -export const LTI_ASSIGNMENT_SELECTION_PLACEMENT = 'assignment_selection'; -export const LTI_RESOURCE_SELECTION_PLACEMENT = 'resource_selection'; -export const LANTERN_PLACEMENT_CUSTOM_KEY = 'lantern_placement_id'; export const CANVAS_LTI_SCOPES = [ LTI_AGS_SCORE_SCOPE, LTI_AGS_LINEITEM_SCOPE, @@ -19,7 +13,6 @@ export const CANVAS_LTI_SCOPES = [ ] as const; export type LmsType = 'canvas' | 'moodle' | 'sakai'; -export type PersistedDeploymentLmsType = LmsType | 'preview'; export type NonCanvasLmsType = Exclude; export function buildLtiActivityResourceId(input: { @@ -60,15 +53,6 @@ export interface CanvasPlatformConfig { jwksUrl: string; } -export interface DynamicRegistrationStateRecord { - state: string; - appId: string; - lms: LmsType; - createdAt: string; - expiresAt: string; - usedAt: string | null; -} - export interface LaunchAssignmentAndGradeServices { scope: string[]; lineitemsUrl: string | null; @@ -85,42 +69,11 @@ export interface LaunchServiceClaims { nrps: LaunchNamesAndRolesService | null; } -export interface ValidatedLaunch { - lms: LmsType; - canvasEnvironment: CanvasEnvironment | null; - issuer: string; - clientId: string; - deploymentId: string; - internalDeploymentId: number; - internalDeploymentSlug: string; - appId: string; - packageVersionId: number; - packageVersion: string; - contentPath: string; - attemptId: string; - userId: string; - userDisplayName: string | null; - userEmail: string | null; - userLogin: string | null; - userRole: UserRole; - resourceLinkId: string; - resourceLinkTitle: string | null; - contextId: string | null; - contextTitle: string | null; - targetLinkUri: string; - returnUrl: string | null; - activityId: string; - services: LaunchServiceClaims; - issuedAt: string; -} - export interface RuntimeSessionRecord { sessionId: string; sessionToken: string; ltiToolSessionId: string | null; attemptId: string; - deploymentRecordId: number; - deploymentSlug: string; appId: string; packageVersionId: number; packageVersion: string; @@ -136,90 +89,9 @@ export interface RuntimeSessionRecord { activityId: string; submissionMode: SubmissionMode; }; - preview?: { + preview: { previewSessionId: string; }; createdAt: string; expiresAt: string; } - -export type DeepLinkingAcceptType = 'ltiResourceLink'; -export type DeepLinkingPresentationDocumentTarget = 'iframe' | 'window' | 'embed'; - -export interface DeepLinkingSettings { - acceptTypes: DeepLinkingAcceptType[]; - acceptMultiple: boolean; - acceptPresentationDocumentTargets: DeepLinkingPresentationDocumentTarget[]; - acceptLineItem: boolean; -} - -export type LtiMessageType = - | typeof LTI_RESOURCE_LINK_REQUEST_MESSAGE_TYPE - | typeof LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE; - -export type LtiPlacement = - | typeof LTI_ASSIGNMENT_SELECTION_PLACEMENT - | typeof LTI_RESOURCE_SELECTION_PLACEMENT; - -export interface ValidatedDeepLinkingRequest { - ltiToolSessionId: string | null; - lms: LmsType; - canvasEnvironment: CanvasEnvironment | null; - issuer: string; - clientId: string; - deploymentId: string; - internalDeploymentId: number; - internalDeploymentSlug: string; - appId: string; - userId: string | null; - userRole: UserRole; - contextId: string | null; - contextTitle: string | null; - targetLinkUri: string; - deepLinkReturnUrl: string; - data: string | null; - placement: LtiPlacement; - settings: DeepLinkingSettings; - issuedAt: string; -} - -export interface DeepLinkingSessionSelection { - packageVersionId: number; - packageVersion: string; - activityId: string; - contentPath: string; -} - -export interface DeepLinkingSessionRecord { - sessionId: string; - sessionToken: string; - ltiToolSessionId: string | null; - deploymentRecordId: number; - deploymentSlug: string; - appId: string; - userId: string | null; - userRole: UserRole; - contextId: string | null; - contextTitle: string | null; - deepLinkReturnUrl: string; - data: string | null; - placement: LtiPlacement; - acceptTypes: DeepLinkingAcceptType[]; - acceptMultiple: boolean; - acceptPresentationDocumentTargets: DeepLinkingPresentationDocumentTarget[]; - acceptLineItem: boolean; - selection: DeepLinkingSessionSelection | null; - createdAt: string; - expiresAt: string; - usedAt: string | null; -} - -export type DeepLinkingResponseContentItem = DeepLinkingLtiResourceLink; - -export interface DeepLinkingResponseSubmission { - returnUrl: string; - jwt: string; - formFields: { - JWT: string; - }; -} diff --git a/src/ops/broker_verification_paths.ts b/src/ops/broker_verification_paths.ts deleted file mode 100644 index c143d9f..0000000 --- a/src/ops/broker_verification_paths.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { DeploymentBinding, LmsType } from '../lti/types.ts'; -import type { - BrokerVerificationStatus, - BrokerVerificationSupportedPath, - ControlPlaneDeploymentInventoryRow, -} from './types.ts'; - -export const BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS: Record< - LmsType, - BrokerVerificationSupportedPath -> = { - canvas: 'lti13LaunchAgsNrps', - moodle: 'lti13LaunchAgsScore', - sakai: 'lti13LaunchAgsScore', -}; - -export const BROKER_VERIFICATION_SUPPORTED_PATHS = [ - 'lti13LaunchAgsNrps', - 'lti13LaunchAgsScore', -] as const satisfies readonly BrokerVerificationSupportedPath[]; - -export function isBrokerVerificationSupportedPath( - value: string, -): value is BrokerVerificationSupportedPath { - return BROKER_VERIFICATION_SUPPORTED_PATHS.includes(value as BrokerVerificationSupportedPath); -} - -export function describeSupportedPath( - supportedPath: BrokerVerificationStatus['supportedPath'], -): string { - switch (supportedPath) { - case 'lti13LaunchAgsNrps': - return 'LTI 1.3 launch, AGS, and NRPS'; - case 'lti13LaunchAgsScore': - return 'LTI 1.3 launch and AGS score publish'; - } -} - -export function resolveSupportedPathForBinding( - binding: DeploymentBinding | null, -): BrokerVerificationStatus['supportedPath'] | null { - if (binding === null) { - return null; - } - - return BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS[binding.lms]; -} - -export function resolveSupportedPathForDeployment( - deployment: Pick, -): BrokerVerificationStatus['supportedPath'] | null { - return ( - deployment.brokerVerification?.supportedPath ?? - resolveSupportedPathForBinding(deployment.binding) - ); -} diff --git a/src/ops/diagnostics.ts b/src/ops/diagnostics.ts deleted file mode 100644 index b7ea977..0000000 --- a/src/ops/diagnostics.ts +++ /dev/null @@ -1,252 +0,0 @@ -import type { ControlPlaneDiagnosticItem } from './types.ts'; - -const SECRET_DETAIL_KEYS = new Set([ - 'accessToken', - 'authorization', - 'bearerToken', - 'clientAssertion', - 'clientSecret', - 'idToken', - 'refreshToken', - 'secret', - 'token', -]); - -const NON_RETRYABLE_GRADE_FAILURE_CODES = new Set([ - 'line_item_failed', - 'missing_ags_context', - 'missing_ags_scope', - 'missing_binding', -]); - -export function formatDiagnosticItem( - item: ControlPlaneDiagnosticItem, - options: { - retryableAttemptId?: string | null; - } = {}, -): ControlPlaneDiagnosticItem { - const detail = sanitizeDetailRecord(item.detail); - - return { - ...item, - operatorSummary: buildOperatorSummary({ - ...item, - detail, - }), - retryable: isRetryableDiagnostic( - { - ...item, - detail, - }, - options.retryableAttemptId ?? null, - ), - detail, - }; -} - -function buildOperatorSummary(item: ControlPlaneDiagnosticItem): string { - if (item.kind === 'launch') { - return buildLaunchOperatorSummary(item); - } - - if (item.kind === 'deepLinking') { - return buildDeepLinkingOperatorSummary(item); - } - - if (item.kind === 'nrps') { - return item.status === 'failed' - ? 'Roster verification failed for the saved deployment path.' - : 'Roster verification evidence was recorded for this deployment.'; - } - - if (item.kind === 'brokerVerification') { - const savedDeployment = describeSavedDeploymentPath(item.detail); - - return item.status === 'failed' - ? `Broker verification failed for ${savedDeployment} path.` - : `Broker verification evidence was recorded for ${savedDeployment} path.`; - } - - if (item.kind === 'runtime') { - return buildRuntimeOperatorSummary(item); - } - - if (item.kind === 'reviewer') { - return item.status === 'failed' - ? 'Reviewer activity ended in a failed state and needs follow-up.' - : 'Reviewer evidence was recorded for this reviewed placement.'; - } - - if (item.code === 'token_request_failed') { - return `Lantern could not get a service token for ${ - describeSavedDeploymentPath( - item.detail, - ) - } from the control plane.`; - } - - return item.status === 'failed' - ? 'Grade publish failed and can be retried from the control plane.' - : 'Grade publication evidence was recorded for this deployment.'; -} - -function buildLaunchOperatorSummary(item: ControlPlaneDiagnosticItem): string { - const savedDeployment = describeSavedDeploymentPath(item.detail); - - switch (item.code) { - case 'deployment_binding_missing': - return `Launch rejected because Lantern could not find ${savedDeployment}.`; - case 'deployment_mismatch': - return `Launch rejected because the incoming launch claims did not match ${savedDeployment}.`; - case 'login_state_expired': - return 'Launch rejected because the saved login state expired before Lantern could continue the governed resource-link baseline.'; - case 'login_state_missing': - return `Launch rejected because Lantern could not find the saved login state for ${savedDeployment}.`; - case 'login_state_used': - return 'Launch rejected because the saved login state was already used.'; - case 'missing_baseline_claim': { - const claim = readStringDetail(item.detail, 'claim') ?? 'a required governed baseline claim'; - - return `Launch rejected because ${savedDeployment} did not include governed baseline claim ${claim}.`; - } - case 'missing_pinned_package_version': - return `Launch rejected because ${savedDeployment} does not have an approved governed runtime pin.`; - case 'package_not_approved': - return `Launch rejected because the governed package version is not approved for ${savedDeployment}.`; - case 'reviewed_placement_context_mismatch': - return 'Launch rejected because the reviewed placement does not match the saved governed launch context.'; - case 'reviewed_placement_deployment_mismatch': - return `Launch rejected because the reviewed placement does not belong to ${savedDeployment}.`; - case 'reviewed_placement_not_found': - return `Launch rejected because the reviewed placement is no longer available on ${savedDeployment}.`; - case 'reviewed_placement_resource_link_conflict': - return `Launch rejected because the reviewed placement binding conflicts with the saved resource link on ${savedDeployment}.`; - case 'signature_validation_failed': - return `Launch rejected because Lantern could not verify the signed id_token against ${savedDeployment}.`; - case 'unsupported_lti_version': - return `Launch rejected because ${savedDeployment} only supports the governed resource-link baseline on LTI 1.3.0.`; - case 'unsupported_message_type': - return `Launch rejected because ${savedDeployment} only supports the governed resource-link baseline on /lti/launch.`; - default: - return item.status === 'failed' - ? 'Launch failed before Lantern could hand the learner into the governed resource-link runtime.' - : 'Launch evidence was recorded for this deployment.'; - } -} - -function buildDeepLinkingOperatorSummary(item: ControlPlaneDiagnosticItem): string { - const savedDeployment = describeSavedDeploymentPath(item.detail); - - if (item.boundaryDenialCategory === 'policyDenied') { - return `Deep Linking request matched ${savedDeployment}, but the active LTI profile denied it before picker handoff.`; - } - - return `Deep Linking request was invalid for ${savedDeployment}, so Lantern stopped before opening the governed picker.`; -} - -function buildRuntimeOperatorSummary(item: ControlPlaneDiagnosticItem): string { - const capability = readStringDetail(item.detail, 'capability'); - const code = readStringDetail(item.detail, 'code'); - const deliverySubstrate = readStringDetail(item.detail, 'deliverySubstrate'); - - switch (item.eventType) { - case 'runtime.capability.denied': - return capability === null - ? 'Lantern denied a reviewed app capability at the governed runtime boundary.' - : `Lantern denied reviewed app capability ${capability} at the governed runtime boundary.`; - case 'runtime.session.timeout': - return 'Runtime session timed out before the reviewed app could continue.'; - case 'runtime.session.integrity_failed': - if (code === 'runtime_delivery_failed' && deliverySubstrate === 'dynamic_worker') { - return 'Dynamic Worker delivery failed before Lantern could serve the reviewed runtime bytes.'; - } - - if (code === 'runtime_file_missing') { - return 'A reviewed runtime file was missing from the approved package snapshot.'; - } - - return 'Reviewed runtime integrity checks blocked this session before app code could continue.'; - case 'runtime.session.denied': - return item.boundaryDenialCategory === 'policyDenied' - ? 'Lantern denied reviewed runtime session access at the governed runtime boundary.' - : 'Reviewed runtime session access was denied before app code could continue.'; - case 'runtime.score_proposal.accepted': - return 'Lantern accepted an app score proposal without granting direct grade-write power.'; - case 'runtime.session.exited': - return "Exited the reviewed runtime through Lantern's finalize boundary."; - default: - return item.status === 'failed' - ? 'Reviewed runtime activity failed and needs operator follow-up.' - : 'Reviewed runtime evidence was recorded for this deployment.'; - } -} - -function isRetryableDiagnostic( - item: ControlPlaneDiagnosticItem, - retryableAttemptId: string | null, -): boolean { - if ( - item.kind !== 'gradePublication' || - item.status !== 'failed' || - item.attemptId === null || - item.attemptId !== retryableAttemptId - ) { - return false; - } - - return !NON_RETRYABLE_GRADE_FAILURE_CODES.has(item.code ?? ''); -} - -function sanitizeDetailRecord(detail: Record): Record { - const sanitized: Record = {}; - - for (const [key, value] of Object.entries(detail)) { - if (SECRET_DETAIL_KEYS.has(key)) { - continue; - } - - sanitized[key] = sanitizeDetailValue(value); - } - - return sanitized; -} - -function sanitizeDetailValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((entry) => sanitizeDetailValue(entry)); - } - - if (isRecord(value)) { - return sanitizeDetailRecord(value); - } - - return value; -} - -function describeSavedDeploymentPath(detail: Record): string { - const lms = readStringDetail(detail, 'lms'); - - if (lms === 'canvas') { - return 'the saved Canvas deployment'; - } - - if (lms === 'moodle') { - return 'the saved Moodle deployment'; - } - - if (lms === 'sakai') { - return 'the saved Sakai deployment'; - } - - return 'the saved deployment'; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function readStringDetail(detail: Record, key: string): string | null { - const value = detail[key]; - - return typeof value === 'string' && value.trim() !== '' ? value.trim() : null; -} diff --git a/src/ops/health.ts b/src/ops/health.ts deleted file mode 100644 index 49309e2..0000000 --- a/src/ops/health.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { DeploymentBinding } from '../lti/types.ts'; -import type { ApprovalStatus, GradePublicationStatus } from '../package_review/types.ts'; -import { - buildActivityDimension, - buildBrokerVerificationDimension, - buildEnablementDimension, - buildGradePublicationDimension, - buildReviewDimension, - summarizeOverallHealth, -} from './health_support.ts'; -import type { - BrokerVerificationRunStatus, - ControlPlaneActivityStatus, - ControlPlaneDeploymentHealth, - PilotUsageMetrics, -} from './types.ts'; - -export interface DeploymentHealthInput { - approvalStatus: ApprovalStatus | null; - reviewedAt?: string | null; - enabledPackageVersionId: number | null; - binding: DeploymentBinding | null; - lastLaunchStatus: ControlPlaneActivityStatus | null; - lastLaunchAt?: string | null; - lastGradePublishStatus: GradePublicationStatus | null; - lastGradePublishAt?: string | null; - lastNrpsReadStatus: ControlPlaneActivityStatus | null; - lastNrpsReadAt?: string | null; - brokerVerificationStatus: BrokerVerificationRunStatus | null; - brokerCheckedAt?: string | null; -} - -export function deriveDeploymentHealth(input: DeploymentHealthInput): ControlPlaneDeploymentHealth { - const review = buildReviewDimension(input.approvalStatus, input.reviewedAt); - const enablement = buildEnablementDimension(input.enabledPackageVersionId, input.binding); - const launch = buildActivityDimension({ - name: 'launch', - status: input.lastLaunchStatus, - checkedAt: input.lastLaunchAt ?? null, - notRunSummary: 'No launch has been recorded yet.', - healthySummary: 'Latest launch reached the governed runtime handoff.', - failedSummary: 'Latest launch failed and needs operator review.', - pendingSummary: 'Latest launch evidence is still pending review.', - }); - const gradePublication = buildGradePublicationDimension( - input.lastGradePublishStatus, - input.lastGradePublishAt ?? null, - ); - const nrps = buildActivityDimension({ - name: 'nrps', - status: input.lastNrpsReadStatus, - checkedAt: input.lastNrpsReadAt ?? null, - notRunSummary: 'Roster verification has not run yet.', - healthySummary: 'Roster verification succeeded on the saved deployment path.', - failedSummary: 'Latest roster verification failed.', - pendingSummary: 'Roster verification is still pending review.', - }); - const brokerVerification = buildBrokerVerificationDimension( - input.brokerVerificationStatus, - input.brokerCheckedAt ?? null, - ); - const dimensions = { - review, - enablement, - launch, - gradePublication, - nrps, - brokerVerification, - }; - const statuses = Object.values(dimensions).map((dimension) => dimension.status); - const overallStatus = review.status === 'failed' - ? 'failed' - : statuses.every((status) => status === 'healthy') - ? 'healthy' - : statuses.some((status) => status === 'failed' || status === 'attention') - ? 'attention' - : 'unknown'; - - return { - overallStatus, - summary: summarizeOverallHealth(overallStatus, dimensions), - dimensions, - }; -} - -export function summarizePilotUsage(metrics: PilotUsageMetrics): Array<{ - label: string; - value: string; -}> { - return [ - { - label: 'Launches recorded', - value: String(metrics.totalLaunches), - }, - { - label: 'Attempts completed', - value: String(metrics.attemptsCompleted), - }, - { - label: 'Grade publishes', - value: `${metrics.gradePublishesSucceeded} passed / ${metrics.gradePublishesFailed} failed`, - }, - { - label: 'Recent active users', - value: String(metrics.recentActiveUsers), - }, - ]; -} diff --git a/src/ops/health_support.ts b/src/ops/health_support.ts deleted file mode 100644 index bc63e0a..0000000 --- a/src/ops/health_support.ts +++ /dev/null @@ -1,237 +0,0 @@ -import type { DeploymentBinding } from '../lti/types.ts'; -import type { ApprovalStatus, GradePublicationStatus } from '../package_review/types.ts'; -import type { - BrokerVerificationRunStatus, - ControlPlaneActivityStatus, - ControlPlaneDeploymentHealth, - ControlPlaneHealthDimension, -} from './types.ts'; - -export function buildReviewDimension( - approvalStatus: ApprovalStatus | null, - reviewedAt: string | null | undefined, -): ControlPlaneHealthDimension { - if (approvalStatus === 'approved') { - return { - name: 'review', - status: 'healthy', - summary: 'Reviewed version is approved.', - checkedAt: reviewedAt ?? null, - }; - } - - if (approvalStatus === 'rejected') { - return { - name: 'review', - status: 'failed', - summary: 'Pinned version is rejected and cannot be launched.', - checkedAt: reviewedAt ?? null, - }; - } - - if (approvalStatus === 'pending') { - return { - name: 'review', - status: 'attention', - summary: 'Pinned version is still awaiting approval.', - checkedAt: reviewedAt ?? null, - }; - } - - return { - name: 'review', - status: 'unknown', - summary: 'No reviewed version is pinned yet.', - checkedAt: reviewedAt ?? null, - }; -} - -export function buildEnablementDimension( - enabledPackageVersionId: number | null, - binding: DeploymentBinding | null, -): ControlPlaneHealthDimension { - if (enabledPackageVersionId !== null && binding !== null) { - return { - name: 'enablement', - status: 'healthy', - summary: `Deployment pin and ${describeBinding(binding)} are present.`, - checkedAt: null, - }; - } - - if (enabledPackageVersionId !== null) { - return { - name: 'enablement', - status: 'attention', - summary: 'Exact version is pinned, but the deployment binding is still missing.', - checkedAt: null, - }; - } - - if (binding !== null) { - return { - name: 'enablement', - status: 'attention', - summary: `${describeBinding(binding)} is saved, but no exact reviewed version is pinned.`, - checkedAt: null, - }; - } - - return { - name: 'enablement', - status: 'unknown', - summary: 'Deployment still needs an exact version pin and deployment binding.', - checkedAt: null, - }; -} - -export function buildActivityDimension(input: { - name: 'launch' | 'nrps'; - status: ControlPlaneActivityStatus | null; - checkedAt: string | null; - notRunSummary: string; - healthySummary: string; - failedSummary: string; - pendingSummary: string; -}): ControlPlaneHealthDimension { - switch (input.status) { - case 'succeeded': - return { - name: input.name, - status: 'healthy', - summary: input.healthySummary, - checkedAt: input.checkedAt, - }; - case 'failed': - return { - name: input.name, - status: 'failed', - summary: input.failedSummary, - checkedAt: input.checkedAt, - }; - case 'pending': - return { - name: input.name, - status: 'attention', - summary: input.pendingSummary, - checkedAt: input.checkedAt, - }; - case 'notRun': - case null: - return { - name: input.name, - status: 'unknown', - summary: input.notRunSummary, - checkedAt: input.checkedAt, - }; - } -} - -export function buildGradePublicationDimension( - status: GradePublicationStatus | null, - checkedAt: string | null, -): ControlPlaneHealthDimension { - switch (status) { - case 'published': - return { - name: 'gradePublication', - status: 'healthy', - summary: 'Latest grade publish succeeded.', - checkedAt, - }; - case 'failed': - return { - name: 'gradePublication', - status: 'failed', - summary: 'Latest grade publish failed and may need retry.', - checkedAt, - }; - case 'pending': - return { - name: 'gradePublication', - status: 'attention', - summary: 'Latest grade publish is still pending.', - checkedAt, - }; - case null: - return { - name: 'gradePublication', - status: 'unknown', - summary: 'No grade publish has been recorded yet.', - checkedAt, - }; - } -} - -export function buildBrokerVerificationDimension( - status: BrokerVerificationRunStatus | null, - checkedAt: string | null, -): ControlPlaneHealthDimension { - switch (status) { - case 'passed': - return { - name: 'brokerVerification', - status: 'healthy', - summary: 'Latest deployment-scoped broker verification passed.', - checkedAt, - }; - case 'failed': - return { - name: 'brokerVerification', - status: 'failed', - summary: 'Latest deployment-scoped broker verification failed.', - checkedAt, - }; - case 'pending': - return { - name: 'brokerVerification', - status: 'attention', - summary: 'Deployment-scoped broker verification is still pending.', - checkedAt, - }; - case 'notRun': - case null: - return { - name: 'brokerVerification', - status: 'unknown', - summary: 'No deployment-scoped broker verification evidence has been recorded yet.', - checkedAt, - }; - } -} - -export function summarizeOverallHealth( - overallStatus: ControlPlaneDeploymentHealth['overallStatus'], - dimensions: ControlPlaneDeploymentHealth['dimensions'], -): string { - if (overallStatus === 'healthy') { - return 'Deployment is healthy across review, launch, grading, and verification.'; - } - - if (overallStatus === 'failed') { - return dimensions.review.status === 'failed' - ? 'Deployment is blocked by review state.' - : 'Deployment has a failing control-plane signal that needs operator action.'; - } - - if (overallStatus === 'attention') { - if (dimensions.gradePublication.status === 'failed') { - return 'Deployment is readable in the control plane and needs a retry or grading follow-up.'; - } - - return 'Deployment is readable in the control plane and needs one operator follow-up.'; - } - - return 'Deployment does not have enough evidence yet to determine health.'; -} - -function describeBinding(binding: DeploymentBinding): string { - switch (binding.lms) { - case 'canvas': - return 'Canvas binding'; - case 'moodle': - return 'Moodle binding'; - case 'sakai': - return 'Sakai binding'; - } -} diff --git a/src/ops/repository.ts b/src/ops/repository.ts deleted file mode 100644 index 4d9865e..0000000 --- a/src/ops/repository.ts +++ /dev/null @@ -1 +0,0 @@ -export type { OpsRepository, RecordBrokerVerificationRunInput } from './repository_types.ts'; diff --git a/src/ops/repository_d1.ts b/src/ops/repository_d1.ts deleted file mode 100644 index 4859437..0000000 --- a/src/ops/repository_d1.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { D1Database } from '../db/d1.ts'; -import { queryD1Objects } from '../db/d1.ts'; -import type { PackageReviewRepository } from '../package_review/repository.ts'; -import { type D1InventoryQueryRow, mapD1InventoryRow } from './repository_d1_mapping.ts'; -import { - getLatestBrokerVerificationStatusForD1, - getLatestOfficialCertificationEvidenceForD1, - listCertificationWorkflowStatusesForD1, - recordBrokerVerificationRunForD1, -} from './repository_d1_broker.ts'; -import { - getControlPlaneDeploymentDetailForD1, - getRetryableGradePublicationLookupForD1, -} from './repository_d1_detail.ts'; -import { D1_INVENTORY_QUERY } from './repository_d1_queries.ts'; -import { mapInventoryRow } from './repository_mapping.ts'; -import type { OpsRepository } from './repository_types.ts'; - -export function createD1OpsRepository( - db: D1Database, - packageReviewRepository: PackageReviewRepository, -): OpsRepository { - return { - async listControlPlaneDeployments() { - const rows = await queryD1Objects( - db, - `${D1_INVENTORY_QUERY} ORDER BY deployments.updated_at DESC, deployments.id DESC`, - ); - - return rows.map((row) => mapInventoryRow(mapD1InventoryRow(row))); - }, - - async getControlPlaneDeploymentDetail(deploymentRecordId) { - return await getControlPlaneDeploymentDetailForD1(db, deploymentRecordId); - }, - - async listCertificationWorkflowStatuses() { - return await listCertificationWorkflowStatusesForD1(db); - }, - - async getLatestOfficialCertificationEvidence() { - return await getLatestOfficialCertificationEvidenceForD1(db); - }, - - async getLatestBrokerVerificationStatus() { - return await getLatestBrokerVerificationStatusForD1(db); - }, - - async recordBrokerVerificationRun(input) { - await recordBrokerVerificationRunForD1(db, input); - }, - - async getRetryableGradePublicationLookup(attemptId) { - return await getRetryableGradePublicationLookupForD1(db, attemptId); - }, - - async getPlacementAuditSnapshot(placementId) { - return await packageReviewRepository.requirePlacementAuditSnapshotById(placementId); - }, - }; -} diff --git a/src/ops/repository_d1_broker.ts b/src/ops/repository_d1_broker.ts deleted file mode 100644 index b48e65b..0000000 --- a/src/ops/repository_d1_broker.ts +++ /dev/null @@ -1,193 +0,0 @@ -import type { D1Database } from '../db/d1.ts'; -import { queryD1First, runD1 } from '../db/d1.ts'; -import { - type D1CertificationWorkflowStatusRow, - type D1InternalBrokerVerificationRow, - type D1LatestOfficialCertificationEvidenceRow, - type D1OfficialBrokerVerificationRow, - mapD1CertificationWorkflowStatusRow, - mapD1InternalBrokerVerificationRow, - mapD1LatestOfficialCertificationEvidenceRow, - mapD1OfficialBrokerVerificationRow, -} from './repository_d1_mapping.ts'; -import { - assertBrokerVerificationRunInput, - mapBrokerVerificationStatusRows, - mapCertificationWorkflowStatusRow, - mapLatestOfficialCertificationEvidenceRow, -} from './repository_mapping.ts'; -import type { - CertificationWorkflowStatusRow, - RecordBrokerVerificationRunInput, -} from './repository_types.ts'; - -export async function listCertificationWorkflowStatusesForD1(db: D1Database) { - const workflowKeys = ['core', 'deepLinking', 'nrps', 'ags'] as const; - const rows: CertificationWorkflowStatusRow[] = []; - - for (const workflowKey of workflowKeys) { - const row = await queryD1First( - db, - ` - SELECT - ? AS workflowKey, - broker_verification_runs.deployment_record_id AS deploymentRecordId, - deployments.label AS deploymentLabel, - broker_verification_runs.status, - broker_verification_runs.summary, - broker_verification_runs.detail_url AS detailUrl, - broker_verification_runs.checked_at AS checkedAt - FROM broker_verification_runs - LEFT JOIN deployments - ON deployments.id = broker_verification_runs.deployment_record_id - WHERE broker_verification_runs.workflow_key = ? - AND broker_verification_runs.deployment_record_id IS NOT NULL - AND broker_verification_runs.source IN ('manual', 'ci') - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 - `, - [workflowKey, workflowKey], - ); - - rows.push( - row === null - ? { - workflowKey, - deploymentRecordId: null, - deploymentLabel: null, - status: null, - summary: null, - detailUrl: null, - checkedAt: null, - } - : mapD1CertificationWorkflowStatusRow(row), - ); - } - - return rows.map((row) => mapCertificationWorkflowStatusRow(row)); -} - -export async function getLatestOfficialCertificationEvidenceForD1(db: D1Database) { - const row = await queryD1First( - db, - ` - SELECT - workflow_key AS workflowKey, - status, - certification_state AS certificationState, - summary, - detail_url AS detailUrl, - checked_at AS checkedAt - FROM broker_verification_runs - WHERE source = '1edtech' - AND workflow_key IS NOT NULL - ORDER BY checked_at DESC, id DESC - LIMIT 1 - `, - ); - - return mapLatestOfficialCertificationEvidenceRow( - row === null ? null : mapD1LatestOfficialCertificationEvidenceRow(row), - ); -} - -export async function getLatestBrokerVerificationStatusForD1(db: D1Database) { - const internalRow = await queryD1First( - db, - ` - SELECT - scope, - source, - status, - summary, - detail_url AS detailUrl, - checked_at AS checkedAt - FROM broker_verification_runs - WHERE deployment_record_id IS NOT NULL - AND source IN ('manual', 'ci') - ORDER BY checked_at DESC, id DESC - LIMIT 1 - `, - ); - - if (internalRow !== null) { - const internal = mapD1InternalBrokerVerificationRow(internalRow); - const officialRow = await queryD1First( - db, - ` - SELECT - scope, - status, - certification_state AS certificationState, - detail_url AS detailUrl, - checked_at AS checkedAt - FROM broker_verification_runs - WHERE scope = ? - AND source = '1edtech' - ORDER BY checked_at DESC, id DESC - LIMIT 1 - `, - [internal.scope], - ); - - return mapBrokerVerificationStatusRows( - internal, - officialRow === null ? null : mapD1OfficialBrokerVerificationRow(officialRow), - ); - } - - const officialRow = await queryD1First( - db, - ` - SELECT - scope, - status, - certification_state AS certificationState, - detail_url AS detailUrl, - checked_at AS checkedAt - FROM broker_verification_runs - WHERE source = '1edtech' - ORDER BY checked_at DESC, id DESC - LIMIT 1 - `, - ); - - return mapBrokerVerificationStatusRows( - null, - officialRow === null ? null : mapD1OfficialBrokerVerificationRow(officialRow), - ); -} - -export async function recordBrokerVerificationRunForD1( - db: D1Database, - input: RecordBrokerVerificationRunInput, -): Promise { - assertBrokerVerificationRunInput(input); - await runD1( - db, - ` - INSERT INTO broker_verification_runs ( - deployment_record_id, - scope, - workflow_key, - source, - status, - summary, - detail_url, - certification_state, - checked_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - input.deploymentRecordId, - input.scope, - input.workflowKey, - input.source, - input.status, - input.summary, - input.detailUrl, - input.certificationState, - input.checkedAt, - ], - ); -} diff --git a/src/ops/repository_d1_detail.ts b/src/ops/repository_d1_detail.ts deleted file mode 100644 index a2f9b58..0000000 --- a/src/ops/repository_d1_detail.ts +++ /dev/null @@ -1,364 +0,0 @@ -import type { D1Database } from '../db/d1.ts'; -import { queryD1First, queryD1Objects } from '../db/d1.ts'; -import { mapAttemptEvidenceArtifactRow } from '../package_review/repository_mappers_attempts.ts'; -import type { AttemptEvidenceArtifactRecord } from '../package_review/types.ts'; -import { - type D1ActivitySnapshotRow, - type D1AttemptEvidenceArtifactRow, - type D1DiagnosticRow, - type D1GradePublicationSnapshotRow, - type D1InventoryQueryRow, - type D1RecentLaunchRow, - type D1RetryLookupRow, - mapD1ActivitySnapshotRow, - mapD1AttemptEvidenceArtifactRow, - mapD1DiagnosticRow, - mapD1GradePublicationRow, - mapD1InventoryRow, - mapD1RecentLaunchRow, - mapD1RetryLookupRow, -} from './repository_d1_mapping.ts'; -import { - D1_INVENTORY_QUERY, - D1_LATEST_AGS_SMOKE_QUERY, - D1_LATEST_COMPATIBILITY_PATH_QUERY, - D1_LATEST_LAUNCH_QUERY, - D1_LATEST_NRPS_QUERY, - D1_LATEST_RUNTIME_OUTCOME_QUERY, - D1_LATEST_RUNTIME_SESSION_QUERY, -} from './repository_d1_queries.ts'; -import { - mapActivitySnapshotRow, - mapDiagnosticRows, - mapGradePublicationSnapshotRow, - mapInventoryRow, - mapRecentLaunchRows, - mapRuntimeEvidenceSnapshotRow, -} from './repository_mapping.ts'; -import { mapRetryLookupRow } from './repository_retry_mapping.ts'; -import type { - ControlPlaneAnonymousEvidenceArtifact, - ControlPlaneDiagnosticItem, - ControlPlaneRuntimeEvidenceSnapshot, - DeploymentActivitySnapshot, - DeploymentGradePublicationSnapshot, - RetryableGradePublicationLookup, -} from './types.ts'; - -export async function getControlPlaneDeploymentDetailForD1( - db: D1Database, - deploymentRecordId: number, -) { - const inventory = await getInventoryRow(db, deploymentRecordId); - - if (inventory === null) { - return null; - } - - const latestLaunch = await getActivitySnapshot(db, D1_LATEST_LAUNCH_QUERY, deploymentRecordId); - const latestRuntimeSession = await getRuntimeEvidenceSnapshot( - db, - D1_LATEST_RUNTIME_SESSION_QUERY, - deploymentRecordId, - ); - const latestRuntimeOutcome = await getRuntimeEvidenceSnapshot( - db, - D1_LATEST_RUNTIME_OUTCOME_QUERY, - deploymentRecordId, - ); - const latestGradePublish = await getLatestGradePublication(db, deploymentRecordId); - const retryableGradePublication = latestGradePublish?.status === 'failed' - ? await getRetryableGradePublicationLookupForD1(db, latestGradePublish.attemptId) - : null; - const latestRuntimeAttemptId = latestRuntimeOutcome?.attemptId ?? - latestRuntimeSession?.attemptId ?? null; - - return { - inventory, - latestInstallEvidence: inventory.installEvidence, - latestLaunch, - latestRuntimeSession, - latestRuntimeOutcome, - latestAnonymousEvidence: deriveLatestAnonymousEvidence( - inventory.appId, - latestRuntimeAttemptId === null - ? [] - : await listAttemptEvidenceArtifacts(db, latestRuntimeAttemptId), - ), - recentLaunches: await listRecentAcceptedLaunches(db, deploymentRecordId), - latestCompatibilityPath: await getActivitySnapshot( - db, - D1_LATEST_COMPATIBILITY_PATH_QUERY, - deploymentRecordId, - ), - latestAgsSmoke: await getActivitySnapshot( - db, - D1_LATEST_AGS_SMOKE_QUERY, - deploymentRecordId, - ), - latestNrpsRead: await getActivitySnapshot(db, D1_LATEST_NRPS_QUERY, deploymentRecordId), - latestGradePublish, - pilotUsage: inventory.pilotUsage, - diagnostics: await listDiagnostics( - db, - deploymentRecordId, - retryableGradePublication?.attemptId ?? null, - ), - retryableGradePublication, - brokerVerification: inventory.brokerVerification, - }; -} - -export async function getInventoryRow(db: D1Database, deploymentRecordId: number) { - const row = await queryD1First( - db, - ` - ${D1_INVENTORY_QUERY} - AND deployments.id = ? - ORDER BY deployments.updated_at DESC, deployments.id DESC - `, - [deploymentRecordId], - ); - - return row === null ? null : mapInventoryRow(mapD1InventoryRow(row)); -} - -export async function getRetryableGradePublicationLookupForD1( - db: D1Database, - attemptId: string, -): Promise { - const row = await queryD1First( - db, - ` - SELECT - attempts.attempt_id AS attemptId, - attempts.deployment_record_id AS deploymentRecordId, - attempts.deployment_slug AS deploymentSlug, - grade_publications.status AS publicationStatus, - grade_publications.line_item_url AS lineItemUrl, - grade_publications.platform_user_id AS platformUserId, - grade_publications.score_given AS scoreGiven, - grade_publications.score_maximum AS scoreMaximum, - grade_publications.activity_progress AS activityProgress, - grade_publications.grading_progress AS gradingProgress, - grade_publications.published_at AS publishedAt, - grade_publications.updated_at AS updatedAt, - grade_publications.error_code AS errorCode, - grade_publications.error_detail AS errorDetail, - deployments.canvas_environment AS bindingCanvasEnvironment, - deployments.issuer AS bindingIssuer, - deployments.client_id AS bindingClientId, - deployments.deployment_id AS bindingDeploymentId, - runtime_sessions.session_id AS sessionId, - runtime_sessions.lti_tool_session_id AS runtimeLtiToolSessionId, - runtime_sessions.attempt_id AS runtimeAttemptId, - runtime_sessions.deployment_record_id AS runtimeDeploymentRecordId, - runtime_sessions.deployment_slug AS runtimeDeploymentSlug, - runtime_sessions.app_id AS runtimeAppId, - runtime_sessions.package_version_id AS runtimePackageVersionId, - runtime_sessions.package_version AS runtimePackageVersion, - runtime_sessions.ags_scope AS runtimeAgsScope, - runtime_sessions.ags_lineitems_url AS runtimeAgsLineitemsUrl, - runtime_sessions.ags_lineitem_url AS runtimeAgsLineitemUrl, - runtime_sessions.nrps_context_memberships_url AS runtimeNrpsContextMembershipsUrl, - runtime_sessions.nrps_service_versions AS runtimeNrpsServiceVersions, - runtime_sessions.created_at AS runtimeCreatedAt, - runtime_sessions.expires_at AS runtimeExpiresAt - FROM attempts - INNER JOIN grade_publications - ON grade_publications.attempt_id = attempts.attempt_id - INNER JOIN deployments - ON deployments.id = attempts.deployment_record_id - LEFT JOIN runtime_sessions - ON runtime_sessions.session_id = ( - SELECT session_id - FROM runtime_sessions AS candidate_sessions - WHERE candidate_sessions.attempt_id = attempts.attempt_id - ORDER BY candidate_sessions.created_at DESC, candidate_sessions.session_id DESC - LIMIT 1 - ) - WHERE attempts.attempt_id = ? - AND grade_publications.status = 'failed' - LIMIT 1 - `, - [attemptId], - ); - - return row === null ? null : mapRetryLookupRow(mapD1RetryLookupRow(row)); -} - -async function getActivitySnapshot( - db: D1Database, - sql: string, - deploymentRecordId: number, -): Promise { - const row = await queryD1First(db, sql, [deploymentRecordId]); - - return row === null ? null : mapActivitySnapshotRow(mapD1ActivitySnapshotRow(row)); -} - -async function getRuntimeEvidenceSnapshot( - db: D1Database, - sql: string, - deploymentRecordId: number, -): Promise { - const row = await queryD1First(db, sql, [deploymentRecordId]); - - return row === null ? null : mapRuntimeEvidenceSnapshotRow(mapD1ActivitySnapshotRow(row)); -} - -async function listRecentAcceptedLaunches(db: D1Database, deploymentRecordId: number) { - const rows = await queryD1Objects( - db, - ` - SELECT - audit_events.summary, - audit_events.actor_id AS actorId, - COALESCE( - attempts.user_id, - NULLIF(json_extract(audit_events.detail, '$.userId'), ''), - audit_events.actor_id - ) AS userId, - COALESCE( - attempts.user_display_name, - NULLIF(json_extract(audit_events.detail, '$.userDisplayName'), '') - ) AS userDisplayName, - COALESCE( - attempts.user_email, - NULLIF(json_extract(audit_events.detail, '$.userEmail'), '') - ) AS userEmail, - COALESCE( - attempts.user_login, - NULLIF(json_extract(audit_events.detail, '$.userLogin'), '') - ) AS userLogin, - audit_events.attempt_id AS attemptId, - audit_events.detail, - audit_events.occurred_at AS occurredAt - FROM audit_events - LEFT JOIN attempts - ON attempts.attempt_id = audit_events.attempt_id - WHERE audit_events.deployment_record_id = ? - AND audit_events.event_type = 'launch.accepted' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 10 - `, - [deploymentRecordId], - ); - - return mapRecentLaunchRows(rows.map(mapD1RecentLaunchRow)); -} - -async function getLatestGradePublication( - db: D1Database, - deploymentRecordId: number, -): Promise { - const row = await queryD1First( - db, - ` - SELECT - grade_publications.attempt_id AS attemptId, - grade_publications.status, - grade_publications.line_item_url AS lineItemUrl, - grade_publications.platform_user_id AS platformUserId, - grade_publications.score_given AS scoreGiven, - grade_publications.score_maximum AS scoreMaximum, - grade_publications.activity_progress AS activityProgress, - grade_publications.grading_progress AS gradingProgress, - grade_publications.published_at AS publishedAt, - grade_publications.updated_at AS updatedAt, - grade_publications.error_code AS errorCode, - grade_publications.error_detail AS errorDetail - FROM attempts - INNER JOIN grade_publications - ON grade_publications.attempt_id = attempts.attempt_id - WHERE attempts.deployment_record_id = ? - ORDER BY grade_publications.updated_at DESC, grade_publications.id DESC - LIMIT 1 - `, - [deploymentRecordId], - ); - - return row === null ? null : mapGradePublicationSnapshotRow(mapD1GradePublicationRow(row)); -} - -async function listDiagnostics( - db: D1Database, - deploymentRecordId: number, - retryableAttemptId: string | null, -): Promise { - const rows = await queryD1Objects( - db, - ` - SELECT - id, - event_type AS eventType, - actor_type AS actorType, - status, - deployment_record_id AS deploymentRecordId, - attempt_id AS attemptId, - summary, - detail, - occurred_at AS occurredAt - FROM audit_events - WHERE deployment_record_id = ? - AND status = 'failed' - AND ( - event_type LIKE 'launch.%' - OR event_type = 'deep_linking.request.rejected' - OR event_type = 'deployment.nrps_verified' - OR event_type LIKE 'grade_publish.%' - OR event_type LIKE 'broker_verification.%' - OR event_type LIKE 'runtime.%' - OR event_type LIKE 'reviewer.%' - ) - ORDER BY occurred_at DESC, id DESC - `, - [deploymentRecordId], - ); - - return mapDiagnosticRows(rows.map(mapD1DiagnosticRow), retryableAttemptId); -} - -async function listAttemptEvidenceArtifacts( - db: D1Database, - attemptId: string, -): Promise { - const rows = await queryD1Objects( - db, - ` - SELECT - artifact_id AS artifactId, - attempt_id AS attemptId, - sequence, - kind, - content_type AS contentType, - file_name AS fileName, - storage_key AS storageKey, - byte_size AS byteSize, - sha256, - created_at AS createdAt - FROM attempt_evidence_artifacts - WHERE attempt_id = ? - ORDER BY sequence ASC - `, - [attemptId], - ); - - return rows.map((row) => mapAttemptEvidenceArtifactRow(mapD1AttemptEvidenceArtifactRow(row))); -} - -function deriveLatestAnonymousEvidence( - appId: string, - evidenceArtifacts: AttemptEvidenceArtifactRecord[], -): ControlPlaneAnonymousEvidenceArtifact[] { - return evidenceArtifacts.map((artifact) => ({ - artifactId: artifact.artifactId, - kind: artifact.kind, - fileName: artifact.fileName, - contentType: artifact.contentType, - byteSize: artifact.byteSize, - sha256: artifact.sha256, - createdAt: artifact.createdAt, - artifactUrl: `/admin/packages/${appId}/deployment/evidence/${artifact.artifactId}`, - })); -} diff --git a/src/ops/repository_d1_mapping.ts b/src/ops/repository_d1_mapping.ts deleted file mode 100644 index 16dba85..0000000 --- a/src/ops/repository_d1_mapping.ts +++ /dev/null @@ -1,592 +0,0 @@ -import { - expectD1JsonRecord, - expectD1NullableNumber, - expectD1NullableString, - expectD1NullableStringLiteral, - expectD1Number, - expectD1String, - expectD1StringArray, - expectD1StringLiteral, - parseD1JsonField, - parseD1NullableJsonField, -} from '../db/d1_row.ts'; -export { - type D1AttemptEvidenceArtifactRow, - mapD1AttemptEvidenceArtifactRow, -} from '../package_review/repository_grading_d1_mapping.ts'; -import type { - ActivitySnapshotRow, - CertificationWorkflowStatusRow, - DiagnosticRow, - GradePublicationSnapshotRow, - InternalBrokerVerificationRow, - InventoryQueryRow, - LatestOfficialCertificationEvidenceRow, - OfficialBrokerVerificationRow, - RecentLaunchRow, - RetryLookupRow, -} from './repository_types.ts'; - -export interface D1InventoryQueryRow { - deploymentId: unknown; - deploymentSlug: unknown; - deploymentLabel: unknown; - appId: unknown; - appTitle: unknown; - ownerId: unknown; - enabledPackageVersionId: unknown; - enabledPackageVersion: unknown; - approvalStatus: unknown; - reviewedAt: unknown; - bindingLmsType: unknown; - installEvidenceStatus: unknown; - installEvidenceSummary: unknown; - installEvidenceDetail: unknown; - installEvidenceOccurredAt: unknown; - internalBrokerVerificationScope: unknown; - internalBrokerVerificationSource: unknown; - internalBrokerVerificationStatus: unknown; - internalBrokerVerificationSummary: unknown; - internalBrokerVerificationDetailUrl: unknown; - internalBrokerVerificationCheckedAt: unknown; - officialBrokerVerificationScope: unknown; - officialBrokerVerificationStatus: unknown; - officialBrokerVerificationCertificationState: unknown; - officialBrokerVerificationDetailUrl: unknown; - officialBrokerVerificationCheckedAt: unknown; - bindingCanvasEnvironment: unknown; - bindingIssuer: unknown; - bindingClientId: unknown; - bindingDeploymentId: unknown; - bindingAuthorizationEndpoint: unknown; - bindingAccessTokenUrl: unknown; - bindingJwksUrl: unknown; - updatedAt: unknown; - lastLaunchAt: unknown; - lastLaunchStatus: unknown; - lastNrpsReadAt: unknown; - lastNrpsReadStatus: unknown; - lastGradePublishAt: unknown; - lastGradePublishStatus: unknown; - totalLaunches: unknown; - attemptsStarted: unknown; - attemptsCompleted: unknown; - gradePublishesSucceeded: unknown; - gradePublishesFailed: unknown; - recentActiveUsers: unknown; - usageLastLaunchAt: unknown; - measuredAt: unknown; -} - -export interface D1ActivitySnapshotRow { - eventType: unknown; - status: unknown; - summary: unknown; - attemptId: unknown; - detail: unknown; - occurredAt: unknown; -} - -export interface D1RecentLaunchRow { - summary: unknown; - actorId: unknown; - userId: unknown; - userDisplayName: unknown; - userEmail: unknown; - userLogin: unknown; - attemptId: unknown; - detail: unknown; - occurredAt: unknown; -} - -export interface D1GradePublicationSnapshotRow { - attemptId: unknown; - status: unknown; - lineItemUrl: unknown; - platformUserId: unknown; - scoreGiven: unknown; - scoreMaximum: unknown; - activityProgress: unknown; - gradingProgress: unknown; - publishedAt: unknown; - updatedAt: unknown; - errorCode: unknown; - errorDetail: unknown; -} - -export interface D1DiagnosticRow { - id: unknown; - eventType: unknown; - actorType: unknown; - status: unknown; - deploymentRecordId: unknown; - attemptId: unknown; - summary: unknown; - detail: unknown; - occurredAt: unknown; -} - -export interface D1InternalBrokerVerificationRow { - scope: unknown; - source: unknown; - status: unknown; - summary: unknown; - detailUrl: unknown; - checkedAt: unknown; -} - -export interface D1OfficialBrokerVerificationRow { - scope: unknown; - status: unknown; - certificationState: unknown; - detailUrl: unknown; - checkedAt: unknown; -} - -export interface D1CertificationWorkflowStatusRow { - workflowKey: unknown; - deploymentRecordId: unknown; - deploymentLabel: unknown; - status: unknown; - summary: unknown; - detailUrl: unknown; - checkedAt: unknown; -} - -export interface D1LatestOfficialCertificationEvidenceRow { - workflowKey: unknown; - status: unknown; - certificationState: unknown; - summary: unknown; - detailUrl: unknown; - checkedAt: unknown; -} - -export interface D1RetryLookupRow { - attemptId: unknown; - deploymentRecordId: unknown; - deploymentSlug: unknown; - publicationStatus: unknown; - lineItemUrl: unknown; - platformUserId: unknown; - scoreGiven: unknown; - scoreMaximum: unknown; - activityProgress: unknown; - gradingProgress: unknown; - publishedAt: unknown; - updatedAt: unknown; - errorCode: unknown; - errorDetail: unknown; - bindingCanvasEnvironment: unknown; - bindingIssuer: unknown; - bindingClientId: unknown; - bindingDeploymentId: unknown; - sessionId: unknown; - runtimeLtiToolSessionId: unknown; - runtimeAttemptId: unknown; - runtimeDeploymentRecordId: unknown; - runtimeDeploymentSlug: unknown; - runtimeAppId: unknown; - runtimePackageVersionId: unknown; - runtimePackageVersion: unknown; - runtimeAgsScope: unknown; - runtimeAgsLineitemsUrl: unknown; - runtimeAgsLineitemUrl: unknown; - runtimeNrpsContextMembershipsUrl: unknown; - runtimeNrpsServiceVersions: unknown; - runtimeCreatedAt: unknown; - runtimeExpiresAt: unknown; -} -export function mapD1InventoryRow(row: D1InventoryQueryRow): InventoryQueryRow { - return { - deploymentId: expectD1Number(row.deploymentId, 'deploymentId'), - deploymentSlug: expectD1String(row.deploymentSlug, 'deploymentSlug'), - deploymentLabel: expectD1String(row.deploymentLabel, 'deploymentLabel'), - appId: expectD1String(row.appId, 'appId'), - appTitle: expectD1String(row.appTitle, 'appTitle'), - ownerId: expectD1NullableString(row.ownerId, 'ownerId'), - enabledPackageVersionId: expectD1NullableNumber( - row.enabledPackageVersionId, - 'enabledPackageVersionId', - ), - enabledPackageVersion: expectD1NullableString( - row.enabledPackageVersion, - 'enabledPackageVersion', - ), - approvalStatus: expectD1NullableStringLiteral(row.approvalStatus, 'approvalStatus', [ - 'pending', - 'approved', - 'rejected', - ]), - reviewedAt: expectD1NullableString(row.reviewedAt, 'reviewedAt'), - bindingLmsType: expectD1NullableStringLiteral(row.bindingLmsType, 'bindingLmsType', [ - 'canvas', - 'moodle', - 'sakai', - ]), - installEvidenceStatus: expectD1NullableStringLiteral( - row.installEvidenceStatus, - 'installEvidenceStatus', - ['accepted', 'succeeded', 'failed'], - ), - installEvidenceSummary: expectD1NullableString( - row.installEvidenceSummary, - 'installEvidenceSummary', - ), - installEvidenceDetail: parseD1NullableJsonField( - row.installEvidenceDetail, - 'installEvidenceDetail', - expectD1JsonRecord, - ), - installEvidenceOccurredAt: expectD1NullableString( - row.installEvidenceOccurredAt, - 'installEvidenceOccurredAt', - ), - internalBrokerVerificationScope: expectNullableSupportedPath( - row.internalBrokerVerificationScope, - 'internalBrokerVerificationScope', - ), - internalBrokerVerificationSource: expectD1NullableStringLiteral( - row.internalBrokerVerificationSource, - 'internalBrokerVerificationSource', - ['manual', 'ci', '1edtech'], - ), - internalBrokerVerificationStatus: expectD1NullableStringLiteral( - row.internalBrokerVerificationStatus, - 'internalBrokerVerificationStatus', - ['passed', 'failed', 'notRun'], - ), - internalBrokerVerificationSummary: expectD1NullableString( - row.internalBrokerVerificationSummary, - 'internalBrokerVerificationSummary', - ), - internalBrokerVerificationDetailUrl: expectD1NullableString( - row.internalBrokerVerificationDetailUrl, - 'internalBrokerVerificationDetailUrl', - ), - internalBrokerVerificationCheckedAt: expectD1NullableString( - row.internalBrokerVerificationCheckedAt, - 'internalBrokerVerificationCheckedAt', - ), - officialBrokerVerificationScope: expectNullableSupportedPath( - row.officialBrokerVerificationScope, - 'officialBrokerVerificationScope', - ), - officialBrokerVerificationStatus: expectD1NullableStringLiteral( - row.officialBrokerVerificationStatus, - 'officialBrokerVerificationStatus', - ['passed', 'failed', 'notRun', 'notCertified'], - ), - officialBrokerVerificationCertificationState: expectD1NullableStringLiteral( - row.officialBrokerVerificationCertificationState, - 'officialBrokerVerificationCertificationState', - ['ltiAdvantageCertified', 'ltiAdvantageComplete'], - ), - officialBrokerVerificationDetailUrl: expectD1NullableString( - row.officialBrokerVerificationDetailUrl, - 'officialBrokerVerificationDetailUrl', - ), - officialBrokerVerificationCheckedAt: expectD1NullableString( - row.officialBrokerVerificationCheckedAt, - 'officialBrokerVerificationCheckedAt', - ), - bindingCanvasEnvironment: expectD1NullableString( - row.bindingCanvasEnvironment, - 'bindingCanvasEnvironment', - ), - bindingIssuer: expectD1NullableString(row.bindingIssuer, 'bindingIssuer'), - bindingClientId: expectD1NullableString(row.bindingClientId, 'bindingClientId'), - bindingDeploymentId: expectD1NullableString(row.bindingDeploymentId, 'bindingDeploymentId'), - bindingAuthorizationEndpoint: expectD1NullableString( - row.bindingAuthorizationEndpoint, - 'bindingAuthorizationEndpoint', - ), - bindingAccessTokenUrl: expectD1NullableString( - row.bindingAccessTokenUrl, - 'bindingAccessTokenUrl', - ), - bindingJwksUrl: expectD1NullableString(row.bindingJwksUrl, 'bindingJwksUrl'), - updatedAt: expectD1String(row.updatedAt, 'updatedAt'), - lastLaunchAt: expectD1NullableString(row.lastLaunchAt, 'lastLaunchAt'), - lastLaunchStatus: expectD1NullableStringLiteral(row.lastLaunchStatus, 'lastLaunchStatus', [ - 'succeeded', - 'failed', - 'pending', - ]), - lastNrpsReadAt: expectD1NullableString(row.lastNrpsReadAt, 'lastNrpsReadAt'), - lastNrpsReadStatus: expectD1NullableStringLiteral( - row.lastNrpsReadStatus, - 'lastNrpsReadStatus', - [ - 'succeeded', - 'failed', - 'pending', - ], - ), - lastGradePublishAt: expectD1NullableString(row.lastGradePublishAt, 'lastGradePublishAt'), - lastGradePublishStatus: expectD1NullableStringLiteral( - row.lastGradePublishStatus, - 'lastGradePublishStatus', - ['pending', 'published', 'failed'], - ), - totalLaunches: expectD1Number(row.totalLaunches, 'totalLaunches'), - attemptsStarted: expectD1Number(row.attemptsStarted, 'attemptsStarted'), - attemptsCompleted: expectD1Number(row.attemptsCompleted, 'attemptsCompleted'), - gradePublishesSucceeded: expectD1Number(row.gradePublishesSucceeded, 'gradePublishesSucceeded'), - gradePublishesFailed: expectD1Number(row.gradePublishesFailed, 'gradePublishesFailed'), - recentActiveUsers: expectD1Number(row.recentActiveUsers, 'recentActiveUsers'), - usageLastLaunchAt: expectD1NullableString(row.usageLastLaunchAt, 'usageLastLaunchAt'), - measuredAt: expectD1String(row.measuredAt, 'measuredAt'), - }; -} - -export function mapD1ActivitySnapshotRow(row: D1ActivitySnapshotRow): ActivitySnapshotRow { - return { - eventType: expectD1String(row.eventType, 'eventType'), - status: expectD1StringLiteral(row.status, 'status', ['accepted', 'succeeded', 'failed']), - summary: expectD1String(row.summary, 'summary'), - attemptId: expectD1NullableString(row.attemptId, 'attemptId'), - detail: parseD1JsonField(row.detail, 'detail', expectD1JsonRecord), - occurredAt: expectD1String(row.occurredAt, 'occurredAt'), - }; -} - -export function mapD1RecentLaunchRow(row: D1RecentLaunchRow): RecentLaunchRow { - return { - summary: expectD1String(row.summary, 'summary'), - actorId: expectD1NullableString(row.actorId, 'actorId'), - userId: expectD1NullableString(row.userId, 'userId'), - userDisplayName: expectD1NullableString(row.userDisplayName, 'userDisplayName'), - userEmail: expectD1NullableString(row.userEmail, 'userEmail'), - userLogin: expectD1NullableString(row.userLogin, 'userLogin'), - attemptId: expectD1NullableString(row.attemptId, 'attemptId'), - detail: parseD1JsonField(row.detail, 'detail', expectD1JsonRecord), - occurredAt: expectD1String(row.occurredAt, 'occurredAt'), - }; -} - -export function mapD1GradePublicationRow( - row: D1GradePublicationSnapshotRow, -): GradePublicationSnapshotRow { - return { - attemptId: expectD1String(row.attemptId, 'attemptId'), - status: expectD1StringLiteral(row.status, 'status', ['pending', 'published', 'failed']), - lineItemUrl: expectD1String(row.lineItemUrl, 'lineItemUrl'), - platformUserId: expectD1String(row.platformUserId, 'platformUserId'), - scoreGiven: expectD1Number(row.scoreGiven, 'scoreGiven'), - scoreMaximum: expectD1Number(row.scoreMaximum, 'scoreMaximum'), - activityProgress: expectD1StringLiteral(row.activityProgress, 'activityProgress', [ - 'Completed', - 'InProgress', - 'Initialized', - ]), - gradingProgress: expectD1StringLiteral(row.gradingProgress, 'gradingProgress', [ - 'Pending', - 'PendingManual', - 'FullyGraded', - 'Failed', - ]), - publishedAt: expectD1NullableString(row.publishedAt, 'publishedAt'), - updatedAt: expectD1String(row.updatedAt, 'updatedAt'), - errorCode: expectD1NullableString(row.errorCode, 'errorCode'), - errorDetail: parseD1NullableJsonField(row.errorDetail, 'errorDetail', expectD1JsonRecord), - }; -} - -export function mapD1DiagnosticRow(row: D1DiagnosticRow): DiagnosticRow { - return { - id: expectD1Number(row.id, 'id'), - eventType: expectD1String(row.eventType, 'eventType'), - actorType: expectD1StringLiteral(row.actorType, 'actorType', ['user', 'system', 'platform']), - status: expectD1StringLiteral(row.status, 'status', ['accepted', 'succeeded', 'failed']), - deploymentRecordId: expectD1NullableNumber(row.deploymentRecordId, 'deploymentRecordId'), - attemptId: expectD1NullableString(row.attemptId, 'attemptId'), - summary: expectD1String(row.summary, 'summary'), - detail: parseD1JsonField(row.detail, 'detail', expectD1JsonRecord), - occurredAt: expectD1String(row.occurredAt, 'occurredAt'), - }; -} - -export function mapD1InternalBrokerVerificationRow( - row: D1InternalBrokerVerificationRow, -): InternalBrokerVerificationRow { - return { - scope: expectSupportedPath(row.scope, 'scope'), - source: expectD1StringLiteral(row.source, 'source', ['manual', 'ci', '1edtech']), - status: expectD1StringLiteral(row.status, 'status', ['passed', 'failed', 'notRun']), - summary: expectD1String(row.summary, 'summary'), - detailUrl: expectD1NullableString(row.detailUrl, 'detailUrl'), - checkedAt: expectD1String(row.checkedAt, 'checkedAt'), - }; -} - -export function mapD1OfficialBrokerVerificationRow( - row: D1OfficialBrokerVerificationRow, -): OfficialBrokerVerificationRow { - return { - scope: expectSupportedPath(row.scope, 'scope'), - status: expectD1StringLiteral(row.status, 'status', [ - 'passed', - 'failed', - 'notRun', - 'notCertified', - ]), - certificationState: expectD1NullableStringLiteral( - row.certificationState, - 'certificationState', - [ - 'ltiAdvantageCertified', - 'ltiAdvantageComplete', - ], - ), - detailUrl: expectD1NullableString(row.detailUrl, 'detailUrl'), - checkedAt: expectD1String(row.checkedAt, 'checkedAt'), - }; -} - -export function mapD1CertificationWorkflowStatusRow( - row: D1CertificationWorkflowStatusRow, -): CertificationWorkflowStatusRow { - return { - workflowKey: expectD1StringLiteral(row.workflowKey, 'workflowKey', [ - 'core', - 'deepLinking', - 'nrps', - 'ags', - ]), - deploymentRecordId: expectD1NullableNumber(row.deploymentRecordId, 'deploymentRecordId'), - deploymentLabel: expectD1NullableString(row.deploymentLabel, 'deploymentLabel'), - status: expectD1NullableStringLiteral(row.status, 'status', ['passed', 'failed', 'pending']), - summary: expectD1NullableString(row.summary, 'summary'), - detailUrl: expectD1NullableString(row.detailUrl, 'detailUrl'), - checkedAt: expectD1NullableString(row.checkedAt, 'checkedAt'), - }; -} - -export function mapD1LatestOfficialCertificationEvidenceRow( - row: D1LatestOfficialCertificationEvidenceRow, -): LatestOfficialCertificationEvidenceRow { - return { - workflowKey: expectD1StringLiteral(row.workflowKey, 'workflowKey', [ - 'core', - 'deepLinking', - 'nrps', - 'ags', - ]), - status: expectD1StringLiteral(row.status, 'status', [ - 'passed', - 'failed', - 'notRun', - 'notCertified', - ]), - certificationState: expectD1NullableStringLiteral( - row.certificationState, - 'certificationState', - [ - 'ltiAdvantageCertified', - 'ltiAdvantageComplete', - ], - ), - summary: expectD1String(row.summary, 'summary'), - detailUrl: expectD1NullableString(row.detailUrl, 'detailUrl'), - checkedAt: expectD1String(row.checkedAt, 'checkedAt'), - }; -} - -export function mapD1RetryLookupRow(row: D1RetryLookupRow): RetryLookupRow { - return { - attemptId: expectD1String(row.attemptId, 'attemptId'), - deploymentRecordId: expectD1Number(row.deploymentRecordId, 'deploymentRecordId'), - deploymentSlug: expectD1String(row.deploymentSlug, 'deploymentSlug'), - publicationStatus: expectD1StringLiteral(row.publicationStatus, 'publicationStatus', [ - 'pending', - 'published', - 'failed', - ]), - lineItemUrl: expectD1String(row.lineItemUrl, 'lineItemUrl'), - platformUserId: expectD1String(row.platformUserId, 'platformUserId'), - scoreGiven: expectD1Number(row.scoreGiven, 'scoreGiven'), - scoreMaximum: expectD1Number(row.scoreMaximum, 'scoreMaximum'), - activityProgress: expectD1StringLiteral(row.activityProgress, 'activityProgress', [ - 'Completed', - 'InProgress', - 'Initialized', - ]), - gradingProgress: expectD1StringLiteral(row.gradingProgress, 'gradingProgress', [ - 'Pending', - 'PendingManual', - 'FullyGraded', - 'Failed', - ]), - publishedAt: expectD1NullableString(row.publishedAt, 'publishedAt'), - updatedAt: expectD1String(row.updatedAt, 'updatedAt'), - errorCode: expectD1NullableString(row.errorCode, 'errorCode'), - errorDetail: parseD1NullableJsonField(row.errorDetail, 'errorDetail', expectD1JsonRecord), - bindingCanvasEnvironment: expectD1NullableString( - row.bindingCanvasEnvironment, - 'bindingCanvasEnvironment', - ), - bindingIssuer: expectD1NullableString(row.bindingIssuer, 'bindingIssuer'), - bindingClientId: expectD1NullableString(row.bindingClientId, 'bindingClientId'), - bindingDeploymentId: expectD1NullableString(row.bindingDeploymentId, 'bindingDeploymentId'), - sessionId: expectD1NullableString(row.sessionId, 'sessionId'), - runtimeLtiToolSessionId: expectD1NullableString( - row.runtimeLtiToolSessionId, - 'runtimeLtiToolSessionId', - ), - runtimeAttemptId: expectD1NullableString(row.runtimeAttemptId, 'runtimeAttemptId'), - runtimeDeploymentRecordId: expectD1NullableNumber( - row.runtimeDeploymentRecordId, - 'runtimeDeploymentRecordId', - ), - runtimeDeploymentSlug: expectD1NullableString( - row.runtimeDeploymentSlug, - 'runtimeDeploymentSlug', - ), - runtimeAppId: expectD1NullableString(row.runtimeAppId, 'runtimeAppId'), - runtimePackageVersionId: expectD1NullableNumber( - row.runtimePackageVersionId, - 'runtimePackageVersionId', - ), - runtimePackageVersion: expectD1NullableString( - row.runtimePackageVersion, - 'runtimePackageVersion', - ), - runtimeAgsScope: parseD1NullableJsonField( - row.runtimeAgsScope, - 'runtimeAgsScope', - expectD1StringArray, - ), - runtimeAgsLineitemsUrl: expectD1NullableString( - row.runtimeAgsLineitemsUrl, - 'runtimeAgsLineitemsUrl', - ), - runtimeAgsLineitemUrl: expectD1NullableString( - row.runtimeAgsLineitemUrl, - 'runtimeAgsLineitemUrl', - ), - runtimeNrpsContextMembershipsUrl: expectD1NullableString( - row.runtimeNrpsContextMembershipsUrl, - 'runtimeNrpsContextMembershipsUrl', - ), - runtimeNrpsServiceVersions: parseD1NullableJsonField( - row.runtimeNrpsServiceVersions, - 'runtimeNrpsServiceVersions', - expectD1StringArray, - ), - runtimeCreatedAt: expectD1NullableString(row.runtimeCreatedAt, 'runtimeCreatedAt'), - runtimeExpiresAt: expectD1NullableString(row.runtimeExpiresAt, 'runtimeExpiresAt'), - }; -} - -function expectSupportedPath(value: unknown, fieldName: string) { - return expectD1StringLiteral(value, fieldName, ['lti13LaunchAgsNrps', 'lti13LaunchAgsScore']); -} - -function expectNullableSupportedPath(value: unknown, fieldName: string) { - if (value === null) { - return null; - } - - return expectSupportedPath(value, fieldName); -} diff --git a/src/ops/repository_d1_queries.ts b/src/ops/repository_d1_queries.ts deleted file mode 100644 index 1e72cda..0000000 --- a/src/ops/repository_d1_queries.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS } from './broker_verification_paths.ts'; - -export const DEPLOYMENT_SUPPORTED_PATH_SQL = ` - CASE deployments.lms_type - WHEN 'canvas' THEN '${BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS.canvas}' - WHEN 'moodle' THEN '${BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS.moodle}' - WHEN 'sakai' THEN '${BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS.sakai}' - ELSE NULL - END -`; - -const INTERNAL_BROKER_VERIFICATION_FILTER = ` - broker_verification_runs.deployment_record_id = deployments.id - AND broker_verification_runs.scope = ${DEPLOYMENT_SUPPORTED_PATH_SQL} - AND broker_verification_runs.source IN ('manual', 'ci') -`; - -const OFFICIAL_BROKER_VERIFICATION_FILTER = ` - broker_verification_runs.scope = ${DEPLOYMENT_SUPPORTED_PATH_SQL} - AND broker_verification_runs.source = '1edtech' -`; - -function latestBrokerVerificationColumn(column: string, filter: string): string { - return ` - ( - SELECT broker_verification_runs.${column} - FROM broker_verification_runs - WHERE ${filter} - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 - ) - `; -} - -export const D1_INVENTORY_QUERY = ` - SELECT - deployments.id AS deploymentId, - deployments.slug AS deploymentSlug, - deployments.label AS deploymentLabel, - deployments.app_id AS appId, - COALESCE( - enabled_package.title, - ( - SELECT package_versions.title - FROM package_versions - WHERE package_versions.app_id = deployments.app_id - ORDER BY package_versions.imported_at DESC, package_versions.id DESC - LIMIT 1 - ), - deployments.label - ) AS appTitle, - COALESCE( - enabled_package.owner_id, - ( - SELECT package_versions.owner_id - FROM package_versions - WHERE package_versions.app_id = deployments.app_id - ORDER BY package_versions.imported_at DESC, package_versions.id DESC - LIMIT 1 - ) - ) AS ownerId, - deployments.enabled_package_version_id AS enabledPackageVersionId, - enabled_package.version AS enabledPackageVersion, - enabled_package.approval_status AS approvalStatus, - enabled_package.reviewed_at AS reviewedAt, - deployments.lms_type AS bindingLmsType, - ( - SELECT audit_events.status - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'deployment.binding_saved' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS installEvidenceStatus, - ( - SELECT audit_events.summary - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'deployment.binding_saved' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS installEvidenceSummary, - ( - SELECT audit_events.detail - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'deployment.binding_saved' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS installEvidenceDetail, - ( - SELECT audit_events.occurred_at - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'deployment.binding_saved' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS installEvidenceOccurredAt, - ${ - latestBrokerVerificationColumn('scope', INTERNAL_BROKER_VERIFICATION_FILTER) -} AS internalBrokerVerificationScope, - ${ - latestBrokerVerificationColumn('source', INTERNAL_BROKER_VERIFICATION_FILTER) -} AS internalBrokerVerificationSource, - ${ - latestBrokerVerificationColumn('status', INTERNAL_BROKER_VERIFICATION_FILTER) -} AS internalBrokerVerificationStatus, - ${ - latestBrokerVerificationColumn('summary', INTERNAL_BROKER_VERIFICATION_FILTER) -} AS internalBrokerVerificationSummary, - ${ - latestBrokerVerificationColumn('detail_url', INTERNAL_BROKER_VERIFICATION_FILTER) -} AS internalBrokerVerificationDetailUrl, - ${ - latestBrokerVerificationColumn('checked_at', INTERNAL_BROKER_VERIFICATION_FILTER) -} AS internalBrokerVerificationCheckedAt, - ${ - latestBrokerVerificationColumn('scope', OFFICIAL_BROKER_VERIFICATION_FILTER) -} AS officialBrokerVerificationScope, - ${ - latestBrokerVerificationColumn('status', OFFICIAL_BROKER_VERIFICATION_FILTER) -} AS officialBrokerVerificationStatus, - ${ - latestBrokerVerificationColumn('certification_state', OFFICIAL_BROKER_VERIFICATION_FILTER) -} AS officialBrokerVerificationCertificationState, - ${ - latestBrokerVerificationColumn('detail_url', OFFICIAL_BROKER_VERIFICATION_FILTER) -} AS officialBrokerVerificationDetailUrl, - ${ - latestBrokerVerificationColumn('checked_at', OFFICIAL_BROKER_VERIFICATION_FILTER) -} AS officialBrokerVerificationCheckedAt, - deployments.canvas_environment AS bindingCanvasEnvironment, - deployments.issuer AS bindingIssuer, - deployments.client_id AS bindingClientId, - deployments.deployment_id AS bindingDeploymentId, - deployments.authorization_endpoint AS bindingAuthorizationEndpoint, - deployments.access_token_url AS bindingAccessTokenUrl, - deployments.jwks_url AS bindingJwksUrl, - deployments.updated_at AS updatedAt, - ( - SELECT audit_events.occurred_at - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type LIKE 'launch.%' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS lastLaunchAt, - ( - SELECT CASE - WHEN audit_events.event_type = 'launch.rejected' - OR audit_events.status = 'failed' - THEN 'failed' - ELSE 'succeeded' - END - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type LIKE 'launch.%' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS lastLaunchStatus, - ( - SELECT audit_events.occurred_at - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'deployment.nrps_verified' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS lastNrpsReadAt, - ( - SELECT CASE - WHEN audit_events.status = 'failed' THEN 'failed' - WHEN audit_events.status = 'succeeded' THEN 'succeeded' - ELSE 'pending' - END - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'deployment.nrps_verified' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS lastNrpsReadStatus, - ( - SELECT grade_publications.updated_at - FROM attempts - INNER JOIN grade_publications - ON grade_publications.attempt_id = attempts.attempt_id - WHERE attempts.deployment_record_id = deployments.id - ORDER BY grade_publications.updated_at DESC, grade_publications.id DESC - LIMIT 1 - ) AS lastGradePublishAt, - ( - SELECT grade_publications.status - FROM attempts - INNER JOIN grade_publications - ON grade_publications.attempt_id = attempts.attempt_id - WHERE attempts.deployment_record_id = deployments.id - ORDER BY grade_publications.updated_at DESC, grade_publications.id DESC - LIMIT 1 - ) AS lastGradePublishStatus, - ( - SELECT COUNT(*) - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'launch.accepted' - ) AS totalLaunches, - ( - SELECT COUNT(*) - FROM attempts - WHERE attempts.deployment_record_id = deployments.id - ) AS attemptsStarted, - ( - SELECT COUNT(*) - FROM attempts - WHERE attempts.deployment_record_id = deployments.id - AND attempts.completion_state = 'completed' - ) AS attemptsCompleted, - ( - SELECT COUNT(*) - FROM attempts - INNER JOIN grade_publications - ON grade_publications.attempt_id = attempts.attempt_id - WHERE attempts.deployment_record_id = deployments.id - AND grade_publications.status = 'published' - ) AS gradePublishesSucceeded, - ( - SELECT COUNT(*) - FROM attempts - INNER JOIN grade_publications - ON grade_publications.attempt_id = attempts.attempt_id - WHERE attempts.deployment_record_id = deployments.id - AND grade_publications.status = 'failed' - ) AS gradePublishesFailed, - ( - SELECT COUNT(DISTINCT attempts.user_id) - FROM attempts - WHERE attempts.deployment_record_id = deployments.id - ) AS recentActiveUsers, - COALESCE( - ( - SELECT MAX(audit_events.occurred_at) - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'launch.accepted' - ), - ( - SELECT audit_events.occurred_at - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type LIKE 'launch.%' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) - ) AS usageLastLaunchAt, - CURRENT_TIMESTAMP AS measuredAt - FROM deployments - LEFT JOIN package_versions AS enabled_package - ON enabled_package.id = deployments.enabled_package_version_id - WHERE deployments.lms_type <> 'preview' -`; - -const D1_ACTIVITY_SELECT = ` - SELECT - event_type AS eventType, - status, - summary, - attempt_id AS attemptId, - detail, - occurred_at AS occurredAt - FROM audit_events -`; - -export const D1_LATEST_LAUNCH_QUERY = ` - ${D1_ACTIVITY_SELECT} - WHERE deployment_record_id = ? - AND event_type LIKE 'launch.%' - ORDER BY occurred_at DESC, id DESC - LIMIT 1 -`; - -export const D1_LATEST_RUNTIME_SESSION_QUERY = ` - ${D1_ACTIVITY_SELECT} - WHERE deployment_record_id = ? - AND event_type = 'runtime.session.started' - ORDER BY occurred_at DESC, id DESC - LIMIT 1 -`; - -export const D1_LATEST_RUNTIME_OUTCOME_QUERY = ` - ${D1_ACTIVITY_SELECT} - WHERE deployment_record_id = ? - AND event_type LIKE 'runtime.%' - AND event_type NOT IN ('runtime.session.started', 'runtime.capability.allowed') - ORDER BY occurred_at DESC, id DESC - LIMIT 1 -`; - -export const D1_LATEST_COMPATIBILITY_PATH_QUERY = ` - ${D1_ACTIVITY_SELECT} - WHERE deployment_record_id = ? - AND event_type = 'interop.path_used' - AND COALESCE(json_extract(detail, '$.scope'), '') IN ( - 'login', - 'launch', - 'deep_linking', - 'service' - ) - ORDER BY occurred_at DESC, id DESC - LIMIT 1 -`; - -export const D1_LATEST_NRPS_QUERY = ` - ${D1_ACTIVITY_SELECT} - WHERE deployment_record_id = ? - AND event_type = 'deployment.nrps_verified' - ORDER BY occurred_at DESC, id DESC - LIMIT 1 -`; - -export const D1_LATEST_AGS_SMOKE_QUERY = ` - ${D1_ACTIVITY_SELECT} - WHERE deployment_record_id = ? - AND event_type = 'deployment.ags_smoke_verified' - ORDER BY occurred_at DESC, id DESC - LIMIT 1 -`; diff --git a/src/ops/repository_inventory_queries.ts b/src/ops/repository_inventory_queries.ts deleted file mode 100644 index 6615f8f..0000000 --- a/src/ops/repository_inventory_queries.ts +++ /dev/null @@ -1,195 +0,0 @@ -export { BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS } from './broker_verification_paths.ts'; -import { BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS } from './broker_verification_paths.ts'; - -const DEPLOYMENT_BROKER_SUPPORTED_PATH_SQL = ` - CASE deployments.lms_type - WHEN 'canvas' THEN '${BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS.canvas}' - WHEN 'moodle' THEN '${BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS.moodle}' - WHEN 'sakai' THEN '${BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS.sakai}' - ELSE NULL - END -`; - -export const INVENTORY_BASE_QUERY = ` - SELECT - deployments.id AS deployment_id, - deployments.slug AS deployment_slug, - deployments.label AS deployment_label, - deployments.app_id, - COALESCE(enabled_package.title, latest_package.title, deployments.label) AS app_title, - COALESCE(enabled_package.owner_id, latest_package.owner_id) AS owner_id, - deployments.enabled_package_version_id, - enabled_package.version AS enabled_package_version, - enabled_package.approval_status, - enabled_package.reviewed_at, - deployments.lms_type AS binding_lms_type, - install_evidence.status AS install_evidence_status, - install_evidence.summary AS install_evidence_summary, - install_evidence.detail AS install_evidence_detail, - install_evidence.occurred_at AS install_evidence_occurred_at, - internal_broker_verification.scope AS internal_broker_verification_scope, - internal_broker_verification.source AS internal_broker_verification_source, - internal_broker_verification.status AS internal_broker_verification_status, - internal_broker_verification.summary AS internal_broker_verification_summary, - internal_broker_verification.detail_url - AS internal_broker_verification_detail_url, - internal_broker_verification.checked_at - AS internal_broker_verification_checked_at, - official_broker_verification.scope AS official_broker_verification_scope, - official_broker_verification.status AS official_broker_verification_status, - official_broker_verification.certification_state - AS official_broker_verification_certification_state, - official_broker_verification.detail_url - AS official_broker_verification_detail_url, - official_broker_verification.checked_at - AS official_broker_verification_checked_at, - deployments.canvas_environment AS binding_canvas_environment, - deployments.issuer AS binding_issuer, - deployments.client_id AS binding_client_id, - deployments.deployment_id AS binding_deployment_id, - deployments.authorization_endpoint AS binding_authorization_endpoint, - deployments.access_token_url AS binding_access_token_url, - deployments.jwks_url AS binding_jwks_url, - deployments.updated_at, - latest_launch.last_launch_at, - latest_launch.last_launch_status, - latest_nrps.last_nrps_read_at, - latest_nrps.last_nrps_read_status, - latest_grade.last_grade_publish_at, - latest_grade.last_grade_publish_status, - COALESCE(launch_usage.total_launches, 0)::integer AS total_launches, - COALESCE(attempt_usage.attempts_started, 0)::integer AS attempts_started, - COALESCE(attempt_usage.attempts_completed, 0)::integer AS attempts_completed, - COALESCE(grade_usage.grade_publishes_succeeded, 0)::integer AS grade_publishes_succeeded, - COALESCE(grade_usage.grade_publishes_failed, 0)::integer AS grade_publishes_failed, - COALESCE(attempt_usage.recent_active_users, 0)::integer AS recent_active_users, - COALESCE(launch_usage.last_launch_at, latest_launch.last_launch_at) AS usage_last_launch_at, - CURRENT_TIMESTAMP AS measured_at - FROM deployments - LEFT JOIN package_versions AS enabled_package - ON enabled_package.id = deployments.enabled_package_version_id - LEFT JOIN LATERAL ( - SELECT - package_versions.title, - package_versions.owner_id - FROM package_versions - WHERE package_versions.app_id = deployments.app_id - ORDER BY package_versions.imported_at DESC, package_versions.id DESC - LIMIT 1 - ) AS latest_package ON TRUE - LEFT JOIN LATERAL ( - SELECT - audit_events.status, - audit_events.summary, - audit_events.detail, - audit_events.occurred_at - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'deployment.binding_saved' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS install_evidence ON TRUE - LEFT JOIN LATERAL ( - SELECT - broker_verification_runs.scope, - broker_verification_runs.source, - broker_verification_runs.status, - broker_verification_runs.summary, - broker_verification_runs.detail_url, - broker_verification_runs.checked_at - FROM broker_verification_runs - WHERE broker_verification_runs.deployment_record_id = deployments.id - AND broker_verification_runs.scope = ${DEPLOYMENT_BROKER_SUPPORTED_PATH_SQL} - AND broker_verification_runs.source IN ('manual', 'ci') - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 - ) AS internal_broker_verification ON TRUE - LEFT JOIN LATERAL ( - SELECT - broker_verification_runs.scope, - broker_verification_runs.status, - broker_verification_runs.certification_state, - broker_verification_runs.detail_url, - broker_verification_runs.checked_at - FROM broker_verification_runs - WHERE broker_verification_runs.scope = ${DEPLOYMENT_BROKER_SUPPORTED_PATH_SQL} - AND broker_verification_runs.source = '1edtech' - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 - ) AS official_broker_verification ON TRUE - LEFT JOIN LATERAL ( - SELECT - audit_events.occurred_at AS last_launch_at, - CASE - WHEN audit_events.event_type = 'launch.rejected' - OR audit_events.status = 'failed' - THEN 'failed' - ELSE 'succeeded' - END AS last_launch_status - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type LIKE 'launch.%' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS latest_launch ON TRUE - LEFT JOIN LATERAL ( - SELECT - audit_events.occurred_at AS last_nrps_read_at, - CASE - WHEN audit_events.status = 'failed' - THEN 'failed' - WHEN audit_events.status = 'succeeded' - THEN 'succeeded' - ELSE 'pending' - END AS last_nrps_read_status - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'deployment.nrps_verified' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 - ) AS latest_nrps ON TRUE - LEFT JOIN LATERAL ( - SELECT - grade_publications.updated_at AS last_grade_publish_at, - grade_publications.status AS last_grade_publish_status - FROM attempts - INNER JOIN grade_publications - ON grade_publications.attempt_id = attempts.attempt_id - WHERE attempts.deployment_record_id = deployments.id - ORDER BY grade_publications.updated_at DESC, grade_publications.id DESC - LIMIT 1 - ) AS latest_grade ON TRUE - LEFT JOIN LATERAL ( - SELECT - COUNT(*)::integer AS total_launches, - MAX(audit_events.occurred_at) AS last_launch_at - FROM audit_events - WHERE audit_events.deployment_record_id = deployments.id - AND audit_events.event_type = 'launch.accepted' - ) AS launch_usage ON TRUE - LEFT JOIN LATERAL ( - SELECT - COUNT(*)::integer AS attempts_started, - COUNT(*) FILTER (WHERE attempts.completion_state = 'completed')::integer - AS attempts_completed, - COUNT(DISTINCT attempts.user_id)::integer AS recent_active_users - FROM attempts - WHERE attempts.deployment_record_id = deployments.id - ) AS attempt_usage ON TRUE - LEFT JOIN LATERAL ( - SELECT - COUNT(*) FILTER (WHERE grade_publications.status = 'published')::integer - AS grade_publishes_succeeded, - COUNT(*) FILTER (WHERE grade_publications.status = 'failed')::integer - AS grade_publishes_failed - FROM attempts - INNER JOIN grade_publications - ON grade_publications.attempt_id = attempts.attempt_id - WHERE attempts.deployment_record_id = deployments.id - ) AS grade_usage ON TRUE - WHERE deployments.lms_type <> 'preview' -`; - -export const INVENTORY_ORDER_BY = ` - ORDER BY deployments.updated_at DESC, deployments.id DESC -`; diff --git a/src/ops/repository_inventory_test.ts b/src/ops/repository_inventory_test.ts deleted file mode 100644 index b4c8459..0000000 --- a/src/ops/repository_inventory_test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { assertEquals } from '@std/assert'; -import { mapInventoryRow } from './repository_mapping.ts'; -import { buildInventoryQueryRow } from './repository_inventory_test_support.ts'; - -Deno.test('ops inventory mapping keeps exact Canvas, Moodle, and Sakai bindings legible in shared control-plane rows', () => { - const canvasInventory = mapInventoryRow( - buildInventoryQueryRow({ - bindingLmsType: 'canvas', - bindingCanvasEnvironment: 'production', - bindingIssuer: 'https://canvas.instructure.com', - bindingClientId: '10000000000001', - bindingDeploymentId: 'canvas-deployment-123', - }), - null, - ); - const moodleInventory = mapInventoryRow( - buildInventoryQueryRow({ - deploymentId: 2, - deploymentSlug: 'chapter-4-asteroids-moodle', - deploymentLabel: 'Chapter 4 Asteroids Moodle Deployment', - bindingLmsType: 'moodle', - bindingCanvasEnvironment: null, - bindingIssuer: 'https://moodle.example', - bindingClientId: 'moodle-client-123', - bindingDeploymentId: 'moodle-deployment-123', - bindingAuthorizationEndpoint: 'https://moodle.example/mod/lti/auth.php', - bindingAccessTokenUrl: 'https://moodle.example/mod/lti/token.php', - bindingJwksUrl: 'https://moodle.example/mod/lti/certs.php', - }), - null, - ); - const sakaiInventory = mapInventoryRow( - buildInventoryQueryRow({ - deploymentId: 3, - deploymentSlug: 'chapter-4-asteroids-sakai', - deploymentLabel: 'Chapter 4 Asteroids Sakai Deployment', - bindingLmsType: 'sakai', - bindingCanvasEnvironment: null, - bindingIssuer: 'https://sakai.example', - bindingClientId: 'sakai-client-123', - bindingDeploymentId: 'sakai-deployment-123', - bindingAuthorizationEndpoint: 'https://sakai.example/imsoidc/lti13/oidc_auth', - bindingAccessTokenUrl: 'https://sakai.example/imsblis/lti13/token/3', - bindingJwksUrl: 'https://sakai.example/imsblis/lti13/keyset', - }), - null, - ); - - assertEquals(canvasInventory.binding, { - lms: 'canvas', - canvasEnvironment: 'production', - issuer: 'https://canvas.instructure.com', - clientId: '10000000000001', - deploymentId: 'canvas-deployment-123', - }); - assertEquals(moodleInventory.binding, { - lms: 'moodle', - issuer: 'https://moodle.example', - clientId: 'moodle-client-123', - deploymentId: 'moodle-deployment-123', - authorizationEndpoint: 'https://moodle.example/mod/lti/auth.php', - accessTokenUrl: 'https://moodle.example/mod/lti/token.php', - jwksUrl: 'https://moodle.example/mod/lti/certs.php', - }); - assertEquals(sakaiInventory.binding, { - lms: 'sakai', - issuer: 'https://sakai.example', - clientId: 'sakai-client-123', - deploymentId: 'sakai-deployment-123', - authorizationEndpoint: 'https://sakai.example/imsoidc/lti13/oidc_auth', - accessTokenUrl: 'https://sakai.example/imsblis/lti13/token/3', - jwksUrl: 'https://sakai.example/imsblis/lti13/keyset', - }); - assertEquals( - canvasInventory.health.dimensions.enablement.summary, - 'Deployment pin and Canvas binding are present.', - ); - assertEquals( - moodleInventory.health.dimensions.enablement.summary, - 'Deployment pin and Moodle binding are present.', - ); - assertEquals( - sakaiInventory.health.dimensions.enablement.summary, - 'Deployment pin and Sakai binding are present.', - ); -}); - -Deno.test('ops inventory health keeps broker verification wording deployment-scoped across canvas, moodle, and sakai', () => { - const canvasInventory = mapInventoryRow( - buildInventoryQueryRow({ - internalBrokerVerificationScope: 'lti13LaunchAgsNrps', - internalBrokerVerificationSource: 'manual', - internalBrokerVerificationStatus: 'passed', - internalBrokerVerificationSummary: - 'Canvas launch, AGS publish, and NRPS verification passed.', - internalBrokerVerificationCheckedAt: '2026-03-24T12:50:00Z', - }), - ); - const moodleInventory = mapInventoryRow( - buildInventoryQueryRow({ - deploymentId: 2, - deploymentSlug: 'chapter-4-asteroids-moodle', - deploymentLabel: 'Chapter 4 Asteroids Moodle Deployment', - bindingLmsType: 'moodle', - bindingCanvasEnvironment: null, - bindingIssuer: 'https://moodle.example', - bindingClientId: 'moodle-client-123', - bindingDeploymentId: 'moodle-deployment-123', - bindingAuthorizationEndpoint: 'https://moodle.example/mod/lti/auth.php', - bindingAccessTokenUrl: 'https://moodle.example/mod/lti/token.php', - bindingJwksUrl: 'https://moodle.example/mod/lti/certs.php', - internalBrokerVerificationScope: 'lti13LaunchAgsScore', - internalBrokerVerificationSource: 'ci', - internalBrokerVerificationStatus: 'failed', - internalBrokerVerificationSummary: - 'Latest Moodle CI verification failed on the AGS score publish.', - internalBrokerVerificationCheckedAt: '2026-03-24T13:10:00Z', - }), - ); - const sakaiInventory = mapInventoryRow( - buildInventoryQueryRow({ - deploymentId: 3, - deploymentSlug: 'chapter-4-asteroids-sakai', - deploymentLabel: 'Chapter 4 Asteroids Sakai Deployment', - bindingLmsType: 'sakai', - bindingCanvasEnvironment: null, - bindingIssuer: 'https://sakai.example', - bindingClientId: 'sakai-client-123', - bindingDeploymentId: 'sakai-deployment-123', - bindingAuthorizationEndpoint: 'https://sakai.example/imsoidc/lti13/oidc_auth', - bindingAccessTokenUrl: 'https://sakai.example/imsblis/lti13/token/3', - bindingJwksUrl: 'https://sakai.example/imsblis/lti13/keyset', - internalBrokerVerificationScope: 'lti13LaunchAgsScore', - internalBrokerVerificationSource: 'manual', - internalBrokerVerificationStatus: 'pending', - internalBrokerVerificationSummary: - 'Sakai launch and AGS smoke verification is pending follow-up.', - internalBrokerVerificationCheckedAt: '2026-03-24T13:20:00Z', - }), - ); - - assertEquals( - canvasInventory.health.dimensions.brokerVerification.summary, - 'Latest deployment-scoped broker verification passed.', - ); - assertEquals( - moodleInventory.health.dimensions.brokerVerification.summary, - 'Latest deployment-scoped broker verification failed.', - ); - assertEquals( - sakaiInventory.health.dimensions.brokerVerification.summary, - 'Deployment-scoped broker verification is still pending.', - ); -}); diff --git a/src/ops/repository_inventory_test_support.ts b/src/ops/repository_inventory_test_support.ts deleted file mode 100644 index b29f6d9..0000000 --- a/src/ops/repository_inventory_test_support.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { InventoryQueryRow } from './repository_types.ts'; -import type { DeploymentActivitySnapshot } from './types.ts'; - -export function buildInventoryQueryRow( - overrides: Partial = {}, -): InventoryQueryRow { - return { - deploymentId: overrides.deploymentId ?? 1, - deploymentSlug: overrides.deploymentSlug ?? 'chapter-4-asteroids-pilot', - deploymentLabel: overrides.deploymentLabel ?? 'Chapter 4 Asteroids Pilot Deployment', - appId: overrides.appId ?? 'chapter-4-asteroids', - appTitle: overrides.appTitle ?? 'Chapter 4 Asteroids', - ownerId: overrides.ownerId ?? 'instructor_123', - enabledPackageVersionId: overrides.enabledPackageVersionId ?? 1, - enabledPackageVersion: overrides.enabledPackageVersion ?? '0.1.0', - approvalStatus: overrides.approvalStatus ?? 'approved', - reviewedAt: overrides.reviewedAt ?? '2026-03-23T18:05:00Z', - bindingLmsType: overrides.bindingLmsType ?? 'canvas', - installEvidenceStatus: overrides.installEvidenceStatus ?? null, - installEvidenceSummary: overrides.installEvidenceSummary ?? null, - installEvidenceDetail: overrides.installEvidenceDetail ?? null, - installEvidenceOccurredAt: overrides.installEvidenceOccurredAt ?? null, - internalBrokerVerificationScope: overrides.internalBrokerVerificationScope ?? null, - internalBrokerVerificationSource: overrides.internalBrokerVerificationSource ?? null, - internalBrokerVerificationStatus: overrides.internalBrokerVerificationStatus ?? null, - internalBrokerVerificationSummary: overrides.internalBrokerVerificationSummary ?? null, - internalBrokerVerificationDetailUrl: overrides.internalBrokerVerificationDetailUrl ?? null, - internalBrokerVerificationCheckedAt: overrides.internalBrokerVerificationCheckedAt ?? null, - officialBrokerVerificationScope: overrides.officialBrokerVerificationScope ?? null, - officialBrokerVerificationStatus: overrides.officialBrokerVerificationStatus ?? null, - officialBrokerVerificationCertificationState: - overrides.officialBrokerVerificationCertificationState ?? null, - officialBrokerVerificationDetailUrl: overrides.officialBrokerVerificationDetailUrl ?? null, - officialBrokerVerificationCheckedAt: overrides.officialBrokerVerificationCheckedAt ?? null, - bindingCanvasEnvironment: overrides.bindingCanvasEnvironment ?? 'production', - bindingIssuer: overrides.bindingIssuer ?? 'https://canvas.instructure.com', - bindingClientId: overrides.bindingClientId ?? '10000000000001', - bindingDeploymentId: overrides.bindingDeploymentId ?? 'deployment-123', - bindingAuthorizationEndpoint: overrides.bindingAuthorizationEndpoint ?? null, - bindingAccessTokenUrl: overrides.bindingAccessTokenUrl ?? null, - bindingJwksUrl: overrides.bindingJwksUrl ?? null, - updatedAt: overrides.updatedAt ?? '2026-03-24T12:30:00Z', - lastLaunchAt: overrides.lastLaunchAt ?? '2026-03-24T12:30:00Z', - lastLaunchStatus: overrides.lastLaunchStatus ?? 'succeeded', - lastNrpsReadAt: overrides.lastNrpsReadAt ?? '2026-03-24T12:33:00Z', - lastNrpsReadStatus: overrides.lastNrpsReadStatus ?? 'succeeded', - lastGradePublishAt: overrides.lastGradePublishAt ?? '2026-03-24T12:35:00Z', - lastGradePublishStatus: overrides.lastGradePublishStatus ?? 'failed', - totalLaunches: overrides.totalLaunches ?? 1, - attemptsStarted: overrides.attemptsStarted ?? 1, - attemptsCompleted: overrides.attemptsCompleted ?? 1, - gradePublishesSucceeded: overrides.gradePublishesSucceeded ?? 0, - gradePublishesFailed: overrides.gradePublishesFailed ?? 1, - recentActiveUsers: overrides.recentActiveUsers ?? 1, - usageLastLaunchAt: overrides.usageLastLaunchAt ?? '2026-03-24T12:30:00Z', - measuredAt: overrides.measuredAt ?? '2026-03-24T12:50:00Z', - }; -} - -export function readInventoryInstallEvidence(row: unknown): DeploymentActivitySnapshot | null { - return ( - (row as { installEvidence?: DeploymentActivitySnapshot | null } | undefined)?.installEvidence ?? - null - ); -} - -export function readDetailInstallEvidence(detail: unknown): DeploymentActivitySnapshot | null { - return ( - (detail as { latestInstallEvidence?: DeploymentActivitySnapshot | null } | undefined) - ?.latestInstallEvidence ?? null - ); -} diff --git a/src/ops/repository_mapping.ts b/src/ops/repository_mapping.ts deleted file mode 100644 index d0b1e65..0000000 --- a/src/ops/repository_mapping.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { deriveDeploymentHealth, formatDiagnosticItem } from './service.ts'; -import type { - BrokerVerificationStatus, - CertificationWorkflowStatus, - ControlPlaneDeploymentInventoryRow, - ControlPlaneDiagnosticItem, - ControlPlaneRuntimeEvidenceSnapshot, - DeploymentActivitySnapshot, - DeploymentGradePublicationSnapshot, - DeploymentRecentLaunch, - LatestOfficialCertificationEvidence, -} from './types.ts'; -import type { - CertificationWorkflowStatusRow, - DiagnosticRow, - GradePublicationSnapshotRow, - InternalBrokerVerificationRow, - InventoryQueryRow, - LatestOfficialCertificationEvidenceRow, - OfficialBrokerVerificationRow, - RecentLaunchRow, -} from './repository_types.ts'; -import { - mapAuditActivityStatus, - mapDeploymentBinding, - mapDiagnosticKind, - normalizeNumeric, - normalizeOptionalTimestamp, - normalizeTimestamp, - readBoundaryDenialCategoryDetail, - readLtiProfileIdDetail, - readLtiProfileSourceDetail, - readNumberDetail, - readRuntimeBoundaryDetail, - readRuntimeDeliverySubstrateDetail, - readRuntimeSandboxModelDetail, - readStringDetail, -} from './repository_mapping_support.ts'; - -export { - assertBrokerVerificationRunInput, - mapDeploymentBinding, - normalizeNumeric, - normalizeOptionalTimestamp, - normalizeTimestamp, -} from './repository_mapping_support.ts'; - -export function mapInventoryRow( - row: InventoryQueryRow, - brokerVerification: BrokerVerificationStatus | null = mapInventoryBrokerVerification(row), -): ControlPlaneDeploymentInventoryRow { - const binding = mapDeploymentBinding({ - lmsType: row.bindingLmsType, - canvasEnvironment: row.bindingCanvasEnvironment, - issuer: row.bindingIssuer, - clientId: row.bindingClientId, - deploymentId: row.bindingDeploymentId, - authorizationEndpoint: row.bindingAuthorizationEndpoint, - accessTokenUrl: row.bindingAccessTokenUrl, - jwksUrl: row.bindingJwksUrl, - }); - - return { - deploymentId: row.deploymentId, - deploymentSlug: row.deploymentSlug, - deploymentLabel: row.deploymentLabel, - appId: row.appId, - appTitle: row.appTitle, - ownerId: row.ownerId, - enabledPackageVersionId: row.enabledPackageVersionId, - enabledPackageVersion: row.enabledPackageVersion, - approvalStatus: row.approvalStatus, - binding, - installEvidence: mapInventoryInstallEvidence(row), - updatedAt: normalizeTimestamp(row.updatedAt), - lastLaunchAt: normalizeOptionalTimestamp(row.lastLaunchAt), - lastLaunchStatus: row.lastLaunchStatus, - lastGradePublishAt: normalizeOptionalTimestamp(row.lastGradePublishAt), - lastGradePublishStatus: row.lastGradePublishStatus, - lastNrpsReadAt: normalizeOptionalTimestamp(row.lastNrpsReadAt), - lastNrpsReadStatus: row.lastNrpsReadStatus, - pilotUsage: { - deploymentRecordId: row.deploymentId, - totalLaunches: normalizeNumeric(row.totalLaunches), - attemptsStarted: normalizeNumeric(row.attemptsStarted), - attemptsCompleted: normalizeNumeric(row.attemptsCompleted), - gradePublishesSucceeded: normalizeNumeric(row.gradePublishesSucceeded), - gradePublishesFailed: normalizeNumeric(row.gradePublishesFailed), - recentActiveUsers: normalizeNumeric(row.recentActiveUsers), - lastLaunchAt: normalizeOptionalTimestamp(row.usageLastLaunchAt), - measuredAt: normalizeTimestamp(row.measuredAt), - }, - health: deriveDeploymentHealth({ - approvalStatus: row.approvalStatus, - reviewedAt: normalizeOptionalTimestamp(row.reviewedAt), - enabledPackageVersionId: row.enabledPackageVersionId, - binding, - lastLaunchStatus: row.lastLaunchStatus, - lastLaunchAt: normalizeOptionalTimestamp(row.lastLaunchAt), - lastGradePublishStatus: row.lastGradePublishStatus, - lastGradePublishAt: normalizeOptionalTimestamp(row.lastGradePublishAt), - lastNrpsReadStatus: row.lastNrpsReadStatus, - lastNrpsReadAt: normalizeOptionalTimestamp(row.lastNrpsReadAt), - brokerVerificationStatus: brokerVerification?.internal?.status ?? null, - brokerCheckedAt: brokerVerification?.internal?.checkedAt ?? - brokerVerification?.official.checkedAt ?? null, - }), - brokerVerification, - }; -} - -export function mapActivitySnapshotRow(row: { - eventType: string; - status: string; - summary: string; - attemptId: string | null; - detail: Record; - occurredAt: Date | string; -}): DeploymentActivitySnapshot { - return { - status: mapAuditActivityStatus(row.eventType, row.status), - occurredAt: normalizeTimestamp(row.occurredAt), - summary: row.summary, - attemptId: row.attemptId, - contextId: readStringDetail(row.detail, 'contextId'), - detail: row.detail, - }; -} - -export function mapRecentLaunchRows(rows: RecentLaunchRow[]): DeploymentRecentLaunch[] { - return rows.map((row) => ({ - occurredAt: normalizeTimestamp(row.occurredAt), - summary: row.summary, - attemptId: row.attemptId, - userId: row.userId ?? row.actorId, - userDisplayName: row.userDisplayName, - userEmail: row.userEmail, - userLogin: row.userLogin, - contextId: readStringDetail(row.detail, 'contextId'), - resourceLinkId: readStringDetail(row.detail, 'resourceLinkId'), - ltiProfileId: readLtiProfileIdDetail(row.detail), - ltiProfileSource: readLtiProfileSourceDetail(row.detail), - })); -} - -export function mapGradePublicationSnapshotRow( - row: GradePublicationSnapshotRow, -): DeploymentGradePublicationSnapshot { - return { - attemptId: row.attemptId, - status: row.status, - lineItemUrl: row.lineItemUrl, - platformUserId: row.platformUserId, - scoreGiven: normalizeNumeric(row.scoreGiven), - scoreMaximum: normalizeNumeric(row.scoreMaximum), - activityProgress: row.activityProgress, - gradingProgress: row.gradingProgress, - publishedAt: normalizeOptionalTimestamp(row.publishedAt), - updatedAt: normalizeTimestamp(row.updatedAt), - errorCode: row.errorCode, - errorDetail: row.errorDetail, - }; -} - -export function mapDiagnosticRows( - rows: DiagnosticRow[], - retryableAttemptId: string | null, -): ControlPlaneDiagnosticItem[] { - return rows.map((row) => - formatDiagnosticItem( - { - id: row.id, - kind: mapDiagnosticKind(row.eventType), - eventType: row.eventType, - actorType: row.actorType, - status: row.status, - deploymentRecordId: row.deploymentRecordId, - attemptId: row.attemptId, - code: readStringDetail(row.detail, 'code'), - boundaryDenialCategory: readBoundaryDenialCategoryDetail(row.detail), - summary: row.summary, - operatorSummary: row.summary, - retryable: false, - detail: row.detail, - occurredAt: normalizeTimestamp(row.occurredAt), - }, - { - retryableAttemptId, - }, - ) - ); -} - -export function mapRuntimeEvidenceSnapshotRow(row: { - eventType: string; - status: string; - summary: string; - attemptId: string | null; - detail: Record; - occurredAt: Date | string; -}): ControlPlaneRuntimeEvidenceSnapshot { - return { - eventType: row.eventType, - status: mapAuditActivityStatus(row.eventType, row.status), - occurredAt: normalizeTimestamp(row.occurredAt), - summary: row.summary, - attemptId: row.attemptId, - sessionId: readStringDetail(row.detail, 'sessionId'), - packageVersionId: readNumberDetail(row.detail, 'packageVersionId'), - packageVersion: readStringDetail(row.detail, 'packageVersion'), - artifactDigest: readStringDetail(row.detail, 'artifactDigest'), - runtimeContractSignature: readStringDetail(row.detail, 'runtimeContractSignature'), - sandboxModel: readRuntimeSandboxModelDetail(row.detail), - boundary: readRuntimeBoundaryDetail(row.detail), - deliverySubstrate: readRuntimeDeliverySubstrateDetail(row.detail), - deliveryWorkerId: readStringDetail(row.detail, 'deliveryWorkerId'), - deliveryState: mapRuntimeDeliveryState(row.eventType, row.detail), - route: readStringDetail(row.detail, 'route'), - capability: readStringDetail(row.detail, 'capability'), - code: readStringDetail(row.detail, 'code'), - boundaryDenialCategory: readBoundaryDenialCategoryDetail(row.detail), - detail: row.detail, - }; -} - -function mapRuntimeDeliveryState( - eventType: string, - detail: Record, -): ControlPlaneRuntimeEvidenceSnapshot['deliveryState'] { - const code = readStringDetail(detail, 'code'); - - switch (eventType) { - case 'runtime.session.started': - return 'started'; - case 'runtime.session.exited': - return 'exited'; - case 'runtime.session.timeout': - return 'timedOut'; - case 'runtime.session.denied': - return 'denied'; - case 'runtime.capability.denied': - return 'capabilityDenied'; - case 'runtime.session.integrity_failed': - if (code === 'runtime_delivery_failed') { - return 'deliveryFailed'; - } - - if (code === 'runtime_file_missing') { - return 'assetMissing'; - } - - return 'integrityFailed'; - default: - return null; - } -} - -export function mapBrokerVerificationStatusRows( - internalRow: InternalBrokerVerificationRow | null, - officialRow: OfficialBrokerVerificationRow | null, -): BrokerVerificationStatus | null { - const supportedPath = internalRow?.scope ?? officialRow?.scope ?? null; - - if (supportedPath === null) { - return null; - } - - return { - supportedPath, - internal: internalRow === null ? null : { - source: internalRow.source, - status: internalRow.status, - checkedAt: normalizeTimestamp(internalRow.checkedAt), - summary: internalRow.summary, - evidenceUrl: internalRow.detailUrl, - }, - official: officialRow === null - ? { - state: 'notCertified', - checkedAt: null, - directoryUrl: null, - } - : { - state: officialRow.certificationState ?? 'notCertified', - checkedAt: normalizeTimestamp(officialRow.checkedAt), - directoryUrl: officialRow.detailUrl, - }, - }; -} - -export function mapCertificationWorkflowStatusRow( - row: CertificationWorkflowStatusRow, -): CertificationWorkflowStatus { - return { - workflowKey: row.workflowKey, - latestInternal: row.deploymentRecordId === null || - row.deploymentLabel === null || - row.status === null || - row.summary === null || - row.checkedAt === null - ? null - : { - deploymentRecordId: normalizeNumeric(row.deploymentRecordId), - deploymentLabel: row.deploymentLabel, - status: row.status, - checkedAt: normalizeTimestamp(row.checkedAt), - summary: row.summary, - evidenceUrl: row.detailUrl, - }, - }; -} - -export function mapLatestOfficialCertificationEvidenceRow( - row: LatestOfficialCertificationEvidenceRow | null, -): LatestOfficialCertificationEvidence | null { - if (row === null) { - return null; - } - - return { - workflowKey: row.workflowKey, - state: row.certificationState ?? 'notCertified', - checkedAt: normalizeTimestamp(row.checkedAt), - summary: row.summary, - directoryUrl: row.detailUrl, - }; -} - -function mapInventoryInstallEvidence(row: InventoryQueryRow): DeploymentActivitySnapshot | null { - if ( - row.installEvidenceStatus === null || - row.installEvidenceSummary === null || - row.installEvidenceDetail === null || - row.installEvidenceOccurredAt === null - ) { - return null; - } - - return mapActivitySnapshotRow({ - eventType: 'deployment.binding_saved', - status: row.installEvidenceStatus, - summary: row.installEvidenceSummary, - attemptId: null, - detail: row.installEvidenceDetail, - occurredAt: row.installEvidenceOccurredAt, - }); -} - -function mapInventoryBrokerVerification(row: InventoryQueryRow): BrokerVerificationStatus | null { - return mapBrokerVerificationStatusRows( - row.internalBrokerVerificationScope === null || - row.internalBrokerVerificationSource === null || - row.internalBrokerVerificationStatus === null || - row.internalBrokerVerificationSummary === null || - row.internalBrokerVerificationCheckedAt === null - ? null - : { - scope: row.internalBrokerVerificationScope, - source: row.internalBrokerVerificationSource, - status: row.internalBrokerVerificationStatus, - summary: row.internalBrokerVerificationSummary, - detailUrl: row.internalBrokerVerificationDetailUrl, - checkedAt: row.internalBrokerVerificationCheckedAt, - }, - row.officialBrokerVerificationScope === null || - row.officialBrokerVerificationStatus === null || - row.officialBrokerVerificationCheckedAt === null - ? null - : { - scope: row.officialBrokerVerificationScope, - status: row.officialBrokerVerificationStatus, - certificationState: row.officialBrokerVerificationCertificationState, - detailUrl: row.officialBrokerVerificationDetailUrl, - checkedAt: row.officialBrokerVerificationCheckedAt, - }, - ); -} diff --git a/src/ops/repository_mapping_support.ts b/src/ops/repository_mapping_support.ts deleted file mode 100644 index d99973a..0000000 --- a/src/ops/repository_mapping_support.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { isLtiProfileId } from '../lti/profile.ts'; -import type { LtiProfileId, ResolvedLtiProfile } from '../lti/profile.ts'; -import type { CanvasEnvironment, DeploymentBinding, LmsType } from '../lti/types.ts'; -import type { - CertificationWorkflowKey, - ControlPlaneActivityStatus, - ControlPlaneBoundaryDenialCategory, - ControlPlaneDiagnosticItem, - ControlPlaneRuntimeEvidenceSnapshot, -} from './types.ts'; -import type { RecordBrokerVerificationRunInput } from './repository_types.ts'; - -export function assertBrokerVerificationRunInput(input: RecordBrokerVerificationRunInput): void { - if (!isCertificationWorkflowKey(input.workflowKey)) { - throw new Error('Choose one supported certification workflow.'); - } - - if (input.source === '1edtech') { - if (input.deploymentRecordId !== null) { - throw new Error('Official 1EdTech verification runs cannot be tied to one deployment.'); - } - - if (input.status === 'notCertified' && input.certificationState !== null) { - throw new Error( - 'Official not-certified verification runs cannot carry a certification state.', - ); - } - - if (input.status === 'passed' && input.certificationState === null) { - throw new Error('Official passed verification runs require an explicit certification state.'); - } - - return; - } - - if (input.deploymentRecordId === null) { - throw new Error('Internal verification runs require an explicit deployment record id.'); - } - - if (input.status === 'notCertified') { - throw new Error('Only official 1EdTech verification runs can use the notCertified status.'); - } - - if (input.certificationState !== null) { - throw new Error('Internal verification runs cannot carry an official certification state.'); - } -} - -export function isCertificationWorkflowKey(value: string): value is CertificationWorkflowKey { - return value === 'core' || value === 'deepLinking' || value === 'nrps' || value === 'ags'; -} - -export function mapDeploymentBinding(input: { - lmsType?: LmsType | null; - canvasEnvironment: string | null; - issuer: string | null; - clientId: string | null; - deploymentId: string | null; - authorizationEndpoint?: string | null; - accessTokenUrl?: string | null; - jwksUrl?: string | null; -}): DeploymentBinding | null { - if (input.issuer === null || input.clientId === null || input.deploymentId === null) { - return null; - } - - const lmsType = input.lmsType ?? (input.canvasEnvironment === null ? null : 'canvas'); - - switch (lmsType) { - case 'canvas': - if (input.canvasEnvironment === null) { - return null; - } - - return { - lms: 'canvas', - canvasEnvironment: input.canvasEnvironment as CanvasEnvironment, - issuer: input.issuer, - clientId: input.clientId, - deploymentId: input.deploymentId, - }; - case 'moodle': - case 'sakai': - if ( - input.authorizationEndpoint === null || - input.authorizationEndpoint === undefined || - input.accessTokenUrl === null || - input.accessTokenUrl === undefined || - input.jwksUrl === null || - input.jwksUrl === undefined - ) { - return null; - } - - return { - lms: lmsType, - issuer: input.issuer, - clientId: input.clientId, - deploymentId: input.deploymentId, - authorizationEndpoint: input.authorizationEndpoint, - accessTokenUrl: input.accessTokenUrl, - jwksUrl: input.jwksUrl, - }; - case null: - return null; - } -} - -export function normalizeTimestamp(value: Date | string | null): string { - if (value instanceof Date) { - return value.toISOString(); - } - - if (value === null) { - throw new Error('Expected a timestamp value.'); - } - - return value; -} - -export function normalizeOptionalTimestamp(value: Date | string | null): string | null { - if (value === null) { - return null; - } - - return normalizeTimestamp(value); -} - -export function normalizeNumeric(value: number | string | bigint): number { - if (typeof value === 'number') { - return value; - } - - if (typeof value === 'bigint') { - return Number(value); - } - - return Number(value); -} - -export function mapAuditActivityStatus( - eventType: string, - status: string, -): ControlPlaneActivityStatus { - if (eventType === 'launch.rejected' || status === 'failed') { - return 'failed'; - } - - if (status === 'accepted' || status === 'succeeded') { - return 'succeeded'; - } - - return 'pending'; -} - -export function mapDiagnosticKind(eventType: string): ControlPlaneDiagnosticItem['kind'] { - if (eventType.startsWith('launch.')) { - return 'launch'; - } - - if (eventType.startsWith('deep_linking.')) { - return 'deepLinking'; - } - - if (eventType === 'deployment.nrps_verified') { - return 'nrps'; - } - - if (eventType.startsWith('broker_verification.')) { - return 'brokerVerification'; - } - - if (eventType.startsWith('runtime.')) { - return 'runtime'; - } - - if (eventType.startsWith('reviewer.')) { - return 'reviewer'; - } - - return 'gradePublication'; -} - -export function readStringDetail(detail: Record, key: string): string | null { - const value = detail[key]; - return typeof value === 'string' ? value : null; -} - -export function readNumberDetail(detail: Record, key: string): number | null { - const value = detail[key]; - - if (typeof value === 'number' && Number.isFinite(value)) { - return value; - } - - if (typeof value === 'string' && value.trim() !== '') { - const numeric = Number(value); - - return Number.isFinite(numeric) ? numeric : null; - } - - return null; -} - -export function readBoundaryDenialCategoryDetail( - detail: Record, -): ControlPlaneBoundaryDenialCategory | null { - const value = readStringDetail(detail, 'category'); - - return value === 'policyDenied' || value === 'specInvalid' ? value : null; -} - -export function readRuntimeSandboxModelDetail( - detail: Record, -): ControlPlaneRuntimeEvidenceSnapshot['sandboxModel'] { - const value = readStringDetail(detail, 'sandboxModel'); - - return value === 'contained_browser_runtime' ? value : null; -} - -export function readRuntimeBoundaryDetail( - detail: Record, -): ControlPlaneRuntimeEvidenceSnapshot['boundary'] { - const value = readStringDetail(detail, 'boundary'); - - return value === 'app_runtime_origin' ? value : null; -} - -export function readRuntimeDeliverySubstrateDetail( - detail: Record, -): ControlPlaneRuntimeEvidenceSnapshot['deliverySubstrate'] { - const value = readStringDetail(detail, 'deliverySubstrate'); - - return value === 'direct' || value === 'dynamic_worker' ? value : null; -} - -export function readLtiProfileIdDetail(detail: Record): LtiProfileId | null { - const value = readStringDetail(detail, 'ltiProfileId'); - - return value !== null && isLtiProfileId(value) ? value : null; -} - -export function readLtiProfileSourceDetail( - detail: Record, -): ResolvedLtiProfile['source'] | null { - const value = readStringDetail(detail, 'ltiProfileSource'); - - return value === 'lanternDefault' || value === 'deploymentOverride' ? value : null; -} diff --git a/src/ops/repository_queries.ts b/src/ops/repository_queries.ts deleted file mode 100644 index ab55712..0000000 --- a/src/ops/repository_queries.ts +++ /dev/null @@ -1,356 +0,0 @@ -export { - BROKER_VERIFICATION_SUPPORTED_PATH_BY_LMS, - INVENTORY_BASE_QUERY, - INVENTORY_ORDER_BY, -} from './repository_inventory_queries.ts'; - -const CERTIFICATION_WORKFLOW_ORDER_SQL = ` - CASE certification_workflows.workflow_key - WHEN 'core' THEN 1 - WHEN 'deepLinking' THEN 2 - WHEN 'nrps' THEN 3 - WHEN 'ags' THEN 4 - ELSE 5 - END -`; - -export const LATEST_CERTIFICATION_WORKFLOW_STATUSES_QUERY = ` - WITH certification_workflows (workflow_key) AS ( - VALUES ('core'), ('deepLinking'), ('nrps'), ('ags') - ) - SELECT - certification_workflows.workflow_key, - internal_run.deployment_record_id, - deployments.label AS deployment_label, - internal_run.status, - internal_run.summary, - internal_run.detail_url, - internal_run.checked_at - FROM certification_workflows - LEFT JOIN LATERAL ( - SELECT - broker_verification_runs.deployment_record_id, - broker_verification_runs.status, - broker_verification_runs.summary, - broker_verification_runs.detail_url, - broker_verification_runs.checked_at - FROM broker_verification_runs - WHERE broker_verification_runs.workflow_key = - certification_workflows.workflow_key - AND broker_verification_runs.deployment_record_id IS NOT NULL - AND broker_verification_runs.source IN ('manual', 'ci') - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 - ) AS internal_run ON TRUE - LEFT JOIN deployments - ON deployments.id = internal_run.deployment_record_id - ORDER BY ${CERTIFICATION_WORKFLOW_ORDER_SQL} -`; - -export const LATEST_OFFICIAL_CERTIFICATION_EVIDENCE_QUERY = ` - SELECT - broker_verification_runs.workflow_key, - broker_verification_runs.status, - broker_verification_runs.certification_state, - broker_verification_runs.summary, - broker_verification_runs.detail_url, - broker_verification_runs.checked_at - FROM broker_verification_runs - WHERE broker_verification_runs.source = '1edtech' - AND broker_verification_runs.workflow_key IS NOT NULL - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 -`; - -export const LATEST_GLOBAL_INTERNAL_BROKER_VERIFICATION_QUERY = ` - SELECT - broker_verification_runs.scope, - broker_verification_runs.source, - broker_verification_runs.status, - broker_verification_runs.summary, - broker_verification_runs.detail_url, - broker_verification_runs.checked_at - FROM broker_verification_runs - WHERE broker_verification_runs.deployment_record_id IS NOT NULL - AND broker_verification_runs.source IN ('manual', 'ci') - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 -`; -export const LATEST_LAUNCH_QUERY = ` - SELECT - audit_events.event_type, - audit_events.status, - audit_events.summary, - audit_events.attempt_id, - audit_events.detail, - audit_events.occurred_at - FROM audit_events - WHERE audit_events.deployment_record_id = $1 - AND audit_events.event_type LIKE 'launch.%' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 -`; -export const LATEST_RUNTIME_SESSION_QUERY = ` - SELECT - audit_events.event_type, - audit_events.status, - audit_events.summary, - audit_events.attempt_id, - audit_events.detail, - audit_events.occurred_at - FROM audit_events - WHERE audit_events.deployment_record_id = $1 - AND audit_events.event_type = 'runtime.session.started' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 -`; -export const LATEST_RUNTIME_OUTCOME_QUERY = ` - SELECT - audit_events.event_type, - audit_events.status, - audit_events.summary, - audit_events.attempt_id, - audit_events.detail, - audit_events.occurred_at - FROM audit_events - WHERE audit_events.deployment_record_id = $1 - AND audit_events.event_type LIKE 'runtime.%' - AND audit_events.event_type NOT IN ( - 'runtime.session.started', - 'runtime.capability.allowed' - ) - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 -`; -export const RECENT_ACCEPTED_LAUNCHES_QUERY = ` - SELECT - audit_events.summary, - audit_events.actor_id, - COALESCE( - attempts.user_id, - NULLIF(audit_events.detail ->> 'userId', ''), - audit_events.actor_id - ) AS user_id, - COALESCE( - attempts.user_display_name, - NULLIF(audit_events.detail ->> 'userDisplayName', '') - ) AS user_display_name, - COALESCE( - attempts.user_email, - NULLIF(audit_events.detail ->> 'userEmail', '') - ) AS user_email, - COALESCE( - attempts.user_login, - NULLIF(audit_events.detail ->> 'userLogin', '') - ) AS user_login, - audit_events.attempt_id, - audit_events.detail, - audit_events.occurred_at - FROM audit_events - LEFT JOIN attempts - ON attempts.attempt_id = audit_events.attempt_id - WHERE audit_events.deployment_record_id = $1 - AND audit_events.event_type = 'launch.accepted' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 10 -`; -export const LATEST_COMPATIBILITY_PATH_QUERY = ` - SELECT - audit_events.event_type, - audit_events.status, - audit_events.summary, - audit_events.attempt_id, - audit_events.detail, - audit_events.occurred_at - FROM audit_events - WHERE audit_events.deployment_record_id = $1 - AND audit_events.event_type = 'interop.path_used' - AND COALESCE(audit_events.detail ->> 'scope', '') IN ( - 'login', - 'launch', - 'deep_linking', - 'service' - ) - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 -`; -export const LATEST_NRPS_QUERY = ` - SELECT - audit_events.event_type, - audit_events.status, - audit_events.summary, - audit_events.attempt_id, - audit_events.detail, - audit_events.occurred_at - FROM audit_events - WHERE audit_events.deployment_record_id = $1 - AND audit_events.event_type = 'deployment.nrps_verified' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 -`; -export const LATEST_AGS_SMOKE_QUERY = ` - SELECT - audit_events.event_type, - audit_events.status, - audit_events.summary, - audit_events.attempt_id, - audit_events.detail, - audit_events.occurred_at - FROM audit_events - WHERE audit_events.deployment_record_id = $1 - AND audit_events.event_type = 'deployment.ags_smoke_verified' - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC - LIMIT 1 -`; -export const LATEST_GRADE_PUBLICATION_QUERY = ` - SELECT - grade_publications.attempt_id, - grade_publications.status, - grade_publications.line_item_url, - grade_publications.platform_user_id, - grade_publications.score_given, - grade_publications.score_maximum, - grade_publications.activity_progress, - grade_publications.grading_progress, - grade_publications.published_at, - grade_publications.updated_at, - grade_publications.error_code, - grade_publications.error_detail - FROM attempts - INNER JOIN grade_publications - ON grade_publications.attempt_id = attempts.attempt_id - WHERE attempts.deployment_record_id = $1 - ORDER BY grade_publications.updated_at DESC, grade_publications.id DESC - LIMIT 1 -`; - -export const DIAGNOSTICS_QUERY = ` - SELECT - audit_events.id, - audit_events.event_type, - audit_events.actor_type, - audit_events.status, - audit_events.deployment_record_id, - audit_events.attempt_id, - audit_events.summary, - audit_events.detail, - audit_events.occurred_at - FROM audit_events - WHERE audit_events.deployment_record_id = $1 - AND audit_events.status = 'failed' - AND ( - audit_events.event_type LIKE 'launch.%' - OR audit_events.event_type = 'deep_linking.request.rejected' - OR audit_events.event_type = 'deployment.nrps_verified' - OR audit_events.event_type LIKE 'grade_publish.%' - OR audit_events.event_type LIKE 'broker_verification.%' - OR audit_events.event_type LIKE 'runtime.%' - OR audit_events.event_type LIKE 'reviewer.%' - ) - ORDER BY audit_events.occurred_at DESC, audit_events.id DESC -`; - -export const LATEST_OFFICIAL_BROKER_VERIFICATION_QUERY = ` - SELECT - broker_verification_runs.scope, - broker_verification_runs.status, - broker_verification_runs.certification_state, - broker_verification_runs.detail_url, - broker_verification_runs.checked_at - FROM broker_verification_runs - WHERE broker_verification_runs.scope = $1 - AND broker_verification_runs.source = '1edtech' - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 -`; -export const LATEST_GLOBAL_OFFICIAL_BROKER_VERIFICATION_QUERY = ` - SELECT - broker_verification_runs.scope, - broker_verification_runs.status, - broker_verification_runs.certification_state, - broker_verification_runs.detail_url, - broker_verification_runs.checked_at - FROM broker_verification_runs - WHERE broker_verification_runs.source = '1edtech' - ORDER BY broker_verification_runs.checked_at DESC, broker_verification_runs.id DESC - LIMIT 1 -`; - -export const INSERT_BROKER_VERIFICATION_RUN_QUERY = ` - INSERT INTO broker_verification_runs ( - deployment_record_id, - scope, - workflow_key, - source, - status, - summary, - detail_url, - certification_state, - checked_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) -`; - -export const RETRYABLE_GRADE_PUBLICATION_LOOKUP_QUERY = ` - SELECT - attempts.attempt_id, - attempts.deployment_record_id, - attempts.deployment_slug, - grade_publications.status AS publication_status, - grade_publications.line_item_url, - grade_publications.platform_user_id, - grade_publications.score_given, - grade_publications.score_maximum, - grade_publications.activity_progress, - grade_publications.grading_progress, - grade_publications.published_at, - grade_publications.updated_at, - grade_publications.error_code, - grade_publications.error_detail, - deployments.canvas_environment AS binding_canvas_environment, - deployments.issuer AS binding_issuer, - deployments.client_id AS binding_client_id, - deployments.deployment_id AS binding_deployment_id, - runtime_session.session_id, - runtime_session.attempt_id AS runtime_attempt_id, - runtime_session.deployment_record_id AS runtime_deployment_record_id, - runtime_session.deployment_slug AS runtime_deployment_slug, - runtime_session.app_id AS runtime_app_id, - runtime_session.package_version_id AS runtime_package_version_id, - runtime_session.package_version AS runtime_package_version, - runtime_session.ags_scope AS runtime_ags_scope, - runtime_session.ags_lineitems_url AS runtime_ags_lineitems_url, - runtime_session.ags_lineitem_url AS runtime_ags_lineitem_url, - runtime_session.nrps_context_memberships_url AS runtime_nrps_context_memberships_url, - runtime_session.nrps_service_versions AS runtime_nrps_service_versions, - runtime_session.created_at AS runtime_created_at, - runtime_session.expires_at AS runtime_expires_at - FROM attempts - INNER JOIN grade_publications - ON grade_publications.attempt_id = attempts.attempt_id - INNER JOIN deployments - ON deployments.id = attempts.deployment_record_id - LEFT JOIN LATERAL ( - SELECT - runtime_sessions.session_id, - runtime_sessions.attempt_id, - runtime_sessions.deployment_record_id, - runtime_sessions.deployment_slug, - runtime_sessions.app_id, - runtime_sessions.package_version_id, - runtime_sessions.package_version, - runtime_sessions.ags_scope, - runtime_sessions.ags_lineitems_url, - runtime_sessions.ags_lineitem_url, - runtime_sessions.nrps_context_memberships_url, - runtime_sessions.nrps_service_versions, - runtime_sessions.created_at, - runtime_sessions.expires_at - FROM runtime_sessions - WHERE runtime_sessions.attempt_id = attempts.attempt_id - ORDER BY runtime_sessions.created_at DESC, runtime_sessions.session_id DESC - LIMIT 1 - ) AS runtime_session ON TRUE - WHERE attempts.attempt_id = $1 - AND grade_publications.status = 'failed' - LIMIT 1 -`; diff --git a/src/ops/repository_retry_mapping.ts b/src/ops/repository_retry_mapping.ts deleted file mode 100644 index 4d8c60d..0000000 --- a/src/ops/repository_retry_mapping.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { LaunchServiceClaims } from '../lti/types.ts'; -import type { RetryableGradePublicationLookup, RetryRuntimeSessionLookup } from './types.ts'; -import type { RetryLookupRow } from './repository_types.ts'; -import { - mapDeploymentBinding, - normalizeNumeric, - normalizeOptionalTimestamp, - normalizeTimestamp, -} from './repository_mapping.ts'; - -export function mapRetryLookupRow(row: RetryLookupRow): RetryableGradePublicationLookup { - return { - attemptId: row.attemptId, - deploymentRecordId: row.deploymentRecordId, - deploymentSlug: row.deploymentSlug, - publication: { - attemptId: row.attemptId, - status: row.publicationStatus, - lineItemUrl: row.lineItemUrl, - platformUserId: row.platformUserId, - scoreGiven: normalizeNumeric(row.scoreGiven), - scoreMaximum: normalizeNumeric(row.scoreMaximum), - activityProgress: row.activityProgress, - gradingProgress: row.gradingProgress, - publishedAt: normalizeOptionalTimestamp(row.publishedAt), - updatedAt: normalizeTimestamp(row.updatedAt), - errorCode: row.errorCode, - errorDetail: row.errorDetail, - }, - binding: mapDeploymentBinding({ - canvasEnvironment: row.bindingCanvasEnvironment, - issuer: row.bindingIssuer, - clientId: row.bindingClientId, - deploymentId: row.bindingDeploymentId, - }), - runtimeSession: mapRetryRuntimeSession(row), - }; -} - -function mapRetryRuntimeSession(row: RetryLookupRow): RetryRuntimeSessionLookup | null { - if ( - row.sessionId === null || - row.runtimeDeploymentRecordId === null || - row.runtimeDeploymentSlug === null || - row.runtimeAppId === null || - row.runtimePackageVersionId === null || - row.runtimePackageVersion === null || - row.runtimeCreatedAt === null || - row.runtimeExpiresAt === null - ) { - return null; - } - - return { - sessionId: row.sessionId, - ltiToolSessionId: row.runtimeLtiToolSessionId, - attemptId: row.runtimeAttemptId ?? row.attemptId, - deploymentRecordId: row.runtimeDeploymentRecordId, - deploymentSlug: row.runtimeDeploymentSlug, - appId: row.runtimeAppId, - packageVersionId: row.runtimePackageVersionId, - packageVersion: row.runtimePackageVersion, - services: mapLaunchServices({ - agsScope: row.runtimeAgsScope ?? [], - agsLineitemsUrl: row.runtimeAgsLineitemsUrl, - agsLineitemUrl: row.runtimeAgsLineitemUrl, - nrpsContextMembershipsUrl: row.runtimeNrpsContextMembershipsUrl, - nrpsServiceVersions: row.runtimeNrpsServiceVersions ?? [], - }), - createdAt: normalizeTimestamp(row.runtimeCreatedAt), - expiresAt: normalizeTimestamp(row.runtimeExpiresAt), - }; -} - -function mapLaunchServices(input: { - agsScope: string[]; - agsLineitemsUrl: string | null; - agsLineitemUrl: string | null; - nrpsContextMembershipsUrl: string | null; - nrpsServiceVersions: string[]; -}): LaunchServiceClaims { - const hasAgs = input.agsScope.length > 0 || input.agsLineitemsUrl !== null || - input.agsLineitemUrl !== null; - const hasNrps = input.nrpsContextMembershipsUrl !== null; - - return { - ags: hasAgs - ? { - scope: input.agsScope, - lineitemsUrl: input.agsLineitemsUrl, - lineitemUrl: input.agsLineitemUrl, - } - : null, - nrps: hasNrps - ? { - contextMembershipsUrl: input.nrpsContextMembershipsUrl!, - serviceVersions: input.nrpsServiceVersions, - } - : null, - }; -} diff --git a/src/ops/repository_types.ts b/src/ops/repository_types.ts deleted file mode 100644 index f5893e6..0000000 --- a/src/ops/repository_types.ts +++ /dev/null @@ -1,227 +0,0 @@ -import type { - ApprovalStatus, - AuditActorType, - AuditEventStatus, - GradePublicationStatus, - PlacementAuditSnapshot, -} from '../package_review/types.ts'; -import type { LmsType } from '../lti/types.ts'; -import type { - BrokerVerificationRunStatus, - BrokerVerificationSource, - BrokerVerificationStatus, - CertificationWorkflowKey, - CertificationWorkflowStatus, - ControlPlaneActivityStatus, - ControlPlaneDeploymentDetailSnapshot, - ControlPlaneDeploymentInventoryRow, - DeploymentGradePublicationSnapshot, - LatestOfficialCertificationEvidence, - OfficialCertificationState, - RetryableGradePublicationLookup, -} from './types.ts'; - -export interface InventoryQueryRow { - deploymentId: number; - deploymentSlug: string; - deploymentLabel: string; - appId: string; - appTitle: string; - ownerId: string | null; - enabledPackageVersionId: number | null; - enabledPackageVersion: string | null; - approvalStatus: ApprovalStatus | null; - reviewedAt: Date | string | null; - bindingLmsType: LmsType | null; - installEvidenceStatus: AuditEventStatus | null; - installEvidenceSummary: string | null; - installEvidenceDetail: Record | null; - installEvidenceOccurredAt: Date | string | null; - internalBrokerVerificationScope: BrokerVerificationStatus['supportedPath'] | null; - internalBrokerVerificationSource: BrokerVerificationSource | null; - internalBrokerVerificationStatus: BrokerVerificationRunStatus | null; - internalBrokerVerificationSummary: string | null; - internalBrokerVerificationDetailUrl: string | null; - internalBrokerVerificationCheckedAt: Date | string | null; - officialBrokerVerificationScope: BrokerVerificationStatus['supportedPath'] | null; - officialBrokerVerificationStatus: PersistedBrokerVerificationRunStatus | null; - officialBrokerVerificationCertificationState: - | Exclude< - OfficialCertificationState, - 'notCertified' - > - | null; - officialBrokerVerificationDetailUrl: string | null; - officialBrokerVerificationCheckedAt: Date | string | null; - bindingCanvasEnvironment: string | null; - bindingIssuer: string | null; - bindingClientId: string | null; - bindingDeploymentId: string | null; - bindingAuthorizationEndpoint: string | null; - bindingAccessTokenUrl: string | null; - bindingJwksUrl: string | null; - updatedAt: Date | string; - lastLaunchAt: Date | string | null; - lastLaunchStatus: ControlPlaneActivityStatus | null; - lastNrpsReadAt: Date | string | null; - lastNrpsReadStatus: ControlPlaneActivityStatus | null; - lastGradePublishAt: Date | string | null; - lastGradePublishStatus: GradePublicationStatus | null; - totalLaunches: number | string; - attemptsStarted: number | string; - attemptsCompleted: number | string; - gradePublishesSucceeded: number | string; - gradePublishesFailed: number | string; - recentActiveUsers: number | string; - usageLastLaunchAt: Date | string | null; - measuredAt: Date | string; -} - -export interface ActivitySnapshotRow { - eventType: string; - status: AuditEventStatus; - summary: string; - attemptId: string | null; - detail: Record; - occurredAt: Date | string; -} - -export interface RecentLaunchRow { - summary: string; - actorId: string | null; - userId: string | null; - userDisplayName: string | null; - userEmail: string | null; - userLogin: string | null; - attemptId: string | null; - detail: Record; - occurredAt: Date | string; -} - -export interface GradePublicationSnapshotRow { - attemptId: string; - status: GradePublicationStatus; - lineItemUrl: string; - platformUserId: string; - scoreGiven: number | string; - scoreMaximum: number | string; - activityProgress: DeploymentGradePublicationSnapshot['activityProgress']; - gradingProgress: DeploymentGradePublicationSnapshot['gradingProgress']; - publishedAt: Date | string | null; - updatedAt: Date | string; - errorCode: string | null; - errorDetail: Record | null; -} - -export interface DiagnosticRow { - id: number; - eventType: string; - actorType: AuditActorType; - status: AuditEventStatus; - deploymentRecordId: number | null; - attemptId: string | null; - summary: string; - detail: Record; - occurredAt: Date | string; -} - -export type PersistedBrokerVerificationRunStatus = BrokerVerificationRunStatus | 'notCertified'; - -export interface RecordBrokerVerificationRunInput { - deploymentRecordId: number | null; - source: BrokerVerificationSource; - scope: BrokerVerificationStatus['supportedPath']; - workflowKey: CertificationWorkflowKey; - status: PersistedBrokerVerificationRunStatus; - certificationState: Exclude | null; - summary: string; - detailUrl: string | null; - checkedAt: string; -} - -export interface InternalBrokerVerificationRow { - scope: BrokerVerificationStatus['supportedPath']; - source: BrokerVerificationSource; - status: BrokerVerificationRunStatus; - summary: string; - detailUrl: string | null; - checkedAt: Date | string; -} - -export interface OfficialBrokerVerificationRow { - scope: BrokerVerificationStatus['supportedPath']; - status: PersistedBrokerVerificationRunStatus; - certificationState: Exclude | null; - detailUrl: string | null; - checkedAt: Date | string; -} - -export interface CertificationWorkflowStatusRow { - workflowKey: CertificationWorkflowStatus['workflowKey']; - deploymentRecordId: number | string | bigint | null; - deploymentLabel: string | null; - status: Exclude | null; - summary: string | null; - detailUrl: string | null; - checkedAt: Date | string | null; -} - -export interface LatestOfficialCertificationEvidenceRow { - workflowKey: LatestOfficialCertificationEvidence['workflowKey']; - status: PersistedBrokerVerificationRunStatus; - certificationState: Exclude | null; - summary: string; - detailUrl: string | null; - checkedAt: Date | string; -} - -export interface RetryLookupRow { - attemptId: string; - deploymentRecordId: number; - deploymentSlug: string; - publicationStatus: GradePublicationStatus; - lineItemUrl: string; - platformUserId: string; - scoreGiven: number | string; - scoreMaximum: number | string; - activityProgress: DeploymentGradePublicationSnapshot['activityProgress']; - gradingProgress: DeploymentGradePublicationSnapshot['gradingProgress']; - publishedAt: Date | string | null; - updatedAt: Date | string; - errorCode: string | null; - errorDetail: Record | null; - bindingCanvasEnvironment: string | null; - bindingIssuer: string | null; - bindingClientId: string | null; - bindingDeploymentId: string | null; - sessionId: string | null; - runtimeLtiToolSessionId: string | null; - runtimeAttemptId: string | null; - runtimeDeploymentRecordId: number | null; - runtimeDeploymentSlug: string | null; - runtimeAppId: string | null; - runtimePackageVersionId: number | null; - runtimePackageVersion: string | null; - runtimeAgsScope: string[] | null; - runtimeAgsLineitemsUrl: string | null; - runtimeAgsLineitemUrl: string | null; - runtimeNrpsContextMembershipsUrl: string | null; - runtimeNrpsServiceVersions: string[] | null; - runtimeCreatedAt: Date | string | null; - runtimeExpiresAt: Date | string | null; -} - -export interface OpsRepository { - listControlPlaneDeployments(): Promise; - getControlPlaneDeploymentDetail( - deploymentRecordId: number, - ): Promise; - listCertificationWorkflowStatuses(): Promise; - getLatestOfficialCertificationEvidence(): Promise; - getLatestBrokerVerificationStatus(): Promise; - recordBrokerVerificationRun(input: RecordBrokerVerificationRunInput): Promise; - getRetryableGradePublicationLookup( - attemptId: string, - ): Promise; - getPlacementAuditSnapshot(placementId: string): Promise; -} diff --git a/src/ops/retry.ts b/src/ops/retry.ts deleted file mode 100644 index e961f5f..0000000 --- a/src/ops/retry.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { requireVerifiedLtiSessionWithContext } from '../lti/session_boundary.ts'; -import type { LanternLtiToolContext } from '../lti/tool_provider.ts'; -import type { PackageReviewRepository } from '../package_review/repository.ts'; -import type { RetryableGradePublicationLookup } from './types.ts'; - -export interface RetryLookupRepository extends - Pick< - PackageReviewRepository, - 'updateGradePublication' - > { - getRetryableGradePublicationLookup( - attemptId: string, - ): Promise; -} - -export async function retryFailedGradePublication(input: { - repository: RetryLookupRepository; - attemptId: string; - getLtiToolContext: () => Promise; - now?: () => Date; -}): Promise { - const lookup = await input.repository.getRetryableGradePublicationLookup(input.attemptId); - - if (lookup === null) { - throw new Error( - `Retry blocked: Lantern could not find a failed grade publication for attempt ${input.attemptId}.`, - ); - } - - const runtimeSession = lookup.runtimeSession; - - if (runtimeSession === null) { - throw new Error( - 'Retry blocked: Lantern no longer has the attempt-scoped runtime session for this grade publication.', - ); - } - - const ags = runtimeSession.services.ags; - - if (ags === null) { - throw new Error( - 'Retry blocked: the saved runtime session does not include AGS service context.', - ); - } - - const { context, session } = await requireVerifiedLtiSessionWithContext({ - getContext: input.getLtiToolContext, - sessionId: runtimeSession.ltiToolSessionId, - missingMessage: - 'Retry blocked: the saved runtime session does not include a verified LTI session.', - notFoundMessage: - 'Retry blocked: the verified LTI session for this grade publication was not found.', - }); - - const now = input.now ?? (() => new Date()); - const timestamp = now().toISOString(); - const result = await context.tool.createAdvantage(session).submitScore( - { - userId: lookup.publication.platformUserId, - scoreGiven: lookup.publication.scoreGiven, - scoreMaximum: lookup.publication.scoreMaximum, - activityProgress: lookup.publication.activityProgress, - gradingProgress: 'FullyGraded', - timestamp, - }, - { lineItemUrl: lookup.publication.lineItemUrl }, - ); - const gradePublication = result.success - ? await input.repository.updateGradePublication({ - attemptId: lookup.attemptId, - status: 'published', - updatedAt: timestamp, - publishedAt: timestamp, - errorCode: null, - errorDetail: null, - }) - : await input.repository.updateGradePublication({ - attemptId: lookup.attemptId, - status: 'failed', - updatedAt: timestamp, - publishedAt: null, - errorCode: 'score_publish_failed', - errorDetail: { - message: result.error.message, - code: result.error.code, - serviceKind: result.error.serviceKind, - operation: result.error.operation, - }, - }); - - return { - ...lookup, - publication: { - ...lookup.publication, - status: gradePublication.status, - publishedAt: gradePublication.publishedAt, - updatedAt: gradePublication.updatedAt, - errorCode: gradePublication.errorCode, - errorDetail: gradePublication.errorDetail, - }, - }; -} diff --git a/src/ops/service.ts b/src/ops/service.ts deleted file mode 100644 index ad9266f..0000000 --- a/src/ops/service.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { formatDiagnosticItem } from './diagnostics.ts'; -export { deriveDeploymentHealth, summarizePilotUsage } from './health.ts'; -export type { DeploymentHealthInput } from './health.ts'; -export { retryFailedGradePublication, type RetryLookupRepository } from './retry.ts'; diff --git a/src/ops/service_health_test.ts b/src/ops/service_health_test.ts deleted file mode 100644 index 4fb2b2e..0000000 --- a/src/ops/service_health_test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { assertEquals } from '@std/assert'; -import { buildControlPlaneDiagnosticItem } from '../test_helpers/package_review.ts'; - -Deno.test('ops service derives separate health dimensions instead of one opaque control-plane badge', async () => { - const modulePath = `./${'service.ts'}`; - const opsServiceModule = await import(modulePath); - const health = opsServiceModule.deriveDeploymentHealth({ - approvalStatus: 'approved', - enabledPackageVersionId: 1, - binding: { - lms: 'canvas', - canvasEnvironment: 'production', - issuer: 'https://canvas.instructure.com', - clientId: '10000000000001', - deploymentId: 'deployment-123', - }, - lastLaunchStatus: 'succeeded', - lastGradePublishStatus: 'failed', - lastNrpsReadStatus: 'succeeded', - brokerVerificationStatus: 'passed', - }); - - assertEquals(health.overallStatus, 'attention'); - assertEquals(health.dimensions.review.status, 'healthy'); - assertEquals(health.dimensions.enablement.status, 'healthy'); - assertEquals(health.dimensions.gradePublication.status, 'failed'); - assertEquals(health.dimensions.nrps.status, 'healthy'); - assertEquals(health.dimensions.brokerVerification.status, 'healthy'); -}); - -Deno.test('ops service formats launch, NRPS, and AGS diagnostics into operator-readable summaries without leaking secrets', async () => { - const modulePath = `./${'service.ts'}`; - const opsServiceModule = await import(modulePath); - const formatted = opsServiceModule.formatDiagnosticItem( - buildControlPlaneDiagnosticItem({ - kind: 'launch', - eventType: 'launch.rejected', - code: 'unsupported_message_type', - summary: 'Rejected launch before runtime handoff.', - detail: { - lms: 'sakai', - messageType: 'LtiDeepLinkingRequest', - supportedMessageType: 'LtiResourceLinkRequest', - issuer: 'https://sakai.example', - clientId: '10000000000001', - deploymentId: 'deployment-999', - idToken: 'secret-id-token', - }, - }), - ); - - assertEquals(formatted.kind, 'launch'); - assertEquals(formatted.operatorSummary.includes('resource-link baseline'), true); - assertEquals(formatted.operatorSummary.includes('Canvas'), false); - assertEquals(JSON.stringify(formatted.detail).includes('secret-id-token'), false); -}); - -Deno.test('ops service marks only attempt-scoped AGS failures as retryable diagnostics', async () => { - const modulePath = `./${'service.ts'}`; - const opsServiceModule = await import(modulePath); - const formatted = opsServiceModule.formatDiagnosticItem( - buildControlPlaneDiagnosticItem({ - kind: 'gradePublication', - eventType: 'grade_publish.failed', - attemptId: 'attempt-123', - code: 'token_request_failed', - summary: 'Canvas AGS score publish failed.', - }), - { - retryableAttemptId: 'attempt-123', - }, - ); - - assertEquals(formatted.retryable, true); - assertEquals(formatted.operatorSummary.includes('control plane'), true); -}); diff --git a/src/ops/service_test.ts b/src/ops/service_test.ts deleted file mode 100644 index 5b135bd..0000000 --- a/src/ops/service_test.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { assertEquals, assertRejects } from '@std/assert'; -import { - buildAttemptRecord, - buildControlPlaneDiagnosticItem, - buildDeploymentRecord, - buildGradePublicationRecord, - buildLineItemBindingRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from '../test_helpers/package_review.ts'; -import { - buildDeploymentBinding, - buildLaunchServiceClaims, - buildRuntimeSessionRecord, -} from '../test_helpers/lti.ts'; -import { createTestLtiToolContext } from '../test_helpers/lti_tool_context.ts'; -import { formatDiagnosticItem, retryFailedGradePublication } from './service.ts'; -import { publishRuntimeAttemptScore } from '../runtime/gateway_publication.ts'; - -Deno.test('ops service retries failed grade publication through the verified lti-tool session', async () => { - const ltiToolContext = createTestLtiToolContext({ - sessionId: 'lti-tool-session-123', - }); - const repository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - id: 1, - binding: buildDeploymentBinding(), - }), - ], - attempts: [ - buildAttemptRecord({ - id: 1, - attemptId: 'attempt-123', - status: 'completed', - completionState: 'completed', - finalizedAt: '2026-03-24T12:31:00Z', - }), - ], - gradePublications: [ - buildGradePublicationRecord({ - attemptId: 'attempt-123', - status: 'failed', - gradingProgress: 'Failed', - publishedAt: null, - updatedAt: '2026-03-24T12:35:00Z', - }), - ], - runtimeSessions: [ - buildRuntimeSessionRecord({ - ltiToolSessionId: 'lti-tool-session-123', - sessionId: 'runtime-session-123', - attemptId: 'attempt-123', - createdAt: '2026-03-24T12:30:00Z', - expiresAt: '2026-03-25T12:30:00Z', - }), - ], - }); - - const result = await retryFailedGradePublication({ - repository, - attemptId: 'attempt-123', - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - now: () => new Date('2026-03-24T12:45:00Z'), - }); - const savedPublication = await repository.getGradePublicationByAttemptId('attempt-123'); - - assertEquals(ltiToolContext.submittedScores.length, 1); - assertEquals(result.attemptId, 'attempt-123'); - assertEquals(result.publication.status, 'published'); - assertEquals(savedPublication?.id, 1); - assertEquals(savedPublication?.status, 'published'); - assertEquals(savedPublication?.publishedAt, '2026-03-24T12:45:00.000Z'); -}); - -Deno.test('ops service blocks retry when no failed publication or AGS context remains for the attempt', async () => { - const missingPublicationRepository = createInMemoryPackageReviewRepository({ - gradePublications: [ - buildGradePublicationRecord({ - attemptId: 'attempt-123', - status: 'published', - publishedAt: '2026-03-24T12:35:00Z', - }), - ], - runtimeSessions: [ - buildRuntimeSessionRecord({ - ltiToolSessionId: 'lti-tool-session-123', - attemptId: 'attempt-123', - expiresAt: '2026-03-25T12:30:00Z', - }), - ], - }); - const missingAgsRepository = createInMemoryPackageReviewRepository({ - deployments: [ - buildDeploymentRecord({ - id: 1, - binding: buildDeploymentBinding(), - }), - ], - attempts: [ - buildAttemptRecord({ - id: 2, - attemptId: 'attempt-456', - }), - ], - gradePublications: [ - buildGradePublicationRecord({ - attemptId: 'attempt-456', - status: 'failed', - publishedAt: null, - }), - ], - runtimeSessions: [ - buildRuntimeSessionRecord({ - ltiToolSessionId: 'lti-tool-session-123', - attemptId: 'attempt-456', - services: buildLaunchServiceClaims({ - ags: null, - }), - }), - ], - }); - const ltiToolContext = createTestLtiToolContext(); - - await assertRejects( - () => - retryFailedGradePublication({ - repository: missingPublicationRepository, - attemptId: 'attempt-123', - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - }), - Error, - 'could not find a failed grade publication', - ); - - await assertRejects( - () => - retryFailedGradePublication({ - repository: missingAgsRepository, - attemptId: 'attempt-456', - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - }), - Error, - 'does not include AGS service context', - ); -}); - -Deno.test('ops service formats governed runtime failures with runtime-specific operator summaries', () => { - const capabilityDenied = formatDiagnosticItem( - buildControlPlaneDiagnosticItem({ - kind: 'runtime', - eventType: 'runtime.capability.denied', - code: 'capability_not_granted', - boundaryDenialCategory: 'policyDenied', - summary: 'Denied reviewed app capability write_local_state.', - detail: { - capability: 'write_local_state', - category: 'policyDenied', - sandboxModel: 'contained_browser_runtime', - boundary: 'app_runtime_origin', - }, - }), - ); - const timeout = formatDiagnosticItem( - buildControlPlaneDiagnosticItem({ - kind: 'runtime', - eventType: 'runtime.session.timeout', - code: 'session_expired', - summary: 'Runtime session expired before the reviewed app could continue.', - detail: { - sandboxModel: 'contained_browser_runtime', - boundary: 'app_runtime_origin', - }, - }), - ); - const integrityFailure = formatDiagnosticItem( - buildControlPlaneDiagnosticItem({ - kind: 'runtime', - eventType: 'runtime.session.integrity_failed', - code: 'package_version_missing', - summary: 'Reviewed runtime integrity checks blocked this session.', - detail: { - sandboxModel: 'contained_browser_runtime', - boundary: 'app_runtime_origin', - }, - }), - ); - - assertEquals( - capabilityDenied.operatorSummary, - 'Lantern denied reviewed app capability write_local_state at the governed runtime boundary.', - ); - assertEquals( - timeout.operatorSummary, - 'Runtime session timed out before the reviewed app could continue.', - ); - assertEquals( - integrityFailure.operatorSummary, - 'Reviewed runtime integrity checks blocked this session before app code could continue.', - ); -}); - -Deno.test('runtime publication uses lti-tool to publish final scores', async () => { - const ltiToolContext = createTestLtiToolContext({ - sessionId: 'lti-tool-session-123', - }); - const deployment = buildDeploymentRecord({ - id: 1, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }); - const attempt = buildAttemptRecord({ - id: 1, - attemptId: 'attempt-123', - deploymentRecordId: deployment.id, - deploymentSlug: deployment.slug, - packageVersionId: 1, - status: 'completed', - completionState: 'completed', - finalizedAt: '2026-03-24T12:31:00Z', - }); - const session = buildRuntimeSessionRecord({ - ltiToolSessionId: 'lti-tool-session-123', - attemptId: attempt.attemptId, - deploymentRecordId: deployment.id, - deploymentSlug: deployment.slug, - packageVersionId: 1, - packageVersion: '0.1.0', - }); - const packageVersion = buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }); - const repository = createInMemoryPackageReviewRepository({ - deployments: [deployment], - attempts: [attempt], - runtimeSessions: [session], - packageVersions: [packageVersion], - lineItemBindings: [ - buildLineItemBindingRecord({ - deploymentRecordId: deployment.id, - packageVersionId: packageVersion.id, - contextId: attempt.contextId, - resourceLinkId: attempt.resourceLinkId, - activityId: attempt.activityId, - }), - ], - }); - - const result = await publishRuntimeAttemptScore({ - repository, - session, - attempt, - packageVersion, - score: { - scoreGiven: 85, - scoreMaximum: 100, - }, - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - now: () => new Date('2026-03-24T12:45:00Z'), - }); - - assertEquals(result.gradePublishedNow, true); - assertEquals(result.publishError, null); - assertEquals(result.gradePublication?.status, 'published'); - assertEquals(ltiToolContext.submittedScores.length, 1); -}); - -Deno.test('runtime publication records lti-tool score publish failures', async () => { - const ltiToolContext = createTestLtiToolContext({ - sessionId: 'lti-tool-session-123', - submitScoreSuccess: false, - submitScoreMessage: 'score publish failed', - }); - const deployment = buildDeploymentRecord({ - id: 1, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - binding: buildDeploymentBinding(), - }); - const attempt = buildAttemptRecord({ - id: 1, - attemptId: 'attempt-123', - deploymentRecordId: deployment.id, - deploymentSlug: deployment.slug, - packageVersionId: 1, - status: 'completed', - completionState: 'completed', - finalizedAt: '2026-03-24T12:31:00Z', - }); - const session = buildRuntimeSessionRecord({ - ltiToolSessionId: 'lti-tool-session-123', - attemptId: attempt.attemptId, - deploymentRecordId: deployment.id, - deploymentSlug: deployment.slug, - packageVersionId: 1, - packageVersion: '0.1.0', - }); - const packageVersion = buildPackageVersionRecord({ - id: 1, - approvalStatus: 'approved', - reviewedAt: '2026-03-23T18:05:00Z', - }); - const repository = createInMemoryPackageReviewRepository({ - deployments: [deployment], - attempts: [attempt], - runtimeSessions: [session], - packageVersions: [packageVersion], - lineItemBindings: [ - buildLineItemBindingRecord({ - deploymentRecordId: deployment.id, - packageVersionId: packageVersion.id, - contextId: attempt.contextId, - resourceLinkId: attempt.resourceLinkId, - activityId: attempt.activityId, - }), - ], - }); - - const result = await publishRuntimeAttemptScore({ - repository, - session, - attempt, - packageVersion, - score: { - scoreGiven: 85, - scoreMaximum: 100, - }, - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - now: () => new Date('2026-03-24T12:45:00Z'), - }); - - assertEquals(result.gradePublishedNow, false); - assertEquals(result.publishError?.code, 'score_publish_failed'); - assertEquals(result.publishError?.message, 'score publish failed'); - assertEquals(result.gradePublication?.status, 'failed'); -}); diff --git a/src/ops/types.ts b/src/ops/types.ts deleted file mode 100644 index 86c8b9d..0000000 --- a/src/ops/types.ts +++ /dev/null @@ -1,294 +0,0 @@ -import type { DeploymentBinding, LaunchServiceClaims } from '../lti/types.ts'; -import type { LtiProfileId, ResolvedLtiProfile } from '../lti/profile.ts'; -import type { - ApprovalStatus, - AuditActorType, - AuditEventStatus, - GradePublicationRecord, - GradePublicationStatus, -} from '../package_review/types.ts'; - -export type ControlPlaneHealthStatus = 'healthy' | 'attention' | 'failed' | 'unknown'; - -export type ControlPlaneHealthDimensionName = - | 'review' - | 'enablement' - | 'launch' - | 'gradePublication' - | 'nrps' - | 'brokerVerification'; - -export type ControlPlaneActivityStatus = 'succeeded' | 'failed' | 'pending' | 'notRun'; - -export type ControlPlaneDiagnosticKind = - | 'launch' - | 'deepLinking' - | 'nrps' - | 'gradePublication' - | 'brokerVerification' - | 'runtime' - | 'reviewer'; - -export type ControlPlaneBoundaryDenialCategory = 'specInvalid' | 'policyDenied'; - -export type ControlPlaneRuntimeSandboxModel = 'contained_browser_runtime'; -export type ControlPlaneRuntimeBoundary = 'app_runtime_origin'; -export type ControlPlaneRuntimeDeliverySubstrate = 'direct' | 'dynamic_worker'; -export type ControlPlaneRuntimeDeliveryState = - | 'started' - | 'exited' - | 'deliveryFailed' - | 'assetMissing' - | 'integrityFailed' - | 'timedOut' - | 'denied' - | 'capabilityDenied'; - -export type BrokerVerificationSource = 'manual' | 'ci' | '1edtech'; -export type BrokerVerificationRunStatus = 'passed' | 'failed' | 'pending' | 'notRun'; -export type CertificationWorkflowKey = 'core' | 'deepLinking' | 'nrps' | 'ags'; -export type OfficialCertificationState = - | 'notCertified' - | 'ltiAdvantageCertified' - | 'ltiAdvantageComplete'; -export type BrokerVerificationSupportedPath = - | 'lti13LaunchAgsNrps' - | 'lti13LaunchAgsScore' - | 'lti13LaunchAgsScore'; - -export const CERTIFICATION_WORKFLOW_KEYS = [ - 'core', - 'deepLinking', - 'nrps', - 'ags', -] as const satisfies readonly CertificationWorkflowKey[]; - -export interface ControlPlaneHealthDimension { - name: ControlPlaneHealthDimensionName; - status: ControlPlaneHealthStatus; - summary: string; - checkedAt: string | null; -} - -export interface ControlPlaneHealthDimensions { - review: ControlPlaneHealthDimension; - enablement: ControlPlaneHealthDimension; - launch: ControlPlaneHealthDimension; - gradePublication: ControlPlaneHealthDimension; - nrps: ControlPlaneHealthDimension; - brokerVerification: ControlPlaneHealthDimension; -} - -export interface ControlPlaneDeploymentHealth { - overallStatus: ControlPlaneHealthStatus; - summary: string; - dimensions: ControlPlaneHealthDimensions; -} - -export interface PilotUsageMetrics { - deploymentRecordId: number; - totalLaunches: number; - attemptsStarted: number; - attemptsCompleted: number; - gradePublishesSucceeded: number; - gradePublishesFailed: number; - recentActiveUsers: number; - lastLaunchAt: string | null; - measuredAt: string; -} - -export interface DeploymentActivitySnapshot { - status: ControlPlaneActivityStatus; - occurredAt: string; - summary: string; - attemptId: string | null; - contextId: string | null; - detail: Record; -} - -export interface DeploymentRecentLaunch { - occurredAt: string; - summary: string; - attemptId: string | null; - userId: string | null; - userDisplayName: string | null; - userEmail: string | null; - userLogin: string | null; - contextId: string | null; - resourceLinkId: string | null; - ltiProfileId: LtiProfileId | null; - ltiProfileSource: ResolvedLtiProfile['source'] | null; -} - -export interface DeploymentGradePublicationSnapshot { - attemptId: string; - status: GradePublicationStatus; - lineItemUrl: string; - platformUserId: string; - scoreGiven: number; - scoreMaximum: number; - activityProgress: GradePublicationRecord['activityProgress']; - gradingProgress: GradePublicationRecord['gradingProgress']; - publishedAt: string | null; - updatedAt: string; - errorCode: string | null; - errorDetail: Record | null; -} - -export interface ControlPlaneDiagnosticItem { - id: number; - kind: ControlPlaneDiagnosticKind; - eventType: string; - actorType: AuditActorType; - status: AuditEventStatus; - deploymentRecordId: number | null; - attemptId: string | null; - code: string | null; - boundaryDenialCategory: ControlPlaneBoundaryDenialCategory | null; - summary: string; - operatorSummary: string; - retryable: boolean; - detail: Record; - occurredAt: string; -} - -export interface ControlPlaneRuntimeEvidenceSnapshot { - eventType: string; - status: ControlPlaneActivityStatus; - occurredAt: string; - summary: string; - attemptId: string | null; - sessionId: string | null; - packageVersionId: number | null; - packageVersion: string | null; - artifactDigest: string | null; - runtimeContractSignature: string | null; - sandboxModel: ControlPlaneRuntimeSandboxModel | null; - boundary: ControlPlaneRuntimeBoundary | null; - deliverySubstrate: ControlPlaneRuntimeDeliverySubstrate | null; - deliveryWorkerId: string | null; - deliveryState: ControlPlaneRuntimeDeliveryState | null; - route: string | null; - capability: string | null; - code: string | null; - boundaryDenialCategory: ControlPlaneBoundaryDenialCategory | null; - detail: Record; -} - -export interface ControlPlaneAnonymousEvidenceArtifact { - artifactId: string; - kind: string; - fileName: string; - contentType: string | null; - byteSize: number | null; - sha256: string | null; - createdAt: string | null; - artifactUrl: string; -} - -export interface RetryRuntimeSessionLookup { - sessionId: string; - ltiToolSessionId: string | null; - attemptId: string; - deploymentRecordId: number; - deploymentSlug: string; - appId: string; - packageVersionId: number; - packageVersion: string; - services: LaunchServiceClaims; - createdAt: string; - expiresAt: string; -} - -export interface RetryableGradePublicationLookup { - attemptId: string; - deploymentRecordId: number; - deploymentSlug: string; - publication: DeploymentGradePublicationSnapshot; - binding: DeploymentBinding | null; - runtimeSession: RetryRuntimeSessionLookup | null; -} - -export interface InternalBrokerVerificationStatus { - source: BrokerVerificationSource; - status: BrokerVerificationRunStatus; - checkedAt: string; - summary: string; - evidenceUrl: string | null; -} - -export interface OfficialBrokerCertificationStatus { - state: OfficialCertificationState; - checkedAt: string | null; - directoryUrl: string | null; -} - -export interface BrokerVerificationStatus { - supportedPath: BrokerVerificationSupportedPath; - internal: InternalBrokerVerificationStatus | null; - official: OfficialBrokerCertificationStatus; -} - -export interface CertificationWorkflowInternalEvidence { - deploymentRecordId: number; - deploymentLabel: string; - status: Exclude; - checkedAt: string; - summary: string; - evidenceUrl: string | null; -} - -export interface CertificationWorkflowStatus { - workflowKey: CertificationWorkflowKey; - latestInternal: CertificationWorkflowInternalEvidence | null; -} - -export interface LatestOfficialCertificationEvidence { - workflowKey: CertificationWorkflowKey; - state: OfficialCertificationState; - checkedAt: string; - summary: string; - directoryUrl: string | null; -} - -export interface ControlPlaneDeploymentInventoryRow { - deploymentId: number; - deploymentSlug: string; - deploymentLabel: string; - appId: string; - appTitle: string; - ownerId: string | null; - enabledPackageVersionId: number | null; - enabledPackageVersion: string | null; - approvalStatus: ApprovalStatus | null; - binding: DeploymentBinding | null; - installEvidence: DeploymentActivitySnapshot | null; - updatedAt: string; - lastLaunchAt: string | null; - lastLaunchStatus: ControlPlaneActivityStatus | null; - lastGradePublishAt: string | null; - lastGradePublishStatus: GradePublicationStatus | null; - lastNrpsReadAt: string | null; - lastNrpsReadStatus: ControlPlaneActivityStatus | null; - pilotUsage: PilotUsageMetrics; - health: ControlPlaneDeploymentHealth; - brokerVerification: BrokerVerificationStatus | null; -} - -export interface ControlPlaneDeploymentDetailSnapshot { - inventory: ControlPlaneDeploymentInventoryRow; - latestInstallEvidence: DeploymentActivitySnapshot | null; - latestLaunch: DeploymentActivitySnapshot | null; - latestRuntimeSession: ControlPlaneRuntimeEvidenceSnapshot | null; - latestRuntimeOutcome: ControlPlaneRuntimeEvidenceSnapshot | null; - latestAnonymousEvidence: ControlPlaneAnonymousEvidenceArtifact[]; - recentLaunches: DeploymentRecentLaunch[]; - latestCompatibilityPath?: DeploymentActivitySnapshot | null; - latestAgsSmoke: DeploymentActivitySnapshot | null; - latestNrpsRead: DeploymentActivitySnapshot | null; - latestGradePublish: DeploymentGradePublicationSnapshot | null; - pilotUsage: PilotUsageMetrics; - diagnostics: ControlPlaneDiagnosticItem[]; - retryableGradePublication: RetryableGradePublicationLookup | null; - brokerVerification: BrokerVerificationStatus | null; -} diff --git a/src/package_review/enablement_test.ts b/src/package_review/enablement_test.ts deleted file mode 100644 index 40a92ce..0000000 --- a/src/package_review/enablement_test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; -import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; -import { buildCanvasDeploymentBinding } from '../test_helpers/lti.ts'; -import { - buildAccessibilityReview, - buildDeploymentRecord, - buildPackageVersionRecord, -} from '../test_helpers/package_review.ts'; -import { renderDeploymentDetailPage } from '../admin/deployment_detail.ts'; - -Deno.test('renderDeploymentDetailPage shows semver history with strong status badges and an active pin', () => { - const approved = buildPackageVersionRecord({ - id: 7, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - accessibilityReview: buildAccessibilityReview({ - contrast: 'fail', - failureNotes: 'Low-contrast score meter needs adjustment.', - exceptionNote: 'Reviewed for a short pilot with classroom support.', - }), - reviewedAt: '2026-03-23T18:05:00Z', - }); - const pending = buildPackageVersionRecord({ - id: 8, - version: '0.2.0', - approvalStatus: 'pending', - }); - const body = renderDeploymentDetailPage({ - identity: ADMIN_TEST_IDENTITY, - appId: 'chapter-4-asteroids', - appTitle: 'Chapter 4 Asteroids', - history: [pending, approved], - deployments: [ - buildDeploymentRecord({ - enabledPackageVersionId: 7, - enabledPackageVersion: '0.1.0', - }), - ], - }); - - assertStringIncludes(body, 'Pinned to version 0.1.0.'); - assertStringIncludes(body, 'Version 0.2.0'); - assertStringIncludes(body, 'Pending review'); - assertStringIncludes(body, 'Live now'); - assertStringIncludes(body, 'Accessibility'); - assertStringIncludes(body, 'Flagged review'); - assertStringIncludes(body, 'Failed checks: Contrast.'); - assertStringIncludes(body, 'Reviewed for a short pilot with classroom support.'); -}); - -Deno.test('renderDeploymentDetailPage only offers approved versions in the picker', () => { - const approved = buildPackageVersionRecord({ - id: 7, - approvalStatus: 'approved', - reviewNotes: 'Ready for pilot.', - accessibilityReview: buildAccessibilityReview(), - reviewedAt: '2026-03-23T18:05:00Z', - }); - const pending = buildPackageVersionRecord({ - id: 8, - version: '0.2.0', - approvalStatus: 'pending', - }); - const body = renderDeploymentDetailPage({ - identity: ADMIN_TEST_IDENTITY, - appId: 'chapter-4-asteroids', - appTitle: 'Chapter 4 Asteroids', - history: [pending, approved], - deployments: [ - buildDeploymentRecord({ - enabledPackageVersionId: null, - enabledPackageVersion: null, - binding: buildCanvasDeploymentBinding(), - }), - ], - }); - - assertStringIncludes(body, 'option value="7"'); - assertEquals(body.includes('option value="8"'), false); - assertStringIncludes(body, 'Passed review'); - assertStringIncludes( - body, - 'Pending and rejected versions stay visible in history, but they cannot become active pins.', - ); -}); diff --git a/src/package_review/intake.ts b/src/package_review/intake.ts index 0298072..fc60d7c 100644 --- a/src/package_review/intake.ts +++ b/src/package_review/intake.ts @@ -12,6 +12,7 @@ export const REFERENCE_PACKAGE_SOURCE_ROOTS = { 'quick-study': 'examples/apps/quick-study', 'web-checkup': 'examples/apps/web-checkup', 'typescript-ladder-game': 'examples/apps/typescript-ladder-game', + 'photo-roster': 'examples/apps/photo-roster', } as const; export const REFERENCE_PACKAGE_BUCKET_ROOT = 'reference-packages'; @@ -118,7 +119,11 @@ export async function loadPackageSnapshot( return await finalizeImportedPackageVersion({ reviewData, - artifact: await buildArtifactRecord(input.snapshotStore, snapshotRoot, reviewData.entrypoint), + artifact: await buildArtifactRecord( + input.snapshotStore, + snapshotRoot, + reviewData.runtime.entrypoint, + ), env: input.env, }); } @@ -170,7 +175,7 @@ export async function importPackage(input: PackageSnapshotInput): Promise; roles: UserRole[]; install_scope?: InstallScope; - capabilities: Capability[]; + capabilities: BrowserCapability[]; grading: { mode: GradingSettings['mode']; rubric_file?: string; @@ -27,12 +40,36 @@ export interface AppManifest { fixtures_file: string; tests_file: string; }; - authoring?: { - kind: 'browser_autograder'; - grader_spec_files: string[]; - evidence_example_file: string; + authoring?: + | { + kind: 'browser_autograder'; + grader_spec_files: string[]; + evidence_example_file: string; + } + | { + kind: 'python_function'; + grader_spec_files?: never; + evidence_example_file?: never; + }; +} + +export interface WorkerAppManifest extends ManifestBase { + runtime: Extract; + roles: 'instructor'[]; + install_scope?: 'course'; + capabilities: RosterCapability[]; + grading: { + mode: 'completion'; + rubric_file?: never; + max_score?: never; + }; + browser?: never; + content_files?: never; + authoring?: never; + preview?: { + fixtures_file: string; + tests_file: string; }; - icon?: string; } export interface ManifestReviewData { @@ -41,7 +78,7 @@ export interface ManifestReviewData { title: string; description: string | null; owner: PackageOwner; - entrypoint: string; + runtime: AppRuntime; roles: UserRole[]; installScope: InstallScope; capabilities: Capability[]; @@ -101,7 +138,7 @@ export function buildManifestReviewData(manifest: AppManifest): ManifestReviewDa title: manifest.title, description: manifest.description ?? null, owner: manifest.owner, - entrypoint: manifest.entrypoint, + runtime: manifest.runtime, roles: manifest.roles, installScope: manifest.install_scope ?? 'course', capabilities: manifest.capabilities, @@ -128,12 +165,12 @@ function mapManifestIssue(error: ErrorObject): ValidationIssue { }; } - if (error.keyword === 'pattern' && error.instancePath === '/entrypoint') { + if (error.keyword === 'pattern' && error.instancePath === '/runtime/entrypoint') { return { - field: '/entrypoint', + field: '/runtime/entrypoint', keyword: error.keyword, severity: 'error', - message: 'Entrypoint must stay inside /dist and end in .html.', + message: 'Runtime entrypoint must stay inside /dist and match its runtime kind.', }; } @@ -155,7 +192,7 @@ function mapManifestIssue(error: ErrorObject): ValidationIssue { field: '/schema_version', keyword: error.keyword, severity: 'error', - message: 'Schema version must stay at "1" for this pilot.', + message: 'Schema version must be "2".', }; } @@ -168,6 +205,15 @@ function mapManifestIssue(error: ErrorObject): ValidationIssue { }; } + if (error.keyword === 'const' && error.instancePath === '/grading/max_score') { + return { + field: '/grading/max_score', + keyword: error.keyword, + severity: 'error', + message: 'Python sandbox grading uses a fixed maximum score of 10.', + }; + } + if (error.keyword === 'contains' && error.instancePath === '/capabilities') { return buildSimpleIssue( error, diff --git a/src/package_review/manifest_files.ts b/src/package_review/manifest_files.ts index d8811ad..99b8573 100644 --- a/src/package_review/manifest_files.ts +++ b/src/package_review/manifest_files.ts @@ -48,8 +48,8 @@ export async function collectReferencedFileIssuesFromSource( ): Promise { const references: Array<{ field: string; path: string }> = [ { - field: '/entrypoint', - path: manifest.entrypoint, + field: '/runtime/entrypoint', + path: manifest.runtime.entrypoint, }, ]; @@ -78,7 +78,7 @@ export async function collectReferencedFileIssuesFromSource( }); } - if (manifest.authoring) { + if (manifest.authoring?.kind === 'browser_autograder') { for (const [index, graderSpecPath] of manifest.authoring.grader_spec_files.entries()) { references.push({ field: `/authoring/grader_spec_files/${index}`, @@ -109,13 +109,30 @@ export async function collectReferencedFileIssuesFromSource( } } - const entrypointPath = trimLeadingSlash(manifest.entrypoint); - const entrypointHtml = await source.readText(entrypointPath); + if (manifest.runtime.kind === 'worker') { + const workerSource = await source.readText(trimLeadingSlash(manifest.runtime.entrypoint)); + if ( + workerSource !== null && + (/\bimport\s*(?:\(|[\w*{])/m.test(workerSource) || + /\bexport\s+(?:\*|{[^}]*})\s+from\s/m.test(workerSource)) + ) { + issues.push({ + field: '/runtime/entrypoint', + keyword: 'unbundled_worker', + severity: 'error', + message: + 'Reviewed Worker entrypoint must be a precompiled, self-contained ESM bundle with no imports.', + }); + } + return issues; + } + const entrypointPath = trimLeadingSlash(manifest.runtime.entrypoint); + const entrypointHtml = await source.readText(entrypointPath); if (entrypointHtml !== null) { for (const assetUrl of listRootRelativeReviewedAssetUrls(entrypointHtml)) { issues.push({ - field: '/entrypoint', + field: '/runtime/entrypoint', keyword: 'invalid_asset_path', severity: 'error', message: @@ -128,7 +145,7 @@ export async function collectReferencedFileIssuesFromSource( if (!layoutEntrypointExists) { issues.push({ - field: '/entrypoint', + field: '/runtime/entrypoint', keyword: 'missing_file', severity: 'error', message: 'Package must include /dist/index.html for the reviewed app shell.', diff --git a/src/package_review/manifest_test.ts b/src/package_review/manifest_test.ts index 560dd7e..b8e95c6 100644 --- a/src/package_review/manifest_test.ts +++ b/src/package_review/manifest_test.ts @@ -24,7 +24,10 @@ type ManifestFixture = { type: 'user'; id: string; }; - entrypoint: string; + runtime: { + kind: 'browser' | 'worker'; + entrypoint: string; + }; roles: string[]; install_scope?: string; capabilities: string[]; @@ -131,7 +134,7 @@ async function createPackageFixture( function buildValidManifest(): ManifestFixture { return { - schema_version: '1', + schema_version: '2', app_id: 'chapter-4-asteroids', version: '0.1.0', title: 'Chapter 4 Asteroids', @@ -140,7 +143,7 @@ function buildValidManifest(): ManifestFixture { type: 'user', id: 'instructor_123', }, - entrypoint: '/dist/index.html', + runtime: { kind: 'browser', entrypoint: '/dist/index.html' }, roles: ['learner', 'instructor'], install_scope: 'course', capabilities: [ @@ -194,12 +197,35 @@ Deno.test('validateManifest accepts the demo manifest and returns typed review d assertEquals(result.reviewData.version, '0.1.0'); assertEquals(result.reviewData.title, 'Chapter 4 Asteroids'); assertEquals(result.reviewData.owner.id, 'instructor_123'); - assertEquals(result.reviewData.entrypoint, '/dist/index.html'); + assertEquals(result.reviewData.runtime.entrypoint, '/dist/index.html'); assertEquals(result.reviewData.grading.mode, 'declarative'); assertEquals(result.reviewData.grading.rubricFile, '/scoring/rubric.json'); assertEquals(result.reviewData.validationIssues, []); }); +Deno.test('validateManifest rejects manifest v1 instead of using a compatibility parser', async () => { + const root = await createPackageFixture({ + ...buildValidManifest(), + schema_version: '1', + }); + try { + const result = await validateManifest(createFileSystemPackageSource(root)); + assertEquals(result.ok, false); + if (result.ok) { + throw new Error('Expected manifest v1 to be rejected.'); + } + assertEquals( + result.issues.some((issue) => + issue.field === '/schema_version' && + issue.message === 'Schema version must be "2".' + ), + true, + ); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + Deno.test('reviewed runtime contracts fail closed when the approved artifact, capability manifest, or signature drifts', async () => { const result = await validateManifest(createFileSystemPackageSource(DEMO_SOURCE_ROOT)); @@ -272,15 +298,19 @@ Deno.test('validateManifest accepts each curated reference app manifest and keep } assertEquals(result.reviewData.appId, appId); - assertEquals(result.reviewData.capabilities.includes('finalize_attempt'), true); - assertEquals(result.reviewData.capabilities.includes('read_activity_content'), true); + if (result.reviewData.runtime.kind === 'browser') { + assertEquals(result.reviewData.capabilities.includes('finalize_attempt'), true); + assertEquals(result.reviewData.capabilities.includes('read_activity_content'), true); + } else { + assertEquals(result.reviewData.capabilities.includes('lms.roster.members.read'), true); + } } }); Deno.test('validateManifest maps schema failures into a plain-language fix list', async () => { const root = await createPackageFixture({ ...buildValidManifest(), - entrypoint: '/index.html', + runtime: { kind: 'browser', entrypoint: '/index.html' }, grading: { mode: 'declarative', }, @@ -297,11 +327,17 @@ Deno.test('validateManifest maps schema failures into a plain-language fix list' assertEquals( result.issues.map((issue: { field: string }) => issue.field), - ['/entrypoint', '/grading/rubric_file', '/grading/max_score'], + [ + '/runtime/entrypoint', + '/runtime/kind', + '/runtime', + '/grading/rubric_file', + '/grading/max_score', + ], ); - assertStringIncludes(result.issues[0]?.message ?? '', 'Entrypoint'); - assertStringIncludes(result.issues[1]?.message ?? '', 'rubric file'); - assertStringIncludes(result.issues[2]?.message ?? '', 'max score'); + assertStringIncludes(result.issues[0]?.message ?? '', 'entrypoint'); + assertStringIncludes(result.issues[3]?.message ?? '', 'rubric file'); + assertStringIncludes(result.issues[4]?.message ?? '', 'max score'); for (const issue of result.issues) { assert(!issue.message.includes('must match pattern')); assert(!issue.message.includes('must have required property')); @@ -355,7 +391,7 @@ Deno.test('validateManifest rejects root-relative reviewed asset URLs in the ent assertEquals( result.issues.some( (issue: { field: string; keyword: string }) => - issue.field === '/entrypoint' && issue.keyword === 'invalid_asset_path', + issue.field === '/runtime/entrypoint' && issue.keyword === 'invalid_asset_path', ), true, ); diff --git a/src/package_review/repository.ts b/src/package_review/repository.ts index c4d371f..bbfa7ba 100644 --- a/src/package_review/repository.ts +++ b/src/package_review/repository.ts @@ -5,33 +5,23 @@ import type { AttemptRecord, AuditEventRecord, AuthoringDraftRecord, - DeepLinkingResourceOption, - DeploymentRecord, - GradePublicationRecord, - LineItemBindingRecord, PackageVersionRecord, - PlacementAuditSnapshot, PreviewEvidenceRecord, PreviewSessionOrigin, PreviewSessionRecord, - ReviewedPlacementRecord, } from './types.ts'; import type { ImportedPackageVersion } from './intake.ts'; -import type { - DeepLinkingSessionRecord, - DeploymentBinding, - DynamicRegistrationStateRecord, - LtiPlacement, - PersistedDeploymentLmsType, - RuntimeSessionRecord, -} from '../lti/types.ts'; -import type { LtiProfileId } from '../lti/profile.ts'; +import type { RuntimeSessionRecord } from '../lti/types.ts'; import type { AppGenerationRunRecord, AppGenerationWorkspaceRecord } from '../app_writer/types.ts'; export interface PackageReviewRepository { registerPackageVersion(input: ImportedPackageVersion): Promise; + registerPythonLabTrialPackageVersion( + input: ImportedPackageVersion, + ): Promise; listPackageVersions(): Promise; listPackageVersionsByApp(appId: string): Promise; + deletePythonLabPackageVersion(id: number): Promise; getPackageVersionById(id: number): Promise; getPackageVersionByAppVersion( appId: string, @@ -47,55 +37,6 @@ export interface PackageReviewRepository { reviewNotes: string | null; accessibilityReview: AccessibilityReview | null; }): Promise; - getDeploymentBySlug(slug: string): Promise; - listDeploymentsByApp(appId: string): Promise; - getDeploymentByBinding( - binding: Pick, - ): Promise; - getDeploymentByPlatformIdentity(input: { - issuer: string; - clientId: string | null; - deploymentId: string; - }): Promise; - completePendingCanvasBinding(input: { - issuer: string; - clientId: string; - deploymentId: string; - }): Promise; - createDynamicRegistrationState( - record: DynamicRegistrationStateRecord, - ): Promise; - getDynamicRegistrationStateByState(state: string): Promise; - consumeDynamicRegistrationState(input: { - state: string; - usedAt: string; - }): Promise; - createDeepLinkingSession(record: DeepLinkingSessionRecord): Promise; - getDeepLinkingSessionById(sessionId: string): Promise; - consumeDeepLinkingSession(input: { - sessionId: string; - usedAt: string; - }): Promise; - updateDeepLinkingSessionSelection(input: { - sessionId: string; - selection: DeepLinkingSessionRecord['selection']; - }): Promise; - listDeepLinkingResourceOptions( - appId: string, - placement: LtiPlacement, - ): Promise; - createReviewedPlacement(record: ReviewedPlacementRecord): Promise; - getReviewedPlacementById(placementId: string): Promise; - listReviewedPlacementsByPackageVersion( - packageVersionId: number, - ): Promise; - getPlacementAuditSnapshotById(placementId: string): Promise; - requirePlacementAuditSnapshotById(placementId: string): Promise; - bindReviewedPlacementResourceLink(input: { - placementId: string; - resourceLinkId: string; - boundAt: string; - }): Promise; createPreviewSession(record: PreviewSessionRecord): Promise; getPreviewSessionById(sessionId: string): Promise; getLatestPreviewSessionByPackageVersion( @@ -140,9 +81,6 @@ export interface PackageReviewRepository { listPreviewEvidence(previewSessionId: string): Promise; createRuntimeSession(record: RuntimeSessionRecord): Promise; getRuntimeSessionById(sessionId: string): Promise; - getLatestRuntimeSessionByDeploymentId( - deploymentRecordId: number, - ): Promise; createAttempt(record: Omit): Promise; getAttemptById(attemptId: string): Promise; listAttemptsByApp(appId: string): Promise; @@ -171,58 +109,7 @@ export interface PackageReviewRepository { attemptId: string; localState: AttemptRecord['localState']; }): Promise; - getLineItemBinding(input: { - deploymentRecordId: number; - packageVersionId: number; - contextId: string; - resourceLinkId: string; - activityId: string; - }): Promise; - saveLineItemBinding(record: Omit): Promise; - getGradePublicationByAttemptId(attemptId: string): Promise; - createGradePublication( - record: Omit, - ): Promise; - updateGradePublication(input: { - attemptId: string; - status: GradePublicationRecord['status']; - updatedAt: string; - publishedAt: string | null; - errorCode: string | null; - errorDetail: Record | null; - }): Promise; recordAuditEvent(record: Omit): Promise; listAuditEventsByAttemptId(attemptId: string): Promise; listAuditEventsByEventType(eventType: string): Promise; - saveDeploymentBinding(input: { - slug: string; - label: string; - appId: string; - binding: DeploymentBinding; - }): Promise; - saveCanvasRegistration(input: { - slug: string; - label: string; - appId: string; - canvasEnvironment: Extract['canvasEnvironment']; - issuer: string; - clientId: string; - }): Promise; - pinDeploymentVersion(input: { - slug: string; - label: string; - appId: string; - packageVersionId: number; - lmsType: PersistedDeploymentLmsType; - }): Promise; - getLanternLtiProfileSettings(): Promise; - saveLanternDefaultLtiProfile(input: { - defaultLtiProfile: LtiProfileId; - }): Promise; - saveDeploymentLtiProfileOverride(input: { - deploymentId: number; - ltiProfileOverride: LtiProfileId | null; - }): Promise; } - -export { derivePlacementAuditStatus } from './repository_mappers_review.ts'; diff --git a/src/package_review/repository_activity_d1.ts b/src/package_review/repository_activity_d1.ts new file mode 100644 index 0000000..80f1001 --- /dev/null +++ b/src/package_review/repository_activity_d1.ts @@ -0,0 +1,247 @@ +import type { D1Database } from '../db/d1.ts'; +import { queryD1First, queryD1Objects, runD1Batch } from '../db/d1.ts'; +import { + mapAttemptEvidenceArtifactRow, + mapAuditEventRow, + mapOptionalAttemptEvidenceArtifact, +} from './repository_mappers_attempts.ts'; +import { + type D1AttemptEvidenceArtifactRow, + type D1AuditEventRow, + mapD1AttemptEvidenceArtifactRow, + mapD1AuditEventFields, +} from './repository_activity_d1_mapping.ts'; +import type { PackageReviewRepository } from './repository.ts'; + +export function createD1ActivityRepositoryMethods( + db: D1Database, +): Pick< + PackageReviewRepository, + | 'createAttemptEvidenceArtifact' + | 'getAttemptEvidenceArtifactById' + | 'listAttemptEvidenceArtifacts' + | 'recordAuditEvent' + | 'listAuditEventsByAttemptId' + | 'listAuditEventsByEventType' +> { + return { + async createAttemptEvidenceArtifact(input) { + try { + const results = await runD1Batch( + db, + [ + { + sql: ` + INSERT INTO attempt_evidence_artifacts ( + artifact_id, + attempt_id, + sequence, + kind, + content_type, + file_name, + storage_key, + byte_size, + sha256, + created_at + ) + SELECT + ?, + attempts.attempt_id, + attempts.next_evidence_artifact_sequence, + ?, + ?, + ?, + ?, + ?, + ?, + ? + FROM attempts + WHERE attempts.attempt_id = ? + RETURNING + artifact_id AS artifactId, + attempt_id AS attemptId, + sequence, + kind, + content_type AS contentType, + file_name AS fileName, + storage_key AS storageKey, + byte_size AS byteSize, + sha256, + created_at AS createdAt + `, + parameters: [ + input.artifactId, + input.kind, + input.contentType, + input.fileName, + input.storageKey, + input.byteSize, + input.sha256, + input.createdAt, + input.attemptId, + ], + }, + { + sql: ` + UPDATE attempts + SET next_evidence_artifact_sequence = next_evidence_artifact_sequence + 1 + WHERE attempt_id = ? + `, + parameters: [input.attemptId], + }, + ], + ); + const row = results[0]?.results?.[0] ?? null; + + if (row === null) { + throw new Error(`Attempt ${input.attemptId} was not found.`); + } + + return mapAttemptEvidenceArtifactRow(mapD1AttemptEvidenceArtifactRow(row)); + } catch (error) { + if (isD1UniqueViolation(error)) { + throw new Error( + `Attempt evidence artifact ${input.artifactId} already exists and cannot be replaced.`, + ); + } + + throw error; + } + }, + + async getAttemptEvidenceArtifactById(artifactId) { + const row = await queryD1First( + db, + `${D1_ATTEMPT_EVIDENCE_ARTIFACT_SELECT} WHERE artifact_id = ?`, + [artifactId], + ); + + return mapOptionalAttemptEvidenceArtifact( + row === null ? undefined : mapD1AttemptEvidenceArtifactRow(row), + ); + }, + + async listAttemptEvidenceArtifacts(attemptId) { + const rows = await queryD1Objects( + db, + ` + ${D1_ATTEMPT_EVIDENCE_ARTIFACT_SELECT} + WHERE attempt_id = ? + ORDER BY sequence ASC + `, + [attemptId], + ); + + return rows.map((row) => mapAttemptEvidenceArtifactRow(mapD1AttemptEvidenceArtifactRow(row))); + }, + + async recordAuditEvent(record) { + const row = await queryD1First( + db, + ` + INSERT INTO audit_events ( + event_type, + actor_type, + actor_id, + package_version_id, + attempt_id, + status, + summary, + detail, + occurred_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING + id, + event_type AS eventType, + actor_type AS actorType, + actor_id AS actorId, + package_version_id AS packageVersionId, + attempt_id AS attemptId, + status, + summary, + detail, + occurred_at AS occurredAt + `, + [ + record.eventType, + record.actorType, + record.actorId, + record.packageVersionId, + record.attemptId, + record.status, + record.summary, + record.detail, + record.occurredAt, + ], + ); + + if (row === null) { + throw new Error('Expected audit event row after D1 insert.'); + } + + return mapAuditEventRow(mapD1AuditEventFields(row)); + }, + + async listAuditEventsByAttemptId(attemptId) { + const rows = await queryD1Objects( + db, + ` + ${D1_AUDIT_EVENT_SELECT} + WHERE attempt_id = ? + ORDER BY occurred_at ASC, id ASC + `, + [attemptId], + ); + + return rows.map((row) => mapAuditEventRow(mapD1AuditEventFields(row))); + }, + + async listAuditEventsByEventType(eventType) { + const rows = await queryD1Objects( + db, + ` + ${D1_AUDIT_EVENT_SELECT} + WHERE event_type = ? + ORDER BY occurred_at ASC, id ASC + `, + [eventType], + ); + + return rows.map((row) => mapAuditEventRow(mapD1AuditEventFields(row))); + }, + }; +} + +const D1_ATTEMPT_EVIDENCE_ARTIFACT_SELECT = ` + SELECT + artifact_id AS artifactId, + attempt_id AS attemptId, + sequence, + kind, + content_type AS contentType, + file_name AS fileName, + storage_key AS storageKey, + byte_size AS byteSize, + sha256, + created_at AS createdAt + FROM attempt_evidence_artifacts +`; + +const D1_AUDIT_EVENT_SELECT = ` + SELECT + id, + event_type AS eventType, + actor_type AS actorType, + actor_id AS actorId, + package_version_id AS packageVersionId, + attempt_id AS attemptId, + status, + summary, + detail, + occurred_at AS occurredAt + FROM audit_events +`; + +function isD1UniqueViolation(error: unknown): boolean { + return error instanceof Error && error.message.includes('UNIQUE constraint failed'); +} diff --git a/src/package_review/repository_activity_d1_mapping.ts b/src/package_review/repository_activity_d1_mapping.ts new file mode 100644 index 0000000..bc17178 --- /dev/null +++ b/src/package_review/repository_activity_d1_mapping.ts @@ -0,0 +1,68 @@ +import { + expectD1JsonRecord, + expectD1NullableNumber, + expectD1NullableString, + expectD1Number, + expectD1String, + expectD1StringLiteral, + parseD1JsonField, +} from '../db/d1_row.ts'; +import type { AttemptEvidenceArtifactRow, AuditEventRow } from './repository_row_types.ts'; + +export interface D1AttemptEvidenceArtifactRow extends Record { + artifactId: unknown; + attemptId: unknown; + sequence: unknown; + kind: unknown; + contentType: unknown; + fileName: unknown; + storageKey: unknown; + byteSize: unknown; + sha256: unknown; + createdAt: unknown; +} + +export interface D1AuditEventRow extends Record { + id: unknown; + eventType: unknown; + actorType: unknown; + actorId: unknown; + packageVersionId: unknown; + attemptId: unknown; + status: unknown; + summary: unknown; + detail: unknown; + occurredAt: unknown; +} + +export function mapD1AttemptEvidenceArtifactRow( + row: D1AttemptEvidenceArtifactRow, +): AttemptEvidenceArtifactRow { + return { + artifactId: expectD1String(row.artifactId, 'artifactId'), + attemptId: expectD1String(row.attemptId, 'attemptId'), + sequence: expectD1Number(row.sequence, 'sequence'), + kind: expectD1StringLiteral(row.kind, 'kind', ['screenshot_png', 'structured_json']), + contentType: expectD1String(row.contentType, 'contentType'), + fileName: expectD1String(row.fileName, 'fileName'), + storageKey: expectD1String(row.storageKey, 'storageKey'), + byteSize: expectD1Number(row.byteSize, 'byteSize'), + sha256: expectD1String(row.sha256, 'sha256'), + createdAt: expectD1String(row.createdAt, 'createdAt'), + }; +} + +export function mapD1AuditEventFields(row: D1AuditEventRow): AuditEventRow { + return { + id: expectD1Number(row.id, 'id'), + eventType: expectD1String(row.eventType, 'eventType'), + actorType: expectD1StringLiteral(row.actorType, 'actorType', ['user', 'system', 'platform']), + actorId: expectD1NullableString(row.actorId, 'actorId'), + packageVersionId: expectD1NullableNumber(row.packageVersionId, 'packageVersionId'), + attemptId: expectD1NullableString(row.attemptId, 'attemptId'), + status: expectD1StringLiteral(row.status, 'status', ['accepted', 'succeeded', 'failed']), + summary: expectD1String(row.summary, 'summary'), + detail: parseD1JsonField(row.detail, 'detail', expectD1JsonRecord), + occurredAt: expectD1String(row.occurredAt, 'occurredAt'), + }; +} diff --git a/src/package_review/repository_deep_linking_d1.ts b/src/package_review/repository_deep_linking_d1.ts deleted file mode 100644 index e83d21d..0000000 --- a/src/package_review/repository_deep_linking_d1.ts +++ /dev/null @@ -1,515 +0,0 @@ -import type { D1Database } from '../db/d1.ts'; -import { queryD1First, queryD1Objects, runD1 } from '../db/d1.ts'; -import { - type D1DeepLinkingSessionRow, - type D1PlacementAuditSnapshotRow, - type D1ReviewedPlacementRow, - mapD1DeepLinkingSessionFields, - mapD1PlacementAuditSnapshotFields, - mapD1ReviewedPlacementFields, -} from './repository_deep_linking_d1_mapping.ts'; -import { mapOptionalPlacementAuditSnapshot } from './repository_mappers_review.ts'; -import { - mapDeepLinkingSessionRow, - mapOptionalDeepLinkingSession, - mapOptionalReviewedPlacement, - mapReviewedPlacementRow, -} from './repository_mappers_sessions.ts'; -import { - buildDeepLinkingResourceOptions, - sortPackageVersions, -} from './repository_resource_options.ts'; -import type { PackageReviewRepository } from './repository.ts'; -import { - D1_PACKAGE_VERSION_SELECT, - type D1PackageVersionRow, - mapD1PackageVersionRow, -} from './repository_package_versions_d1.ts'; - -export function createD1DeepLinkingRepositoryMethods( - db: D1Database, -): Pick< - PackageReviewRepository, - | 'createDeepLinkingSession' - | 'getDeepLinkingSessionById' - | 'consumeDeepLinkingSession' - | 'updateDeepLinkingSessionSelection' - | 'listDeepLinkingResourceOptions' - | 'createReviewedPlacement' - | 'getReviewedPlacementById' - | 'listReviewedPlacementsByPackageVersion' - | 'getPlacementAuditSnapshotById' - | 'requirePlacementAuditSnapshotById' - | 'bindReviewedPlacementResourceLink' -> { - const methods: Pick< - PackageReviewRepository, - | 'createDeepLinkingSession' - | 'getDeepLinkingSessionById' - | 'consumeDeepLinkingSession' - | 'updateDeepLinkingSessionSelection' - | 'listDeepLinkingResourceOptions' - | 'createReviewedPlacement' - | 'getReviewedPlacementById' - | 'listReviewedPlacementsByPackageVersion' - | 'getPlacementAuditSnapshotById' - | 'requirePlacementAuditSnapshotById' - | 'bindReviewedPlacementResourceLink' - > = { - async createDeepLinkingSession(record) { - try { - await runD1( - db, - ` - INSERT INTO deep_linking_sessions ( - session_id, - session_token, - lti_tool_session_id, - deployment_record_id, - deployment_slug, - app_id, - user_id, - user_role, - context_id, - context_title, - deep_link_return_url, - data, - placement, - accept_types, - accept_multiple, - accept_presentation_document_targets, - accept_line_item, - selected_package_version_id, - selected_package_version, - selected_activity_id, - selected_content_path, - created_at, - expires_at, - used_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - record.sessionId, - record.sessionToken, - record.ltiToolSessionId, - record.deploymentRecordId, - record.deploymentSlug, - record.appId, - record.userId, - record.userRole, - record.contextId, - record.contextTitle, - record.deepLinkReturnUrl, - record.data, - record.placement, - record.acceptTypes, - record.acceptMultiple, - record.acceptPresentationDocumentTargets, - record.acceptLineItem, - record.selection?.packageVersionId ?? null, - record.selection?.packageVersion ?? null, - record.selection?.activityId ?? null, - record.selection?.contentPath ?? null, - record.createdAt, - record.expiresAt, - record.usedAt, - ], - ); - } catch (error) { - if (isD1UniqueViolation(error)) { - throw new Error( - `Deep Linking session ${record.sessionId} already exists and cannot be replaced.`, - ); - } - - throw error; - } - - return await requireDeepLinkingSession(db, record.sessionId); - }, - - async getDeepLinkingSessionById(sessionId) { - const row = await queryD1First( - db, - `${D1_DEEP_LINKING_SESSION_SELECT} WHERE session_id = ?`, - [sessionId], - ); - - return mapOptionalDeepLinkingSession( - row === null ? undefined : mapD1DeepLinkingSessionFields(row), - ); - }, - - async consumeDeepLinkingSession(input) { - await runD1( - db, - ` - UPDATE deep_linking_sessions - SET used_at = ? - WHERE session_id = ? - AND used_at IS NULL - `, - [input.usedAt, input.sessionId], - ); - - const existing = await requireDeepLinkingSession(db, input.sessionId); - - if (existing.usedAt === input.usedAt) { - return existing; - } - - if (existing.usedAt !== null) { - throw new Error(`Deep Linking session ${input.sessionId} has already been used.`); - } - - throw new Error(`Deep Linking session ${input.sessionId} could not be consumed.`); - }, - - async updateDeepLinkingSessionSelection(input) { - await runD1( - db, - ` - UPDATE deep_linking_sessions - SET - selected_package_version_id = ?, - selected_package_version = ?, - selected_activity_id = ?, - selected_content_path = ? - WHERE session_id = ? - AND used_at IS NULL - `, - [ - input.selection?.packageVersionId ?? null, - input.selection?.packageVersion ?? null, - input.selection?.activityId ?? null, - input.selection?.contentPath ?? null, - input.sessionId, - ], - ); - - const existing = await requireDeepLinkingSession(db, input.sessionId); - - if (existing.usedAt !== null) { - throw new Error(`Deep Linking session ${input.sessionId} has already been used.`); - } - - return existing; - }, - - async listDeepLinkingResourceOptions(appId, placement) { - const installScope = placement === 'assignment_selection' ? 'assignment' : 'course'; - const rows = await queryD1Objects( - db, - ` - ${D1_PACKAGE_VERSION_SELECT} - WHERE app_id = ? - AND install_scope = ? - AND approval_status = 'approved' - AND reviewed_at IS NOT NULL - `, - [appId, installScope], - ); - - return buildDeepLinkingResourceOptions( - sortPackageVersions(rows.map(mapD1PackageVersionRow)), - placement, - ); - }, - - async createReviewedPlacement(record) { - try { - await runD1( - db, - ` - INSERT INTO reviewed_placements ( - placement_id, - deployment_record_id, - deployment_slug, - app_id, - context_id, - context_title, - package_version_id, - package_version, - package_title, - activity_id, - content_path, - content_title, - created_by_user_id, - resource_link_id, - created_at, - bound_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - record.placementId, - record.deploymentRecordId, - record.deploymentSlug, - record.appId, - record.contextId, - record.contextTitle, - record.packageVersionId, - record.packageVersion, - record.packageTitle, - record.activityId, - record.contentPath, - record.contentTitle, - record.createdByUserId, - record.resourceLinkId, - record.createdAt, - record.boundAt, - ], - ); - } catch (error) { - if (isD1UniqueViolation(error)) { - throw new Error( - `Reviewed placement ${record.placementId} already exists and cannot be replaced.`, - ); - } - - throw error; - } - - return await requireReviewedPlacement(db, record.placementId); - }, - - async getReviewedPlacementById(placementId) { - const row = await queryD1First( - db, - `${D1_REVIEWED_PLACEMENT_SELECT} WHERE placement_id = ?`, - [placementId], - ); - - return mapOptionalReviewedPlacement( - row === null ? undefined : mapD1ReviewedPlacementFields(row), - ); - }, - - async listReviewedPlacementsByPackageVersion(packageVersionId) { - const rows = await queryD1Objects( - db, - `${D1_REVIEWED_PLACEMENT_SELECT} WHERE package_version_id = ? ORDER BY created_at DESC, placement_id ASC`, - [packageVersionId], - ); - - return rows.map((row) => mapReviewedPlacementRow(mapD1ReviewedPlacementFields(row))); - }, - - async getPlacementAuditSnapshotById(placementId) { - const row = await queryD1First( - db, - `${D1_PLACEMENT_AUDIT_SNAPSHOT_SELECT} WHERE reviewed_placements.placement_id = ?`, - [placementId], - ); - - return mapOptionalPlacementAuditSnapshot( - row === null ? undefined : mapD1PlacementAuditSnapshotFields(row), - ); - }, - - async requirePlacementAuditSnapshotById(placementId) { - const snapshot = await methods.getPlacementAuditSnapshotById(placementId); - - if (snapshot === null) { - throw new Error(`Reviewed placement ${placementId} was not found.`); - } - - return snapshot; - }, - - async bindReviewedPlacementResourceLink(input) { - const existing = await requireReviewedPlacement(db, input.placementId); - - if (existing.resourceLinkId !== null && existing.resourceLinkId !== input.resourceLinkId) { - throw new Error( - `Reviewed placement ${input.placementId} is already bound to Canvas resource link ${existing.resourceLinkId}.`, - ); - } - - if (existing.resourceLinkId === input.resourceLinkId) { - return existing; - } - - try { - await runD1( - db, - ` - UPDATE reviewed_placements - SET - resource_link_id = ?, - bound_at = ? - WHERE placement_id = ? - `, - [input.resourceLinkId, input.boundAt, input.placementId], - ); - } catch (error) { - if (isD1UniqueViolation(error)) { - throw new Error( - `Canvas resource link ${input.resourceLinkId} is already bound to another reviewed placement in deployment ${existing.deploymentSlug}.`, - ); - } - - throw error; - } - - return await requireReviewedPlacement(db, input.placementId); - }, - }; - - return methods; -} - -const D1_DEEP_LINKING_SESSION_SELECT = ` - SELECT - session_id AS sessionId, - session_token AS sessionToken, - lti_tool_session_id AS ltiToolSessionId, - deployment_record_id AS deploymentRecordId, - deployment_slug AS deploymentSlug, - app_id AS appId, - user_id AS userId, - user_role AS userRole, - context_id AS contextId, - context_title AS contextTitle, - deep_link_return_url AS deepLinkReturnUrl, - data, - placement, - accept_types AS acceptTypes, - accept_multiple AS acceptMultiple, - accept_presentation_document_targets AS acceptPresentationDocumentTargets, - accept_line_item AS acceptLineItem, - selected_package_version_id AS selectedPackageVersionId, - selected_package_version AS selectedPackageVersion, - selected_activity_id AS selectedActivityId, - selected_content_path AS selectedContentPath, - created_at AS createdAt, - expires_at AS expiresAt, - used_at AS usedAt - FROM deep_linking_sessions -`; - -const D1_REVIEWED_PLACEMENT_SELECT = ` - SELECT - placement_id AS placementId, - deployment_record_id AS deploymentRecordId, - deployment_slug AS deploymentSlug, - app_id AS appId, - context_id AS contextId, - context_title AS contextTitle, - package_version_id AS packageVersionId, - package_version AS packageVersion, - package_title AS packageTitle, - activity_id AS activityId, - content_path AS contentPath, - content_title AS contentTitle, - created_by_user_id AS createdByUserId, - resource_link_id AS resourceLinkId, - created_at AS createdAt, - bound_at AS boundAt - FROM reviewed_placements -`; - -const LATEST_PREVIEW_SESSION_ID = ` - SELECT preview_sessions.session_id - FROM preview_sessions - WHERE preview_sessions.package_version_id = reviewed_placements.package_version_id - ORDER BY preview_sessions.created_at DESC, preview_sessions.session_id DESC - LIMIT 1 -`; - -const D1_PLACEMENT_AUDIT_SNAPSHOT_SELECT = ` - SELECT - reviewed_placements.placement_id AS placementId, - reviewed_placements.deployment_record_id AS deploymentRecordId, - reviewed_placements.deployment_slug AS deploymentSlug, - reviewed_placements.app_id AS appId, - reviewed_placements.context_id AS contextId, - reviewed_placements.context_title AS contextTitle, - reviewed_placements.package_version_id AS packageVersionId, - reviewed_placements.package_version AS packageVersion, - reviewed_placements.package_title AS packageTitle, - reviewed_placements.activity_id AS activityId, - reviewed_placements.content_path AS contentPath, - reviewed_placements.content_title AS contentTitle, - reviewed_placements.created_by_user_id AS createdByUserId, - reviewed_placements.resource_link_id AS resourceLinkId, - reviewed_placements.created_at AS createdAt, - reviewed_placements.bound_at AS boundAt, - (${LATEST_PREVIEW_SESSION_ID}) AS latestPreviewSessionId, - ( - SELECT MAX(preview_evidence.occurred_at) - FROM preview_evidence - WHERE preview_evidence.preview_session_id = (${LATEST_PREVIEW_SESSION_ID}) - ) AS latestPreviewOccurredAt, - ( - SELECT COUNT(*) - FROM preview_evidence - WHERE preview_evidence.preview_session_id = (${LATEST_PREVIEW_SESSION_ID}) - ) AS previewEvidenceCount, - ( - SELECT COUNT(*) - FROM audit_events - WHERE audit_events.deployment_record_id = reviewed_placements.deployment_record_id - AND audit_events.package_version_id = reviewed_placements.package_version_id - AND audit_events.event_type LIKE 'deep_linking.request.%' - ) AS deepLinkingRequestCount, - ( - SELECT COUNT(*) - FROM audit_events - WHERE audit_events.deployment_record_id = reviewed_placements.deployment_record_id - AND audit_events.package_version_id = reviewed_placements.package_version_id - AND audit_events.event_type LIKE 'deep_linking.placement.%' - AND json_extract(audit_events.detail, '$.placementId') = reviewed_placements.placement_id - ) AS placementEventCount, - ( - SELECT COUNT(*) - FROM audit_events - WHERE audit_events.deployment_record_id = reviewed_placements.deployment_record_id - AND audit_events.package_version_id = reviewed_placements.package_version_id - AND audit_events.event_type LIKE 'reviewer.%' - AND json_extract(audit_events.detail, '$.placementId') = reviewed_placements.placement_id - ) AS reviewerEventCount, - ( - SELECT MAX(audit_events.occurred_at) - FROM audit_events - WHERE audit_events.deployment_record_id = reviewed_placements.deployment_record_id - AND audit_events.package_version_id = reviewed_placements.package_version_id - AND ( - audit_events.event_type LIKE 'deep_linking.request.%' - OR audit_events.event_type LIKE 'deep_linking.placement.%' - OR audit_events.event_type LIKE 'reviewer.%' - ) - ) AS latestAuditOccurredAt - FROM reviewed_placements -`; - -async function requireDeepLinkingSession(db: D1Database, sessionId: string) { - const row = await queryD1First( - db, - `${D1_DEEP_LINKING_SESSION_SELECT} WHERE session_id = ?`, - [sessionId], - ); - - if (row === null) { - throw new Error(`Deep Linking session ${sessionId} was not found.`); - } - - return mapDeepLinkingSessionRow(mapD1DeepLinkingSessionFields(row)); -} - -async function requireReviewedPlacement(db: D1Database, placementId: string) { - const row = await queryD1First( - db, - `${D1_REVIEWED_PLACEMENT_SELECT} WHERE placement_id = ?`, - [placementId], - ); - - if (row === null) { - throw new Error(`Reviewed placement ${placementId} was not found.`); - } - - return mapReviewedPlacementRow(mapD1ReviewedPlacementFields(row)); -} - -function isD1UniqueViolation(error: unknown): boolean { - return error instanceof Error && error.message.includes('UNIQUE constraint failed'); -} diff --git a/src/package_review/repository_deep_linking_d1_mapping.ts b/src/package_review/repository_deep_linking_d1_mapping.ts deleted file mode 100644 index 3cc67c5..0000000 --- a/src/package_review/repository_deep_linking_d1_mapping.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { - expectD1NullableNumber, - expectD1NullableString, - expectD1Number, - expectD1String, - expectD1StringArray, - expectD1StringLiteral, - parseD1JsonField, -} from '../db/d1_row.ts'; -import type { - DeepLinkingSessionRow, - PlacementAuditSnapshotRow, - ReviewedPlacementRow, -} from './repository_row_types.ts'; - -export interface D1DeepLinkingSessionRow extends Record { - sessionId: unknown; - sessionToken: unknown; - ltiToolSessionId: unknown; - deploymentRecordId: unknown; - deploymentSlug: unknown; - appId: unknown; - userId: unknown; - userRole: unknown; - contextId: unknown; - contextTitle: unknown; - deepLinkReturnUrl: unknown; - data: unknown; - placement: unknown; - acceptTypes: unknown; - acceptMultiple: unknown; - acceptPresentationDocumentTargets: unknown; - acceptLineItem: unknown; - selectedPackageVersionId: unknown; - selectedPackageVersion: unknown; - selectedActivityId: unknown; - selectedContentPath: unknown; - createdAt: unknown; - expiresAt: unknown; - usedAt: unknown; -} - -export interface D1ReviewedPlacementRow extends Record { - placementId: unknown; - deploymentRecordId: unknown; - deploymentSlug: unknown; - appId: unknown; - contextId: unknown; - contextTitle: unknown; - packageVersionId: unknown; - packageVersion: unknown; - packageTitle: unknown; - activityId: unknown; - contentPath: unknown; - contentTitle: unknown; - createdByUserId: unknown; - resourceLinkId: unknown; - createdAt: unknown; - boundAt: unknown; -} - -export interface D1PlacementAuditSnapshotRow extends D1ReviewedPlacementRow { - latestPreviewSessionId: unknown; - latestPreviewOccurredAt: unknown; - previewEvidenceCount: unknown; - deepLinkingRequestCount: unknown; - placementEventCount: unknown; - reviewerEventCount: unknown; - latestAuditOccurredAt: unknown; -} - -export function mapD1DeepLinkingSessionFields( - row: D1DeepLinkingSessionRow, -): DeepLinkingSessionRow { - return { - sessionId: expectD1String(row.sessionId, 'sessionId'), - sessionToken: expectD1String(row.sessionToken, 'sessionToken'), - ltiToolSessionId: expectD1NullableString(row.ltiToolSessionId, 'ltiToolSessionId'), - deploymentRecordId: expectD1Number(row.deploymentRecordId, 'deploymentRecordId'), - deploymentSlug: expectD1String(row.deploymentSlug, 'deploymentSlug'), - appId: expectD1String(row.appId, 'appId'), - userId: expectD1NullableString(row.userId, 'userId'), - userRole: expectD1StringLiteral(row.userRole, 'userRole', ['learner', 'instructor']), - contextId: expectD1NullableString(row.contextId, 'contextId'), - contextTitle: expectD1NullableString(row.contextTitle, 'contextTitle'), - deepLinkReturnUrl: expectD1String(row.deepLinkReturnUrl, 'deepLinkReturnUrl'), - data: expectD1NullableString(row.data, 'data'), - placement: expectD1StringLiteral(row.placement, 'placement', [ - 'assignment_selection', - 'resource_selection', - ]), - acceptTypes: parseD1JsonField( - row.acceptTypes, - 'acceptTypes', - parseDeepLinkingAcceptTypes, - ), - acceptMultiple: expectBooleanInteger(row.acceptMultiple, 'acceptMultiple'), - acceptPresentationDocumentTargets: parseD1JsonField( - row.acceptPresentationDocumentTargets, - 'acceptPresentationDocumentTargets', - parseDeepLinkingPresentationTargets, - ), - acceptLineItem: expectBooleanInteger(row.acceptLineItem, 'acceptLineItem'), - selectedPackageVersionId: expectD1NullableNumber( - row.selectedPackageVersionId, - 'selectedPackageVersionId', - ), - selectedPackageVersion: expectD1NullableString( - row.selectedPackageVersion, - 'selectedPackageVersion', - ), - selectedActivityId: expectD1NullableString(row.selectedActivityId, 'selectedActivityId'), - selectedContentPath: expectD1NullableString(row.selectedContentPath, 'selectedContentPath'), - createdAt: expectD1String(row.createdAt, 'createdAt'), - expiresAt: expectD1String(row.expiresAt, 'expiresAt'), - usedAt: expectD1NullableString(row.usedAt, 'usedAt'), - }; -} - -export function mapD1ReviewedPlacementFields(row: D1ReviewedPlacementRow): ReviewedPlacementRow { - return { - placementId: expectD1String(row.placementId, 'placementId'), - deploymentRecordId: expectD1Number(row.deploymentRecordId, 'deploymentRecordId'), - deploymentSlug: expectD1String(row.deploymentSlug, 'deploymentSlug'), - appId: expectD1String(row.appId, 'appId'), - contextId: expectD1NullableString(row.contextId, 'contextId'), - contextTitle: expectD1NullableString(row.contextTitle, 'contextTitle'), - packageVersionId: expectD1Number(row.packageVersionId, 'packageVersionId'), - packageVersion: expectD1String(row.packageVersion, 'packageVersion'), - packageTitle: expectD1String(row.packageTitle, 'packageTitle'), - activityId: expectD1String(row.activityId, 'activityId'), - contentPath: expectD1String(row.contentPath, 'contentPath'), - contentTitle: expectD1NullableString(row.contentTitle, 'contentTitle'), - createdByUserId: expectD1NullableString(row.createdByUserId, 'createdByUserId'), - resourceLinkId: expectD1NullableString(row.resourceLinkId, 'resourceLinkId'), - createdAt: expectD1String(row.createdAt, 'createdAt'), - boundAt: expectD1NullableString(row.boundAt, 'boundAt'), - }; -} - -export function mapD1PlacementAuditSnapshotFields( - row: D1PlacementAuditSnapshotRow, -): PlacementAuditSnapshotRow { - return { - ...mapD1ReviewedPlacementFields(row), - latestPreviewSessionId: expectD1NullableString( - row.latestPreviewSessionId, - 'latestPreviewSessionId', - ), - latestPreviewOccurredAt: expectD1NullableString( - row.latestPreviewOccurredAt, - 'latestPreviewOccurredAt', - ), - previewEvidenceCount: expectD1Number(row.previewEvidenceCount, 'previewEvidenceCount'), - deepLinkingRequestCount: expectD1Number(row.deepLinkingRequestCount, 'deepLinkingRequestCount'), - placementEventCount: expectD1Number(row.placementEventCount, 'placementEventCount'), - reviewerEventCount: expectD1Number(row.reviewerEventCount, 'reviewerEventCount'), - latestAuditOccurredAt: expectD1NullableString( - row.latestAuditOccurredAt, - 'latestAuditOccurredAt', - ), - }; -} - -function expectBooleanInteger(value: unknown, fieldName: string): boolean { - if (value === 0) { - return false; - } - - if (value === 1) { - return true; - } - - throw new TypeError(`Expected D1 ${fieldName} to be 0 or 1.`); -} - -function parseDeepLinkingAcceptTypes( - value: unknown, - fieldName: string, -): DeepLinkingSessionRow['acceptTypes'] { - return expectD1StringArray(value, fieldName).map((item, index) => - expectD1StringLiteral(item, `${fieldName}[${index}]`, ['ltiResourceLink']) - ); -} - -function parseDeepLinkingPresentationTargets( - value: unknown, - fieldName: string, -): DeepLinkingSessionRow['acceptPresentationDocumentTargets'] { - return expectD1StringArray(value, fieldName).map((item, index) => - expectD1StringLiteral(item, `${fieldName}[${index}]`, ['iframe', 'window', 'embed']) - ); -} diff --git a/src/package_review/repository_deep_linking_d1_test.ts b/src/package_review/repository_deep_linking_d1_test.ts deleted file mode 100644 index ec16fd4..0000000 --- a/src/package_review/repository_deep_linking_d1_test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { assertEquals, assertRejects } from '@std/assert'; -import type { D1Database, D1Parameter, D1PreparedStatement, D1Result } from '../db/d1.ts'; -import { buildDeepLinkingSessionRecord } from '../test_helpers/lti_session_builders.ts'; -import { createD1DeepLinkingRepositoryMethods } from './repository_deep_linking_d1.ts'; - -Deno.test('D1 deep linking repository creates and parses JSON-backed session settings', async () => { - const sessionRow = buildDeepLinkingSessionRow({ - acceptMultiple: 1, - acceptPresentationDocumentTargets: '["iframe","window"]', - acceptLineItem: 1, - selectedPackageVersionId: 2, - selectedPackageVersion: '0.1.0', - selectedActivityId: 'asteroids', - selectedContentPath: 'activities/asteroids.html', - }); - const db = createPlannedD1Database({ - firstResults: [sessionRow], - }); - const repository = createD1DeepLinkingRepositoryMethods(db); - - const session = await repository.createDeepLinkingSession( - buildDeepLinkingSessionRecord({ - acceptMultiple: true, - acceptPresentationDocumentTargets: ['iframe', 'window'], - acceptLineItem: true, - selection: { - packageVersionId: 2, - packageVersion: '0.1.0', - activityId: 'asteroids', - contentPath: 'activities/asteroids.html', - }, - }), - ); - - assertEquals(session.acceptMultiple, true); - assertEquals(session.acceptPresentationDocumentTargets, ['iframe', 'window']); - assertEquals(session.selection?.activityId, 'asteroids'); - assertEquals(db.statements[0]?.parameters[13], '["ltiResourceLink"]'); - assertEquals(db.statements[0]?.parameters[14], 1); -}); - -Deno.test('D1 deep linking repository rejects already-used sessions', async () => { - const db = createPlannedD1Database({ - firstResults: [ - buildDeepLinkingSessionRow({ - usedAt: '2026-05-13T12:00:00.000Z', - }), - ], - }); - const repository = createD1DeepLinkingRepositoryMethods(db); - - await assertRejects( - () => - repository.consumeDeepLinkingSession({ - sessionId: 'deep-linking-session-123', - usedAt: '2026-05-13T12:05:00.000Z', - }), - Error, - 'Deep Linking session deep-linking-session-123 has already been used.', - ); -}); - -Deno.test('D1 reviewed placement binding returns existing idempotent binding', async () => { - const db = createPlannedD1Database({ - firstResults: [ - buildReviewedPlacementRow({ - resourceLinkId: 'resource-link-1', - boundAt: '2026-05-13T12:00:00.000Z', - }), - ], - }); - const repository = createD1DeepLinkingRepositoryMethods(db); - - const placement = await repository.bindReviewedPlacementResourceLink({ - placementId: 'placement-1', - resourceLinkId: 'resource-link-1', - boundAt: '2026-05-13T12:00:00.000Z', - }); - - assertEquals(placement.resourceLinkId, 'resource-link-1'); - assertEquals(db.statements.length, 1); -}); - -Deno.test('D1 reviewed placement binding rejects conflicting resource links', async () => { - const db = createPlannedD1Database({ - firstResults: [ - buildReviewedPlacementRow({ - resourceLinkId: 'resource-link-1', - boundAt: '2026-05-13T12:00:00.000Z', - }), - ], - }); - const repository = createD1DeepLinkingRepositoryMethods(db); - - await assertRejects( - () => - repository.bindReviewedPlacementResourceLink({ - placementId: 'placement-1', - resourceLinkId: 'resource-link-2', - boundAt: '2026-05-13T12:05:00.000Z', - }), - Error, - 'Reviewed placement placement-1 is already bound to Canvas resource link resource-link-1.', - ); -}); - -Deno.test('D1 reviewed placement repository lists placements by package version', async () => { - const db = createPlannedD1Database({ - allResults: [ - [ - buildReviewedPlacementRow({ - placementId: 'placement-2', - packageVersionId: 42, - createdAt: '2026-05-13T12:05:00.000Z', - }), - buildReviewedPlacementRow({ - placementId: 'placement-1', - packageVersionId: 42, - createdAt: '2026-05-13T12:00:00.000Z', - }), - ], - ], - }); - const repository = createD1DeepLinkingRepositoryMethods(db); - - const placements = await repository.listReviewedPlacementsByPackageVersion(42); - - assertEquals( - placements.map((placement) => placement.placementId), - ['placement-2', 'placement-1'], - ); - assertEquals(db.statements[0]?.parameters, [42]); -}); - -Deno.test('D1 reviewed placement audit snapshot maps counts and status', async () => { - const db = createPlannedD1Database({ - firstResults: [ - { - ...buildReviewedPlacementRow({ - resourceLinkId: 'resource-link-1', - boundAt: '2026-05-13T12:00:00.000Z', - }), - latestPreviewSessionId: 'preview-session-1', - latestPreviewOccurredAt: '2026-05-13T12:03:00.000Z', - previewEvidenceCount: 2, - deepLinkingRequestCount: 1, - placementEventCount: 1, - reviewerEventCount: 1, - latestAuditOccurredAt: '2026-05-13T12:04:00.000Z', - }, - ], - }); - const repository = createD1DeepLinkingRepositoryMethods(db); - - const snapshot = await repository.requirePlacementAuditSnapshotById('placement-1'); - - assertEquals(snapshot.status, 'reviewed'); - assertEquals(snapshot.previewEvidenceCount, 2); - assertEquals(snapshot.evidenceSummary.reviewerEventCount, 1); -}); - -interface PlannedD1Options { - allResults?: Array>>; - firstResults?: Array | null>; - runError?: Error; -} - -interface RecordedStatement { - query: string; - parameters: D1Parameter[]; -} - -function createPlannedD1Database( - options: PlannedD1Options, -): D1Database & { statements: RecordedStatement[] } { - const statements: RecordedStatement[] = []; - const allResults = [...(options.allResults ?? [])]; - const firstResults = [...(options.firstResults ?? [])]; - - return { - statements, - prepare(query) { - return createPlannedD1Statement(query, statements, allResults, firstResults, options); - }, - batch(_statements) { - return Promise.resolve([]); - }, - exec(_query) { - return Promise.resolve({ - count: 0, - duration: 0, - }); - }, - }; -} - -function createPlannedD1Statement( - query: string, - statements: RecordedStatement[], - allResults: Array>>, - firstResults: Array | null>, - options: PlannedD1Options, -): D1PreparedStatement { - let parameters: D1Parameter[] = []; - - return { - bind(...values) { - parameters = values; - return this; - }, - all() { - statements.push({ query, parameters }); - return Promise.resolve({ - success: true, - results: (allResults.shift() ?? []) as T[], - }); - }, - first() { - statements.push({ query, parameters }); - return Promise.resolve((firstResults.shift() as T | null | undefined) ?? null); - }, - run() { - statements.push({ query, parameters }); - - if (options.runError) { - throw options.runError; - } - - return Promise.resolve({ success: true } satisfies D1Result>); - }, - raw() { - return Promise.resolve([] as T[]); - }, - }; -} - -function buildDeepLinkingSessionRow( - overrides: Partial> = {}, -): Record { - return { - sessionId: 'deep-linking-session-123', - sessionToken: 'deep-linking-token-123', - ltiToolSessionId: null, - deploymentRecordId: 1, - deploymentSlug: 'chapter-4-asteroids-pilot', - appId: 'chapter-4-asteroids', - userId: 'canvas-user-123', - userRole: 'instructor', - contextId: 'course-42', - contextTitle: 'Physics 101', - deepLinkReturnUrl: 'https://canvas.example/courses/42/deep_link_return', - data: 'deep-linking-state-token', - placement: 'assignment_selection', - acceptTypes: '["ltiResourceLink"]', - acceptMultiple: 0, - acceptPresentationDocumentTargets: '["iframe"]', - acceptLineItem: 0, - selectedPackageVersionId: null, - selectedPackageVersion: null, - selectedActivityId: null, - selectedContentPath: null, - createdAt: '2026-05-13T12:00:00.000Z', - expiresAt: '2026-05-13T12:10:00.000Z', - usedAt: null, - ...overrides, - }; -} - -function buildReviewedPlacementRow( - overrides: Partial> = {}, -): Record { - return { - placementId: 'placement-1', - deploymentRecordId: 1, - deploymentSlug: 'chapter-4-asteroids-pilot', - appId: 'chapter-4-asteroids', - contextId: 'course-42', - contextTitle: 'Physics 101', - packageVersionId: 2, - packageVersion: '0.1.0', - packageTitle: 'Chapter 4 Asteroids', - activityId: 'asteroids', - contentPath: 'activities/asteroids.html', - contentTitle: 'Asteroids', - createdByUserId: 'canvas-user-123', - resourceLinkId: null, - createdAt: '2026-05-13T12:00:00.000Z', - boundAt: null, - ...overrides, - }; -} diff --git a/src/package_review/repository_deployments_d1.ts b/src/package_review/repository_deployments_d1.ts deleted file mode 100644 index fdb167d..0000000 --- a/src/package_review/repository_deployments_d1.ts +++ /dev/null @@ -1,601 +0,0 @@ -import type { D1Database } from '../db/d1.ts'; -import { queryD1First, queryD1Objects, runD1 } from '../db/d1.ts'; -import { DEFAULT_LTI_PROFILE_ID, requireLtiProfileId } from '../lti/profile.ts'; -import { mapDeploymentRow } from './repository_mappers_package.ts'; -import type { DeploymentRow } from './repository_row_types.ts'; -import type { PackageReviewRepository } from './repository.ts'; -import type { LanternLtiProfileSettingsRecord } from './types.ts'; -import { - D1_DEPLOYMENT_SELECT, - type D1DeploymentRow, - type D1LanternSettingsRow, - mapD1DeploymentFields, - mapD1LanternLtiProfileSettingsRow, -} from './repository_deployments_d1_mapping.ts'; - -export function createD1DeploymentRepositoryMethods( - db: D1Database, -): Pick< - PackageReviewRepository, - | 'getDeploymentBySlug' - | 'listDeploymentsByApp' - | 'getDeploymentByBinding' - | 'getDeploymentByPlatformIdentity' - | 'completePendingCanvasBinding' - | 'saveDeploymentBinding' - | 'saveCanvasRegistration' - | 'pinDeploymentVersion' - | 'getLanternLtiProfileSettings' - | 'saveLanternDefaultLtiProfile' - | 'saveDeploymentLtiProfileOverride' -> { - return { - async getDeploymentBySlug(slug) { - return await getDeploymentByField(db, 'deployments.slug = ?', [slug]); - }, - - async listDeploymentsByApp(appId) { - const rows = await queryD1Objects( - db, - ` - ${D1_DEPLOYMENT_SELECT} - WHERE deployments.app_id = ? - AND deployments.lms_type <> 'preview' - ORDER BY deployments.lms_type ASC, deployments.slug ASC - `, - [appId], - ); - - return rows.map((row) => mapDeploymentRow(mapD1DeploymentFields(row))); - }, - - async getDeploymentByBinding(binding) { - return await getDeploymentByField( - db, - ` - deployments.lms_type = ? - AND deployments.issuer = ? - AND deployments.client_id = ? - AND deployments.deployment_id = ? - `, - [binding.lms, binding.issuer, binding.clientId, binding.deploymentId], - ); - }, - - async getDeploymentByPlatformIdentity(input) { - const rows = await queryD1Objects( - db, - ` - ${D1_DEPLOYMENT_SELECT} - WHERE deployments.issuer = ? - ${input.clientId === null ? '' : 'AND deployments.client_id = ?'} - AND deployments.deployment_id = ? - ORDER BY deployments.lms_type ASC, deployments.id ASC - `, - input.clientId === null - ? [input.issuer, input.deploymentId] - : [input.issuer, input.clientId, input.deploymentId], - ); - - if (rows.length === 0) { - if (input.clientId !== null) { - await rejectMismatchedCanvasDeployment(db, input); - } - - return null; - } - - if (rows.length > 1) { - const clientLabel = input.clientId === null ? '' : ` with client ${input.clientId}`; - throw new Error( - `Multiple deployments matched issuer ${input.issuer}${clientLabel} and deployment ${input.deploymentId}. Resolve the duplicate LMS bindings before login can continue.`, - ); - } - - return mapDeploymentRow(mapD1DeploymentFields(rows[0]!)); - }, - - async completePendingCanvasBinding(input) { - const exact = await getDeploymentByField( - db, - ` - deployments.lms_type = 'canvas' - AND deployments.issuer = ? - AND deployments.client_id = ? - AND deployments.deployment_id = ? - `, - [input.issuer, input.clientId, input.deploymentId], - ); - - if (exact !== null) { - return exact; - } - - const pendingRows = await queryD1Objects( - db, - ` - ${D1_DEPLOYMENT_SELECT} - WHERE deployments.lms_type = 'canvas' - AND deployments.issuer = ? - AND deployments.client_id = ? - AND deployments.deployment_id IS NULL - ORDER BY deployments.id ASC - `, - [input.issuer, input.clientId], - ); - - if (pendingRows.length === 0) { - return null; - } - - if (pendingRows.length > 1) { - throw new Error( - `Multiple Canvas registrations matched issuer ${input.issuer} with client ${input.clientId}. Resolve the duplicate Canvas registrations before login can continue.`, - ); - } - - const pending = mapD1DeploymentFields(pendingRows[0]!); - await runD1( - db, - ` - UPDATE deployments - SET deployment_id = ?, - updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `, - [input.deploymentId, pending.id], - ); - - return await requireDeploymentById(db, pending.id); - }, - - async saveDeploymentBinding(input) { - await assertDeploymentSlotAvailable(db, input.slug, input.appId, input.binding.lms); - await assertBindingAvailable(db, { - slug: input.slug, - lmsType: input.binding.lms, - issuer: input.binding.issuer, - clientId: input.binding.clientId, - deploymentId: input.binding.deploymentId, - }); - - const columns = bindingColumns(input.binding); - - await runD1( - db, - ` - INSERT INTO deployments ( - slug, - label, - app_id, - lms_type, - canvas_environment, - issuer, - client_id, - deployment_id, - authorization_endpoint, - access_token_url, - jwks_url - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (slug) DO UPDATE SET - label = excluded.label, - app_id = excluded.app_id, - lms_type = excluded.lms_type, - canvas_environment = excluded.canvas_environment, - issuer = excluded.issuer, - client_id = excluded.client_id, - deployment_id = excluded.deployment_id, - authorization_endpoint = excluded.authorization_endpoint, - access_token_url = excluded.access_token_url, - jwks_url = excluded.jwks_url, - updated_at = CURRENT_TIMESTAMP - `, - [ - input.slug, - input.label, - input.appId, - input.binding.lms, - columns.canvasEnvironment, - input.binding.issuer, - input.binding.clientId, - input.binding.deploymentId, - columns.authorizationEndpoint, - columns.accessTokenUrl, - columns.jwksUrl, - ], - ); - - return await requireDeploymentBySlug(db, input.slug); - }, - - async saveCanvasRegistration(input) { - await assertDeploymentSlotAvailable(db, input.slug, input.appId, 'canvas'); - await assertPendingCanvasRegistrationAvailable(db, input); - - await runD1( - db, - ` - INSERT INTO deployments ( - slug, - label, - app_id, - lms_type, - canvas_environment, - issuer, - client_id, - deployment_id, - authorization_endpoint, - access_token_url, - jwks_url - ) VALUES (?, ?, ?, 'canvas', ?, ?, ?, NULL, NULL, NULL, NULL) - ON CONFLICT (slug) DO UPDATE SET - label = excluded.label, - app_id = excluded.app_id, - lms_type = excluded.lms_type, - canvas_environment = excluded.canvas_environment, - issuer = excluded.issuer, - client_id = excluded.client_id, - deployment_id = NULL, - authorization_endpoint = NULL, - access_token_url = NULL, - jwks_url = NULL, - updated_at = CURRENT_TIMESTAMP - `, - [ - input.slug, - input.label, - input.appId, - input.canvasEnvironment, - input.issuer, - input.clientId, - ], - ); - - return await requireDeploymentBySlug(db, input.slug); - }, - - async pinDeploymentVersion(input) { - const packageVersion = await queryD1First<{ - appId: string; - approvalStatus: string; - version: string; - }>( - db, - ` - SELECT - app_id AS appId, - approval_status AS approvalStatus, - version - FROM package_versions - WHERE id = ? - `, - [input.packageVersionId], - ); - - if (packageVersion === null) { - throw new Error(`Package version id ${input.packageVersionId} was not found.`); - } - - if ( - packageVersion.approvalStatus !== 'approved' && - !(input.lmsType === 'preview' && packageVersion.approvalStatus === 'pending') - ) { - throw new Error('Only approved package versions can be enabled.'); - } - - const existingDeployment = await requireCompatibleDeploymentSlot( - db, - input.slug, - input.appId, - input.lmsType, - ); - const deploymentAppId = existingDeployment?.appId ?? input.appId; - - if (packageVersion.appId !== deploymentAppId) { - throw new Error( - `Package version ${packageVersion.appId}@${packageVersion.version} does not belong to deployment app ${deploymentAppId}.`, - ); - } - - await runD1( - db, - ` - INSERT INTO deployments ( - slug, - label, - app_id, - lms_type, - enabled_package_version_id - ) VALUES (?, ?, ?, ?, ?) - ON CONFLICT (slug) DO UPDATE SET - label = excluded.label, - lms_type = excluded.lms_type, - enabled_package_version_id = excluded.enabled_package_version_id, - updated_at = CURRENT_TIMESTAMP - `, - [input.slug, input.label, deploymentAppId, input.lmsType, input.packageVersionId], - ); - - return await requireDeploymentBySlug(db, input.slug); - }, - - async getLanternLtiProfileSettings() { - return await ensureLanternLtiProfileSettings(db); - }, - - async saveLanternDefaultLtiProfile(input) { - const defaultLtiProfile = requireLtiProfileId(input.defaultLtiProfile); - - await runD1( - db, - ` - INSERT INTO lantern_settings ( - singleton, - default_lti_profile - ) VALUES (1, ?) - ON CONFLICT (singleton) DO UPDATE SET - default_lti_profile = excluded.default_lti_profile, - updated_at = CURRENT_TIMESTAMP - `, - [defaultLtiProfile], - ); - - return await ensureLanternLtiProfileSettings(db); - }, - - async saveDeploymentLtiProfileOverride(input) { - const ltiProfileOverride = input.ltiProfileOverride === null - ? null - : requireLtiProfileId(input.ltiProfileOverride); - const existing = await requireDeploymentById(db, input.deploymentId); - - await runD1( - db, - ` - UPDATE deployments - SET lti_profile_override = ?, - updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `, - [ltiProfileOverride, existing.id], - ); - - return await requireDeploymentById(db, existing.id); - }, - }; -} - -async function getDeploymentByField( - db: D1Database, - whereClause: string, - parameters: readonly unknown[], -) { - const row = await queryD1First( - db, - `${D1_DEPLOYMENT_SELECT} WHERE ${whereClause}`, - parameters, - ); - - return row === null ? null : mapDeploymentRow(mapD1DeploymentFields(row)); -} - -async function requireDeploymentBySlug(db: D1Database, slug: string) { - const deployment = await getDeploymentByField(db, 'deployments.slug = ?', [slug]); - - if (deployment === null) { - throw new Error(`Expected deployment ${slug} after D1 write.`); - } - - return deployment; -} - -async function requireDeploymentById(db: D1Database, id: number) { - const deployment = await getDeploymentByField(db, 'deployments.id = ?', [id]); - - if (deployment === null) { - throw new Error(`Deployment id ${id} was not found.`); - } - - return deployment; -} - -async function rejectMismatchedCanvasDeployment( - db: D1Database, - input: { - issuer: string; - clientId: string | null; - deploymentId: string; - }, -): Promise { - const canvasRows = await queryD1Objects( - db, - ` - ${D1_DEPLOYMENT_SELECT} - WHERE deployments.lms_type = 'canvas' - AND deployments.issuer = ? - AND deployments.client_id = ? - ORDER BY deployments.id ASC - `, - [input.issuer, input.clientId], - ); - const savedCanvas = canvasRows[0] ? mapD1DeploymentFields(canvasRows[0]) : null; - - if ( - canvasRows.length === 1 && - savedCanvas !== null && - savedCanvas.deploymentId !== null && - savedCanvas.deploymentId !== input.deploymentId - ) { - throw new Error( - `Canvas sent deployment ${input.deploymentId} for issuer ${input.issuer} and client ${input.clientId}, but Lantern saved deployment ${savedCanvas.deploymentId}. Update the saved Canvas binding or relaunch from the correct Canvas placement.`, - ); - } -} - -async function assertDeploymentSlotAvailable( - db: D1Database, - slug: string, - appId: string, - lmsType: DeploymentRow['lmsType'], -): Promise { - await requireCompatibleDeploymentSlot(db, slug, appId, lmsType); - - const existingAppSlot = await getDeploymentByField( - db, - ` - deployments.app_id = ? - AND deployments.lms_type = ? - AND deployments.slug <> ? - `, - [appId, lmsType, slug], - ); - - if (existingAppSlot !== null) { - throw new Error(`App ${appId} already has a ${lmsType} deployment.`); - } -} - -async function requireCompatibleDeploymentSlot( - db: D1Database, - slug: string, - appId: string, - lmsType: DeploymentRow['lmsType'], -) { - const existing = await getDeploymentByField(db, 'deployments.slug = ?', [slug]); - - if (existing !== null && existing.appId !== appId) { - throw new Error(`Deployment ${slug} belongs to app ${existing.appId}.`); - } - - if (existing !== null && existing.lmsType !== lmsType) { - throw new Error( - `Deployment ${slug} is already reserved as ${existing.lmsType} and cannot change to ${lmsType}.`, - ); - } - - return existing; -} - -async function assertBindingAvailable( - db: D1Database, - input: { - slug: string; - lmsType: DeploymentRow['lmsType']; - issuer: string; - clientId: string; - deploymentId: string; - }, -): Promise { - const conflictingBinding = await getDeploymentByField( - db, - ` - deployments.lms_type = ? - AND deployments.issuer = ? - AND deployments.client_id = ? - AND deployments.deployment_id = ? - AND deployments.slug <> ? - `, - [input.lmsType, input.issuer, input.clientId, input.deploymentId, input.slug], - ); - - if (conflictingBinding !== null) { - throw new Error( - `${ - formatBindingLabel( - input.lmsType, - ) - } ${input.clientId} / ${input.deploymentId} already belongs to another deployment.`, - ); - } -} - -async function assertPendingCanvasRegistrationAvailable( - db: D1Database, - input: { - slug: string; - issuer: string; - clientId: string; - }, -): Promise { - const conflictingPendingRegistration = await getDeploymentByField( - db, - ` - deployments.lms_type = 'canvas' - AND deployments.issuer = ? - AND deployments.client_id = ? - AND deployments.deployment_id IS NULL - AND deployments.slug <> ? - `, - [input.issuer, input.clientId, input.slug], - ); - - if (conflictingPendingRegistration !== null) { - throw new Error( - `Canvas registration ${input.clientId} is already reserved for another deployment.`, - ); - } -} - -async function ensureLanternLtiProfileSettings( - db: D1Database, -): Promise { - const existing = await queryD1First( - db, - ` - SELECT - default_lti_profile AS defaultLtiProfile, - updated_at AS updatedAt - FROM lantern_settings - WHERE singleton = 1 - `, - ); - - if (existing !== null) { - return mapD1LanternLtiProfileSettingsRow(existing); - } - - await runD1( - db, - ` - INSERT INTO lantern_settings ( - singleton, - default_lti_profile - ) VALUES (1, ?) - ON CONFLICT (singleton) DO NOTHING - `, - [DEFAULT_LTI_PROFILE_ID], - ); - - return await ensureLanternLtiProfileSettings(db); -} - -function bindingColumns( - binding: Parameters[0]['binding'], -): { - canvasEnvironment: DeploymentRow['canvasEnvironment']; - authorizationEndpoint: DeploymentRow['authorizationEndpoint']; - accessTokenUrl: DeploymentRow['accessTokenUrl']; - jwksUrl: DeploymentRow['jwksUrl']; -} { - switch (binding.lms) { - case 'canvas': - return { - canvasEnvironment: binding.canvasEnvironment, - authorizationEndpoint: null, - accessTokenUrl: null, - jwksUrl: null, - }; - case 'moodle': - case 'sakai': - return { - canvasEnvironment: null, - authorizationEndpoint: binding.authorizationEndpoint, - accessTokenUrl: binding.accessTokenUrl, - jwksUrl: binding.jwksUrl, - }; - } -} - -function formatBindingLabel(lms: DeploymentRow['lmsType']): string { - return `${lms[0]?.toUpperCase() ?? ''}${lms.slice(1)} binding`; -} diff --git a/src/package_review/repository_deployments_d1_mapping.ts b/src/package_review/repository_deployments_d1_mapping.ts deleted file mode 100644 index 42ef4e5..0000000 --- a/src/package_review/repository_deployments_d1_mapping.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { - expectD1NullableNumber, - expectD1NullableString, - expectD1NullableStringLiteral, - expectD1Number, - expectD1String, - expectD1StringLiteral, -} from '../db/d1_row.ts'; -import { requireLtiProfileId } from '../lti/profile.ts'; -import type { DeploymentRow } from './repository_row_types.ts'; -import { normalizeTimestamp } from './repository_value_support.ts'; -import type { LanternLtiProfileSettingsRecord } from './types.ts'; - -export const D1_DEPLOYMENT_SELECT = ` - SELECT - deployments.id, - deployments.slug, - deployments.label, - deployments.app_id AS appId, - deployments.enabled_package_version_id AS enabledPackageVersionId, - package_versions.version AS enabledPackageVersion, - deployments.lms_type AS lmsType, - deployments.canvas_environment AS canvasEnvironment, - deployments.issuer, - deployments.client_id AS clientId, - deployments.deployment_id AS deploymentId, - deployments.authorization_endpoint AS authorizationEndpoint, - deployments.access_token_url AS accessTokenUrl, - deployments.jwks_url AS jwksUrl, - deployments.lti_profile_override AS ltiProfileOverride, - deployments.updated_at AS updatedAt - FROM deployments - LEFT JOIN package_versions - ON package_versions.id = deployments.enabled_package_version_id -`; - -export interface D1DeploymentRow extends Record { - id: unknown; - slug: unknown; - label: unknown; - appId: unknown; - enabledPackageVersionId: unknown; - enabledPackageVersion: unknown; - lmsType: unknown; - canvasEnvironment: unknown; - issuer: unknown; - clientId: unknown; - deploymentId: unknown; - authorizationEndpoint: unknown; - accessTokenUrl: unknown; - jwksUrl: unknown; - ltiProfileOverride: unknown; - updatedAt: unknown; -} - -export interface D1LanternSettingsRow extends Record { - defaultLtiProfile: unknown; - updatedAt: unknown; -} - -export function mapD1DeploymentFields(row: D1DeploymentRow): DeploymentRow { - return { - id: expectD1Number(row.id, 'deployments.id'), - slug: expectD1String(row.slug, 'deployments.slug'), - label: expectD1String(row.label, 'deployments.label'), - appId: expectD1String(row.appId, 'deployments.appId'), - enabledPackageVersionId: expectD1NullableNumber( - row.enabledPackageVersionId, - 'deployments.enabledPackageVersionId', - ), - enabledPackageVersion: expectD1NullableString( - row.enabledPackageVersion, - 'deployments.enabledPackageVersion', - ), - lmsType: expectD1StringLiteral(row.lmsType, 'deployments.lmsType', [ - 'canvas', - 'moodle', - 'sakai', - 'preview', - ]), - canvasEnvironment: expectD1NullableStringLiteral( - row.canvasEnvironment, - 'deployments.canvasEnvironment', - [ - 'production', - 'beta', - 'test', - ], - ), - issuer: expectD1NullableString(row.issuer, 'deployments.issuer'), - clientId: expectD1NullableString(row.clientId, 'deployments.clientId'), - deploymentId: expectD1NullableString(row.deploymentId, 'deployments.deploymentId'), - authorizationEndpoint: expectD1NullableString( - row.authorizationEndpoint, - 'deployments.authorizationEndpoint', - ), - accessTokenUrl: expectD1NullableString(row.accessTokenUrl, 'deployments.accessTokenUrl'), - jwksUrl: expectD1NullableString(row.jwksUrl, 'deployments.jwksUrl'), - ltiProfileOverride: expectD1NullableStringLiteral( - row.ltiProfileOverride, - 'deployments.ltiProfileOverride', - [ - 'certification', - 'governedCompatibility', - ], - ), - updatedAt: expectD1String(row.updatedAt, 'deployments.updatedAt'), - }; -} - -export function mapD1LanternLtiProfileSettingsRow( - row: D1LanternSettingsRow, -): LanternLtiProfileSettingsRecord { - const defaultLtiProfile = expectD1String( - row.defaultLtiProfile, - 'lantern_settings.defaultLtiProfile', - ); - - return { - defaultLtiProfile: requireLtiProfileId( - defaultLtiProfile, - `Unsupported saved LTI profile ${defaultLtiProfile}.`, - ), - updatedAt: normalizeTimestamp(expectD1String(row.updatedAt, 'lantern_settings.updatedAt')), - }; -} diff --git a/src/package_review/repository_deployments_d1_test.ts b/src/package_review/repository_deployments_d1_test.ts deleted file mode 100644 index 18465ed..0000000 --- a/src/package_review/repository_deployments_d1_test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { assertEquals, assertRejects } from '@std/assert'; -import type { D1Database, D1Parameter, D1PreparedStatement, D1Result } from '../db/d1.ts'; -import { createD1DeploymentRepositoryMethods } from './repository_deployments_d1.ts'; - -Deno.test('D1 deployment repository maps deployment rows', async () => { - const db = createPlannedD1Database({ - allResults: [[buildD1DeploymentRow()]], - }); - const repository = createD1DeploymentRepositoryMethods(db); - - const deployments = await repository.listDeploymentsByApp('chapter-4-asteroids'); - - assertEquals(deployments.length, 1); - assertEquals(deployments[0]?.binding, { - lms: 'canvas', - canvasEnvironment: 'production', - issuer: 'https://canvas.example', - clientId: 'client-1', - deploymentId: 'deployment-1', - }); - assertEquals(db.statements[0]?.parameters, ['chapter-4-asteroids']); -}); - -Deno.test('D1 deployment repository rejects malformed deployment row literals', async () => { - const db = createPlannedD1Database({ - allResults: [[buildD1DeploymentRow({ lmsType: 'blackboard' })]], - }); - const repository = createD1DeploymentRepositoryMethods(db); - - await assertRejects( - () => repository.listDeploymentsByApp('chapter-4-asteroids'), - Error, - 'Unexpected D1 deployments.lmsType value.', - ); -}); - -Deno.test('D1 deployment repository saves exact non-Canvas bindings', async () => { - const saved = buildD1DeploymentRow({ - lmsType: 'moodle', - canvasEnvironment: null, - authorizationEndpoint: 'https://moodle.example/oidc', - accessTokenUrl: 'https://moodle.example/token', - jwksUrl: 'https://moodle.example/jwks', - }); - const db = createPlannedD1Database({ - firstResults: [null, null, null, saved], - }); - const repository = createD1DeploymentRepositoryMethods(db); - - const deployment = await repository.saveDeploymentBinding({ - slug: 'chapter-4-asteroids-moodle', - label: 'Moodle', - appId: 'chapter-4-asteroids', - binding: { - lms: 'moodle', - issuer: 'https://moodle.example', - clientId: 'moodle-client', - deploymentId: 'moodle-deployment', - authorizationEndpoint: 'https://moodle.example/oidc', - accessTokenUrl: 'https://moodle.example/token', - jwksUrl: 'https://moodle.example/jwks', - }, - }); - - assertEquals(deployment.binding?.lms, 'moodle'); - assertEquals(db.statements[3]?.parameters.slice(0, 4), [ - 'chapter-4-asteroids-moodle', - 'Moodle', - 'chapter-4-asteroids', - 'moodle', - ]); -}); - -Deno.test('D1 deployment repository rejects unapproved package pins', async () => { - const db = createPlannedD1Database({ - firstResults: [ - { - appId: 'chapter-4-asteroids', - approvalStatus: 'pending', - version: '0.1.0', - }, - ], - }); - const repository = createD1DeploymentRepositoryMethods(db); - - await assertRejects( - () => - repository.pinDeploymentVersion({ - slug: 'chapter-4-asteroids-canvas', - label: 'Canvas', - appId: 'chapter-4-asteroids', - packageVersionId: 1, - lmsType: 'canvas', - }), - Error, - 'Only approved package versions can be enabled.', - ); -}); - -Deno.test('D1 deployment repository allows pending package pins for preview deployments', async () => { - const db = createPlannedD1Database({ - firstResults: [ - { - appId: 'chapter-4-asteroids', - approvalStatus: 'pending', - version: '0.2.0', - }, - null, - buildD1DeploymentRow({ - slug: 'chapter-4-asteroids-preview', - label: 'Chapter 4 Asteroids Preview', - enabledPackageVersionId: 2, - enabledPackageVersion: '0.2.0', - lmsType: 'preview', - canvasEnvironment: null, - }), - ], - }); - const repository = createD1DeploymentRepositoryMethods(db); - - const deployment = await repository.pinDeploymentVersion({ - slug: 'chapter-4-asteroids-preview', - label: 'Chapter 4 Asteroids Preview', - appId: 'chapter-4-asteroids', - packageVersionId: 2, - lmsType: 'preview', - }); - - assertEquals(deployment.lmsType, 'preview'); - assertEquals(deployment.enabledPackageVersionId, 2); - assertEquals(db.statements[2]?.parameters, [ - 'chapter-4-asteroids-preview', - 'Chapter 4 Asteroids Preview', - 'chapter-4-asteroids', - 'preview', - 2, - ]); -}); - -interface PlannedD1Options { - allResults?: Array>>; - firstResults?: Array | null>; -} - -interface RecordedStatement { - query: string; - parameters: D1Parameter[]; -} - -function createPlannedD1Database( - options: PlannedD1Options, -): D1Database & { statements: RecordedStatement[] } { - const statements: RecordedStatement[] = []; - const allResults = [...(options.allResults ?? [])]; - const firstResults = [...(options.firstResults ?? [])]; - - return { - statements, - prepare(query) { - return createPlannedD1Statement(query, statements, allResults, firstResults); - }, - batch(_statements) { - return Promise.resolve([]); - }, - exec(_query) { - return Promise.resolve({ - count: 0, - duration: 0, - }); - }, - }; -} - -function createPlannedD1Statement( - query: string, - statements: RecordedStatement[], - allResults: Array>>, - firstResults: Array | null>, -): D1PreparedStatement { - let parameters: D1Parameter[] = []; - - return { - bind(...values) { - parameters = values; - return this; - }, - all() { - statements.push({ query, parameters }); - return Promise.resolve({ - success: true, - results: (allResults.shift() ?? []) as T[], - }); - }, - first() { - statements.push({ query, parameters }); - return Promise.resolve((firstResults.shift() as T | null | undefined) ?? null); - }, - run() { - statements.push({ query, parameters }); - return Promise.resolve({ success: true } satisfies D1Result>); - }, - raw() { - return Promise.resolve([] as T[]); - }, - }; -} - -function buildD1DeploymentRow( - overrides: Partial> = {}, -): Record { - return { - id: 1, - slug: 'chapter-4-asteroids-canvas', - label: 'Canvas', - appId: 'chapter-4-asteroids', - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - lmsType: 'canvas', - canvasEnvironment: 'production', - issuer: 'https://canvas.example', - clientId: 'client-1', - deploymentId: 'deployment-1', - authorizationEndpoint: null, - accessTokenUrl: null, - jwksUrl: null, - ltiProfileOverride: null, - updatedAt: '2026-05-13T12:00:00.000Z', - ...overrides, - }; -} diff --git a/src/package_review/repository_grading_d1.ts b/src/package_review/repository_grading_d1.ts deleted file mode 100644 index 30f71d1..0000000 --- a/src/package_review/repository_grading_d1.ts +++ /dev/null @@ -1,539 +0,0 @@ -import type { D1Database } from '../db/d1.ts'; -import { queryD1First, queryD1Objects, runD1, runD1Batch } from '../db/d1.ts'; -import { - mapAttemptEvidenceArtifactRow, - mapAuditEventRow, - mapGradePublicationRow, - mapLineItemBindingRow, - mapOptionalAttemptEvidenceArtifact, - mapOptionalGradePublication, - mapOptionalLineItemBinding, -} from './repository_mappers_attempts.ts'; -import { - type D1AttemptEvidenceArtifactRow, - type D1AuditEventRow, - type D1GradePublicationRow, - type D1LineItemBindingRow, - mapD1AttemptEvidenceArtifactRow, - mapD1AuditEventFields, - mapD1GradePublicationFields, - mapD1LineItemBindingFields, -} from './repository_grading_d1_mapping.ts'; -import type { PackageReviewRepository } from './repository.ts'; - -export function createD1GradingRepositoryMethods( - db: D1Database, -): Pick< - PackageReviewRepository, - | 'createAttemptEvidenceArtifact' - | 'getAttemptEvidenceArtifactById' - | 'listAttemptEvidenceArtifacts' - | 'getLineItemBinding' - | 'saveLineItemBinding' - | 'getGradePublicationByAttemptId' - | 'createGradePublication' - | 'updateGradePublication' - | 'recordAuditEvent' - | 'listAuditEventsByAttemptId' - | 'listAuditEventsByEventType' -> { - return { - async createAttemptEvidenceArtifact(input) { - try { - const results = await runD1Batch( - db, - [ - { - sql: ` - INSERT INTO attempt_evidence_artifacts ( - artifact_id, - attempt_id, - sequence, - kind, - content_type, - file_name, - storage_key, - byte_size, - sha256, - created_at - ) - SELECT - ?, - attempts.attempt_id, - attempts.next_evidence_artifact_sequence, - ?, - ?, - ?, - ?, - ?, - ?, - ? - FROM attempts - WHERE attempts.attempt_id = ? - RETURNING - artifact_id AS artifactId, - attempt_id AS attemptId, - sequence, - kind, - content_type AS contentType, - file_name AS fileName, - storage_key AS storageKey, - byte_size AS byteSize, - sha256, - created_at AS createdAt - `, - parameters: [ - input.artifactId, - input.kind, - input.contentType, - input.fileName, - input.storageKey, - input.byteSize, - input.sha256, - input.createdAt, - input.attemptId, - ], - }, - { - sql: ` - UPDATE attempts - SET next_evidence_artifact_sequence = next_evidence_artifact_sequence + 1 - WHERE attempt_id = ? - `, - parameters: [input.attemptId], - }, - ], - ); - const row = results[0]?.results?.[0] ?? null; - - if (row === null) { - throw new Error(`Attempt ${input.attemptId} was not found.`); - } - - return mapAttemptEvidenceArtifactRow(mapD1AttemptEvidenceArtifactRow(row)); - } catch (error) { - if (isD1UniqueViolation(error)) { - throw new Error( - `Attempt evidence artifact ${input.artifactId} already exists and cannot be replaced.`, - ); - } - - throw error; - } - }, - - async getAttemptEvidenceArtifactById(artifactId) { - const row = await queryD1First( - db, - `${D1_ATTEMPT_EVIDENCE_ARTIFACT_SELECT} WHERE artifact_id = ?`, - [artifactId], - ); - - return mapOptionalAttemptEvidenceArtifact( - row === null ? undefined : mapD1AttemptEvidenceArtifactRow(row), - ); - }, - - async listAttemptEvidenceArtifacts(attemptId) { - const rows = await queryD1Objects( - db, - ` - ${D1_ATTEMPT_EVIDENCE_ARTIFACT_SELECT} - WHERE attempt_id = ? - ORDER BY sequence ASC - `, - [attemptId], - ); - - return rows.map((row) => mapAttemptEvidenceArtifactRow(mapD1AttemptEvidenceArtifactRow(row))); - }, - - async getLineItemBinding(input) { - const row = await queryD1First( - db, - ` - ${D1_LINE_ITEM_BINDING_SELECT} - WHERE deployment_record_id = ? - AND package_version_id = ? - AND context_id = ? - AND resource_link_id = ? - AND activity_id = ? - `, - [ - input.deploymentRecordId, - input.packageVersionId, - input.contextId, - input.resourceLinkId, - input.activityId, - ], - ); - - return mapOptionalLineItemBinding(row === null ? undefined : mapD1LineItemBindingFields(row)); - }, - - async saveLineItemBinding(record) { - const existing = await this.getLineItemBinding({ - deploymentRecordId: record.deploymentRecordId, - packageVersionId: record.packageVersionId, - contextId: record.contextId, - resourceLinkId: record.resourceLinkId, - activityId: record.activityId, - }); - - if (existing !== null) { - return existing; - } - - try { - await runD1( - db, - ` - INSERT INTO line_item_bindings ( - deployment_record_id, - package_version_id, - context_id, - resource_link_id, - activity_id, - line_items_url, - line_item_url, - resource_id, - tag, - label, - score_maximum, - created_at, - updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - record.deploymentRecordId, - record.packageVersionId, - record.contextId, - record.resourceLinkId, - record.activityId, - record.lineItemsUrl, - record.lineItemUrl, - record.resourceId, - record.tag, - record.label, - record.scoreMaximum, - record.createdAt, - record.updatedAt, - ], - ); - } catch (error) { - if (!isD1UniqueViolation(error)) { - throw error; - } - - const concurrent = await queryD1First( - db, - ` - ${D1_LINE_ITEM_BINDING_SELECT} - WHERE ( - deployment_record_id = ? - AND package_version_id = ? - AND context_id = ? - AND resource_link_id = ? - AND activity_id = ? - ) OR line_item_url = ? - LIMIT 1 - `, - [ - record.deploymentRecordId, - record.packageVersionId, - record.contextId, - record.resourceLinkId, - record.activityId, - record.lineItemUrl, - ], - ); - - if (concurrent !== null) { - return mapLineItemBindingRow(mapD1LineItemBindingFields(concurrent)); - } - - throw error; - } - - return await requireLineItemBindingByUrl(db, record.lineItemUrl); - }, - - async getGradePublicationByAttemptId(attemptId) { - const row = await queryD1First( - db, - `${D1_GRADE_PUBLICATION_SELECT} WHERE attempt_id = ?`, - [attemptId], - ); - - return mapOptionalGradePublication( - row === null ? undefined : mapD1GradePublicationFields(row), - ); - }, - - async createGradePublication(record) { - try { - await runD1( - db, - ` - INSERT INTO grade_publications ( - attempt_id, - line_item_binding_id, - line_item_url, - platform_user_id, - score_given, - score_maximum, - activity_progress, - grading_progress, - status, - created_at, - updated_at, - published_at, - error_code, - error_detail - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - record.attemptId, - record.lineItemBindingId, - record.lineItemUrl, - record.platformUserId, - record.scoreGiven, - record.scoreMaximum, - record.activityProgress, - record.gradingProgress, - record.status, - record.createdAt, - record.updatedAt, - record.publishedAt, - record.errorCode, - record.errorDetail, - ], - ); - } catch (error) { - if (!isD1UniqueViolation(error)) { - throw error; - } - - const existing = await this.getGradePublicationByAttemptId(record.attemptId); - - if (existing !== null) { - return existing; - } - - throw error; - } - - return await requireGradePublicationByAttemptId(db, record.attemptId); - }, - - async updateGradePublication(input) { - await runD1( - db, - ` - UPDATE grade_publications - SET - status = ?, - updated_at = ?, - published_at = ?, - error_code = ?, - error_detail = ? - WHERE attempt_id = ? - `, - [ - input.status, - input.updatedAt, - input.publishedAt, - input.errorCode, - input.errorDetail, - input.attemptId, - ], - ); - - return await requireGradePublicationByAttemptId(db, input.attemptId); - }, - - async recordAuditEvent(record) { - const row = await queryD1First( - db, - ` - INSERT INTO audit_events ( - event_type, - actor_type, - actor_id, - deployment_record_id, - package_version_id, - attempt_id, - line_item_binding_id, - status, - summary, - detail, - occurred_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - RETURNING - id, - event_type AS eventType, - actor_type AS actorType, - actor_id AS actorId, - deployment_record_id AS deploymentRecordId, - package_version_id AS packageVersionId, - attempt_id AS attemptId, - line_item_binding_id AS lineItemBindingId, - status, - summary, - detail, - occurred_at AS occurredAt - `, - [ - record.eventType, - record.actorType, - record.actorId, - record.deploymentRecordId, - record.packageVersionId, - record.attemptId, - record.lineItemBindingId, - record.status, - record.summary, - record.detail, - record.occurredAt, - ], - ); - - if (row === null) { - throw new Error('Expected audit event row after D1 insert.'); - } - - return mapAuditEventRow(mapD1AuditEventFields(row)); - }, - - async listAuditEventsByAttemptId(attemptId) { - const rows = await queryD1Objects( - db, - ` - ${D1_AUDIT_EVENT_SELECT} - WHERE attempt_id = ? - ORDER BY occurred_at ASC, id ASC - `, - [attemptId], - ); - - return rows.map((row) => mapAuditEventRow(mapD1AuditEventFields(row))); - }, - - async listAuditEventsByEventType(eventType) { - const rows = await queryD1Objects( - db, - ` - ${D1_AUDIT_EVENT_SELECT} - WHERE event_type = ? - ORDER BY occurred_at ASC, id ASC - `, - [eventType], - ); - - return rows.map((row) => mapAuditEventRow(mapD1AuditEventFields(row))); - }, - }; -} - -const D1_ATTEMPT_EVIDENCE_ARTIFACT_SELECT = ` - SELECT - artifact_id AS artifactId, - attempt_id AS attemptId, - sequence, - kind, - content_type AS contentType, - file_name AS fileName, - storage_key AS storageKey, - byte_size AS byteSize, - sha256, - created_at AS createdAt - FROM attempt_evidence_artifacts -`; - -const D1_LINE_ITEM_BINDING_SELECT = ` - SELECT - id, - deployment_record_id AS deploymentRecordId, - package_version_id AS packageVersionId, - context_id AS contextId, - resource_link_id AS resourceLinkId, - activity_id AS activityId, - line_items_url AS lineItemsUrl, - line_item_url AS lineItemUrl, - resource_id AS resourceId, - tag, - label, - score_maximum AS scoreMaximum, - created_at AS createdAt, - updated_at AS updatedAt - FROM line_item_bindings -`; - -const D1_GRADE_PUBLICATION_SELECT = ` - SELECT - id, - attempt_id AS attemptId, - line_item_binding_id AS lineItemBindingId, - line_item_url AS lineItemUrl, - platform_user_id AS platformUserId, - score_given AS scoreGiven, - score_maximum AS scoreMaximum, - activity_progress AS activityProgress, - grading_progress AS gradingProgress, - status, - created_at AS createdAt, - updated_at AS updatedAt, - published_at AS publishedAt, - error_code AS errorCode, - error_detail AS errorDetail - FROM grade_publications -`; - -const D1_AUDIT_EVENT_SELECT = ` - SELECT - id, - event_type AS eventType, - actor_type AS actorType, - actor_id AS actorId, - deployment_record_id AS deploymentRecordId, - package_version_id AS packageVersionId, - attempt_id AS attemptId, - line_item_binding_id AS lineItemBindingId, - status, - summary, - detail, - occurred_at AS occurredAt - FROM audit_events -`; - -async function requireLineItemBindingByUrl(db: D1Database, lineItemUrl: string) { - const row = await queryD1First( - db, - `${D1_LINE_ITEM_BINDING_SELECT} WHERE line_item_url = ?`, - [lineItemUrl], - ); - - if (row === null) { - throw new Error(`Line item binding ${lineItemUrl} was not found.`); - } - - return mapLineItemBindingRow(mapD1LineItemBindingFields(row)); -} - -async function requireGradePublicationByAttemptId(db: D1Database, attemptId: string) { - const row = await queryD1First( - db, - `${D1_GRADE_PUBLICATION_SELECT} WHERE attempt_id = ?`, - [attemptId], - ); - - if (row === null) { - throw new Error(`Grade publication for attempt ${attemptId} was not found.`); - } - - return mapGradePublicationRow(mapD1GradePublicationFields(row)); -} - -function isD1UniqueViolation(error: unknown): boolean { - return error instanceof Error && error.message.includes('UNIQUE constraint failed'); -} diff --git a/src/package_review/repository_grading_d1_mapping.ts b/src/package_review/repository_grading_d1_mapping.ts deleted file mode 100644 index 240806e..0000000 --- a/src/package_review/repository_grading_d1_mapping.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { - expectD1JsonRecord, - expectD1NullableNumber, - expectD1NullableString, - expectD1Number, - expectD1String, - expectD1StringLiteral, - parseD1JsonField, - parseD1NullableJsonField, -} from '../db/d1_row.ts'; -import type { - AttemptEvidenceArtifactRow, - AuditEventRow, - GradePublicationRow, - LineItemBindingRow, -} from './repository_row_types.ts'; - -export interface D1AttemptEvidenceArtifactRow extends Record { - artifactId: unknown; - attemptId: unknown; - sequence: unknown; - kind: unknown; - contentType: unknown; - fileName: unknown; - storageKey: unknown; - byteSize: unknown; - sha256: unknown; - createdAt: unknown; -} - -export interface D1LineItemBindingRow extends Record { - id: unknown; - deploymentRecordId: unknown; - packageVersionId: unknown; - contextId: unknown; - resourceLinkId: unknown; - activityId: unknown; - lineItemsUrl: unknown; - lineItemUrl: unknown; - resourceId: unknown; - tag: unknown; - label: unknown; - scoreMaximum: unknown; - createdAt: unknown; - updatedAt: unknown; -} - -export interface D1GradePublicationRow extends Record { - id: unknown; - attemptId: unknown; - lineItemBindingId: unknown; - lineItemUrl: unknown; - platformUserId: unknown; - scoreGiven: unknown; - scoreMaximum: unknown; - activityProgress: unknown; - gradingProgress: unknown; - status: unknown; - createdAt: unknown; - updatedAt: unknown; - publishedAt: unknown; - errorCode: unknown; - errorDetail: unknown; -} - -export interface D1AuditEventRow extends Record { - id: unknown; - eventType: unknown; - actorType: unknown; - actorId: unknown; - deploymentRecordId: unknown; - packageVersionId: unknown; - attemptId: unknown; - lineItemBindingId: unknown; - status: unknown; - summary: unknown; - detail: unknown; - occurredAt: unknown; -} - -export function mapD1AttemptEvidenceArtifactRow( - row: D1AttemptEvidenceArtifactRow, -): AttemptEvidenceArtifactRow { - return { - artifactId: expectD1String(row.artifactId, 'artifactId'), - attemptId: expectD1String(row.attemptId, 'attemptId'), - sequence: expectD1Number(row.sequence, 'sequence'), - kind: expectD1StringLiteral(row.kind, 'kind', ['screenshot_png', 'structured_json']), - contentType: expectD1String(row.contentType, 'contentType'), - fileName: expectD1String(row.fileName, 'fileName'), - storageKey: expectD1String(row.storageKey, 'storageKey'), - byteSize: expectD1Number(row.byteSize, 'byteSize'), - sha256: expectD1String(row.sha256, 'sha256'), - createdAt: expectD1String(row.createdAt, 'createdAt'), - }; -} - -export function mapD1LineItemBindingFields(row: D1LineItemBindingRow): LineItemBindingRow { - return { - id: expectD1Number(row.id, 'id'), - deploymentRecordId: expectD1Number(row.deploymentRecordId, 'deploymentRecordId'), - packageVersionId: expectD1Number(row.packageVersionId, 'packageVersionId'), - contextId: expectD1String(row.contextId, 'contextId'), - resourceLinkId: expectD1String(row.resourceLinkId, 'resourceLinkId'), - activityId: expectD1String(row.activityId, 'activityId'), - lineItemsUrl: expectD1String(row.lineItemsUrl, 'lineItemsUrl'), - lineItemUrl: expectD1String(row.lineItemUrl, 'lineItemUrl'), - resourceId: expectD1String(row.resourceId, 'resourceId'), - tag: expectD1String(row.tag, 'tag'), - label: expectD1String(row.label, 'label'), - scoreMaximum: expectD1Number(row.scoreMaximum, 'scoreMaximum'), - createdAt: expectD1String(row.createdAt, 'createdAt'), - updatedAt: expectD1String(row.updatedAt, 'updatedAt'), - }; -} - -export function mapD1GradePublicationFields(row: D1GradePublicationRow): GradePublicationRow { - return { - id: expectD1Number(row.id, 'id'), - attemptId: expectD1String(row.attemptId, 'attemptId'), - lineItemBindingId: expectD1Number(row.lineItemBindingId, 'lineItemBindingId'), - lineItemUrl: expectD1String(row.lineItemUrl, 'lineItemUrl'), - platformUserId: expectD1String(row.platformUserId, 'platformUserId'), - scoreGiven: expectD1Number(row.scoreGiven, 'scoreGiven'), - scoreMaximum: expectD1Number(row.scoreMaximum, 'scoreMaximum'), - activityProgress: expectD1StringLiteral(row.activityProgress, 'activityProgress', [ - 'Completed', - 'InProgress', - 'Initialized', - ]), - gradingProgress: expectD1StringLiteral(row.gradingProgress, 'gradingProgress', [ - 'Pending', - 'PendingManual', - 'FullyGraded', - 'Failed', - ]), - status: expectD1StringLiteral(row.status, 'status', ['pending', 'published', 'failed']), - createdAt: expectD1String(row.createdAt, 'createdAt'), - updatedAt: expectD1String(row.updatedAt, 'updatedAt'), - publishedAt: expectD1NullableString(row.publishedAt, 'publishedAt'), - errorCode: expectD1NullableString(row.errorCode, 'errorCode'), - errorDetail: parseD1NullableJsonField(row.errorDetail, 'errorDetail', expectD1JsonRecord), - }; -} - -export function mapD1AuditEventFields(row: D1AuditEventRow): AuditEventRow { - return { - id: expectD1Number(row.id, 'id'), - eventType: expectD1String(row.eventType, 'eventType'), - actorType: expectD1StringLiteral(row.actorType, 'actorType', ['user', 'system', 'platform']), - actorId: expectD1NullableString(row.actorId, 'actorId'), - deploymentRecordId: expectD1NullableNumber(row.deploymentRecordId, 'deploymentRecordId'), - packageVersionId: expectD1NullableNumber(row.packageVersionId, 'packageVersionId'), - attemptId: expectD1NullableString(row.attemptId, 'attemptId'), - lineItemBindingId: expectD1NullableNumber(row.lineItemBindingId, 'lineItemBindingId'), - status: expectD1StringLiteral(row.status, 'status', ['accepted', 'succeeded', 'failed']), - summary: expectD1String(row.summary, 'summary'), - detail: parseD1JsonField(row.detail, 'detail', expectD1JsonRecord), - occurredAt: expectD1String(row.occurredAt, 'occurredAt'), - }; -} diff --git a/src/package_review/repository_grading_d1_test.ts b/src/package_review/repository_grading_d1_test.ts deleted file mode 100644 index 56d1707..0000000 --- a/src/package_review/repository_grading_d1_test.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { assertEquals, assertRejects } from '@std/assert'; -import type { D1Database, D1Parameter, D1PreparedStatement, D1Result } from '../db/d1.ts'; -import { createD1GradingRepositoryMethods } from './repository_grading_d1.ts'; - -Deno.test('D1 grading repository stores attempt evidence with next sequence', async () => { - const artifactRow = buildAttemptEvidenceArtifactRow({ sequence: 2 }); - const db = createPlannedD1Database({ - firstResults: [artifactRow], - }); - const repository = createD1GradingRepositoryMethods(db); - - const artifact = await repository.createAttemptEvidenceArtifact({ - artifactId: 'artifact-1', - attemptId: 'attempt-1', - kind: 'structured_json', - contentType: 'application/json', - fileName: 'evidence.json', - storageKey: 'attempts/attempt-1/evidence.json', - byteSize: 128, - sha256: 'sha256', - createdAt: '2026-05-13T12:00:00.000Z', - }); - - assertEquals(artifact.sequence, 2); - assertEquals(db.statements[0]?.query.includes('next_evidence_artifact_sequence'), true); - assertEquals(db.statements[0]?.query.includes('COALESCE(MAX'), false); - assertEquals(db.statements[0]?.parameters, [ - 'artifact-1', - 'structured_json', - 'application/json', - 'evidence.json', - 'attempts/attempt-1/evidence.json', - 128, - 'sha256', - '2026-05-13T12:00:00.000Z', - 'attempt-1', - ]); - assertEquals(db.statements[1]?.query.includes('SET next_evidence_artifact_sequence'), true); - assertEquals(db.statements[1]?.parameters, ['attempt-1']); -}); - -Deno.test('D1 grading repository reports duplicate evidence artifacts clearly', async () => { - const db = createPlannedD1Database({ - batchError: new Error('UNIQUE constraint failed: attempt_evidence_artifacts.artifact_id'), - }); - const repository = createD1GradingRepositoryMethods(db); - - await assertRejects( - () => - repository.createAttemptEvidenceArtifact({ - artifactId: 'artifact-1', - attemptId: 'attempt-1', - kind: 'structured_json', - contentType: 'application/json', - fileName: 'evidence.json', - storageKey: 'attempts/attempt-1/evidence.json', - byteSize: 128, - sha256: 'sha256', - createdAt: '2026-05-13T12:00:00.000Z', - }), - Error, - 'Attempt evidence artifact artifact-1 already exists and cannot be replaced.', - ); -}); - -Deno.test('D1 grading repository returns a concurrent line item binding on unique conflict', async () => { - const lineItemRow = buildLineItemBindingRow(); - const db = createPlannedD1Database({ - firstResults: [null, lineItemRow], - runError: new Error('UNIQUE constraint failed: line_item_bindings.line_item_url'), - }); - const repository = createD1GradingRepositoryMethods(db); - - const binding = await repository.saveLineItemBinding({ - deploymentRecordId: 1, - packageVersionId: 2, - contextId: 'course-1', - resourceLinkId: 'resource-link-1', - activityId: 'asteroids', - lineItemsUrl: 'https://canvas.example/line_items', - lineItemUrl: 'https://canvas.example/line_items/1', - resourceId: 'resource-1', - tag: 'asteroids', - label: 'Asteroids', - scoreMaximum: 10, - createdAt: '2026-05-13T12:00:00.000Z', - updatedAt: '2026-05-13T12:00:00.000Z', - }); - - assertEquals(binding.id, 10); - assertEquals(db.statements[2]?.parameters, [ - 1, - 2, - 'course-1', - 'resource-link-1', - 'asteroids', - 'https://canvas.example/line_items/1', - ]); -}); - -Deno.test('D1 grading repository stores grade publication error detail as JSON text', async () => { - const gradeRow = buildGradePublicationRow({ - errorDetail: '{"reason":"rate_limited"}', - }); - const db = createPlannedD1Database({ - firstResults: [gradeRow], - }); - const repository = createD1GradingRepositoryMethods(db); - - const publication = await repository.createGradePublication({ - attemptId: 'attempt-1', - lineItemBindingId: 10, - lineItemUrl: 'https://canvas.example/line_items/1', - platformUserId: 'user-1', - scoreGiven: 8, - scoreMaximum: 10, - activityProgress: 'Completed', - gradingProgress: 'Failed', - status: 'failed', - createdAt: '2026-05-13T12:00:00.000Z', - updatedAt: '2026-05-13T12:00:00.000Z', - publishedAt: null, - errorCode: 'rate_limited', - errorDetail: { reason: 'rate_limited' }, - }); - - assertEquals(publication.errorDetail, { reason: 'rate_limited' }); - assertEquals(db.statements[0]?.parameters[13], '{"reason":"rate_limited"}'); -}); - -Deno.test('D1 grading repository records audit events with JSON detail', async () => { - const auditRow = buildAuditEventRow({ - detail: '{"placementId":"placement-1"}', - }); - const db = createPlannedD1Database({ - firstResults: [auditRow], - }); - const repository = createD1GradingRepositoryMethods(db); - - const event = await repository.recordAuditEvent({ - eventType: 'reviewer.placement.approved', - actorType: 'user', - actorId: 'reviewer-1', - deploymentRecordId: 1, - packageVersionId: 2, - attemptId: null, - lineItemBindingId: null, - status: 'succeeded', - summary: 'Placement approved.', - detail: { placementId: 'placement-1' }, - occurredAt: '2026-05-13T12:00:00.000Z', - }); - - assertEquals(event.detail, { placementId: 'placement-1' }); - assertEquals(db.statements[0]?.parameters[9], '{"placementId":"placement-1"}'); -}); - -interface PlannedD1Options { - allResults?: Array>>; - firstResults?: Array | null>; - firstError?: Error; - batchError?: Error; - runError?: Error; -} - -interface RecordedStatement { - query: string; - parameters: D1Parameter[]; -} - -function createPlannedD1Database( - options: PlannedD1Options, -): D1Database & { statements: RecordedStatement[] } { - const statements: RecordedStatement[] = []; - const allResults = [...(options.allResults ?? [])]; - const firstResults = [...(options.firstResults ?? [])]; - - return { - statements, - prepare(query) { - return createPlannedD1Statement(query, statements, allResults, firstResults, options); - }, - batch(batchStatements: D1PreparedStatement[]): Promise[]> { - for (const statement of batchStatements) { - const recorded = statement as D1PreparedStatement & RecordedStatement; - statements.push({ - query: recorded.query, - parameters: recorded.parameters, - }); - } - - if (options.batchError !== undefined) { - return Promise.resolve( - [ - { - success: false, - error: options.batchError.message, - }, - ] satisfies D1Result[], - ); - } - - return Promise.resolve( - batchStatements.map((_statement, index) => ({ - success: true, - results: (index === 0 ? [firstResults.shift()].filter(isRecord) : []) as T[], - })) satisfies D1Result[], - ); - }, - exec(_query) { - return Promise.resolve({ - count: 0, - duration: 0, - }); - }, - }; -} - -function createPlannedD1Statement( - query: string, - statements: RecordedStatement[], - allResults: Array>>, - firstResults: Array | null>, - options: PlannedD1Options, -): D1PreparedStatement { - let parameters: D1Parameter[] = []; - - const statement = { - query, - get parameters() { - return parameters; - }, - bind(...values: D1Parameter[]) { - parameters = values; - return this; - }, - all() { - statements.push({ query, parameters }); - return Promise.resolve({ - success: true, - results: (allResults.shift() ?? []) as T[], - }); - }, - first() { - statements.push({ query, parameters }); - if (options.firstError !== undefined) { - throw options.firstError; - } - - return Promise.resolve((firstResults.shift() as T | null | undefined) ?? null); - }, - run() { - statements.push({ query, parameters }); - - if (options.runError) { - throw options.runError; - } - - return Promise.resolve({ success: true } satisfies D1Result>); - }, - raw() { - return Promise.resolve([] as T[]); - }, - }; - - return statement; -} - -function isRecord( - value: Record | null | undefined, -): value is Record { - return value !== null && value !== undefined; -} - -function buildAttemptEvidenceArtifactRow( - overrides: Partial> = {}, -): Record { - return { - artifactId: 'artifact-1', - attemptId: 'attempt-1', - sequence: 1, - kind: 'structured_json', - contentType: 'application/json', - fileName: 'evidence.json', - storageKey: 'attempts/attempt-1/evidence.json', - byteSize: 128, - sha256: 'sha256', - createdAt: '2026-05-13T12:00:00.000Z', - ...overrides, - }; -} - -function buildLineItemBindingRow( - overrides: Partial> = {}, -): Record { - return { - id: 10, - deploymentRecordId: 1, - packageVersionId: 2, - contextId: 'course-1', - resourceLinkId: 'resource-link-1', - activityId: 'asteroids', - lineItemsUrl: 'https://canvas.example/line_items', - lineItemUrl: 'https://canvas.example/line_items/1', - resourceId: 'resource-1', - tag: 'asteroids', - label: 'Asteroids', - scoreMaximum: 10, - createdAt: '2026-05-13T12:00:00.000Z', - updatedAt: '2026-05-13T12:00:00.000Z', - ...overrides, - }; -} - -function buildGradePublicationRow( - overrides: Partial> = {}, -): Record { - return { - id: 20, - attemptId: 'attempt-1', - lineItemBindingId: 10, - lineItemUrl: 'https://canvas.example/line_items/1', - platformUserId: 'user-1', - scoreGiven: 8, - scoreMaximum: 10, - activityProgress: 'Completed', - gradingProgress: 'Failed', - status: 'failed', - createdAt: '2026-05-13T12:00:00.000Z', - updatedAt: '2026-05-13T12:00:00.000Z', - publishedAt: null, - errorCode: 'rate_limited', - errorDetail: null, - ...overrides, - }; -} - -function buildAuditEventRow( - overrides: Partial> = {}, -): Record { - return { - id: 30, - eventType: 'reviewer.placement.approved', - actorType: 'user', - actorId: 'reviewer-1', - deploymentRecordId: 1, - packageVersionId: 2, - attemptId: null, - lineItemBindingId: null, - status: 'succeeded', - summary: 'Placement approved.', - detail: '{"placementId":"placement-1"}', - occurredAt: '2026-05-13T12:00:00.000Z', - ...overrides, - }; -} diff --git a/src/package_review/repository_launch_state_d1.ts b/src/package_review/repository_launch_state_d1.ts index 5361003..a666152 100644 --- a/src/package_review/repository_launch_state_d1.ts +++ b/src/package_review/repository_launch_state_d1.ts @@ -1,13 +1,20 @@ +/** + * Preview/sandbox runtime sessions only. + * + * The `runtime_sessions` table and the PackageReviewRepository launch-state APIs + * in this module back preview and authoring runs (admin test launches, Python + * Lab previews). They are NOT the LMS launch plane. Governed LMS launches must + * use `governed_runtime_sessions` via `lms_kernel` (see + * `src/lms_kernel/launch_service.ts` and `src/app_governed_runtime_routes.ts`). + */ import type { D1Database } from '../db/d1.ts'; import { queryD1First, queryD1Objects, runD1, runD1Batch } from '../db/d1.ts'; import { type D1AttemptEventRow, type D1AttemptRow, - type D1DynamicRegistrationStateRow, type D1RuntimeSessionRow, mapD1AttemptEventFields, mapD1AttemptFields, - mapD1DynamicRegistrationStateFields, mapD1RuntimeSessionFields, } from './repository_launch_state_d1_mapping.ts'; import { @@ -15,10 +22,6 @@ import { mapAttemptRow, mapOptionalAttempt, } from './repository_mappers_attempts.ts'; -import { - mapDynamicRegistrationStateRow, - mapOptionalDynamicRegistrationState, -} from './repository_mappers_package.ts'; import { mapOptionalRuntimeSession, mapRuntimeSessionRow } from './repository_mappers_sessions.ts'; import type { PackageReviewRepository } from './repository.ts'; @@ -26,12 +29,8 @@ export function createD1LaunchStateRepositoryMethods( db: D1Database, ): Pick< PackageReviewRepository, - | 'createDynamicRegistrationState' - | 'getDynamicRegistrationStateByState' - | 'consumeDynamicRegistrationState' | 'createRuntimeSession' | 'getRuntimeSessionById' - | 'getLatestRuntimeSessionByDeploymentId' | 'createAttempt' | 'getAttemptById' | 'listAttemptsByApp' @@ -41,79 +40,6 @@ export function createD1LaunchStateRepositoryMethods( | 'writeAttemptLocalState' > { return { - async createDynamicRegistrationState(record) { - try { - await runD1( - db, - ` - INSERT INTO dynamic_registration_states ( - state, - app_id, - lms_type, - created_at, - expires_at, - used_at - ) VALUES (?, ?, ?, ?, ?, ?) - `, - [ - record.state, - record.appId, - record.lms, - record.createdAt, - record.expiresAt, - record.usedAt, - ], - ); - } catch (error) { - if (isD1UniqueViolation(error)) { - throw new Error( - `Dynamic registration state ${record.state} already exists and cannot be reused.`, - ); - } - - throw error; - } - - return await requireDynamicRegistrationState(db, record.state); - }, - - async getDynamicRegistrationStateByState(state) { - const row = await queryD1First( - db, - `${D1_DYNAMIC_REGISTRATION_STATE_SELECT} WHERE state = ?`, - [state], - ); - - return mapOptionalDynamicRegistrationState( - row === null ? undefined : mapD1DynamicRegistrationStateFields(row), - ); - }, - - async consumeDynamicRegistrationState(input) { - await runD1( - db, - ` - UPDATE dynamic_registration_states - SET used_at = ? - WHERE state = ? - AND used_at IS NULL - `, - [input.usedAt, input.state], - ); - - const existing = await requireDynamicRegistrationState(db, input.state); - - if (existing.usedAt === input.usedAt) { - return existing; - } - - if (existing.usedAt !== null) { - throw new Error(`Dynamic registration state ${input.state} has already been used.`); - } - - throw new Error(`Dynamic registration state ${input.state} could not be consumed.`); - }, - async createRuntimeSession(record) { try { await runD1( @@ -124,8 +50,6 @@ export function createD1LaunchStateRepositoryMethods( session_token, lti_tool_session_id, attempt_id, - deployment_record_id, - deployment_slug, app_id, package_version_id, package_version, @@ -145,15 +69,13 @@ export function createD1LaunchStateRepositoryMethods( preview_session_id, created_at, expires_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ record.sessionId, record.sessionToken, record.ltiToolSessionId, record.attemptId, - record.deploymentRecordId, - record.deploymentSlug, record.appId, record.packageVersionId, record.packageVersion, @@ -170,7 +92,7 @@ export function createD1LaunchStateRepositoryMethods( record.launch.courseId, record.launch.assignmentId ?? null, record.launch.activityId, - record.preview?.previewSessionId ?? null, + record.preview.previewSessionId, record.createdAt, record.expiresAt, ], @@ -198,21 +120,6 @@ export function createD1LaunchStateRepositoryMethods( return mapOptionalRuntimeSession(row === null ? undefined : mapD1RuntimeSessionFields(row)); }, - async getLatestRuntimeSessionByDeploymentId(deploymentRecordId) { - const row = await queryD1First( - db, - ` - ${D1_RUNTIME_SESSION_SELECT} - WHERE deployment_record_id = ? - ORDER BY created_at DESC - LIMIT 1 - `, - [deploymentRecordId], - ); - - return mapOptionalRuntimeSession(row === null ? undefined : mapD1RuntimeSessionFields(row)); - }, - async createAttempt(record) { try { await runD1( @@ -220,8 +127,7 @@ export function createD1LaunchStateRepositoryMethods( ` INSERT INTO attempts ( attempt_id, - deployment_record_id, - deployment_slug, + preview_session_id, app_id, package_version_id, package_version, @@ -238,12 +144,11 @@ export function createD1LaunchStateRepositoryMethods( local_state, started_at, finalized_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ record.attemptId, - record.deploymentRecordId, - record.deploymentSlug, + record.previewSessionId, record.appId, record.packageVersionId, record.packageVersion, @@ -288,10 +193,7 @@ export function createD1LaunchStateRepositoryMethods( db, ` ${D1_ATTEMPT_SELECT} - LEFT JOIN deployments - ON deployments.id = attempts.deployment_record_id WHERE attempts.app_id = ? - AND COALESCE(deployments.lms_type, 'canvas') <> 'preview' ORDER BY attempts.started_at DESC, attempts.id DESC `, [appId], @@ -423,25 +325,12 @@ export function createD1LaunchStateRepositoryMethods( }; } -const D1_DYNAMIC_REGISTRATION_STATE_SELECT = ` - SELECT - state, - app_id AS appId, - lms_type AS lmsType, - created_at AS createdAt, - expires_at AS expiresAt, - used_at AS usedAt - FROM dynamic_registration_states -`; - const D1_RUNTIME_SESSION_SELECT = ` SELECT session_id AS sessionId, session_token AS sessionToken, lti_tool_session_id AS ltiToolSessionId, attempt_id AS attemptId, - deployment_record_id AS deploymentRecordId, - deployment_slug AS deploymentSlug, app_id AS appId, package_version_id AS packageVersionId, package_version AS packageVersion, @@ -468,8 +357,7 @@ const D1_ATTEMPT_SELECT = ` SELECT attempts.id, attempts.attempt_id AS attemptId, - attempts.deployment_record_id AS deploymentRecordId, - attempts.deployment_slug AS deploymentSlug, + attempts.preview_session_id AS previewSessionId, attempts.app_id AS appId, attempts.package_version_id AS packageVersionId, attempts.package_version AS packageVersion, @@ -504,20 +392,6 @@ const D1_ATTEMPT_EVENT_SELECT = ` FROM attempt_events `; -async function requireDynamicRegistrationState(db: D1Database, state: string) { - const row = await queryD1First( - db, - `${D1_DYNAMIC_REGISTRATION_STATE_SELECT} WHERE state = ?`, - [state], - ); - - if (row === null) { - throw new Error(`Dynamic registration state ${state} was not found.`); - } - - return mapDynamicRegistrationStateRow(mapD1DynamicRegistrationStateFields(row)); -} - async function requireRuntimeSession(db: D1Database, sessionId: string) { const row = await queryD1First( db, diff --git a/src/package_review/repository_launch_state_d1_mapping.ts b/src/package_review/repository_launch_state_d1_mapping.ts index f9a5de2..291a792 100644 --- a/src/package_review/repository_launch_state_d1_mapping.ts +++ b/src/package_review/repository_launch_state_d1_mapping.ts @@ -10,29 +10,13 @@ import { parseD1NullableJsonField, } from '../db/d1_row.ts'; import type { AttemptEvent, Capability, NormalizedAttemptEvent } from '../../sdk/app-sdk.ts'; -import type { - AttemptEventRow, - AttemptRow, - DynamicRegistrationStateRow, - RuntimeSessionRow, -} from './repository_row_types.ts'; - -export interface D1DynamicRegistrationStateRow extends Record { - state: unknown; - appId: unknown; - lmsType: unknown; - createdAt: unknown; - expiresAt: unknown; - usedAt: unknown; -} +import type { AttemptEventRow, AttemptRow, RuntimeSessionRow } from './repository_row_types.ts'; export interface D1RuntimeSessionRow extends Record { sessionId: unknown; sessionToken: unknown; ltiToolSessionId: unknown; attemptId: unknown; - deploymentRecordId: unknown; - deploymentSlug: unknown; appId: unknown; packageVersionId: unknown; packageVersion: unknown; @@ -57,8 +41,7 @@ export interface D1RuntimeSessionRow extends Record { export interface D1AttemptRow extends Record { id: unknown; attemptId: unknown; - deploymentRecordId: unknown; - deploymentSlug: unknown; + previewSessionId: unknown; appId: unknown; packageVersionId: unknown; packageVersion: unknown; @@ -90,27 +73,12 @@ export interface D1AttemptEventRow extends Record { receivedAt: unknown; } -export function mapD1DynamicRegistrationStateFields( - row: D1DynamicRegistrationStateRow, -): DynamicRegistrationStateRow { - return { - state: expectD1String(row.state, 'state'), - appId: expectD1String(row.appId, 'appId'), - lmsType: expectD1StringLiteral(row.lmsType, 'lmsType', ['canvas', 'moodle', 'sakai']), - createdAt: expectD1String(row.createdAt, 'createdAt'), - expiresAt: expectD1String(row.expiresAt, 'expiresAt'), - usedAt: expectD1NullableString(row.usedAt, 'usedAt'), - }; -} - export function mapD1RuntimeSessionFields(row: D1RuntimeSessionRow): RuntimeSessionRow { return { sessionId: expectD1String(row.sessionId, 'sessionId'), sessionToken: expectD1String(row.sessionToken, 'sessionToken'), ltiToolSessionId: expectD1NullableString(row.ltiToolSessionId, 'ltiToolSessionId'), attemptId: expectD1NullableString(row.attemptId, 'attemptId'), - deploymentRecordId: expectD1Number(row.deploymentRecordId, 'deploymentRecordId'), - deploymentSlug: expectD1String(row.deploymentSlug, 'deploymentSlug'), appId: expectD1String(row.appId, 'appId'), packageVersionId: expectD1Number(row.packageVersionId, 'packageVersionId'), packageVersion: expectD1String(row.packageVersion, 'packageVersion'), @@ -141,7 +109,7 @@ export function mapD1RuntimeSessionFields(row: D1RuntimeSessionRow): RuntimeSess launchCourseId: expectD1String(row.launchCourseId, 'launchCourseId'), launchAssignmentId: expectD1NullableString(row.launchAssignmentId, 'launchAssignmentId'), launchActivityId: expectD1String(row.launchActivityId, 'launchActivityId'), - previewSessionId: expectD1NullableString(row.previewSessionId, 'previewSessionId'), + previewSessionId: expectD1String(row.previewSessionId, 'previewSessionId'), createdAt: expectD1String(row.createdAt, 'createdAt'), expiresAt: expectD1String(row.expiresAt, 'expiresAt'), }; @@ -151,8 +119,7 @@ export function mapD1AttemptFields(row: D1AttemptRow): AttemptRow { return { id: expectD1Number(row.id, 'id'), attemptId: expectD1String(row.attemptId, 'attemptId'), - deploymentRecordId: expectD1Number(row.deploymentRecordId, 'deploymentRecordId'), - deploymentSlug: expectD1String(row.deploymentSlug, 'deploymentSlug'), + previewSessionId: expectD1String(row.previewSessionId, 'previewSessionId'), appId: expectD1String(row.appId, 'appId'), packageVersionId: expectD1Number(row.packageVersionId, 'packageVersionId'), packageVersion: expectD1String(row.packageVersion, 'packageVersion'), diff --git a/src/package_review/repository_mappers_attempts.ts b/src/package_review/repository_mappers_attempts.ts index affca0c..4da9520 100644 --- a/src/package_review/repository_mappers_attempts.ts +++ b/src/package_review/repository_mappers_attempts.ts @@ -3,16 +3,12 @@ import type { AttemptEvidenceArtifactRecord, AttemptRecord, AuditEventRecord, - GradePublicationRecord, - LineItemBindingRecord, } from './types.ts'; import type { AttemptEventRow, AttemptEvidenceArtifactRow, AttemptRow, AuditEventRow, - GradePublicationRow, - LineItemBindingRow, } from './repository_row_types.ts'; import { normalizeNumeric, @@ -36,8 +32,7 @@ export function mapAttemptRow(row: AttemptRow | undefined): AttemptRecord { return { id: row.id, attemptId: row.attemptId, - deploymentRecordId: row.deploymentRecordId, - deploymentSlug: row.deploymentSlug, + previewSessionId: row.previewSessionId, appId: row.appId, packageVersionId: row.packageVersionId, packageVersion: row.packageVersion, @@ -107,75 +102,6 @@ export function mapAttemptEvidenceArtifactRow( }; } -export function mapOptionalLineItemBinding( - row: LineItemBindingRow | undefined, -): LineItemBindingRecord | null { - if (!row) { - return null; - } - - return mapLineItemBindingRow(row); -} - -export function mapLineItemBindingRow(row: LineItemBindingRow | undefined): LineItemBindingRecord { - if (!row) { - throw new Error('Expected a line item binding row.'); - } - - return { - id: row.id, - deploymentRecordId: row.deploymentRecordId, - packageVersionId: row.packageVersionId, - contextId: row.contextId, - resourceLinkId: row.resourceLinkId, - activityId: row.activityId, - lineItemsUrl: row.lineItemsUrl, - lineItemUrl: row.lineItemUrl, - resourceId: row.resourceId, - tag: row.tag, - label: row.label, - scoreMaximum: row.scoreMaximum, - createdAt: normalizeTimestamp(row.createdAt), - updatedAt: normalizeTimestamp(row.updatedAt), - }; -} - -export function mapOptionalGradePublication( - row: GradePublicationRow | undefined, -): GradePublicationRecord | null { - if (!row) { - return null; - } - - return mapGradePublicationRow(row); -} - -export function mapGradePublicationRow( - row: GradePublicationRow | undefined, -): GradePublicationRecord { - if (!row) { - throw new Error('Expected a grade publication row.'); - } - - return { - id: row.id, - attemptId: row.attemptId, - lineItemBindingId: row.lineItemBindingId, - lineItemUrl: row.lineItemUrl, - platformUserId: row.platformUserId, - scoreGiven: normalizeNumeric(row.scoreGiven), - scoreMaximum: normalizeNumeric(row.scoreMaximum), - activityProgress: row.activityProgress, - gradingProgress: row.gradingProgress, - status: row.status, - createdAt: normalizeTimestamp(row.createdAt), - updatedAt: normalizeTimestamp(row.updatedAt), - publishedAt: normalizeOptionalTimestamp(row.publishedAt), - errorCode: row.errorCode, - errorDetail: row.errorDetail, - }; -} - export function mapAuditEventRow(row: AuditEventRow | undefined): AuditEventRecord { if (!row) { throw new Error('Expected an audit event row.'); @@ -186,10 +112,8 @@ export function mapAuditEventRow(row: AuditEventRow | undefined): AuditEventReco eventType: row.eventType, actorType: row.actorType, actorId: row.actorId, - deploymentRecordId: row.deploymentRecordId, packageVersionId: row.packageVersionId, attemptId: row.attemptId, - lineItemBindingId: row.lineItemBindingId, status: row.status, summary: row.summary, detail: row.detail, diff --git a/src/package_review/repository_mappers_package.ts b/src/package_review/repository_mappers_package.ts index 1c270d4..3bec83e 100644 --- a/src/package_review/repository_mappers_package.ts +++ b/src/package_review/repository_mappers_package.ts @@ -1,15 +1,6 @@ -import type { DeploymentBinding, DynamicRegistrationStateRecord } from '../lti/types.ts'; import { parseReviewedRuntimeContract } from './runtime_contract.ts'; -import { - type DeploymentRecord, - type PackageVersionRecord, - parseAccessibilityReview, -} from './types.ts'; -import type { - DeploymentRow, - DynamicRegistrationStateRow, - PackageVersionRow, -} from './repository_row_types.ts'; +import { type PackageVersionRecord, parseAccessibilityReview } from './types.ts'; +import type { PackageVersionRow } from './repository_row_types.ts'; import { normalizeOptionalTimestamp, normalizeTimestamp } from './repository_value_support.ts'; export function mapOptionalPackageVersion( @@ -29,7 +20,7 @@ export function mapPackageVersionRow(row: PackageVersionRow | undefined): Packag if (row.runtimeContract === null || row.runtimeContractSignature === null) { throw new Error( - `Package version ${row.appId}@${row.version} is missing its reviewed runtime contract and cannot launch. Register a new reviewed version instead of mutating this legacy row.`, + `Package version ${row.appId}@${row.version} is missing its reviewed runtime contract and cannot launch.`, ); } @@ -43,7 +34,7 @@ export function mapPackageVersionRow(row: PackageVersionRow | undefined): Packag type: row.ownerType, id: row.ownerId, }, - entrypoint: row.entrypoint, + runtime: parseReviewedRuntimeContract(row.runtimeContract).runtime, roles: row.roles, installScope: row.installScope, capabilities: row.capabilities, @@ -52,6 +43,7 @@ export function mapPackageVersionRow(row: PackageVersionRow | undefined): Packag rubricFile: row.gradingRubricFile, maxScore: row.gradingMaxScore, }, + catalogVisibility: row.catalogVisibility, approvalStatus: row.approvalStatus, reviewNotes: row.reviewNotes, accessibilityReview: row.accessibilityReview === null @@ -71,99 +63,3 @@ export function mapPackageVersionRow(row: PackageVersionRow | undefined): Packag importedAt: normalizeTimestamp(row.importedAt), }; } - -export function mapOptionalDeployment(row: DeploymentRow | undefined): DeploymentRecord | null { - if (!row) { - return null; - } - - return mapDeploymentRow(row); -} - -export function mapDeploymentRow(row: DeploymentRow | undefined): DeploymentRecord { - if (!row) { - throw new Error('Expected a deployment row.'); - } - - return { - id: row.id, - slug: row.slug, - label: row.label, - appId: row.appId, - enabledPackageVersionId: row.enabledPackageVersionId, - enabledPackageVersion: row.enabledPackageVersion, - lmsType: row.lmsType, - binding: mapDeploymentBinding(row), - ltiProfileOverride: row.ltiProfileOverride, - updatedAt: normalizeTimestamp(row.updatedAt), - }; -} - -export function mapDeploymentBinding(row: DeploymentRow): DeploymentBinding | null { - if (row.issuer === null || row.clientId === null || row.deploymentId === null) { - return null; - } - - switch (row.lmsType) { - case 'canvas': - if (row.canvasEnvironment === null) { - return null; - } - - return { - lms: 'canvas', - canvasEnvironment: row.canvasEnvironment, - issuer: row.issuer, - clientId: row.clientId, - deploymentId: row.deploymentId, - }; - case 'moodle': - case 'sakai': - if ( - row.authorizationEndpoint === null || - row.accessTokenUrl === null || - row.jwksUrl === null - ) { - return null; - } - - return { - lms: row.lmsType, - issuer: row.issuer, - clientId: row.clientId, - deploymentId: row.deploymentId, - authorizationEndpoint: row.authorizationEndpoint, - accessTokenUrl: row.accessTokenUrl, - jwksUrl: row.jwksUrl, - }; - case 'preview': - return null; - } -} - -export function mapOptionalDynamicRegistrationState( - row: DynamicRegistrationStateRow | undefined, -): DynamicRegistrationStateRecord | null { - if (!row) { - return null; - } - - return mapDynamicRegistrationStateRow(row); -} - -export function mapDynamicRegistrationStateRow( - row: DynamicRegistrationStateRow | undefined, -): DynamicRegistrationStateRecord { - if (!row) { - throw new Error('Expected a dynamic registration state row.'); - } - - return { - state: row.state, - appId: row.appId, - lms: row.lmsType, - createdAt: normalizeTimestamp(row.createdAt), - expiresAt: normalizeTimestamp(row.expiresAt), - usedAt: normalizeOptionalTimestamp(row.usedAt), - }; -} diff --git a/src/package_review/repository_mappers_review.ts b/src/package_review/repository_mappers_review.ts index 7dbcfcc..0520ba8 100644 --- a/src/package_review/repository_mappers_review.ts +++ b/src/package_review/repository_mappers_review.ts @@ -1,79 +1,6 @@ -import type { PlacementAuditStatus } from './types.ts'; -import type { - PlacementAuditSnapshot, - PreviewEvidenceRecord, - PreviewSessionRecord, -} from './types.ts'; -import type { - PlacementAuditSnapshotRow, - PreviewEvidenceRow, - PreviewSessionRow, -} from './repository_row_types.ts'; -import { mapReviewedPlacementRow } from './repository_mappers_sessions.ts'; -import { - normalizeNumeric, - normalizeOptionalTimestamp, - normalizeTimestamp, -} from './repository_value_support.ts'; - -export function mapOptionalPlacementAuditSnapshot( - row: PlacementAuditSnapshotRow | undefined, -): PlacementAuditSnapshot | null { - if (!row) { - return null; - } - - return mapPlacementAuditSnapshotRow(row); -} - -export function mapPlacementAuditSnapshotRow( - row: PlacementAuditSnapshotRow | undefined, -): PlacementAuditSnapshot { - if (!row) { - throw new Error('Expected a placement audit snapshot row.'); - } - - const previewEvidenceCount = normalizeNumeric(row.previewEvidenceCount); - const reviewerEventCount = normalizeNumeric(row.reviewerEventCount); - - return { - placement: mapReviewedPlacementRow(row), - status: derivePlacementAuditStatus({ - resourceLinkId: row.resourceLinkId, - previewEvidenceCount, - reviewerEventCount, - }), - latestPreviewSessionId: row.latestPreviewSessionId, - latestPreviewOccurredAt: normalizeOptionalTimestamp(row.latestPreviewOccurredAt), - previewEvidenceCount, - evidenceSummary: { - deepLinkingRequestCount: normalizeNumeric(row.deepLinkingRequestCount), - placementEventCount: normalizeNumeric(row.placementEventCount), - reviewerEventCount, - latestOccurredAt: normalizeOptionalTimestamp(row.latestAuditOccurredAt), - }, - }; -} - -export function derivePlacementAuditStatus(input: { - resourceLinkId: string | null; - previewEvidenceCount: number; - reviewerEventCount: number; -}): PlacementAuditStatus { - if (input.reviewerEventCount > 0) { - return 'reviewed'; - } - - if (input.resourceLinkId === null) { - return 'awaiting_canvas_binding'; - } - - if (input.previewEvidenceCount > 0) { - return 'bound_with_preview'; - } - - return 'bound_no_preview'; -} +import type { PreviewEvidenceRecord, PreviewSessionRecord } from './types.ts'; +import type { PreviewEvidenceRow, PreviewSessionRow } from './repository_row_types.ts'; +import { normalizeNumeric, normalizeTimestamp } from './repository_value_support.ts'; export function mapOptionalPreviewSession( row: PreviewSessionRow | undefined, @@ -98,7 +25,6 @@ export function mapPreviewSessionRow(row: PreviewSessionRow | undefined): Previe packageTitle: row.packageTitle, origin: row.origin, contentPath: row.contentPath, - deepLinkingSessionId: row.deepLinkingSessionId, capabilities: row.capabilities, snapshotRoot: row.snapshotRoot, entrypointPath: row.entrypointPath, diff --git a/src/package_review/repository_mappers_sessions.ts b/src/package_review/repository_mappers_sessions.ts index f1b4b3a..80767b5 100644 --- a/src/package_review/repository_mappers_sessions.ts +++ b/src/package_review/repository_mappers_sessions.ts @@ -1,15 +1,6 @@ import { resolveSubmissionMode } from '../../sdk/app-sdk.ts'; -import type { - DeepLinkingSessionRecord, - DeepLinkingSessionSelection, - RuntimeSessionRecord, -} from '../lti/types.ts'; -import type { ReviewedPlacementRecord } from './types.ts'; -import type { - DeepLinkingSessionRow, - ReviewedPlacementRow, - RuntimeSessionRow, -} from './repository_row_types.ts'; +import type { RuntimeSessionRecord } from '../lti/types.ts'; +import type { RuntimeSessionRow } from './repository_row_types.ts'; import { normalizeTimestamp } from './repository_value_support.ts'; export function mapOptionalRuntimeSession( @@ -32,8 +23,6 @@ export function mapRuntimeSessionRow(row: RuntimeSessionRow | undefined): Runtim sessionToken: row.sessionToken, ltiToolSessionId: row.ltiToolSessionId, attemptId: row.attemptId ?? row.sessionId, - deploymentRecordId: row.deploymentRecordId, - deploymentSlug: row.deploymentSlug, appId: row.appId, packageVersionId: row.packageVersionId, packageVersion: row.packageVersion, @@ -49,115 +38,11 @@ export function mapRuntimeSessionRow(row: RuntimeSessionRow | undefined): Runtim activityId: row.launchActivityId, submissionMode: resolveSubmissionMode(row.capabilities), }, - ...(row.previewSessionId === null ? {} : { - preview: { - previewSessionId: row.previewSessionId, - }, - }), - createdAt: normalizeTimestamp(row.createdAt), - expiresAt: normalizeTimestamp(row.expiresAt), - }; -} - -export function mapOptionalDeepLinkingSession( - row: DeepLinkingSessionRow | undefined, -): DeepLinkingSessionRecord | null { - if (!row) { - return null; - } - - return mapDeepLinkingSessionRow(row); -} - -export function mapDeepLinkingSessionRow( - row: DeepLinkingSessionRow | undefined, -): DeepLinkingSessionRecord { - if (!row) { - throw new Error('Expected a deep linking session row.'); - } - - return { - sessionId: row.sessionId, - sessionToken: row.sessionToken, - ltiToolSessionId: row.ltiToolSessionId, - deploymentRecordId: row.deploymentRecordId, - deploymentSlug: row.deploymentSlug, - appId: row.appId, - userId: row.userId, - userRole: row.userRole, - contextId: row.contextId, - contextTitle: row.contextTitle, - deepLinkReturnUrl: row.deepLinkReturnUrl, - data: row.data, - placement: row.placement, - acceptTypes: row.acceptTypes, - acceptMultiple: row.acceptMultiple, - acceptPresentationDocumentTargets: row.acceptPresentationDocumentTargets, - acceptLineItem: row.acceptLineItem, - selection: mapDeepLinkingSessionSelection(row), + preview: { + previewSessionId: row.previewSessionId, + }, createdAt: normalizeTimestamp(row.createdAt), expiresAt: normalizeTimestamp(row.expiresAt), - usedAt: row.usedAt === null ? null : normalizeTimestamp(row.usedAt), - }; -} - -export function mapOptionalReviewedPlacement( - row: ReviewedPlacementRow | undefined, -): ReviewedPlacementRecord | null { - if (!row) { - return null; - } - - return mapReviewedPlacementRow(row); -} - -export function mapReviewedPlacementRow( - row: ReviewedPlacementRow | undefined, -): ReviewedPlacementRecord { - if (!row) { - throw new Error('Expected a reviewed placement row.'); - } - - return { - placementId: row.placementId, - deploymentRecordId: row.deploymentRecordId, - deploymentSlug: row.deploymentSlug, - appId: row.appId, - contextId: row.contextId, - contextTitle: row.contextTitle, - packageVersionId: row.packageVersionId, - packageVersion: row.packageVersion, - packageTitle: row.packageTitle, - activityId: row.activityId, - contentPath: row.contentPath, - contentTitle: row.contentTitle, - createdByUserId: row.createdByUserId, - resourceLinkId: row.resourceLinkId, - createdAt: normalizeTimestamp(row.createdAt), - boundAt: row.boundAt === null ? null : normalizeTimestamp(row.boundAt), - }; -} - -export function mapDeepLinkingSessionSelection( - row: DeepLinkingSessionRow, -): DeepLinkingSessionSelection | null { - if (row.selectedPackageVersionId === null) { - return null; - } - - if ( - row.selectedPackageVersion === null || - row.selectedActivityId === null || - row.selectedContentPath === null - ) { - throw new Error(`Deep Linking session ${row.sessionId} has an incomplete selection.`); - } - - return { - packageVersionId: row.selectedPackageVersionId, - packageVersion: row.selectedPackageVersion, - activityId: row.selectedActivityId, - contentPath: row.selectedContentPath, }; } diff --git a/src/package_review/repository_mappers_sessions_test.ts b/src/package_review/repository_mappers_sessions_test.ts index 1fe198d..864332f 100644 --- a/src/package_review/repository_mappers_sessions_test.ts +++ b/src/package_review/repository_mappers_sessions_test.ts @@ -18,8 +18,6 @@ function buildRuntimeSessionRow(overrides: Partial = {}): Run sessionToken: 'runtime-token-123', ltiToolSessionId: null, attemptId: 'attempt-123', - deploymentRecordId: 1, - deploymentSlug: 'flashcard-practice-preview', appId: 'flashcard-practice', packageVersionId: 2, packageVersion: '0.2.0', @@ -36,7 +34,7 @@ function buildRuntimeSessionRow(overrides: Partial = {}): Run launchCourseId: 'course_demo', launchAssignmentId: 'assignment_demo', launchActivityId: 'flashcard-practice', - previewSessionId: null, + previewSessionId: 'preview-session-123', createdAt: '2026-05-16T14:00:00.000Z', expiresAt: '2026-05-16T14:10:00.000Z', ...overrides, diff --git a/src/package_review/repository_package_versions_d1.ts b/src/package_review/repository_package_versions_d1.ts index 7b6b98b..6bda9b2 100644 --- a/src/package_review/repository_package_versions_d1.ts +++ b/src/package_review/repository_package_versions_d1.ts @@ -1,5 +1,5 @@ import type { D1Database } from '../db/d1.ts'; -import { queryD1First, queryD1Objects, runD1 } from '../db/d1.ts'; +import { queryD1First, queryD1Objects, runD1, runD1Batch } from '../db/d1.ts'; import { expectD1JsonRecord, expectD1NullableNumber, @@ -24,9 +24,7 @@ import type { ValidationIssue, } from './types.ts'; import { createD1AuthoringRepositoryMethods } from './repository_authoring_d1.ts'; -import { createD1DeepLinkingRepositoryMethods } from './repository_deep_linking_d1.ts'; -import { createD1DeploymentRepositoryMethods } from './repository_deployments_d1.ts'; -import { createD1GradingRepositoryMethods } from './repository_grading_d1.ts'; +import { createD1ActivityRepositoryMethods } from './repository_activity_d1.ts'; import { createD1LaunchStateRepositoryMethods } from './repository_launch_state_d1.ts'; import { createD1PreviewRepositoryMethods } from './repository_preview_d1.ts'; import { createD1AppGenerationRepositoryMethods } from '../app_writer/repository_d1.ts'; @@ -34,10 +32,8 @@ import { createD1AppGenerationRepositoryMethods } from '../app_writer/repository export function createD1PackageReviewRepository(db: D1Database): PackageReviewRepository { const portedMethods: PackageReviewRepository = { ...createD1PackageVersionRepositoryMethods(db), - ...createD1DeploymentRepositoryMethods(db), ...createD1LaunchStateRepositoryMethods(db), - ...createD1GradingRepositoryMethods(db), - ...createD1DeepLinkingRepositoryMethods(db), + ...createD1ActivityRepositoryMethods(db), ...createD1PreviewRepositoryMethods(db), ...createD1AuthoringRepositoryMethods(db), ...createD1AppGenerationRepositoryMethods(db), @@ -51,91 +47,28 @@ export function createD1PackageVersionRepositoryMethods( ): Pick< PackageReviewRepository, | 'registerPackageVersion' + | 'registerPythonLabTrialPackageVersion' | 'listPackageVersions' | 'listPackageVersionsByApp' + | 'deletePythonLabPackageVersion' | 'getPackageVersionById' | 'getPackageVersionByAppVersion' | 'approvePackageVersion' | 'rejectPackageVersion' > { return { - async registerPackageVersion(input: ImportedPackageVersion) { - try { - await runD1( - db, - ` - INSERT INTO package_versions ( - app_id, - version, - title, - description, - owner_type, - owner_id, - entrypoint, - roles, - install_scope, - capabilities, - grading_mode, - grading_rubric_file, - grading_max_score, - approval_status, - review_notes, - accessibility_review, - reviewed_at, - validation_issues, - manifest_json, - artifact_root, - artifact_digest, - runtime_contract, - runtime_contract_signature - ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, 'pending', NULL, NULL, NULL, ?, ?, ?, ?, ?, ? - ) - `, - [ - input.reviewData.appId, - input.reviewData.version, - input.reviewData.title, - input.reviewData.description, - input.reviewData.owner.type, - input.reviewData.owner.id, - input.reviewData.entrypoint, - input.reviewData.roles, - input.reviewData.installScope, - input.reviewData.capabilities, - input.reviewData.grading.mode, - input.reviewData.grading.rubricFile, - input.reviewData.grading.maxScore, - input.reviewData.validationIssues, - input.reviewData.manifestJson, - input.artifact.snapshotRoot, - input.artifact.digest, - input.runtimeContract, - input.runtimeContractSignature, - ], - ); - } catch (error) { - if (isD1UniqueViolation(error)) { - throw new Error( - `Package version ${input.reviewData.appId}@${input.reviewData.version} already exists and cannot be replaced.`, - ); - } - - throw error; - } + registerPackageVersion(input) { + return registerD1PackageVersion(db, input, 'catalog'); + }, - return await getRequiredPackageVersionByAppVersion( - db, - input.reviewData.appId, - input.reviewData.version, - ); + registerPythonLabTrialPackageVersion(input) { + return registerD1PackageVersion(db, input, 'python_lab_trial'); }, async listPackageVersions() { const rows = await queryD1Objects( db, - `${D1_PACKAGE_VERSION_SELECT} ORDER BY app_id ASC, imported_at DESC`, + `${D1_PACKAGE_VERSION_SELECT} WHERE catalog_visibility = 'catalog' ORDER BY app_id ASC, imported_at DESC`, ); return sortPackageVersions(rows.map(mapD1PackageVersionRow)); @@ -144,7 +77,7 @@ export function createD1PackageVersionRepositoryMethods( async listPackageVersionsByApp(appId) { const rows = await queryD1Objects( db, - `${D1_PACKAGE_VERSION_SELECT} WHERE app_id = ?`, + `${D1_PACKAGE_VERSION_SELECT} WHERE app_id = ? AND catalog_visibility = 'catalog'`, [appId], ); @@ -171,6 +104,39 @@ export function createD1PackageVersionRepositoryMethods( return mapOptionalPackageVersion(row === null ? undefined : mapD1PackageVersionFields(row)); }, + async deletePythonLabPackageVersion(id) { + const packageVersion = await queryD1First( + db, + `${D1_PACKAGE_VERSION_SELECT} WHERE id = ?`, + [id], + ); + + if (packageVersion === null) { + return; + } + + if (packageVersion.catalogVisibility !== 'python_lab_trial') { + throw new Error(`Package version ${id} is not a Python Lab trial package.`); + } + + const installation = await queryD1First>( + db, + `SELECT id FROM app_installations + WHERE package_version_id = ? LIMIT 1`, + [id], + ); + + if (installation !== null) { + throw new Error(`Python Lab package version ${id} is unexpectedly installed.`); + } + + await runD1Batch(db, [{ + sql: `DELETE FROM package_versions + WHERE id = ? AND catalog_visibility = 'python_lab_trial'`, + parameters: [id], + }]); + }, + async approvePackageVersion(input) { return await reviewD1PackageVersion( db, @@ -193,6 +159,90 @@ export function createD1PackageVersionRepositoryMethods( }; } +async function registerD1PackageVersion( + db: D1Database, + input: ImportedPackageVersion, + catalogVisibility: 'catalog' | 'python_lab_trial', +) { + try { + await runD1( + db, + ` + INSERT INTO package_versions ( + app_id, + version, + title, + description, + owner_type, + owner_id, + entrypoint, + roles, + install_scope, + capabilities, + grading_mode, + grading_rubric_file, + grading_max_score, + sandbox_grading_enabled, + catalog_visibility, + approval_status, + review_notes, + accessibility_review, + reviewed_at, + validation_issues, + manifest_json, + artifact_root, + artifact_digest, + runtime_contract, + runtime_contract_signature + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + `, + [ + input.reviewData.appId, + input.reviewData.version, + input.reviewData.title, + input.reviewData.description, + input.reviewData.owner.type, + input.reviewData.owner.id, + input.reviewData.runtime.entrypoint, + input.reviewData.roles, + input.reviewData.installScope, + input.reviewData.capabilities, + storedGradingMode(input.reviewData.grading.mode), + input.reviewData.grading.rubricFile, + input.reviewData.grading.maxScore, + input.reviewData.grading.mode === 'sandbox' ? 1 : 0, + catalogVisibility, + 'pending', + null, + null, + null, + input.reviewData.validationIssues, + input.reviewData.manifestJson, + input.artifact.snapshotRoot, + input.artifact.digest, + input.runtimeContract, + input.runtimeContractSignature, + ], + ); + } catch (error) { + if (isD1UniqueViolation(error)) { + throw new Error( + `Package version ${input.reviewData.appId}@${input.reviewData.version} already exists and cannot be replaced.`, + ); + } + + throw error; + } + + return await getRequiredPackageVersionByAppVersion( + db, + input.reviewData.appId, + input.reviewData.version, + ); +} + export const D1_PACKAGE_VERSION_SELECT = ` SELECT id, @@ -209,6 +259,8 @@ export const D1_PACKAGE_VERSION_SELECT = ` grading_mode AS gradingMode, grading_rubric_file AS gradingRubricFile, grading_max_score AS gradingMaxScore, + sandbox_grading_enabled AS sandboxGradingEnabled, + catalog_visibility AS catalogVisibility, approval_status AS approvalStatus, review_notes AS reviewNotes, accessibility_review AS accessibilityReview, @@ -238,6 +290,8 @@ export interface D1PackageVersionRow extends Record { gradingMode: unknown; gradingRubricFile: unknown; gradingMaxScore: unknown; + sandboxGradingEnabled: unknown; + catalogVisibility: unknown; approvalStatus: unknown; reviewNotes: unknown; accessibilityReview: unknown; @@ -326,12 +380,23 @@ function mapD1PackageVersionFields(row: D1PackageVersionRow): PackageVersionRow 'package_versions.capabilities', parseCapabilityArray, ), - gradingMode: expectD1StringLiteral(row.gradingMode, 'package_versions.gradingMode', [ - 'declarative', - 'manual', - 'completion', - 'browser', - ]), + gradingMode: parseEffectiveGradingMode({ + gradingMode: expectD1StringLiteral(row.gradingMode, 'package_versions.gradingMode', [ + 'declarative', + 'manual', + 'completion', + 'browser', + 'sandbox', + ]), + sandboxGradingEnabled: expectD1Number( + row.sandboxGradingEnabled, + 'package_versions.sandboxGradingEnabled', + ), + }), + sandboxGradingEnabled: expectD1Number( + row.sandboxGradingEnabled, + 'package_versions.sandboxGradingEnabled', + ), gradingRubricFile: expectD1NullableString( row.gradingRubricFile, 'package_versions.gradingRubricFile', @@ -340,6 +405,11 @@ function mapD1PackageVersionFields(row: D1PackageVersionRow): PackageVersionRow row.gradingMaxScore, 'package_versions.gradingMaxScore', ), + catalogVisibility: expectD1StringLiteral( + row.catalogVisibility, + 'package_versions.catalogVisibility', + ['catalog', 'python_lab_trial'], + ), approvalStatus: expectD1StringLiteral(row.approvalStatus, 'package_versions.approvalStatus', [ 'pending', 'approved', @@ -377,6 +447,27 @@ function mapD1PackageVersionFields(row: D1PackageVersionRow): PackageVersionRow }; } +function storedGradingMode( + mode: PackageVersionRow['gradingMode'], +): Exclude { + return mode === 'sandbox' ? 'browser' : mode; +} + +function parseEffectiveGradingMode(input: { + readonly gradingMode: PackageVersionRow['gradingMode']; + readonly sandboxGradingEnabled: number; +}): PackageVersionRow['gradingMode'] { + if (input.sandboxGradingEnabled === 0) { + return input.gradingMode; + } + + if (input.sandboxGradingEnabled === 1 && input.gradingMode === 'browser') { + return 'sandbox'; + } + + throw new TypeError('package_versions.sandboxGradingEnabled was invalid.'); +} + function isD1UniqueViolation(error: unknown): boolean { return error instanceof Error && error.message.includes('UNIQUE constraint failed'); } @@ -397,6 +488,7 @@ function parseCapabilityArray(value: unknown, fieldName: string): Capability[] { 'finalize_attempt', 'read_local_state', 'write_local_state', + 'run_code_submission', ]) ); } @@ -449,14 +541,31 @@ function parseReviewedRuntimeContract( const record = expectD1JsonRecord(value, fieldName); return { + runtimeContractVersion: expectD1StringLiteral( + record.runtimeContractVersion, + `${fieldName}.runtimeContractVersion`, + ['2'], + ), appId: expectD1String(record.appId, `${fieldName}.appId`), packageVersion: expectD1String(record.packageVersion, `${fieldName}.packageVersion`), artifactDigest: expectD1String(record.artifactDigest, `${fieldName}.artifactDigest`), - entrypoint: expectD1String(record.entrypoint, `${fieldName}.entrypoint`), + runtime: parseAppRuntime(record.runtime, `${fieldName}.runtime`), capabilities: parseCapabilityArray(record.capabilities, `${fieldName}.capabilities`), }; } +function parseAppRuntime( + value: unknown, + fieldName: string, +): ReviewedRuntimeContract['runtime'] { + const record = expectD1JsonRecord(value, fieldName); + const kind = expectD1StringLiteral(record.kind, `${fieldName}.kind`, ['browser', 'worker']); + return { + kind, + entrypoint: expectD1String(record.entrypoint, `${fieldName}.entrypoint`), + }; +} + function expectJsonRecordArray( value: unknown, fieldName: string, diff --git a/src/package_review/repository_package_versions_d1_test.ts b/src/package_review/repository_package_versions_d1_test.ts index a9c081a..460f546 100644 --- a/src/package_review/repository_package_versions_d1_test.ts +++ b/src/package_review/repository_package_versions_d1_test.ts @@ -48,6 +48,46 @@ Deno.test('D1 package version repository lists parsed package version rows', asy assertEquals(db.statements[0]?.parameters, ['chapter-4-asteroids']); }); +Deno.test('D1 package version repository persists sandbox grading through the explicit sandbox marker', async () => { + const imported = buildImportedPackageVersion({ + appId: 'python-lab-123', + version: '1.0.0', + capabilities: ['read_activity_content', 'run_code_submission'], + grading: { mode: 'sandbox', rubricFile: null, maxScore: 10 }, + }); + const db = createRecordingD1Database([ + toD1PackageVersionRow(buildPackageVersionRecordFromImport(imported)), + ]); + const repository = createD1PackageVersionRepositoryMethods(db); + + const registered = await repository.registerPackageVersion(imported); + + assertEquals(registered.grading.mode, 'sandbox'); + assertEquals(db.statements[0]?.parameters.slice(10, 14), ['browser', null, 10, 1]); +}); + +Deno.test('D1 package version repository registers a Python Lab package as non-catalog in its insert', async () => { + const imported = buildImportedPackageVersion({ + appId: 'python-lab-123', + version: '1.0.0', + capabilities: ['read_activity_content', 'run_code_submission'], + grading: { mode: 'sandbox', rubricFile: null, maxScore: 10 }, + }); + const db = createRecordingD1Database([ + toD1PackageVersionRow({ + ...buildPackageVersionRecordFromImport(imported), + catalogVisibility: 'python_lab_trial', + }), + ]); + const repository = createD1PackageVersionRepositoryMethods(db); + + const registered = await repository.registerPythonLabTrialPackageVersion(imported); + + assertEquals(registered.catalogVisibility, 'python_lab_trial'); + assertEquals(db.statements.length, 2); + assertEquals(db.statements[0]?.parameters[14], 'python_lab_trial'); +}); + Deno.test('D1 package version repository reports duplicate package versions clearly', async () => { const db = createRecordingD1Database([], { runError: new Error( @@ -168,11 +208,12 @@ function buildPackageVersionRecordFromImport( title: imported.reviewData.title, description: imported.reviewData.description, owner: imported.reviewData.owner, - entrypoint: imported.reviewData.entrypoint, + runtime: imported.reviewData.runtime, roles: imported.reviewData.roles, installScope: imported.reviewData.installScope, capabilities: imported.reviewData.capabilities, grading: imported.reviewData.grading, + catalogVisibility: 'catalog', approvalStatus: 'pending', reviewNotes: null, accessibilityReview: null, @@ -195,13 +236,15 @@ function toD1PackageVersionRow(record: PackageVersionRecord): D1Row { description: record.description, ownerType: record.owner.type, ownerId: record.owner.id, - entrypoint: record.entrypoint, + entrypoint: record.runtime.entrypoint, roles: JSON.stringify(record.roles), installScope: record.installScope, capabilities: JSON.stringify(record.capabilities), - gradingMode: record.grading.mode, + gradingMode: record.grading.mode === 'sandbox' ? 'browser' : record.grading.mode, gradingRubricFile: record.grading.rubricFile, gradingMaxScore: record.grading.maxScore, + sandboxGradingEnabled: record.grading.mode === 'sandbox' ? 1 : 0, + catalogVisibility: record.catalogVisibility, approvalStatus: record.approvalStatus, reviewNotes: record.reviewNotes, accessibilityReview: record.accessibilityReview === null diff --git a/src/package_review/repository_preview_d1.ts b/src/package_review/repository_preview_d1.ts index 420c0f8..ea67dcc 100644 --- a/src/package_review/repository_preview_d1.ts +++ b/src/package_review/repository_preview_d1.ts @@ -57,7 +57,6 @@ export function createD1PreviewRepositoryMethods( package_title, origin, content_path, - deep_linking_session_id, capabilities, snapshot_root, entrypoint_path, @@ -70,7 +69,7 @@ export function createD1PreviewRepositoryMethods( fake_score_maximum, fixture_data, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ record.sessionId, @@ -80,7 +79,6 @@ export function createD1PreviewRepositoryMethods( record.packageTitle, record.origin, record.contentPath, - record.deepLinkingSessionId, record.capabilities, record.snapshotRoot, record.entrypointPath, @@ -222,7 +220,6 @@ const D1_PREVIEW_SESSION_SELECT = ` package_title AS packageTitle, origin, content_path AS contentPath, - deep_linking_session_id AS deepLinkingSessionId, capabilities, snapshot_root AS snapshotRoot, entrypoint_path AS entrypointPath, @@ -259,7 +256,6 @@ interface D1PreviewSessionRow extends Record { packageTitle: unknown; origin: unknown; contentPath: unknown; - deepLinkingSessionId: unknown; capabilities: unknown; snapshotRoot: unknown; entrypointPath: unknown; @@ -308,11 +304,9 @@ function mapD1PreviewSessionFields(row: D1PreviewSessionRow): PreviewSessionRow packageTitle: expectD1String(row.packageTitle, 'packageTitle'), origin: expectD1StringLiteral(row.origin, 'origin', [ 'adminTestLaunch', - 'deepLinkingAuthoring', 'adminAuthoringDraft', ]), contentPath: expectD1String(row.contentPath, 'contentPath'), - deepLinkingSessionId: expectD1NullableString(row.deepLinkingSessionId, 'deepLinkingSessionId'), capabilities: parseD1JsonField( row.capabilities, 'capabilities', diff --git a/src/package_review/repository_preview_d1_test.ts b/src/package_review/repository_preview_d1_test.ts index d2da9a1..a17c099 100644 --- a/src/package_review/repository_preview_d1_test.ts +++ b/src/package_review/repository_preview_d1_test.ts @@ -16,7 +16,7 @@ Deno.test('D1 preview repository creates sessions with JSON capabilities and fix assertEquals(session.sessionId, 'preview-session-123'); assertEquals(session.capabilities.includes('finalize_attempt'), true); - assertEquals(db.statements[1]?.parameters[8], JSON.stringify(session.capabilities)); + assertEquals(db.statements[1]?.parameters[7], JSON.stringify(session.capabilities)); assertEquals(typeof db.statements[1]?.parameters[18], 'string'); }); @@ -207,7 +207,6 @@ function buildPreviewSessionRow( packageTitle: record.packageTitle, origin: record.origin, contentPath: record.contentPath, - deepLinkingSessionId: record.deepLinkingSessionId, capabilities: JSON.stringify(record.capabilities), snapshotRoot: record.snapshotRoot, entrypointPath: record.entrypointPath, diff --git a/src/package_review/repository_resource_options.ts b/src/package_review/repository_resource_options.ts index 40290ec..69dcf1c 100644 --- a/src/package_review/repository_resource_options.ts +++ b/src/package_review/repository_resource_options.ts @@ -1,6 +1,5 @@ -import type { LtiPlacement } from '../lti/types.ts'; import { compare, parse } from '@std/semver'; -import type { DeepLinkingResourceOption, PackageVersionRecord } from './types.ts'; +import type { PackageVersionRecord } from './types.ts'; export function sortPackageVersions(records: PackageVersionRecord[]): PackageVersionRecord[] { return [...records].sort((left, right) => { @@ -17,59 +16,3 @@ export function sortPackageVersions(records: PackageVersionRecord[]): PackageVer return right.importedAt.localeCompare(left.importedAt); }); } - -export function buildDeepLinkingResourceOptions( - versions: PackageVersionRecord[], - placement: LtiPlacement, -): DeepLinkingResourceOption[] { - const installScope = placement === 'assignment_selection' ? 'assignment' : 'course'; - - return versions.flatMap((version) => { - if ( - version.installScope !== installScope || - version.approvalStatus !== 'approved' || - version.reviewedAt === null - ) { - return []; - } - - return readCanonicalContentFiles(version.manifestJson).map((contentPath) => ({ - packageVersionId: version.id, - appId: version.appId, - packageVersion: version.version, - packageTitle: version.title, - ownerId: version.owner.id, - installScope, - approvalStatus: 'approved', - reviewedAt: version.reviewedAt, - activityId: contentPath, - contentPath, - contentTitle: null, - })); - }); -} - -function readCanonicalContentFiles(manifestJson: Record): string[] { - const contentFiles = readStringArray(manifestJson.content_files); - - if (contentFiles.length === 0) { - return ['/content/activity.json']; - } - - return contentFiles.map(normalizeContentPath); -} - -function readStringArray(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - - return value - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .filter((item) => item !== ''); -} - -function normalizeContentPath(value: string): string { - return value.startsWith('/') ? value : `/${value}`; -} diff --git a/src/package_review/repository_row_types.ts b/src/package_review/repository_row_types.ts index 02c166c..aaa368c 100644 --- a/src/package_review/repository_row_types.ts +++ b/src/package_review/repository_row_types.ts @@ -7,21 +7,13 @@ import type { AuditEventRecord, AuthoringDraftFileRecord, AuthoringDraftRecord, - GradePublicationRecord, PackageVersionRecord, PreviewEvidenceRecord, PreviewSessionRecord, ReviewedRuntimeContract, ValidationIssue, } from './types.ts'; -import type { LtiProfileId } from '../lti/profile.ts'; -import type { - CanvasEnvironment, - DeepLinkingSessionRecord, - DynamicRegistrationStateRecord, - PersistedDeploymentLmsType, - RuntimeSessionRecord, -} from '../lti/types.ts'; +import type { RuntimeSessionRecord } from '../lti/types.ts'; export interface PackageVersionRow { id: number; @@ -38,6 +30,8 @@ export interface PackageVersionRow { gradingMode: PackageVersionRecord['grading']['mode']; gradingRubricFile: string | null; gradingMaxScore: number | null; + sandboxGradingEnabled: number; + catalogVisibility: PackageVersionRecord['catalogVisibility']; approvalStatus: ApprovalStatus; reviewNotes: string | null; accessibilityReview: AccessibilityReview | null; @@ -51,46 +45,11 @@ export interface PackageVersionRow { importedAt: Date | string; } -export interface DeploymentRow { - id: number; - slug: string; - label: string; - appId: string; - enabledPackageVersionId: number | null; - enabledPackageVersion: string | null; - lmsType: PersistedDeploymentLmsType; - canvasEnvironment: CanvasEnvironment | null; - issuer: string | null; - clientId: string | null; - deploymentId: string | null; - authorizationEndpoint: string | null; - accessTokenUrl: string | null; - jwksUrl: string | null; - ltiProfileOverride: LtiProfileId | null; - updatedAt: Date | string; -} - -export interface LanternLtiProfileSettingsRow { - defaultLtiProfile: LtiProfileId; - updatedAt: Date | string; -} - -export interface DynamicRegistrationStateRow { - state: string; - appId: string; - lmsType: DynamicRegistrationStateRecord['lms']; - createdAt: Date | string; - expiresAt: Date | string; - usedAt: Date | string | null; -} - export interface RuntimeSessionRow { sessionId: string; sessionToken: string; ltiToolSessionId: string | null; attemptId?: string | null; - deploymentRecordId: number; - deploymentSlug: string; appId: string; packageVersionId: number; packageVersion: string; @@ -107,55 +66,9 @@ export interface RuntimeSessionRow { launchCourseId: string; launchAssignmentId: string | null; launchActivityId: string; - previewSessionId: string | null; - createdAt: Date | string; - expiresAt: Date | string; -} - -export interface DeepLinkingSessionRow { - sessionId: string; - sessionToken: string; - ltiToolSessionId: string | null; - deploymentRecordId: number; - deploymentSlug: string; - appId: string; - userId: string | null; - userRole: DeepLinkingSessionRecord['userRole']; - contextId: string | null; - contextTitle: string | null; - deepLinkReturnUrl: string; - data: string | null; - placement: DeepLinkingSessionRecord['placement']; - acceptTypes: DeepLinkingSessionRecord['acceptTypes']; - acceptMultiple: boolean; - acceptPresentationDocumentTargets: DeepLinkingSessionRecord['acceptPresentationDocumentTargets']; - acceptLineItem: boolean; - selectedPackageVersionId: number | null; - selectedPackageVersion: string | null; - selectedActivityId: string | null; - selectedContentPath: string | null; + previewSessionId: string; createdAt: Date | string; expiresAt: Date | string; - usedAt: Date | string | null; -} - -export interface ReviewedPlacementRow { - placementId: string; - deploymentRecordId: number; - deploymentSlug: string; - appId: string; - contextId: string | null; - contextTitle: string | null; - packageVersionId: number; - packageVersion: string; - packageTitle: string; - activityId: string; - contentPath: string; - contentTitle: string | null; - createdByUserId: string | null; - resourceLinkId: string | null; - createdAt: Date | string; - boundAt: Date | string | null; } export interface PreviewSessionRow { @@ -166,7 +79,6 @@ export interface PreviewSessionRow { packageTitle: string; origin: PreviewSessionRecord['origin']; contentPath: string; - deepLinkingSessionId: string | null; capabilities: PreviewSessionRecord['capabilities']; snapshotRoot: string; entrypointPath: string; @@ -205,16 +117,6 @@ export interface AuthoringDraftFileRow { sequence: AuthoringDraftFileRecord['sequence']; } -export interface PlacementAuditSnapshotRow extends ReviewedPlacementRow { - latestPreviewSessionId: string | null; - latestPreviewOccurredAt: Date | string | null; - previewEvidenceCount: number | string; - deepLinkingRequestCount: number | string; - placementEventCount: number | string; - reviewerEventCount: number | string; - latestAuditOccurredAt: Date | string | null; -} - export interface PreviewEvidenceRow { id: number; previewSessionId: string; @@ -229,8 +131,7 @@ export interface PreviewEvidenceRow { export interface AttemptRow { id: number; attemptId: string; - deploymentRecordId: number; - deploymentSlug: string; + previewSessionId: string; appId: string; packageVersionId: number; packageVersion: string; @@ -275,50 +176,13 @@ export interface AttemptEvidenceArtifactRow { createdAt: Date | string; } -export interface LineItemBindingRow { - id: number; - deploymentRecordId: number; - packageVersionId: number; - contextId: string; - resourceLinkId: string; - activityId: string; - lineItemsUrl: string; - lineItemUrl: string; - resourceId: string; - tag: string; - label: string; - scoreMaximum: number; - createdAt: Date | string; - updatedAt: Date | string; -} - -export interface GradePublicationRow { - id: number; - attemptId: string; - lineItemBindingId: number; - lineItemUrl: string; - platformUserId: string; - scoreGiven: number | string; - scoreMaximum: number | string; - activityProgress: GradePublicationRecord['activityProgress']; - gradingProgress: GradePublicationRecord['gradingProgress']; - status: GradePublicationRecord['status']; - createdAt: Date | string; - updatedAt: Date | string; - publishedAt: Date | string | null; - errorCode: string | null; - errorDetail: Record | null; -} - export interface AuditEventRow { id: number; eventType: string; actorType: AuditEventRecord['actorType']; actorId: string | null; - deploymentRecordId: number | null; packageVersionId: number | null; attemptId: string | null; - lineItemBindingId: number | null; status: AuditEventRecord['status']; summary: string; detail: Record; diff --git a/src/package_review/runtime_contract.ts b/src/package_review/runtime_contract.ts index 4a2db4d..59bb429 100644 --- a/src/package_review/runtime_contract.ts +++ b/src/package_review/runtime_contract.ts @@ -12,8 +12,12 @@ const RUNTIME_CONTRACT_CAPABILITIES = new Set([ 'submit_attempt_event', 'submit_evidence_artifact', 'finalize_attempt', + 'run_code_submission', 'read_local_state', 'write_local_state', + 'lms.roster.members.read', + 'lms.roster.contact.read', + 'lms.roster.profile_photo.read', ]); const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -24,20 +28,21 @@ export interface SignedReviewedRuntimeContract { } export function createReviewedRuntimeContract(input: { - reviewData: Pick; + reviewData: Pick; artifactDigest: string; }): ReviewedRuntimeContract { return { + runtimeContractVersion: '2', appId: input.reviewData.appId, packageVersion: input.reviewData.version, artifactDigest: input.artifactDigest, - entrypoint: input.reviewData.entrypoint, + runtime: input.reviewData.runtime, capabilities: [...input.reviewData.capabilities], }; } export async function buildSignedReviewedRuntimeContract(input: { - reviewData: Pick; + reviewData: Pick; artifactDigest: string; env: EnvReader; }): Promise { @@ -61,10 +66,11 @@ export function parseReviewedRuntimeContract(value: unknown): ReviewedRuntimeCon } return { + runtimeContractVersion: readRuntimeContractVersion(value.runtimeContractVersion), appId: readRequiredString(value, 'appId'), packageVersion: readRequiredString(value, 'packageVersion'), artifactDigest: readRequiredString(value, 'artifactDigest'), - entrypoint: readRequiredString(value, 'entrypoint'), + runtime: readRuntime(value.runtime), capabilities: readCapabilities(value.capabilities), }; } @@ -117,14 +123,22 @@ async function signReviewedRuntimeContract(input: { function serializeReviewedRuntimeContract(runtimeContract: ReviewedRuntimeContract): string { return JSON.stringify({ + runtimeContractVersion: runtimeContract.runtimeContractVersion, appId: runtimeContract.appId, packageVersion: runtimeContract.packageVersion, artifactDigest: runtimeContract.artifactDigest, - entrypoint: runtimeContract.entrypoint, + runtime: runtimeContract.runtime, capabilities: [...runtimeContract.capabilities], }); } +function readRuntimeContractVersion(value: unknown): '2' { + if (value !== '2') { + throw new Error('Reviewed runtime contract version must be "2".'); + } + return value; +} + function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -155,3 +169,15 @@ function readCapabilities(value: unknown): Capability[] { return capability as Capability; }); } + +function readRuntime(value: unknown): ReviewedRuntimeContract['runtime'] { + if (!isObject(value)) { + throw new Error('Reviewed runtime contract runtime must be an object.'); + } + const kind = readRequiredString(value, 'kind'); + const entrypoint = readRequiredString(value, 'entrypoint'); + if (kind !== 'browser' && kind !== 'worker') { + throw new Error('Reviewed runtime contract runtime kind is unsupported.'); + } + return { kind, entrypoint }; +} diff --git a/src/package_review/summary.ts b/src/package_review/summary.ts index 73327a4..9a2bcc1 100644 --- a/src/package_review/summary.ts +++ b/src/package_review/summary.ts @@ -2,7 +2,6 @@ import type { Capability, UserRole } from '../../sdk/app-sdk.ts'; import type { AccessibilityReview, ApprovalStatus, - DeploymentRecord, GradingSettings, PackageVersionRecord, ValidationIssue, @@ -125,6 +124,60 @@ const CAPABILITY_COPY: Record = { sensitivityLabel: 'Normal progress state', sensitivityDetail: 'Expected learning-app behavior when an activity supports resume.', }, + run_code_submission: { + id: 'run_code_submission', + label: 'Run Python submission', + detail: + 'Sends one Python function submission to Lantern’s isolated server-side grader without exposing hidden tests or LMS credentials.', + flagged: true, + flagLabel: 'Graded code', + purpose: + 'Run public practice tests and final hidden-test evaluation for a reviewed Python assignment.', + dataScope: + 'Learner Python source, server-side test outcomes, and a final score scoped to one attempt.', + retention: 'Submission source and evaluated result follow the Python Lab retention policy.', + sensitivityLabel: 'Sensitive learner work', + sensitivityDetail: + 'Lantern, not the browser, executes the submission and controls final score publication.', + }, + 'lms.roster.members.read': { + id: 'lms.roster.members.read', + label: 'Course roster', + detail: 'Reads the names, roles, and enrollment status for the current LMS course.', + flagged: true, + flagLabel: 'Institutional data', + purpose: 'Display the current course roster through Lantern’s governed LMS boundary.', + dataScope: 'Opaque subject IDs, display names, canonical roles, and enrollment status.', + retention: + 'Opaque subject mappings are retained; roster payloads are not stored in audit logs.', + sensitivityLabel: 'Protected roster data', + sensitivityDetail: + 'Requires explicit institution or course approval for this exact app version.', + }, + 'lms.roster.contact.read': { + id: 'lms.roster.contact.read', + label: 'Roster email addresses', + detail: 'Adds LMS-provided email addresses to approved roster results.', + flagged: true, + flagLabel: 'Contact data', + purpose: 'Show course contact information when the institution explicitly permits it.', + dataScope: 'Email addresses returned by the LMS for members of the current course.', + retention: 'Returned to the app for the active session and excluded from governance logs.', + sensitivityLabel: 'Personal contact data', + sensitivityDetail: 'Denied as a whole when this separate permission has not been approved.', + }, + 'lms.roster.profile_photo.read': { + id: 'lms.roster.profile_photo.read', + label: 'Roster profile photos', + detail: 'Proxies LMS-provided profile photos through short-lived Lantern media links.', + flagged: true, + flagLabel: 'Profile media', + purpose: 'Help instructors visually identify learners in the current course.', + dataScope: 'Profile image URLs supplied by NRPS; apps receive only opaque Lantern URLs.', + retention: 'Source references expire after five minutes and image bytes are never persisted.', + sensitivityLabel: 'Protected profile data', + sensitivityDetail: 'Requires a separate explicit grant and never exposes the upstream URL.', + }, }; export function approvalStatusLabel(status: ApprovalStatus): string { @@ -188,6 +241,14 @@ export function summarizeGrading(grading: GradingSettings): { }; } + if (grading.mode === 'sandbox') { + return { + label: 'Python sandbox grading', + detail: + 'Lantern runs the submitted Python function in an isolated Sandbox and publishes only the server-generated score.', + }; + } + return { label: 'Completion-only scoring', detail: 'The app only reports completion state, not rubric-scored output.', @@ -251,14 +312,6 @@ export function summarizeAccessibilityReview( }; } -export function describeDeploymentPin(deployment: DeploymentRecord | null): string { - if (!deployment || deployment.enabledPackageVersion === null) { - return 'No reviewed version is pinned yet.'; - } - - return `Pinned to version ${deployment.enabledPackageVersion}.`; -} - function listAccessibilityFailures(review: AccessibilityReview): string[] { return ACCESSIBILITY_REVIEW_FIELDS.filter(({ key }) => review[key] === 'fail').map( ({ label }) => label, diff --git a/src/package_review/types.ts b/src/package_review/types.ts index af3ae95..65d1bf3 100644 --- a/src/package_review/types.ts +++ b/src/package_review/types.ts @@ -4,18 +4,16 @@ import type { NormalizedAttemptEvent, UserRole, } from '../../sdk/app-sdk.ts'; -import type { LtiProfileId } from '../lti/profile.ts'; -import type { DeploymentBinding, PersistedDeploymentLmsType } from '../lti/types.ts'; export type ApprovalStatus = 'pending' | 'approved' | 'rejected'; +export type CatalogVisibility = 'catalog' | 'python_lab_trial'; export type InstallScope = 'course' | 'assignment'; -export type GradingMode = 'declarative' | 'manual' | 'completion' | 'browser'; +export type GradingMode = 'declarative' | 'manual' | 'completion' | 'browser' | 'sandbox'; export type ValidationSeverity = 'error'; export type AttemptStatus = 'in_progress' | 'completed' | 'abandoned'; export type AttemptCompletionState = 'completed' | 'abandoned'; export type AttemptLocalState = Record | null; export type AttemptEvidenceArtifactKind = 'screenshot_png' | 'structured_json'; -export type GradePublicationStatus = 'pending' | 'published' | 'failed'; export type AuditActorType = 'user' | 'system' | 'platform'; export type AuditEventStatus = 'accepted' | 'succeeded' | 'failed'; export const ACCESSIBILITY_REVIEW_STATUSES = ['pass', 'fail', 'not_applicable'] as const; @@ -104,11 +102,22 @@ export interface PackageArtifactRecord { digest: string; } +export type AppRuntime = + | { + readonly kind: 'browser'; + readonly entrypoint: string; + } + | { + readonly kind: 'worker'; + readonly entrypoint: string; + }; + export interface ReviewedRuntimeContract { + runtimeContractVersion: '2'; appId: string; packageVersion: string; artifactDigest: string; - entrypoint: string; + runtime: AppRuntime; capabilities: Capability[]; } @@ -119,11 +128,12 @@ export interface PackageVersionRecord { title: string; description: string | null; owner: PackageOwner; - entrypoint: string; + runtime: AppRuntime; roles: UserRole[]; installScope: InstallScope; capabilities: Capability[]; grading: GradingSettings; + catalogVisibility: CatalogVisibility; approvalStatus: ApprovalStatus; reviewNotes: string | null; accessibilityReview: AccessibilityReview | null; @@ -154,29 +164,10 @@ export interface ReviewedResourceSelection { contentPath: string; } -export interface DeploymentRecord { - id: number; - slug: string; - label: string; - appId: string; - enabledPackageVersionId: number | null; - enabledPackageVersion: string | null; - lmsType: PersistedDeploymentLmsType; - binding: DeploymentBinding | null; - ltiProfileOverride: LtiProfileId | null; - updatedAt: string; -} - -export interface LanternLtiProfileSettingsRecord { - defaultLtiProfile: LtiProfileId; - updatedAt: string; -} - export interface AttemptRecord { id: number; attemptId: string; - deploymentRecordId: number; - deploymentSlug: string; + previewSessionId: string; appId: string; packageVersionId: number; packageVersion: string; @@ -221,104 +212,19 @@ export interface AttemptEvidenceArtifactRecord { createdAt: string; } -export interface LineItemBindingRecord { - id: number; - deploymentRecordId: number; - packageVersionId: number; - contextId: string; - resourceLinkId: string; - activityId: string; - lineItemsUrl: string; - lineItemUrl: string; - resourceId: string; - tag: string; - label: string; - scoreMaximum: number; - createdAt: string; - updatedAt: string; -} - -export interface GradePublicationRecord { - id: number; - attemptId: string; - lineItemBindingId: number; - lineItemUrl: string; - platformUserId: string; - scoreGiven: number; - scoreMaximum: number; - activityProgress: 'Completed' | 'InProgress' | 'Initialized'; - gradingProgress: 'Pending' | 'PendingManual' | 'FullyGraded' | 'Failed'; - status: GradePublicationStatus; - createdAt: string; - updatedAt: string; - publishedAt: string | null; - errorCode: string | null; - errorDetail: Record | null; -} - export interface AuditEventRecord { id: number; eventType: string; actorType: AuditActorType; actorId: string | null; - deploymentRecordId: number | null; packageVersionId: number | null; attemptId: string | null; - lineItemBindingId: number | null; status: AuditEventStatus; summary: string; detail: Record; occurredAt: string; } -export interface DeepLinkingResourceOption { - packageVersionId: number; - appId: string; - packageVersion: string; - packageTitle: string; - ownerId: string; - installScope: InstallScope; - approvalStatus: 'approved'; - reviewedAt: string | null; - activityId: string; - contentPath: string; - contentTitle: string | null; -} - -export interface DeepLinkingResourceSelection { - packageVersionId: number; - packageVersion: string; - packageTitle: string; - activityId: string; - contentPath: string; - contentTitle: string | null; -} - -export interface ReviewedPlacementRecord { - placementId: string; - deploymentRecordId: number; - deploymentSlug: string; - appId: string; - contextId: string | null; - contextTitle: string | null; - packageVersionId: number; - packageVersion: string; - packageTitle: string; - activityId: string; - contentPath: string; - contentTitle: string | null; - createdByUserId: string | null; - resourceLinkId: string | null; - createdAt: string; - boundAt: string | null; -} - -export type PlacementAuditStatus = - | 'awaiting_canvas_binding' - | 'bound_no_preview' - | 'bound_with_preview' - | 'reviewed'; - export function parseAccessibilityReview(value: unknown): AccessibilityReview { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Accessibility review must include structured criteria.'); @@ -369,22 +275,6 @@ function parseAccessibilityReviewText( return value; } -export interface PlacementAuditEvidenceSummary { - deepLinkingRequestCount: number; - placementEventCount: number; - reviewerEventCount: number; - latestOccurredAt: string | null; -} - -export interface PlacementAuditSnapshot { - placement: ReviewedPlacementRecord; - status: PlacementAuditStatus; - latestPreviewSessionId: string | null; - latestPreviewOccurredAt: string | null; - previewEvidenceCount: number; - evidenceSummary: PlacementAuditEvidenceSummary; -} - export interface PreviewFixtureData { launch: { user_role: UserRole; @@ -425,7 +315,6 @@ export interface AuthoringDraftRecord { export type PreviewSessionOrigin = | 'adminTestLaunch' - | 'deepLinkingAuthoring' | 'adminAuthoringDraft'; export interface PreviewSessionRecord { @@ -436,7 +325,6 @@ export interface PreviewSessionRecord { packageTitle: string; origin: PreviewSessionOrigin; contentPath: string; - deepLinkingSessionId: string | null; capabilities: Capability[]; snapshotRoot: string; entrypointPath: string; diff --git a/src/pages/home.ts b/src/pages/home.ts index 9a8a15e..0d40338 100644 --- a/src/pages/home.ts +++ b/src/pages/home.ts @@ -14,8 +14,8 @@ export function renderHomePage(): string {
    - Admin packages - Deployments + Institution governance + Reviewed apps Health
    `, diff --git a/src/pages/home_test.ts b/src/pages/home_test.ts index 9571fa2..f1620f7 100644 --- a/src/pages/home_test.ts +++ b/src/pages/home_test.ts @@ -7,8 +7,8 @@ Deno.test('renderHomePage includes only the operational app entry points', () => assertStringIncludes(html, 'Lantern runtime'); assertStringIncludes(html, 'Governed learning app runtime and control plane'); assertStringIncludes(html, 'Institution-facing product pages live in the marketing site.'); + assertStringIncludes(html, 'href="/admin/governance"'); assertStringIncludes(html, 'href="/admin/packages"'); - assertStringIncludes(html, 'href="/admin/deployments"'); assertStringIncludes(html, 'href="/health"'); assertEquals(html.includes('why-it-can-say-yes'), false); assertEquals(html.includes('Why IT can say yes'), false); diff --git a/src/preview/capability_log.ts b/src/preview/capability_log.ts new file mode 100644 index 0000000..7e33651 --- /dev/null +++ b/src/preview/capability_log.ts @@ -0,0 +1,33 @@ +import type { PackageReviewRepository } from '../package_review/repository.ts'; +import type { PreviewEvidenceRecord, PreviewSessionRecord } from '../package_review/types.ts'; +import { + loadPreviewRuntimeDiagnostics, + type PreviewRuntimeDiagnostic, +} from './runtime_diagnostics.ts'; + +/** Loads the latest admin preview and its governed capability evidence. */ +export async function loadPreviewCapabilityLog(input: { + readonly repository: PackageReviewRepository; + readonly packageVersionId: number; +}): Promise<{ + readonly session: PreviewSessionRecord | null; + readonly evidence: PreviewEvidenceRecord[]; + readonly diagnostics: PreviewRuntimeDiagnostic[]; +}> { + const session = await input.repository.getLatestPreviewSessionByPackageVersion( + input.packageVersionId, + 'adminTestLaunch', + ); + if (session === null) { + return { session: null, evidence: [], diagnostics: [] }; + } + + const [evidence, diagnostics] = await Promise.all([ + input.repository.listPreviewEvidence(session.sessionId), + loadPreviewRuntimeDiagnostics({ + repository: input.repository, + session, + }), + ]); + return { session, evidence, diagnostics }; +} diff --git a/src/preview/service.ts b/src/preview/service.ts index 931bca1..013f8ce 100644 --- a/src/preview/service.ts +++ b/src/preview/service.ts @@ -37,7 +37,6 @@ export async function createPreviewSession(input: { artifactStore: RuntimeArtifactStore; launch?: PreviewLaunchOverrides | null; previewOrigin?: PreviewSessionRecord['origin']; - deepLinkingSessionId?: string | null; now?: () => Date; createOpaqueToken?: () => string; }): Promise { @@ -64,7 +63,6 @@ export async function launchPreviewRuntimeSession(input: { artifactStore: RuntimeArtifactStore; launch?: PreviewLaunchOverrides | null; previewOrigin?: PreviewSessionRecord['origin']; - deepLinkingSessionId?: string | null; now?: () => Date; createOpaqueToken?: () => string; }): Promise<{ @@ -80,25 +78,14 @@ export async function launchPreviewRuntimeSession(input: { artifactStore: input.artifactStore, launch: input.launch ?? null, ...(input.previewOrigin === undefined ? {} : { previewOrigin: input.previewOrigin }), - ...(input.deepLinkingSessionId === undefined - ? {} - : { deepLinkingSessionId: input.deepLinkingSessionId }), now, createOpaqueToken, }); - const previewDeployment = await input.repository.pinDeploymentVersion({ - slug: buildPreviewDeploymentSlug(created.previewSession.appId), - label: buildPreviewDeploymentLabel(created.previewSession.packageTitle), - appId: created.previewSession.appId, - packageVersionId: created.previewSession.packageVersionId, - lmsType: 'preview', - }); const runtimeAttemptId = buildPreviewRuntimeAttemptId(created.previewSession); await input.repository.createAttempt({ attemptId: runtimeAttemptId, - deploymentRecordId: previewDeployment.id, - deploymentSlug: previewDeployment.slug, + previewSessionId: created.previewSession.sessionId, appId: created.previewSession.appId, packageVersionId: created.previewSession.packageVersionId, packageVersion: created.previewSession.packageVersion, @@ -122,8 +109,6 @@ export async function launchPreviewRuntimeSession(input: { sessionToken: createOpaqueToken(), ltiToolSessionId: null, attemptId: runtimeAttemptId, - deploymentRecordId: previewDeployment.id, - deploymentSlug: previewDeployment.slug, appId: created.previewSession.appId, packageVersionId: created.previewSession.packageVersionId, packageVersion: created.previewSession.packageVersion, @@ -173,20 +158,11 @@ function buildPreviewRuntimeAttemptId(previewSession: PreviewSessionRecord): str return `${previewSession.fakeAttemptId}:${previewSession.sessionId}`; } -function buildPreviewDeploymentSlug(appId: string): string { - return `${appId}-preview`; -} - -function buildPreviewDeploymentLabel(packageTitle: string): string { - return `${packageTitle} Preview`; -} - export async function preparePreviewSession(input: { packageVersion: PackageVersionRecord; artifactStore: RuntimeArtifactStore; launch?: PreviewLaunchOverrides | null; previewOrigin?: PreviewSessionRecord['origin']; - deepLinkingSessionId?: string | null; now?: () => Date; createOpaqueToken?: () => string; }): Promise { @@ -220,7 +196,6 @@ export async function preparePreviewSession(input: { packageTitle: packageVersion.title, origin, contentPath, - deepLinkingSessionId: input.deepLinkingSessionId ?? null, capabilities: packageVersion.capabilities, snapshotRoot: packageVersion.artifact.snapshotRoot, entrypointPath: packageVersion.artifact.entrypointPath, @@ -322,8 +297,7 @@ function resolvePreviewRuntimeContentPath(snapshotRoot: string, contentPath: str } function buildPreviewLaunchSummary(previewSession: PreviewSessionRecord): string { - return previewSession.origin === 'deepLinkingAuthoring' || - previewSession.origin === 'adminAuthoringDraft' + return previewSession.origin === 'adminAuthoringDraft' ? "Started an authoring preview in Lantern's runtime." : "Started a test launch in Lantern's runtime."; } @@ -336,12 +310,7 @@ function buildPreviewLaunchDetail( runtimeSessionId, origin: previewSession.origin, contentPath: previewSession.contentPath, - deepLinkingSessionId: previewSession.deepLinkingSessionId, - route: previewSession.origin === 'deepLinkingAuthoring' - ? previewSession.deepLinkingSessionId === null - ? null - : `/lti/deep-linking/sessions/${previewSession.deepLinkingSessionId}/preview` - : previewSession.origin === 'adminAuthoringDraft' + route: previewSession.origin === 'adminAuthoringDraft' ? `/admin/packages/${previewSession.appId}/versions/${previewSession.packageVersion}/authoring` : `/admin/packages/${previewSession.appId}/versions/${previewSession.packageVersion}/preview`, }; diff --git a/src/preview/service_test.ts b/src/preview/service_test.ts index 27a4afd..130ec66 100644 --- a/src/preview/service_test.ts +++ b/src/preview/service_test.ts @@ -324,7 +324,6 @@ Deno.test('preview service defaults admin launches to admin origin and canonical assertEquals(prepared.origin, 'adminTestLaunch'); assertEquals(prepared.contentPath, '/content/activity.json'); - assertEquals(prepared.deepLinkingSessionId, null); } finally { await Deno.remove(snapshotRoot, { recursive: true }); } @@ -390,8 +389,7 @@ Deno.test('preview service keeps explicit authoring origin and selected content activityId: '/content/bonus.json', contentPath: '/content/bonus.json', }, - previewOrigin: 'deepLinkingAuthoring', - deepLinkingSessionId: 'deep-linking-session-42', + previewOrigin: 'adminAuthoringDraft', now: () => new Date('2026-04-01T10:05:00Z'), createOpaqueToken: () => { const token = tokens[index] ?? 'opaque-fallback'; @@ -400,9 +398,8 @@ Deno.test('preview service keeps explicit authoring origin and selected content }, }); - assertEquals(launched.previewSession.origin, 'deepLinkingAuthoring'); + assertEquals(launched.previewSession.origin, 'adminAuthoringDraft'); assertEquals(launched.previewSession.contentPath, '/content/bonus.json'); - assertEquals(launched.previewSession.deepLinkingSessionId, 'deep-linking-session-42'); assertEquals(launched.runtimeSession.contentPath, `${snapshotRoot}/content/bonus.json`); assertEquals(launched.runtimeSession.launch.activityId, '/content/bonus.json'); assertEquals(launched.runtimeSession.launch.submissionMode, 'anonymous_submission'); @@ -470,8 +467,7 @@ Deno.test('preview runtime sessions from admin and authoring origins both keep l activityId: '/content/bonus.json', contentPath: '/content/bonus.json', }, - previewOrigin: 'deepLinkingAuthoring', - deepLinkingSessionId: 'deep-linking-session-84', + previewOrigin: 'adminAuthoringDraft', now: () => new Date('2026-04-01T10:08:00Z'), createOpaqueToken: () => crypto.randomUUID(), }); @@ -599,7 +595,7 @@ async function writePreviewManifest(snapshotRoot: string, fixturesFile: string): await Deno.writeTextFile( `${snapshotRoot}/manifest.json`, JSON.stringify({ - schema_version: '1', + schema_version: '2', app_id: 'chapter-4-asteroids', version: '0.1.0', title: 'Chapter 4 Asteroids', @@ -607,7 +603,7 @@ async function writePreviewManifest(snapshotRoot: string, fixturesFile: string): type: 'user', id: 'instructor_123', }, - entrypoint: '/dist/index.html', + runtime: { kind: 'browser', entrypoint: '/dist/index.html' }, roles: ['learner', 'instructor'], install_scope: 'course', capabilities: [ diff --git a/src/python_lab/access.ts b/src/python_lab/access.ts new file mode 100644 index 0000000..7fb5aa4 --- /dev/null +++ b/src/python_lab/access.ts @@ -0,0 +1,51 @@ +export function labCookieName(labId: string): string { + return `lantern_python_lab_${labId}`; +} + +export function readCookie(header: string | undefined, name: string): string | null { + if (!header) return null; + for (const part of header.split(';')) { + const [key, ...rest] = part.trim().split('='); + if (key === name) { + try { + return decodeURIComponent(rest.join('=')); + } catch { + return null; + } + } + } + return null; +} + +export function appendCookie( + headers: Headers, + name: string, + value: string, + path: string, + maxAge: number, +): void { + headers.append( + 'set-cookie', + `${name}=${ + encodeURIComponent(value) + }; Path=${path}; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`, + ); +} + +export async function hashToken(value: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +export function timingSafeEqual(left: string, right: string): boolean { + if (left.length !== right.length) return false; + let mismatch = 0; + for (let index = 0; index < left.length; index += 1) { + mismatch |= left.charCodeAt(index) ^ right.charCodeAt(index); + } + return mismatch === 0; +} + +export function randomToken(): string { + return crypto.randomUUID().replaceAll('-', ''); +} diff --git a/src/python_lab/artifact_store.ts b/src/python_lab/artifact_store.ts new file mode 100644 index 0000000..cb7f20b --- /dev/null +++ b/src/python_lab/artifact_store.ts @@ -0,0 +1,157 @@ +import { type EvaluatedCodeTestCase, parseJsonValue } from './contract.ts'; + +export interface PythonLabPrivateArtifact { + readonly kind: 'assignment'; + readonly functionName: string; + readonly referenceCode: string; + readonly cases: readonly EvaluatedCodeTestCase[]; +} + +export interface PythonLabPendingDraftArtifact { + readonly kind: 'pending_draft'; + readonly referenceCode: string; +} + +export interface PythonLabArtifactStore { + writePendingDraft(key: string, artifact: PythonLabPendingDraftArtifact): Promise; + readPendingDraft(key: string): Promise; + writePrivateAssignment(key: string, artifact: PythonLabPrivateArtifact): Promise; + readPrivateAssignment(key: string): Promise; + writeSubmissionSource(key: string, source: string): Promise; + submissionSourceExists(key: string): Promise; + delete(key: string): Promise; +} + +export function createUnavailablePythonLabArtifactStore(message: string): PythonLabArtifactStore { + const unavailable = (): never => { + throw new Error(message); + }; + + return { + writePendingDraft: unavailable, + readPendingDraft: unavailable, + writePrivateAssignment: unavailable, + readPrivateAssignment: unavailable, + writeSubmissionSource: unavailable, + submissionSourceExists: unavailable, + delete: unavailable, + }; +} + +export interface PythonLabArtifactBucket { + get(key: string): Promise<{ arrayBuffer(): Promise } | null>; + put(key: string, value: string): Promise; + delete(key: string): Promise; +} + +export function createR2PythonLabArtifactStore( + bucket: PythonLabArtifactBucket, +): PythonLabArtifactStore { + return { + async writePendingDraft(key, artifact) { + await bucket.put(key, JSON.stringify(artifact)); + }, + async readPendingDraft(key) { + return await readArtifact(bucket, key, parsePendingDraftArtifact); + }, + async writePrivateAssignment(key, artifact) { + await bucket.put(key, JSON.stringify(artifact)); + }, + async readPrivateAssignment(key) { + return await readArtifact(bucket, key, parsePrivateAssignmentArtifact); + }, + async writeSubmissionSource(key, source) { + await bucket.put(key, source); + }, + async submissionSourceExists(key) { + return (await bucket.get(key)) !== null; + }, + async delete(key) { + await bucket.delete(key); + }, + }; +} + +async function readArtifact( + bucket: PythonLabArtifactBucket, + key: string, + parse: (input: unknown) => T, +): Promise { + const object = await bucket.get(key); + + if (object === null) { + throw new Error('Private Python assignment artifact was not found.'); + } + + return parse(JSON.parse(new TextDecoder().decode(await object.arrayBuffer()))); +} + +export function buildPythonLabPrivateArtifactKey(labId: string): string { + return `var/private-python-lab/${labId}/assignment.json`; +} + +export function buildPythonSubmissionArtifactKey( + input: { attemptId: string; runId: string }, +): string { + return `var/private-python-lab/submissions/${input.attemptId}/${input.runId}.py`; +} + +function parsePrivateAssignmentArtifact(input: unknown): PythonLabPrivateArtifact { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('Private Python assignment artifact was invalid.'); + } + + const record = input as Record; + + if ( + record.kind !== 'assignment' || typeof record.functionName !== 'string' || + typeof record.referenceCode !== 'string' || + !Array.isArray(record.cases) + ) { + throw new Error('Private Python assignment artifact was invalid.'); + } + + return { + kind: 'assignment', + functionName: record.functionName, + referenceCode: record.referenceCode, + cases: record.cases.map(parseEvaluatedCase), + }; +} + +function parsePendingDraftArtifact(input: unknown): PythonLabPendingDraftArtifact { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('Private Python draft artifact was invalid.'); + } + + const record = input as Record; + + if (record.kind !== 'pending_draft' || typeof record.referenceCode !== 'string') { + throw new Error('Private Python draft artifact was invalid.'); + } + + return { kind: 'pending_draft', referenceCode: record.referenceCode }; +} + +function parseEvaluatedCase(input: unknown): EvaluatedCodeTestCase { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('Private Python test case was invalid.'); + } + + const record = input as Record; + + if ( + typeof record.label !== 'string' || + (record.visibility !== 'public' && record.visibility !== 'hidden') || + !Array.isArray(record.args) + ) { + throw new Error('Private Python test case was invalid.'); + } + + return { + label: record.label, + visibility: record.visibility, + args: record.args.map((value) => parseJsonValue(value, 'Private Python test args')), + expected: parseJsonValue(record.expected, 'Private Python test expected result'), + }; +} diff --git a/src/python_lab/cloudflare_sandbox.ts b/src/python_lab/cloudflare_sandbox.ts new file mode 100644 index 0000000..3ef64a3 --- /dev/null +++ b/src/python_lab/cloudflare_sandbox.ts @@ -0,0 +1,66 @@ +import { getSandbox, Sandbox } from '@cloudflare/sandbox'; +import { + PYTHON_GRADER_COMMAND, + PYTHON_GRADER_OUTPUT_LIMIT_BYTES, + type PythonGraderSandbox as PythonGraderSandboxPort, + type PythonGraderSandboxFactory, + type SandboxCommandResult, +} from './runner.ts'; + +export class PythonGraderSandbox extends Sandbox { + override enableInternet = false; +} + +type PythonGraderSandboxNamespace = Parameters>[0]; + +export function createCloudflarePythonGraderSandboxFactory( + namespace: PythonGraderSandboxNamespace, +): PythonGraderSandboxFactory { + return { + create(oneTimeId) { + const sandbox = getSandbox(namespace, oneTimeId); + + return Promise.resolve( + { + async writeFile(path, contents) { + await sandbox.writeFile(path, contents); + }, + async exec(input) { + const controller = new AbortController(); + let outputSize = 0; + const result = await sandbox.exec(input.command, { + timeout: input.timeoutMs, + stream: true, + signal: controller.signal, + onOutput(_stream, chunk) { + outputSize += new TextEncoder().encode(chunk).byteLength; + if (outputSize > PYTHON_GRADER_OUTPUT_LIMIT_BYTES) { + controller.abort('Python output exceeded the allowed limit.'); + } + }, + }); + + return normalizeCommandResult(result); + }, + destroy() { + return sandbox.destroy(); + }, + } satisfies PythonGraderSandboxPort, + ); + }, + }; +} + +function normalizeCommandResult(input: { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}): SandboxCommandResult { + return { + exitCode: input.exitCode, + stdout: input.stdout, + stderr: input.stderr, + }; +} + +export { PYTHON_GRADER_COMMAND }; diff --git a/src/python_lab/contract.ts b/src/python_lab/contract.ts new file mode 100644 index 0000000..a441683 --- /dev/null +++ b/src/python_lab/contract.ts @@ -0,0 +1,238 @@ +export const PYTHON_ASSIGNMENT_MAX_SCORE = 10; +export const PYTHON_PUBLIC_CASE_COUNT = 3; +export const PYTHON_HIDDEN_CASE_COUNT = 5; +export const PYTHON_TOTAL_CASE_COUNT = PYTHON_PUBLIC_CASE_COUNT + PYTHON_HIDDEN_CASE_COUNT; + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { readonly [key: string]: JsonValue }; + +export type CodeTestVisibility = 'public' | 'hidden'; + +export interface PythonFunctionContract { + readonly functionName: string; + readonly maxScore: typeof PYTHON_ASSIGNMENT_MAX_SCORE; +} + +export interface GeneratedCodeTestInput { + readonly label: string; + readonly visibility: CodeTestVisibility; + readonly args: JsonValue[]; +} + +export interface EvaluatedCodeTestCase extends GeneratedCodeTestInput { + readonly expected: JsonValue; +} + +export interface PythonAssignmentDraft { + readonly title: string; + readonly prompt: string; + readonly starterCode: string; + readonly referenceCode: string; + readonly contract: PythonFunctionContract; +} + +export interface PythonAssignmentPublicDraft { + readonly title: string; + readonly prompt: string; + readonly starterCode: string; + readonly contract: PythonFunctionContract; +} + +export interface PublicPythonAssignmentContent { + readonly title: string; + readonly prompt: string; + readonly starterCode: string; + readonly functionName: string; + readonly publicCases: ReadonlyArray<{ + readonly label: string; + readonly args: JsonValue[]; + readonly expected: JsonValue; + }>; + readonly scoreMaximum: typeof PYTHON_ASSIGNMENT_MAX_SCORE; +} + +export class PythonContractError extends Error { + readonly _tag = 'PythonContractError' as const; + + constructor(message: string) { + super(message); + } +} + +export function parsePythonAssignmentDraft(input: unknown): PythonAssignmentDraft { + const record = requireRecord(input, 'Python assignment draft must be an object.'); + return { + ...parsePythonAssignmentPublicDraft(record), + referenceCode: requireBoundedText(record.referenceCode, 'Reference solution', 32_000), + }; +} + +export function parsePythonAssignmentPublicDraft(input: unknown): PythonAssignmentPublicDraft { + const record = requireRecord(input, 'Python assignment draft must be an object.'); + const functionName = parsePythonFunctionName(record.functionName); + + return { + title: requireBoundedText(record.title, 'Assignment title', 160), + prompt: requireBoundedText(record.prompt, 'Assignment prompt', 12_000), + starterCode: requireBoundedText(record.starterCode, 'Starter code', 32_000), + contract: { + functionName, + maxScore: PYTHON_ASSIGNMENT_MAX_SCORE, + }, + }; +} + +export function combinePythonAssignmentDraft(input: { + readonly publicDraft: PythonAssignmentPublicDraft; + readonly referenceCode: unknown; +}): PythonAssignmentDraft { + return { + ...input.publicDraft, + referenceCode: requireBoundedText(input.referenceCode, 'Reference solution', 32_000), + }; +} + +export function parsePythonFunctionName(input: unknown): string { + if (typeof input !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,79}$/.test(input)) { + throw new PythonContractError( + 'Function name must be a valid Python identifier with at most 80 characters.', + ); + } + + return input; +} + +export function parseGeneratedCodeTestInputs(input: unknown): GeneratedCodeTestInput[] { + const record = requireRecord(input, 'Generated test data must be an object.'); + const cases = record.cases; + + if (!Array.isArray(cases) || cases.length !== PYTHON_TOTAL_CASE_COUNT) { + throw new PythonContractError( + `Generated test data must include exactly ${PYTHON_TOTAL_CASE_COUNT} cases.`, + ); + } + + const parsed = cases.map((item, index) => parseGeneratedCodeTestInput(item, index)); + const publicCases = parsed.filter((item) => item.visibility === 'public'); + const hiddenCases = parsed.filter((item) => item.visibility === 'hidden'); + + if ( + publicCases.length !== PYTHON_PUBLIC_CASE_COUNT || + hiddenCases.length !== PYTHON_HIDDEN_CASE_COUNT + ) { + throw new PythonContractError( + `Generated test data must include ${PYTHON_PUBLIC_CASE_COUNT} public and ${PYTHON_HIDDEN_CASE_COUNT} hidden cases.`, + ); + } + + const labels = new Set(); + + for (const testCase of parsed) { + if (labels.has(testCase.label)) { + throw new PythonContractError('Generated test case labels must be unique.'); + } + labels.add(testCase.label); + } + + return parsed; +} + +export function parseJsonValue(input: unknown, field = 'Value'): JsonValue { + if ( + input === null || typeof input === 'string' || typeof input === 'boolean' || + (typeof input === 'number' && Number.isFinite(input)) + ) { + return input; + } + + if (Array.isArray(input)) { + return input.map((item) => parseJsonValue(item, field)); + } + + if (typeof input === 'object') { + const record = input as Record; + const result: Record = {}; + + for (const [key, value] of Object.entries(record)) { + result[key] = parseJsonValue(value, field); + } + + return result; + } + + throw new PythonContractError(`${field} must be JSON-compatible.`); +} + +export function buildPublicPythonAssignmentContent(input: { + readonly draft: PythonAssignmentDraft; + readonly cases: readonly EvaluatedCodeTestCase[]; +}): PublicPythonAssignmentContent { + const publicCases = input.cases.filter((testCase) => testCase.visibility === 'public').map( + (testCase) => ({ + label: testCase.label, + args: testCase.args, + expected: testCase.expected, + }), + ); + + if (publicCases.length !== PYTHON_PUBLIC_CASE_COUNT) { + throw new PythonContractError('A Python assignment requires exactly three public cases.'); + } + + return { + title: input.draft.title, + prompt: input.draft.prompt, + starterCode: input.draft.starterCode, + functionName: input.draft.contract.functionName, + publicCases, + scoreMaximum: PYTHON_ASSIGNMENT_MAX_SCORE, + }; +} + +function parseGeneratedCodeTestInput(input: unknown, index: number): GeneratedCodeTestInput { + const record = requireRecord(input, `Generated test case ${index + 1} must be an object.`); + const visibility = record.visibility; + + if (visibility !== 'public' && visibility !== 'hidden') { + throw new PythonContractError( + `Generated test case ${index + 1} must have visibility public or hidden.`, + ); + } + + const args = record.args; + + if (!Array.isArray(args)) { + throw new PythonContractError(`Generated test case ${index + 1} args must be a JSON array.`); + } + + return { + label: requireBoundedText(record.label, `Generated test case ${index + 1} label`, 120), + visibility, + args: args.map((value) => parseJsonValue(value, `Generated test case ${index + 1} args`)), + }; +} + +function requireRecord(input: unknown, message: string): Record { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new PythonContractError(message); + } + + return input as Record; +} + +function requireBoundedText(input: unknown, label: string, maximumLength: number): string { + if (typeof input !== 'string' || input.trim() === '') { + throw new PythonContractError(`${label} is required.`); + } + + if (input.length > maximumLength) { + throw new PythonContractError(`${label} must contain at most ${maximumLength} characters.`); + } + + return input; +} diff --git a/src/python_lab/contract_test.ts b/src/python_lab/contract_test.ts new file mode 100644 index 0000000..3211a50 --- /dev/null +++ b/src/python_lab/contract_test.ts @@ -0,0 +1,51 @@ +import { assertEquals, assertThrows } from '@std/assert'; +import { + parseGeneratedCodeTestInputs, + parsePythonAssignmentDraft, + PythonContractError, +} from './contract.ts'; + +Deno.test('Python contract accepts only a named function draft and JSON test vectors', () => { + const draft = parsePythonAssignmentDraft({ + title: 'Add numbers', + prompt: 'Write a function that returns the sum of two integers.', + starterCode: 'def add(left, right):\n pass\n', + referenceCode: 'def add(left, right):\n return left + right\n', + functionName: 'add', + }); + const cases = parseGeneratedCodeTestInputs({ + cases: [ + { label: 'small positives', visibility: 'public', args: [1, 2] }, + { label: 'zero', visibility: 'public', args: [0, 4] }, + { label: 'negative', visibility: 'public', args: [-1, 5] }, + { label: 'large', visibility: 'hidden', args: [500, 700] }, + { label: 'both negative', visibility: 'hidden', args: [-2, -3] }, + { label: 'mixed', visibility: 'hidden', args: [-3, 9] }, + { label: 'identity', visibility: 'hidden', args: [0, 0] }, + { label: 'boundary', visibility: 'hidden', args: [999, 1] }, + ], + }); + + assertEquals(draft.contract, { functionName: 'add', maxScore: 10 }); + assertEquals(cases.length, 8); +}); + +Deno.test('Python contract rejects wrong case counts and non JSON values', () => { + assertThrows( + () => parseGeneratedCodeTestInputs({ cases: [] }), + PythonContractError, + 'exactly 8 cases', + ); + assertThrows( + () => + parseGeneratedCodeTestInputs({ + cases: Array.from({ length: 8 }, (_, index) => ({ + label: `case ${index}`, + visibility: index < 3 ? 'public' : 'hidden', + args: [Number.NaN], + })), + }), + PythonContractError, + 'JSON-compatible', + ); +}); diff --git a/src/python_lab/forms.ts b/src/python_lab/forms.ts new file mode 100644 index 0000000..b8c9087 --- /dev/null +++ b/src/python_lab/forms.ts @@ -0,0 +1,90 @@ +const LAB_FORM_MAX_BYTES = 80_000; + +export function formValue(value: string | null): string { + return value ?? ''; +} + +export function requireFormText( + value: string | null, + label: string, + maxLength: number, +): string { + const text = formValue(value); + if (text.trim() === '' || text.length > maxLength) { + throw new Error(`${label} is required and must contain at most ${maxLength} characters.`); + } + return text; +} + +export function requireFunctionName(value: string | null): string { + const name = formValue(value); + if (!/^[A-Za-z_][A-Za-z0-9_]{0,79}$/.test(name)) { + throw new Error('Function name must be a valid Python identifier.'); + } + return name; +} + +export function requireWorkEmail(value: string | null): string { + const email = formValue(value).trim(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) { + throw new Error('Work email is required.'); + } + return email; +} + +export async function readBoundedLabForm(request: Request): Promise { + const contentType = request.headers.get('content-type') ?? ''; + + if (!contentType.startsWith('application/x-www-form-urlencoded')) { + throw new Error('Python Lab requests must use a URL-encoded form.'); + } + + const contentLength = request.headers.get('content-length'); + + if (contentLength !== null) { + const declaredLength = Number(contentLength); + + if ( + !Number.isSafeInteger(declaredLength) || declaredLength < 0 || + declaredLength > LAB_FORM_MAX_BYTES + ) { + throw new Error('Python Lab form is too large.'); + } + } + + if (request.body === null) { + throw new Error('Python Lab form is required.'); + } + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + + try { + while (true) { + const next = await reader.read(); + + if (next.done) break; + size += next.value.byteLength; + + if (size > LAB_FORM_MAX_BYTES) { + await reader.cancel('Python Lab form exceeded its size limit.'); + throw new Error('Python Lab form is too large.'); + } + + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + + const body = new Uint8Array(size); + let offset = 0; + + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + + return new URLSearchParams(new TextDecoder().decode(body)); +} diff --git a/src/python_lab/generator.ts b/src/python_lab/generator.ts new file mode 100644 index 0000000..e0536df --- /dev/null +++ b/src/python_lab/generator.ts @@ -0,0 +1,34 @@ +import { readCloudflareAiResponseText } from '../app_writer/agent_response.ts'; +import type { CloudflareAiBinding } from '../app_writer/agent_types.ts'; +import type { PythonTestVectorGenerator } from './service.ts'; + +export function createCloudflarePythonTestVectorGenerator(input: { + readonly ai: CloudflareAiBinding; + readonly model: string; +}): PythonTestVectorGenerator { + return { + async generate(request) { + const response = await input.ai.run(input.model, { + messages: [ + { + role: 'system', + content: + 'Return raw JSON only. Design diverse input vectors for an instructor-authored Python function assignment. Never return Python code, expected values, explanations, markdown, or keys other than cases. Return exactly three public and five hidden cases. Each case must have label, visibility, and args. args must be a JSON array.', + }, + { + role: 'user', + content: JSON.stringify(request), + }, + ], + stream: true, + }); + const text = await readCloudflareAiResponseText(response); + + try { + return JSON.parse(text); + } catch { + throw new Error('Python test-vector generation did not return valid JSON.'); + } + }, + }; +} diff --git a/src/python_lab/lab_service.ts b/src/python_lab/lab_service.ts new file mode 100644 index 0000000..622586b --- /dev/null +++ b/src/python_lab/lab_service.ts @@ -0,0 +1,175 @@ +import type { ImportedPackageVersion } from '../package_review/intake.ts'; +import type { PackageReviewRepository } from '../package_review/repository.ts'; +import type { PackageSource } from '../package_review/package_source.ts'; +import { combinePythonAssignmentDraft, parsePythonAssignmentPublicDraft } from './contract.ts'; +import type { PythonLabArtifactStore } from './artifact_store.ts'; +import { + createPythonLabPackageSource, + PYTHON_LAB_PACKAGE_RELATIVE_PATHS, +} from './package_source.ts'; +import type { PythonLabRecord, PythonLabRepository } from './repository.ts'; +import type { PythonGraderSandboxFactory } from './runner.ts'; +import { + evaluatePythonSubmission, + generateAndValidatePythonAssignment, + type PythonTestVectorGenerator, +} from './service.ts'; + +export async function generatePythonLab(input: { + readonly lab: PythonLabRecord; + readonly repository: PackageReviewRepository; + readonly pythonLabRepository: PythonLabRepository; + readonly pythonLabArtifactStore: PythonLabArtifactStore; + readonly sandboxFactory: PythonGraderSandboxFactory; + readonly testVectorGenerator: PythonTestVectorGenerator; + readonly importPackageFromSource: ( + source: PackageSource, + options: { storageRoot: string }, + ) => Promise< + ImportedPackageVersion + >; + readonly createId: () => string; + readonly now?: () => Date; +}): Promise { + const now = input.now ?? (() => new Date()); + let packageVersionId: number | undefined; + let packageSnapshotRoot: string | null = null; + + try { + if (input.lab.state !== 'generating' || input.lab.privateArtifactKey === null) { + return input.lab; + } + + const pendingDraft = await input.pythonLabArtifactStore.readPendingDraft( + input.lab.privateArtifactKey, + ); + const draft = combinePythonAssignmentDraft({ + publicDraft: parsePythonAssignmentPublicDraft({ + ...input.lab.authorDraft, + functionName: input.lab.authorDraft.functionName, + }), + referenceCode: pendingDraft.referenceCode, + }); + const assignment = await generateAndValidatePythonAssignment({ + draft, + generator: input.testVectorGenerator, + sandboxFactory: input.sandboxFactory, + createOneTimeId: input.createId, + }); + await input.pythonLabArtifactStore.writePrivateAssignment(input.lab.privateArtifactKey, { + kind: 'assignment', + functionName: draft.contract.functionName, + referenceCode: draft.referenceCode, + cases: assignment.privateCases, + }); + const imported = await input.importPackageFromSource( + createPythonLabPackageSource({ + labId: input.lab.labId, + content: assignment.publicContent, + }), + { storageRoot: 'var/python-lab-packages' }, + ); + const packageVersion = await input.repository.registerPythonLabTrialPackageVersion(imported); + packageVersionId = packageVersion.id; + packageSnapshotRoot = packageVersion.artifact.snapshotRoot; + await input.pythonLabRepository.createCodeAssignmentDefinition({ + packageVersionId: packageVersion.id, + functionName: draft.contract.functionName, + privateArtifactKey: input.lab.privateArtifactKey, + createdAt: now().toISOString(), + }); + + return await input.pythonLabRepository.markLabReady({ + labId: input.lab.labId, + packageVersionId: packageVersion.id, + privateArtifactKey: input.lab.privateArtifactKey, + updatedAt: now().toISOString(), + }); + } catch { + const failureMessage = `Python Lab generation failed. Reference: ${input.lab.labId}.`; + const failedLab = await input.pythonLabRepository.markLabFailed({ + labId: input.lab.labId, + failureMessage, + ...(packageVersionId === undefined ? {} : { packageVersionId }), + updatedAt: now().toISOString(), + }); + const cleanupErrors = await cleanFailedPythonLabMaterialization({ + repository: input.repository, + pythonLabArtifactStore: input.pythonLabArtifactStore, + privateArtifactKey: failedLab.privateArtifactKey, + packageVersionId, + packageSnapshotRoot, + }); + + if (cleanupErrors.length === 0) { + return failedLab; + } + + return await input.pythonLabRepository.markLabFailed({ + labId: input.lab.labId, + failureMessage, + updatedAt: now().toISOString(), + }); + } +} + +export async function verifyPythonLabCandidate(input: { + readonly lab: PythonLabRecord; + readonly source: string; + readonly pythonLabArtifactStore: PythonLabArtifactStore; + readonly sandboxFactory: PythonGraderSandboxFactory; + readonly createId: () => string; +}) { + if (input.lab.state !== 'ready' || input.lab.privateArtifactKey === null) { + throw new Error('Python Lab is not ready for candidate verification.'); + } + + const artifact = await input.pythonLabArtifactStore.readPrivateAssignment( + input.lab.privateArtifactKey, + ); + + const evaluation = await evaluatePythonSubmission({ + source: input.source, + contract: { functionName: artifact.functionName, maxScore: 10 }, + cases: artifact.cases, + phase: 'candidate', + sandboxFactory: input.sandboxFactory, + createOneTimeId: input.createId, + }); + + if (evaluation.runnerFailed) { + throw new Error('Python execution infrastructure did not finish candidate verification.'); + } + + return evaluation; +} + +async function cleanFailedPythonLabMaterialization(input: { + readonly repository: PackageReviewRepository; + readonly pythonLabArtifactStore: PythonLabArtifactStore; + readonly privateArtifactKey: string | null; + readonly packageVersionId: number | undefined; + readonly packageSnapshotRoot: string | null; +}): Promise { + const cleanup = [] as Promise[]; + + if (input.privateArtifactKey !== null) { + cleanup.push(input.pythonLabArtifactStore.delete(input.privateArtifactKey)); + } + + if (input.packageSnapshotRoot !== null) { + for (const relativePath of PYTHON_LAB_PACKAGE_RELATIVE_PATHS) { + cleanup.push( + input.pythonLabArtifactStore.delete(`${input.packageSnapshotRoot}/${relativePath}`), + ); + } + } + + if (input.packageVersionId !== undefined) { + cleanup.push(input.repository.deletePythonLabPackageVersion(input.packageVersionId)); + } + + return (await Promise.allSettled(cleanup)).flatMap((result) => + result.status === 'rejected' ? [result.reason] : [] + ); +} diff --git a/src/python_lab/lab_service_test.ts b/src/python_lab/lab_service_test.ts new file mode 100644 index 0000000..0ce7ff7 --- /dev/null +++ b/src/python_lab/lab_service_test.ts @@ -0,0 +1,181 @@ +import { assertEquals } from '@std/assert'; +import { + buildImportedPackageVersion, + createInMemoryPackageReviewRepository, +} from '../test_helpers/package_review.ts'; +import type { + PythonLabArtifactStore, + PythonLabPendingDraftArtifact, + PythonLabPrivateArtifact, +} from './artifact_store.ts'; +import { generatePythonLab } from './lab_service.ts'; +import type { PythonLabRecord, PythonLabRepository } from './repository.ts'; +import { createInMemoryPythonLabRepository } from './repository_memory.ts'; +import type { PythonGraderSandboxFactory } from './runner.ts'; + +Deno.test('failed Lab materialization removes its private artifact and non-catalog package', async () => { + const packageRepository = createInMemoryPackageReviewRepository(); + const baseRepository = createInMemoryPythonLabRepository(); + const lab = generatingLab(); + await baseRepository.reserveLab({ + lab, + visitorDay: '2026-07-13', + ipWindowStartedAt: null, + }); + const pythonLabRepository: PythonLabRepository = { + ...baseRepository, + createCodeAssignmentDefinition() { + return Promise.reject(new Error('definition persistence interrupted')); + }, + }; + const artifacts = createArtifactStore(); + await artifacts.store.writePendingDraft(lab.privateArtifactKey!, { + kind: 'pending_draft', + referenceCode: 'def add(left, right):\n return left + right\n', + }); + + const result = await generatePythonLab({ + lab, + repository: packageRepository, + pythonLabRepository, + pythonLabArtifactStore: artifacts.store, + sandboxFactory: functionSandboxFactory(), + testVectorGenerator: { generate: () => Promise.resolve(generatedCases()) }, + importPackageFromSource: () => + Promise.resolve( + buildImportedPackageVersion({ + appId: 'python-lab-lab-123', + version: '1.0.0', + capabilities: ['read_activity_content', 'run_code_submission'], + grading: { mode: 'sandbox', rubricFile: null, maxScore: 10 }, + }), + ), + createId: createIds(), + now: () => new Date('2026-07-13T00:00:00Z'), + }); + + assertEquals(result.state, 'failed'); + assertEquals(result.packageVersionId, 1); + assertEquals(await packageRepository.getPackageVersionById(1), null); + assertEquals(artifacts.deleted.has(lab.privateArtifactKey!), true); + assertEquals(artifacts.deleted.size > 1, true); + assertEquals('referenceCode' in result.authorDraft, false); +}); + +function generatingLab(): PythonLabRecord { + return { + labId: 'lab-123', + visitorHash: 'visitor', + requestIpHash: null, + accessTokenHash: 'token', + state: 'generating', + authorDraft: { + title: 'Add numbers', + prompt: 'Write a function that returns the sum of two integers.', + starterCode: 'def add(left, right):\n return 0\n', + functionName: 'add', + }, + packageVersionId: null, + privateArtifactKey: 'var/private-python-lab/lab-123/assignment.json', + failureMessage: null, + createdAt: '2026-07-13T00:00:00Z', + updatedAt: '2026-07-13T00:00:00Z', + expiresAt: '2026-07-20T00:00:00Z', + }; +} + +function createArtifactStore(): { + readonly store: PythonLabArtifactStore; + readonly deleted: ReadonlySet; +} { + const pendingDrafts = new Map(); + const assignments = new Map(); + const deleted = new Set(); + + return { + store: { + writePendingDraft(key, artifact) { + pendingDrafts.set(key, structuredClone(artifact)); + return Promise.resolve(); + }, + readPendingDraft(key) { + const artifact = pendingDrafts.get(key); + return artifact === undefined + ? Promise.reject(new Error('Python Lab pending draft was not found.')) + : Promise.resolve(structuredClone(artifact)); + }, + writePrivateAssignment(key, artifact) { + assignments.set(key, structuredClone(artifact)); + return Promise.resolve(); + }, + readPrivateAssignment(key) { + const artifact = assignments.get(key); + return artifact === undefined + ? Promise.reject(new Error('Python Lab assignment artifact was not found.')) + : Promise.resolve(structuredClone(artifact)); + }, + writeSubmissionSource() { + return Promise.resolve(); + }, + submissionSourceExists() { + return Promise.resolve(false); + }, + delete(key) { + deleted.add(key); + pendingDrafts.delete(key); + assignments.delete(key); + return Promise.resolve(); + }, + }, + deleted, + }; +} + +function generatedCases() { + return { + cases: [ + { label: 'one plus two', visibility: 'public', args: [1, 2] }, + { label: 'zero plus four', visibility: 'public', args: [0, 4] }, + { label: 'negative plus five', visibility: 'public', args: [-1, 5] }, + { label: 'large', visibility: 'hidden', args: [500, 700] }, + { label: 'negative', visibility: 'hidden', args: [-2, -3] }, + { label: 'mixed', visibility: 'hidden', args: [-3, 9] }, + { label: 'identity', visibility: 'hidden', args: [0, 0] }, + { label: 'boundary', visibility: 'hidden', args: [999, 1] }, + ], + }; +} + +function functionSandboxFactory(): PythonGraderSandboxFactory { + return { + create() { + let source = ''; + let request = ''; + + return Promise.resolve({ + writeFile(path, contents) { + if (path === '/workspace/solution.py') source = contents; + if (path === '/workspace/request.json') request = contents; + return Promise.resolve(); + }, + exec() { + const args = (JSON.parse(request) as { args: number[] }).args; + const value = source.includes('return 0') ? 0 : args[0]! + args[1]!; + return Promise.resolve({ + exitCode: 0, + stdout: JSON.stringify({ ok: true, value }), + stderr: '', + }); + }, + destroy() { + return Promise.resolve(); + }, + }); + }, + }; +} + +function createIds(): () => string { + let value = 0; + return () => `test-${++value}`; +} diff --git a/src/python_lab/package_source.ts b/src/python_lab/package_source.ts new file mode 100644 index 0000000..41e7ddd --- /dev/null +++ b/src/python_lab/package_source.ts @@ -0,0 +1,162 @@ +import { createMemoryPackageSource, type PackageSource } from '../package_review/package_source.ts'; +import type { PublicPythonAssignmentContent } from './contract.ts'; + +export const PYTHON_LAB_PACKAGE_RELATIVE_PATHS = [ + 'manifest.json', + 'content/activity.json', + 'preview/fixtures.json', + 'preview/tests.json', + 'dist/index.html', +] as const; + +export function createPythonLabPackageSource(input: { + readonly labId: string; + readonly content: PublicPythonAssignmentContent; +}): PackageSource { + const appId = `python-lab-${input.labId}`; + const manifest = { + schema_version: '2', + app_id: appId, + version: '1.0.0', + title: input.content.title, + description: 'A Lantern-reviewed Python function assignment.', + owner: { type: 'user', id: 'python-lab-author' }, + runtime: { kind: 'browser', entrypoint: '/dist/index.html' }, + roles: ['learner', 'instructor'], + install_scope: 'assignment', + capabilities: ['read_launch_context', 'read_activity_content', 'run_code_submission'], + grading: { mode: 'sandbox', max_score: 10 }, + content_files: ['/content/activity.json'], + preview: { + fixtures_file: '/preview/fixtures.json', + tests_file: '/preview/tests.json', + }, + authoring: { kind: 'python_function' }, + }; + + return createMemoryPackageSource([ + { relativePath: 'manifest.json', bytes: JSON.stringify(manifest, null, 2) }, + { relativePath: 'content/activity.json', bytes: JSON.stringify(input.content) }, + { relativePath: 'preview/fixtures.json', bytes: JSON.stringify(PREVIEW_FIXTURES) }, + { relativePath: 'preview/tests.json', bytes: JSON.stringify(PREVIEW_TESTS) }, + { relativePath: 'dist/index.html', bytes: PYTHON_ASSIGNMENT_HTML }, + ]); +} + +const PREVIEW_FIXTURES = { + launch: { + user_role: 'learner', + course_id: 'python-lab-preview', + assignment_id: 'python-function-assignment', + activity_id: '/content/activity.json', + }, + attempt_id: 'python-lab-preview-attempt', + local_state: null, +}; + +const PREVIEW_TESTS = { + name: 'Python function learner engine', + assertions: [ + { selector: '#source', exists: true }, + { selector: '#run-public-tests', exists: true }, + { selector: '#final-submit', exists: true }, + ], +}; + +const PYTHON_ASSIGNMENT_HTML = ` + + + + + Python assignment + + + +
    +

    Python function assignment

    +
    +
    +

    solution.py

    + + +
    + + +
    +

    +

    +
    + +
    +
    + + +`; diff --git a/src/python_lab/package_source_test.ts b/src/python_lab/package_source_test.ts new file mode 100644 index 0000000..b92bdc8 --- /dev/null +++ b/src/python_lab/package_source_test.ts @@ -0,0 +1,41 @@ +import { assertEquals, assertStringIncludes } from '@std/assert'; +import { validateManifest } from '../package_review/manifest.ts'; +import { createPythonLabPackageSource } from './package_source.ts'; + +Deno.test('Python Lab package contains only public assignment content and a sandbox grading contract', async () => { + const source = createPythonLabPackageSource({ + labId: 'lab-123', + content: { + title: 'Add numbers', + prompt: 'Return the sum.', + starterCode: 'def add(left, right):\n return 0\n', + functionName: 'add', + publicCases: [ + { label: 'one plus two', args: [1, 2], expected: 3 }, + { label: 'zero plus four', args: [0, 4], expected: 4 }, + { label: 'negative plus five', args: [-1, 5], expected: 4 }, + ], + scoreMaximum: 10, + }, + }); + const validation = await validateManifest(source); + const content = await source.readText('content/activity.json'); + + assertEquals(validation.ok, true); + if (!validation.ok) { + throw new Error(JSON.stringify(validation.issues)); + } + assertEquals(validation.reviewData.grading, { + mode: 'sandbox', + rubricFile: null, + maxScore: 10, + }); + assertEquals(validation.reviewData.capabilities, [ + 'read_launch_context', + 'read_activity_content', + 'run_code_submission', + ]); + assertStringIncludes(content ?? '', 'publicCases'); + assertEquals((content ?? '').includes('hidden'), false); + assertEquals((content ?? '').includes('referenceCode'), false); +}); diff --git a/src/python_lab/repository.ts b/src/python_lab/repository.ts new file mode 100644 index 0000000..bad4dfe --- /dev/null +++ b/src/python_lab/repository.ts @@ -0,0 +1,139 @@ +import type { CodeRunPhase, CodeSubmissionEvaluation } from './service.ts'; + +export type PythonLabState = 'generating' | 'ready' | 'failed' | 'expired'; +export type PythonLabRequestStatus = 'new' | 'contacted' | 'closed'; + +export interface CodeAssignmentDefinitionRecord { + readonly packageVersionId: number; + readonly functionName: string; + readonly privateArtifactKey: string; + readonly createdAt: string; +} + +export interface CodeSubmissionRunRecord { + readonly runId: string; + readonly attemptId: string; + readonly sourceArtifactKey: string; + readonly phase: Extract; + readonly publicOutcomes: CodeSubmissionEvaluation['publicOutcomes']; + readonly hiddenSummary: NonNullable; + readonly scoreGiven: number; + readonly scoreMaximum: number; + readonly createdAt: string; + readonly sourcePersistedAt: string | null; + readonly finalizedAt: string; +} + +export type CodeSubmissionRunReservation = + | { readonly status: 'created'; readonly run: CodeSubmissionRunRecord } + | { readonly status: 'existing'; readonly run: CodeSubmissionRunRecord }; + +export interface PythonLabRecord { + readonly labId: string; + readonly visitorHash: string; + readonly requestIpHash: string | null; + readonly accessTokenHash: string; + readonly state: PythonLabState; + readonly authorDraft: { + readonly title: string; + readonly prompt: string; + readonly starterCode: string; + readonly functionName: string; + }; + readonly packageVersionId: number | null; + readonly privateArtifactKey: string | null; + readonly failureMessage: string | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly expiresAt: string; +} + +export type PythonLabAdmission = + | { readonly status: 'accepted'; readonly lab: PythonLabRecord } + | { readonly status: 'visitor_limited' } + | { readonly status: 'ip_limited' }; + +export interface PythonLabAccessRequestRecord { + readonly requestId: string; + readonly labId: string; + readonly name: string; + readonly workEmail: string; + readonly institution: string; + readonly status: PythonLabRequestStatus; + readonly createdAt: string; + readonly updatedAt: string; + readonly expiresAt: string; +} + +export interface PythonLabRepository { + reserveLab(input: { + readonly lab: PythonLabRecord; + readonly visitorDay: string; + readonly ipWindowStartedAt: string | null; + }): Promise; + getLabById(labId: string): Promise; + markLabReady(input: { + readonly labId: string; + readonly packageVersionId: number; + readonly privateArtifactKey: string; + readonly updatedAt: string; + }): Promise; + markLabFailed(input: { + readonly labId: string; + readonly failureMessage: string; + readonly packageVersionId?: number; + readonly updatedAt: string; + }): Promise; + listExpiredLabs(now: string): Promise; + deleteLab(labId: string): Promise; + createCodeAssignmentDefinition( + record: CodeAssignmentDefinitionRecord, + ): Promise; + getCodeAssignmentDefinitionByPackageVersionId( + packageVersionId: number, + ): Promise; + reserveCodeSubmissionRun(record: CodeSubmissionRunRecord): Promise; + markCodeSubmissionSourcePersisted(input: { + readonly runId: string; + readonly sourcePersistedAt: string; + }): Promise; + deleteUnpersistedCodeSubmissionRun(runId: string): Promise; + getFinalCodeSubmissionRunByAttemptId(attemptId: string): Promise; + listSubmissionArtifactKeysByPackageVersionId(packageVersionId: number): Promise; + createAccessRequest(record: PythonLabAccessRequestRecord): Promise; + listAccessRequests(): Promise; + updateAccessRequestStatus(input: { + readonly requestId: string; + readonly status: PythonLabRequestStatus; + readonly updatedAt: string; + }): Promise; + listExpiredAccessRequests(now: string): Promise; + deleteAccessRequest(requestId: string): Promise; +} + +export function createUnavailablePythonLabRepository(message: string): PythonLabRepository { + const unavailable = (): never => { + throw new Error(message); + }; + + return { + reserveLab: unavailable, + getLabById: unavailable, + markLabReady: unavailable, + markLabFailed: unavailable, + listExpiredLabs: unavailable, + deleteLab: unavailable, + createCodeAssignmentDefinition: unavailable, + getCodeAssignmentDefinitionByPackageVersionId: unavailable, + reserveCodeSubmissionRun: unavailable, + markCodeSubmissionSourcePersisted: unavailable, + deleteUnpersistedCodeSubmissionRun: unavailable, + getFinalCodeSubmissionRunByAttemptId: unavailable, + listSubmissionArtifactKeysByPackageVersionId: unavailable, + createAccessRequest: unavailable, + listAccessRequests: unavailable, + updateAccessRequestStatus: unavailable, + listExpiredAccessRequests: unavailable, + deleteAccessRequest: unavailable, + }; +} diff --git a/src/python_lab/repository_d1.ts b/src/python_lab/repository_d1.ts new file mode 100644 index 0000000..8b551fe --- /dev/null +++ b/src/python_lab/repository_d1.ts @@ -0,0 +1,482 @@ +import { type D1Database, queryD1First, queryD1Objects, runD1, runD1Batch } from '../db/d1.ts'; +import { + expectD1JsonRecord, + expectD1NullableNumber, + expectD1NullableString, + expectD1Number, + expectD1String, + expectD1StringLiteral, + parseD1JsonField, +} from '../db/d1_row.ts'; +import type { CodeSubmissionEvaluation } from './service.ts'; +import type { + CodeAssignmentDefinitionRecord, + CodeSubmissionRunRecord, + PythonLabAccessRequestRecord, + PythonLabRecord, + PythonLabRepository, + PythonLabRequestStatus, +} from './repository.ts'; + +const LAB_STATES = ['generating', 'ready', 'failed', 'expired'] as const; +const REQUEST_STATUSES = ['new', 'contacted', 'closed'] as const; + +export function createD1PythonLabRepository(db: D1Database): PythonLabRepository { + return { + async reserveLab(input) { + const statements: Array<{ sql: string; parameters: readonly unknown[] }> = + input.ipWindowStartedAt === null ? [] : [{ + sql: `INSERT INTO python_lab_rate_limits ( + request_ip_hash, window_started_at, created_at + ) VALUES (?, ?, ?)`, + parameters: [ + input.lab.requestIpHash, + input.ipWindowStartedAt, + input.lab.createdAt, + ], + }]; + statements.push({ + sql: `INSERT INTO python_lab_trials ( + lab_id, visitor_hash, visitor_day, request_ip_hash, access_token_hash, state, author_draft, + package_version_id, private_artifact_key, failure_message, created_at, updated_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + parameters: [ + input.lab.labId, + input.lab.visitorHash, + input.visitorDay, + input.lab.requestIpHash, + input.lab.accessTokenHash, + input.lab.state, + input.lab.authorDraft, + input.lab.packageVersionId, + input.lab.privateArtifactKey, + input.lab.failureMessage, + input.lab.createdAt, + input.lab.updatedAt, + input.lab.expiresAt, + ], + }); + + try { + await runD1Batch(db, statements); + } catch (error) { + const message = error instanceof Error ? error.message : ''; + + if (message.includes('python_lab_trials.visitor_hash')) { + return { status: 'visitor_limited' }; + } + + if (message.includes('python_lab_rate_limits.request_ip_hash')) { + return { status: 'ip_limited' }; + } + + throw error; + } + + return { status: 'accepted', lab: input.lab }; + }, + async getLabById(labId) { + return mapOptionalLab( + await queryD1First(db, 'SELECT * FROM python_lab_trials WHERE lab_id = ?', [labId]), + ); + }, + async markLabReady(input) { + await runD1( + db, + `UPDATE python_lab_trials + SET state = 'ready', package_version_id = ?, private_artifact_key = ?, + failure_message = NULL, updated_at = ? + WHERE lab_id = ?`, + [input.packageVersionId, input.privateArtifactKey, input.updatedAt, input.labId], + ); + return await requireLab(db, input.labId); + }, + async markLabFailed(input) { + await runD1( + db, + `UPDATE python_lab_trials + SET state = 'failed', failure_message = ?, + package_version_id = COALESCE(?, package_version_id), updated_at = ? + WHERE lab_id = ?`, + [input.failureMessage, input.packageVersionId ?? null, input.updatedAt, input.labId], + ); + return await requireLab(db, input.labId); + }, + async listExpiredLabs(now) { + return (await queryD1Objects>( + db, + 'SELECT * FROM python_lab_trials WHERE expires_at <= ?', + [now], + )) + .map(mapLabRow); + }, + async deleteLab(labId) { + await runD1(db, 'DELETE FROM python_lab_trials WHERE lab_id = ?', [labId]); + }, + async createCodeAssignmentDefinition(record) { + await runD1( + db, + `INSERT INTO code_assignment_definitions ( + package_version_id, function_name, private_artifact_key, created_at + ) VALUES (?, ?, ?, ?)`, + [record.packageVersionId, record.functionName, record.privateArtifactKey, record.createdAt], + ); + return record; + }, + async getCodeAssignmentDefinitionByPackageVersionId(packageVersionId) { + return mapOptionalDefinition( + await queryD1First>( + db, + 'SELECT * FROM code_assignment_definitions WHERE package_version_id = ?', + [packageVersionId], + ), + ); + }, + async reserveCodeSubmissionRun(record) { + try { + await runD1( + db, + `INSERT INTO code_submission_runs ( + run_id, attempt_id, source_artifact_key, phase, public_outcomes, + hidden_summary, score_given, score_maximum, created_at, source_persisted_at, finalized_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + record.runId, + record.attemptId, + record.sourceArtifactKey, + record.phase, + record.publicOutcomes, + record.hiddenSummary, + record.scoreGiven, + record.scoreMaximum, + record.createdAt, + record.sourcePersistedAt, + record.finalizedAt, + ], + ); + return { status: 'created', run: record }; + } catch (error) { + if (!isFinalRunUniqueViolation(error)) { + throw error; + } + + const existing = await requireFinalRun(db, record.attemptId); + return { status: 'existing', run: existing }; + } + }, + async markCodeSubmissionSourcePersisted(input) { + await runD1( + db, + `UPDATE code_submission_runs + SET source_persisted_at = ? + WHERE run_id = ? AND source_persisted_at IS NULL`, + [input.sourcePersistedAt, input.runId], + ); + return await requireRunById(db, input.runId); + }, + async deleteUnpersistedCodeSubmissionRun(runId) { + await runD1( + db, + 'DELETE FROM code_submission_runs WHERE run_id = ? AND source_persisted_at IS NULL', + [runId], + ); + }, + async getFinalCodeSubmissionRunByAttemptId(attemptId) { + return mapOptionalRun( + await queryD1First>( + db, + `SELECT * FROM code_submission_runs + WHERE attempt_id = ? AND phase = 'final' LIMIT 1`, + [attemptId], + ), + ); + }, + async listSubmissionArtifactKeysByPackageVersionId(packageVersionId) { + const rows = await queryD1Objects>( + db, + `SELECT code_submission_runs.source_artifact_key + FROM code_submission_runs + INNER JOIN attempts ON attempts.attempt_id = code_submission_runs.attempt_id + WHERE attempts.package_version_id = ?`, + [packageVersionId], + ); + + return rows.map((row) => + expectD1String(row.source_artifact_key, 'code_submission_runs.source_artifact_key') + ); + }, + async createAccessRequest(record) { + await runD1( + db, + `INSERT INTO python_lab_access_requests ( + request_id, lab_id, name, work_email, institution, status, created_at, updated_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + record.requestId, + record.labId, + record.name, + record.workEmail, + record.institution, + record.status, + record.createdAt, + record.updatedAt, + record.expiresAt, + ], + ); + return record; + }, + async listAccessRequests() { + return (await queryD1Objects>( + db, + 'SELECT * FROM python_lab_access_requests ORDER BY created_at DESC', + )).map(mapAccessRequestRow); + }, + async updateAccessRequestStatus(input) { + await runD1( + db, + 'UPDATE python_lab_access_requests SET status = ?, updated_at = ? WHERE request_id = ?', + [input.status, input.updatedAt, input.requestId], + ); + const row = await queryD1First>( + db, + 'SELECT * FROM python_lab_access_requests WHERE request_id = ?', + [input.requestId], + ); + + if (row === null) { + throw new Error('Python Lab access request was not found.'); + } + + return mapAccessRequestRow(row); + }, + async listExpiredAccessRequests(now) { + return (await queryD1Objects>( + db, + 'SELECT * FROM python_lab_access_requests WHERE expires_at <= ?', + [now], + )).map(mapAccessRequestRow); + }, + async deleteAccessRequest(requestId) { + await runD1(db, 'DELETE FROM python_lab_access_requests WHERE request_id = ?', [requestId]); + }, + }; +} + +async function requireLab(db: D1Database, labId: string): Promise { + const lab = await mapOptionalLab( + await queryD1First>( + db, + 'SELECT * FROM python_lab_trials WHERE lab_id = ?', + [labId], + ), + ); + + if (lab === null) { + throw new Error('Python Lab was not found.'); + } + + return lab; +} + +async function requireFinalRun( + db: D1Database, + attemptId: string, +): Promise { + const run = mapOptionalRun( + await queryD1First>( + db, + `SELECT * FROM code_submission_runs + WHERE attempt_id = ? AND phase = 'final' LIMIT 1`, + [attemptId], + ), + ); + + if (run === null) { + throw new Error('A final Python submission run was not found after a duplicate reservation.'); + } + + return run; +} + +async function requireRunById(db: D1Database, runId: string): Promise { + const run = mapOptionalRun( + await queryD1First>( + db, + 'SELECT * FROM code_submission_runs WHERE run_id = ?', + [runId], + ), + ); + + if (run === null) { + throw new Error('Python submission run was not found.'); + } + + return run; +} + +function mapOptionalLab(row: Record | null): PythonLabRecord | null { + return row === null ? null : mapLabRow(row); +} + +function mapLabRow(row: Record): PythonLabRecord { + const authorDraft = parseD1JsonField( + row.author_draft, + 'python_lab_trials.author_draft', + (value) => { + const record = expectD1JsonRecord(value, 'python_lab_trials.author_draft'); + return { + title: expectD1String(record.title, 'python_lab_trials.author_draft.title'), + prompt: expectD1String(record.prompt, 'python_lab_trials.author_draft.prompt'), + starterCode: expectD1String( + record.starterCode, + 'python_lab_trials.author_draft.starterCode', + ), + functionName: expectD1String( + record.functionName, + 'python_lab_trials.author_draft.functionName', + ), + }; + }, + ); + + return { + labId: expectD1String(row.lab_id, 'python_lab_trials.lab_id'), + visitorHash: expectD1String(row.visitor_hash, 'python_lab_trials.visitor_hash'), + requestIpHash: expectD1NullableString( + row.request_ip_hash, + 'python_lab_trials.request_ip_hash', + ), + accessTokenHash: expectD1String(row.access_token_hash, 'python_lab_trials.access_token_hash'), + state: expectD1StringLiteral(row.state, 'python_lab_trials.state', LAB_STATES), + authorDraft, + packageVersionId: expectD1NullableNumber( + row.package_version_id, + 'python_lab_trials.package_version_id', + ), + privateArtifactKey: expectD1NullableString( + row.private_artifact_key, + 'python_lab_trials.private_artifact_key', + ), + failureMessage: expectD1NullableString( + row.failure_message, + 'python_lab_trials.failure_message', + ), + createdAt: expectD1String(row.created_at, 'python_lab_trials.created_at'), + updatedAt: expectD1String(row.updated_at, 'python_lab_trials.updated_at'), + expiresAt: expectD1String(row.expires_at, 'python_lab_trials.expires_at'), + }; +} + +function mapOptionalDefinition( + row: Record | null, +): CodeAssignmentDefinitionRecord | null { + if (row === null) { + return null; + } + + return { + packageVersionId: expectD1Number( + row.package_version_id, + 'code_assignment_definitions.package_version_id', + ), + functionName: expectD1String(row.function_name, 'code_assignment_definitions.function_name'), + privateArtifactKey: expectD1String( + row.private_artifact_key, + 'code_assignment_definitions.private_artifact_key', + ), + createdAt: expectD1String(row.created_at, 'code_assignment_definitions.created_at'), + }; +} + +function mapOptionalRun(row: Record | null): CodeSubmissionRunRecord | null { + if (row === null) { + return null; + } + + return { + runId: expectD1String(row.run_id, 'code_submission_runs.run_id'), + attemptId: expectD1String(row.attempt_id, 'code_submission_runs.attempt_id'), + sourceArtifactKey: expectD1String( + row.source_artifact_key, + 'code_submission_runs.source_artifact_key', + ), + phase: expectD1StringLiteral(row.phase, 'code_submission_runs.phase', ['final'] as const), + publicOutcomes: parseD1JsonField( + row.public_outcomes, + 'code_submission_runs.public_outcomes', + parsePublicOutcomes, + ), + hiddenSummary: parseD1JsonField( + row.hidden_summary, + 'code_submission_runs.hidden_summary', + parseHiddenSummary, + ), + scoreGiven: expectD1Number(row.score_given, 'code_submission_runs.score_given'), + scoreMaximum: expectD1Number(row.score_maximum, 'code_submission_runs.score_maximum'), + createdAt: expectD1String(row.created_at, 'code_submission_runs.created_at'), + sourcePersistedAt: expectD1NullableString( + row.source_persisted_at, + 'code_submission_runs.source_persisted_at', + ), + finalizedAt: expectD1String(row.finalized_at, 'code_submission_runs.finalized_at'), + }; +} + +function isFinalRunUniqueViolation(error: unknown): boolean { + return error instanceof Error && error.message.includes('UNIQUE constraint failed') && + error.message.includes('code_submission_runs.attempt_id'); +} + +function parsePublicOutcomes( + value: unknown, + fieldName: string, +): CodeSubmissionEvaluation['publicOutcomes'] { + if (!Array.isArray(value)) { + throw new TypeError(`Expected D1 ${fieldName} to be an array.`); + } + + return value.map((item) => { + const record = expectD1JsonRecord(item, fieldName); + const status = expectD1StringLiteral( + record.status, + `${fieldName}.status`, + ['passed', 'failed'] as const, + ); + const message = expectD1NullableString(record.message, `${fieldName}.message`); + + return { + label: expectD1String(record.label, `${fieldName}.label`), + status, + message, + }; + }); +} + +function parseHiddenSummary( + value: unknown, + fieldName: string, +): NonNullable { + const record = expectD1JsonRecord(value, fieldName); + return { + passed: expectD1Number(record.passed, `${fieldName}.passed`), + total: expectD1Number(record.total, `${fieldName}.total`), + }; +} + +function mapAccessRequestRow(row: Record): PythonLabAccessRequestRecord { + return { + requestId: expectD1String(row.request_id, 'python_lab_access_requests.request_id'), + labId: expectD1String(row.lab_id, 'python_lab_access_requests.lab_id'), + name: expectD1String(row.name, 'python_lab_access_requests.name'), + workEmail: expectD1String(row.work_email, 'python_lab_access_requests.work_email'), + institution: expectD1String(row.institution, 'python_lab_access_requests.institution'), + status: expectD1StringLiteral( + row.status, + 'python_lab_access_requests.status', + REQUEST_STATUSES, + ) as PythonLabRequestStatus, + createdAt: expectD1String(row.created_at, 'python_lab_access_requests.created_at'), + updatedAt: expectD1String(row.updated_at, 'python_lab_access_requests.updated_at'), + expiresAt: expectD1String(row.expires_at, 'python_lab_access_requests.expires_at'), + }; +} diff --git a/src/python_lab/repository_d1_test.ts b/src/python_lab/repository_d1_test.ts new file mode 100644 index 0000000..f60ffc9 --- /dev/null +++ b/src/python_lab/repository_d1_test.ts @@ -0,0 +1,126 @@ +import { assertEquals, assertStringIncludes } from '@std/assert'; +import type { D1Database, D1Parameter, D1PreparedStatement, D1Result } from '../db/d1.ts'; +import { createD1PythonLabRepository } from './repository_d1.ts'; +import type { PythonLabRecord } from './repository.ts'; + +Deno.test('D1 Python Lab admission reserves the IP window and daily visitor in one batch', async () => { + const db = createRecordingDatabase(); + const repository = createD1PythonLabRepository(db); + + const admission = await repository.reserveLab({ + lab: lab(), + visitorDay: '2026-07-13', + ipWindowStartedAt: '2026-07-13T12:00:00.000Z', + }); + + assertEquals(admission.status, 'accepted'); + assertEquals(db.batches.length, 1); + assertEquals(db.batches[0]?.length, 2); + assertStringIncludes(db.batches[0]?.[0]?.query ?? '', 'INSERT INTO python_lab_rate_limits'); + assertStringIncludes(db.batches[0]?.[1]?.query ?? '', 'INSERT INTO python_lab_trials'); + assertEquals(db.batches[0]?.[1]?.parameters.slice(0, 3), [ + 'lab-123', + 'visitor-hash', + '2026-07-13', + ]); +}); + +Deno.test('D1 Python Lab admission translates a unique visitor reservation into a rate-limit result', async () => { + const db = createRecordingDatabase( + 'UNIQUE constraint failed: python_lab_trials.visitor_hash, python_lab_trials.visitor_day', + ); + const repository = createD1PythonLabRepository(db); + + const admission = await repository.reserveLab({ + lab: lab(), + visitorDay: '2026-07-13', + ipWindowStartedAt: null, + }); + + assertEquals(admission, { status: 'visitor_limited' }); +}); + +Deno.test('Python Lab migration persists the daily visitor and final-run recovery constraints', async () => { + const migration = await Deno.readTextFile( + 'src/db/d1_migrations/0003_python_autograder_lab.sql', + ); + + assertStringIncludes(migration, 'visitor_day TEXT NOT NULL'); + assertStringIncludes(migration, 'UNIQUE (visitor_hash, visitor_day)'); + assertStringIncludes(migration, 'source_persisted_at TEXT'); + assertStringIncludes(migration, 'UNIQUE (attempt_id, phase)'); +}); + +interface RecordingStatement extends D1PreparedStatement { + readonly query: string; + readonly parameters: D1Parameter[]; +} + +function createRecordingDatabase(batchError?: string): D1Database & { + readonly batches: RecordingStatement[][]; +} { + const batches: RecordingStatement[][] = []; + + return { + batches, + prepare(query) { + let parameters: D1Parameter[] = []; + const statement: RecordingStatement = { + query, + get parameters() { + return parameters; + }, + bind(...values) { + parameters = values; + return statement; + }, + all() { + return Promise.resolve({ success: true, results: [] } satisfies D1Result); + }, + first() { + return Promise.resolve(null as T | null); + }, + run() { + return Promise.resolve({ success: true }); + }, + raw() { + return Promise.resolve([] as T[]); + }, + }; + return statement; + }, + batch(statements) { + const recorded = statements as RecordingStatement[]; + batches.push(recorded); + return Promise.resolve(recorded.map((_statement, index) => ({ + success: batchError === undefined || index !== recorded.length - 1, + ...(batchError === undefined || index !== recorded.length - 1 ? {} : { error: batchError }), + }))); + }, + exec() { + return Promise.resolve({ count: 0, duration: 0 }); + }, + }; +} + +function lab(): PythonLabRecord { + return { + labId: 'lab-123', + visitorHash: 'visitor-hash', + requestIpHash: 'ip-hash', + accessTokenHash: 'access-token-hash', + state: 'generating', + authorDraft: { + title: 'Add numbers', + prompt: 'Write a function that returns the sum of two integers.', + starterCode: 'def add(left, right):\n return 0\n', + functionName: 'add', + }, + packageVersionId: null, + privateArtifactKey: 'var/private-python-lab/lab-123/assignment.json', + failureMessage: null, + createdAt: '2026-07-13T12:00:00.000Z', + updatedAt: '2026-07-13T12:00:00.000Z', + expiresAt: '2026-07-20T12:00:00.000Z', + }; +} diff --git a/src/python_lab/repository_memory.ts b/src/python_lab/repository_memory.ts new file mode 100644 index 0000000..bce10e9 --- /dev/null +++ b/src/python_lab/repository_memory.ts @@ -0,0 +1,198 @@ +import type { + CodeAssignmentDefinitionRecord, + CodeSubmissionRunRecord, + PythonLabAccessRequestRecord, + PythonLabRecord, + PythonLabRepository, +} from './repository.ts'; + +export function createInMemoryPythonLabRepository(input: { + readonly labs?: readonly PythonLabRecord[]; + readonly definitions?: readonly CodeAssignmentDefinitionRecord[]; + readonly runs?: readonly CodeSubmissionRunRecord[]; + readonly accessRequests?: readonly PythonLabAccessRequestRecord[]; +} = {}): PythonLabRepository { + const labs = [...(input.labs ?? [])]; + const definitions = [...(input.definitions ?? [])]; + const runs = [...(input.runs ?? [])]; + const accessRequests = [...(input.accessRequests ?? [])]; + const ipWindowReservations = new Set(); + + return { + async reserveLab(input) { + await Promise.resolve(); + if ( + labs.some((lab) => + lab.visitorHash === input.lab.visitorHash && + lab.createdAt.slice(0, 10) === input.visitorDay + ) + ) { + return { status: 'visitor_limited' }; + } + + if (input.lab.requestIpHash !== null && input.ipWindowStartedAt !== null) { + const key = `${input.lab.requestIpHash}:${input.ipWindowStartedAt}`; + + if (ipWindowReservations.has(key)) { + return { status: 'ip_limited' }; + } + + ipWindowReservations.add(key); + } + + labs.push(structuredClone(input.lab)); + return { status: 'accepted', lab: structuredClone(input.lab) }; + }, + async getLabById(labId) { + await Promise.resolve(); + return cloneOrNull(labs.find((lab) => lab.labId === labId)); + }, + async markLabReady(input) { + await Promise.resolve(); + return updateLab(labs, input.labId, (lab) => ({ + ...lab, + state: 'ready', + packageVersionId: input.packageVersionId, + privateArtifactKey: input.privateArtifactKey, + failureMessage: null, + updatedAt: input.updatedAt, + })); + }, + async markLabFailed(input) { + await Promise.resolve(); + return updateLab(labs, input.labId, (lab) => ({ + ...lab, + state: 'failed', + packageVersionId: input.packageVersionId ?? lab.packageVersionId, + failureMessage: input.failureMessage, + updatedAt: input.updatedAt, + })); + }, + async listExpiredLabs(now) { + await Promise.resolve(); + return labs.filter((lab) => lab.expiresAt <= now).map((lab) => structuredClone(lab)); + }, + async deleteLab(labId) { + await Promise.resolve(); + removeBy(labs, (lab) => lab.labId === labId); + }, + async createCodeAssignmentDefinition(record) { + await Promise.resolve(); + if ( + definitions.some((definition) => definition.packageVersionId === record.packageVersionId) + ) { + throw new Error('A Python assignment definition already exists for this package version.'); + } + definitions.push(structuredClone(record)); + return structuredClone(record); + }, + async getCodeAssignmentDefinitionByPackageVersionId(packageVersionId) { + await Promise.resolve(); + return cloneOrNull( + definitions.find((definition) => definition.packageVersionId === packageVersionId), + ); + }, + async reserveCodeSubmissionRun(record) { + await Promise.resolve(); + const existing = runs.find((run) => + run.attemptId === record.attemptId && run.phase === 'final' + ); + if (existing !== undefined) { + return { status: 'existing', run: structuredClone(existing) }; + } + runs.push(structuredClone(record)); + return { status: 'created', run: structuredClone(record) }; + }, + async markCodeSubmissionSourcePersisted(input) { + await Promise.resolve(); + return updateRun(runs, input.runId, (run) => ({ + ...run, + sourcePersistedAt: input.sourcePersistedAt, + })); + }, + async deleteUnpersistedCodeSubmissionRun(runId) { + await Promise.resolve(); + removeBy(runs, (run) => run.runId === runId && run.sourcePersistedAt === null); + }, + async getFinalCodeSubmissionRunByAttemptId(attemptId) { + await Promise.resolve(); + return cloneOrNull(runs.find((run) => run.attemptId === attemptId && run.phase === 'final')); + }, + async listSubmissionArtifactKeysByPackageVersionId(_packageVersionId) { + await Promise.resolve(); + return runs.map((run) => run.sourceArtifactKey); + }, + async createAccessRequest(record) { + await Promise.resolve(); + accessRequests.push(structuredClone(record)); + return structuredClone(record); + }, + async listAccessRequests() { + await Promise.resolve(); + return accessRequests.map((request) => structuredClone(request)).sort((left, right) => + right.createdAt.localeCompare(left.createdAt) + ); + }, + async updateAccessRequestStatus(input) { + await Promise.resolve(); + const index = accessRequests.findIndex((request) => request.requestId === input.requestId); + if (index < 0) { + throw new Error('Python Lab access request was not found.'); + } + const existing = accessRequests[index]!; + const next = { ...existing, status: input.status, updatedAt: input.updatedAt }; + accessRequests.splice(index, 1, next); + return structuredClone(next); + }, + async listExpiredAccessRequests(now) { + await Promise.resolve(); + return accessRequests.filter((request) => request.expiresAt <= now).map((request) => + structuredClone(request) + ); + }, + async deleteAccessRequest(requestId) { + await Promise.resolve(); + removeBy(accessRequests, (request) => request.requestId === requestId); + }, + }; +} + +function updateRun( + runs: CodeSubmissionRunRecord[], + runId: string, + update: (run: CodeSubmissionRunRecord) => CodeSubmissionRunRecord, +): CodeSubmissionRunRecord { + const index = runs.findIndex((run) => run.runId === runId); + if (index < 0) { + throw new Error('Python submission run was not found.'); + } + const next = update(runs[index]!); + runs.splice(index, 1, next); + return structuredClone(next); +} + +function updateLab( + labs: PythonLabRecord[], + labId: string, + update: (lab: PythonLabRecord) => PythonLabRecord, +): PythonLabRecord { + const index = labs.findIndex((lab) => lab.labId === labId); + if (index < 0) { + throw new Error('Python Lab was not found.'); + } + const next = update(labs[index]!); + labs.splice(index, 1, next); + return structuredClone(next); +} + +function cloneOrNull(value: T | undefined): T | null { + return value === undefined ? null : structuredClone(value); +} + +function removeBy(items: T[], predicate: (item: T) => boolean): void { + for (let index = items.length - 1; index >= 0; index -= 1) { + if (predicate(items[index]!)) { + items.splice(index, 1); + } + } +} diff --git a/src/python_lab/retention.ts b/src/python_lab/retention.ts new file mode 100644 index 0000000..a6e041d --- /dev/null +++ b/src/python_lab/retention.ts @@ -0,0 +1,67 @@ +import type { PackageReviewRepository } from '../package_review/repository.ts'; +import type { PythonLabArtifactStore } from './artifact_store.ts'; +import { PYTHON_LAB_PACKAGE_RELATIVE_PATHS } from './package_source.ts'; +import type { PythonLabRepository } from './repository.ts'; + +export async function purgeExpiredPythonLabs(input: { + readonly repository: PackageReviewRepository; + readonly pythonLabRepository: PythonLabRepository; + readonly pythonLabArtifactStore: PythonLabArtifactStore; + readonly now?: () => Date; +}): Promise<{ readonly deletedLabs: number; readonly deletedAccessRequests: number }> { + const now = (input.now ?? (() => new Date()))().toISOString(); + const expiredLabs = await input.pythonLabRepository.listExpiredLabs(now); + + for (const lab of expiredLabs) { + await purgeExpiredLab(input, lab); + } + + const expiredRequests = await input.pythonLabRepository.listExpiredAccessRequests(now); + + for (const request of expiredRequests) { + await input.pythonLabRepository.deleteAccessRequest(request.requestId); + } + + return { deletedLabs: expiredLabs.length, deletedAccessRequests: expiredRequests.length }; +} + +async function purgeExpiredLab( + input: { + readonly repository: PackageReviewRepository; + readonly pythonLabRepository: PythonLabRepository; + readonly pythonLabArtifactStore: PythonLabArtifactStore; + }, + lab: Awaited>[number], +): Promise { + const privateKeys = new Set(); + + if (lab.privateArtifactKey !== null) { + privateKeys.add(lab.privateArtifactKey); + } + + if (lab.packageVersionId !== null) { + const packageVersion = await input.repository.getPackageVersionById(lab.packageVersionId); + const sourceKeys = await input.pythonLabRepository + .listSubmissionArtifactKeysByPackageVersionId(lab.packageVersionId); + + for (const sourceKey of sourceKeys) { + privateKeys.add(sourceKey); + } + + if (packageVersion !== null) { + for (const relativePath of PYTHON_LAB_PACKAGE_RELATIVE_PATHS) { + privateKeys.add(`${packageVersion.artifact.snapshotRoot}/${relativePath}`); + } + } + } + + for (const key of privateKeys) { + await input.pythonLabArtifactStore.delete(key); + } + + if (lab.packageVersionId !== null) { + await input.repository.deletePythonLabPackageVersion(lab.packageVersionId); + } + + await input.pythonLabRepository.deleteLab(lab.labId); +} diff --git a/src/python_lab/retention_test.ts b/src/python_lab/retention_test.ts new file mode 100644 index 0000000..de101b3 --- /dev/null +++ b/src/python_lab/retention_test.ts @@ -0,0 +1,126 @@ +import { assertEquals } from '@std/assert'; +import { + buildPackageVersionRecord, + createInMemoryPackageReviewRepository, +} from '../test_helpers/package_review.ts'; +import type { PythonLabArtifactStore } from './artifact_store.ts'; +import { createInMemoryPythonLabRepository } from './repository_memory.ts'; +import { purgeExpiredPythonLabs } from './retention.ts'; + +Deno.test('Python Lab retention deletes trial artifacts and packages while preserving live access requests', async () => { + const pythonLabRepository = createInMemoryPythonLabRepository({ + labs: [{ + labId: 'lab-expired', + visitorHash: 'visitor', + requestIpHash: null, + accessTokenHash: 'token', + state: 'ready', + authorDraft: { + title: 'Add', + prompt: 'Add two numbers.', + starterCode: 'def add(left, right):\n return 0\n', + functionName: 'add', + }, + packageVersionId: 1, + privateArtifactKey: 'var/private-python-lab/lab-expired/assignment.json', + failureMessage: null, + createdAt: '2026-07-01T00:00:00Z', + updatedAt: '2026-07-01T00:00:00Z', + expiresAt: '2026-07-08T00:00:00Z', + }], + runs: [{ + runId: 'run-1', + attemptId: 'attempt-1', + sourceArtifactKey: 'var/private-python-lab/submissions/attempt-1/run-1.py', + phase: 'final', + publicOutcomes: [], + hiddenSummary: { passed: 0, total: 5 }, + scoreGiven: 0, + scoreMaximum: 10, + createdAt: '2026-07-01T00:00:00Z', + sourcePersistedAt: '2026-07-01T00:00:00Z', + finalizedAt: '2026-07-01T00:00:00Z', + }], + accessRequests: [ + { + requestId: 'keep-request', + labId: 'lab-expired', + name: 'Instructor', + workEmail: 'instructor@example.edu', + institution: 'Example University', + status: 'new', + createdAt: '2026-07-01T00:00:00Z', + updatedAt: '2026-07-01T00:00:00Z', + expiresAt: '2026-10-01T00:00:00Z', + }, + { + requestId: 'expired-request', + labId: 'lab-old', + name: 'Old Instructor', + workEmail: 'old@example.edu', + institution: 'Example University', + status: 'closed', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + expiresAt: '2026-04-01T00:00:00Z', + }, + ], + }); + const repository = createInMemoryPackageReviewRepository({ + packageVersions: [ + buildPackageVersionRecord({ + appId: 'python-lab-lab-expired', + version: '1.0.0', + catalogVisibility: 'python_lab_trial', + }), + ], + }); + const deleted: string[] = []; + const artifacts: PythonLabArtifactStore = { + writePendingDraft() { + return Promise.resolve(); + }, + readPendingDraft() { + return Promise.reject(new Error('not used by retention')); + }, + writePrivateAssignment() { + return Promise.resolve(); + }, + readPrivateAssignment() { + return Promise.reject(new Error('not used by retention')); + }, + writeSubmissionSource() { + return Promise.resolve(); + }, + submissionSourceExists() { + return Promise.resolve(false); + }, + delete(key) { + deleted.push(key); + return Promise.resolve(); + }, + }; + + const result = await purgeExpiredPythonLabs({ + repository, + pythonLabRepository, + pythonLabArtifactStore: artifacts, + now: () => new Date('2026-07-13T00:00:00Z'), + }); + + assertEquals(result, { deletedLabs: 1, deletedAccessRequests: 1 }); + assertEquals(await pythonLabRepository.getLabById('lab-expired'), null); + assertEquals(await repository.getPackageVersionById(1), null); + assertEquals(deleted.includes('var/private-python-lab/lab-expired/assignment.json'), true); + assertEquals(deleted.includes('var/private-python-lab/submissions/attempt-1/run-1.py'), true); + assertEquals( + deleted.includes('var/packages/chapter-4-asteroids/0.1.0/dist/index.html'), + true, + ); + assertEquals( + (await pythonLabRepository.listAccessRequests()).map((request) => request.requestId), + [ + 'keep-request', + ], + ); +}); diff --git a/src/python_lab/runner.ts b/src/python_lab/runner.ts new file mode 100644 index 0000000..0f7bc9a --- /dev/null +++ b/src/python_lab/runner.ts @@ -0,0 +1,200 @@ +import { type JsonValue, parseJsonValue, type PythonFunctionContract } from './contract.ts'; + +export const PYTHON_GRADER_COMMAND = 'python3 -I -S /workspace/invoke.py < /workspace/request.json'; +export const PYTHON_GRADER_OUTPUT_LIMIT_BYTES = 16_384; +export const PYTHON_GRADER_TIMEOUT_MS = 4_000; + +export interface SandboxCommandResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +export interface PythonGraderSandbox { + writeFile(path: string, contents: string): Promise; + exec(input: { + readonly command: typeof PYTHON_GRADER_COMMAND; + readonly timeoutMs: number; + }): Promise; + destroy(): Promise; +} + +export interface PythonGraderSandboxFactory { + create(oneTimeId: string): Promise; +} + +export function createUnavailablePythonGraderSandboxFactory( + message: string, +): PythonGraderSandboxFactory { + return { + create() { + return Promise.reject(new Error(message)); + }, + }; +} + +export type PythonExecutionOutcome = + | { readonly status: 'passed'; readonly value: JsonValue } + | { + readonly status: 'failed'; + readonly kind: 'submission' | 'runner'; + readonly message: string; + }; + +export async function executePythonFunction(input: { + readonly sandboxFactory: PythonGraderSandboxFactory; + readonly oneTimeId: string; + readonly source: string; + readonly contract: PythonFunctionContract; + readonly args: readonly JsonValue[]; + readonly timeoutMs?: number; +}): Promise { + let sandbox: PythonGraderSandbox; + + try { + sandbox = await input.sandboxFactory.create(input.oneTimeId); + } catch (error) { + return { + status: 'failed', + kind: 'runner', + message: error instanceof Error ? error.message : 'The Python runner failed.', + }; + } + + let outcome: PythonExecutionOutcome; + + try { + await sandbox.writeFile('/workspace/solution.py', input.source); + await sandbox.writeFile('/workspace/invoke.py', PYTHON_INVOCATION_WRAPPER); + await sandbox.writeFile( + '/workspace/request.json', + JSON.stringify({ functionName: input.contract.functionName, args: input.args }), + ); + const result = await sandbox.exec({ + command: PYTHON_GRADER_COMMAND, + timeoutMs: input.timeoutMs ?? PYTHON_GRADER_TIMEOUT_MS, + }); + + outcome = parsePythonExecutionResult(result); + } catch (error) { + outcome = { + status: 'failed', + kind: 'runner', + message: error instanceof Error ? error.message : 'The Python runner failed.', + }; + } + + try { + await sandbox.destroy(); + } catch (error) { + return { + status: 'failed', + kind: 'runner', + message: error instanceof Error ? error.message : 'The Python runner cleanup failed.', + }; + } + + return outcome; +} + +function parsePythonExecutionResult(result: SandboxCommandResult): PythonExecutionOutcome { + if ( + new TextEncoder().encode(result.stdout).byteLength > PYTHON_GRADER_OUTPUT_LIMIT_BYTES || + new TextEncoder().encode(result.stderr).byteLength > PYTHON_GRADER_OUTPUT_LIMIT_BYTES + ) { + return { + status: 'failed', + kind: 'runner', + message: 'Python output exceeded the allowed limit.', + }; + } + + if (result.exitCode !== 0) { + return { status: 'failed', kind: 'runner', message: 'Python execution did not complete.' }; + } + + let payload: unknown; + + try { + payload = JSON.parse(result.stdout); + } catch { + return { status: 'failed', kind: 'runner', message: 'Python returned malformed JSON.' }; + } + + if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) { + return { + status: 'failed', + kind: 'runner', + message: 'Python returned an invalid result envelope.', + }; + } + + const record = payload as Record; + + if (record.ok === true) { + try { + return { status: 'passed', value: parseJsonValue(record.value, 'Python function result') }; + } catch (error) { + return { + status: 'failed', + kind: 'runner', + message: error instanceof Error ? error.message : 'Python returned an invalid JSON value.', + }; + } + } + + if (record.ok === false && typeof record.error === 'string') { + return { status: 'failed', kind: 'submission', message: record.error.slice(0, 400) }; + } + + return { + status: 'failed', + kind: 'runner', + message: 'Python returned an invalid result envelope.', + }; +} + +const PYTHON_INVOCATION_WRAPPER = String.raw`import contextlib +import importlib.util +import io +import json +import sys + +MAX_LEARNER_OUTPUT_BYTES = 4096 + +class BoundedOutput(io.TextIOBase): + def __init__(self): + self.size = 0 + + def write(self, value): + encoded = value.encode("utf-8") + self.size += len(encoded) + if self.size > MAX_LEARNER_OUTPUT_BYTES: + raise RuntimeError("Learner output exceeded the allowed limit.") + return len(value) + +def emit(payload): + sys.stdout.write(json.dumps(payload, separators=(",", ":"), ensure_ascii=False)) + +try: + request = json.load(sys.stdin) + function_name = request["functionName"] + args = request["args"] + if not isinstance(function_name, str) or not isinstance(args, list): + raise ValueError("Invalid invocation request.") + + spec = importlib.util.spec_from_file_location("learner_solution", "/workspace/solution.py") + if spec is None or spec.loader is None: + raise RuntimeError("Could not load solution.py.") + module = importlib.util.module_from_spec(spec) + with contextlib.redirect_stdout(BoundedOutput()), contextlib.redirect_stderr(BoundedOutput()): + spec.loader.exec_module(module) + function = getattr(module, function_name) + if not callable(function): + raise TypeError("Required function was not defined.") + value = function(*args) + json.dumps(value, allow_nan=False) + emit({"ok": True, "value": value}) +except Exception as error: + emit({"ok": False, "error": f"{type(error).__name__}: {error}"}) +`; diff --git a/src/python_lab/runner_test.ts b/src/python_lab/runner_test.ts new file mode 100644 index 0000000..d750387 --- /dev/null +++ b/src/python_lab/runner_test.ts @@ -0,0 +1,108 @@ +import { assertEquals, assertStringIncludes } from '@std/assert'; +import { + executePythonFunction, + PYTHON_GRADER_COMMAND, + type PythonGraderSandbox, + type PythonGraderSandboxFactory, +} from './runner.ts'; + +Deno.test('executePythonFunction stages a fixed wrapper, parses JSON, and destroys the sandbox', async () => { + const writes: Array<{ path: string; contents: string }> = []; + let command = ''; + let destroyed = false; + const result = await executePythonFunction({ + sandboxFactory: fakeSandboxFactory({ + writeFile(path, contents) { + writes.push({ path, contents }); + return Promise.resolve(); + }, + exec(input) { + command = input.command; + return Promise.resolve({ exitCode: 0, stdout: '{"ok":true,"value":3}', stderr: '' }); + }, + destroy() { + destroyed = true; + return Promise.resolve(); + }, + }), + oneTimeId: 'run-1', + source: 'def add(a, b): return a + b', + contract: { functionName: 'add', maxScore: 10 }, + args: [1, 2], + }); + + assertEquals(result, { status: 'passed', value: 3 }); + assertEquals(writes.map((item) => item.path), [ + '/workspace/solution.py', + '/workspace/invoke.py', + '/workspace/request.json', + ]); + assertEquals(command, PYTHON_GRADER_COMMAND); + assertStringIncludes(PYTHON_GRADER_COMMAND, 'python3 -I -S'); + assertStringIncludes(writes[1]?.contents ?? '', 'class BoundedOutput'); + assertEquals((writes[1]?.contents ?? '').includes('StandardLibraryOnlyFinder'), false); + assertEquals(destroyed, true); +}); + +Deno.test('executePythonFunction rejects malformed output and still destroys the sandbox', async () => { + let destroyed = false; + const result = await executePythonFunction({ + sandboxFactory: fakeSandboxFactory({ + writeFile() { + return Promise.resolve(); + }, + exec() { + return Promise.resolve({ exitCode: 0, stdout: 'not json', stderr: '' }); + }, + destroy() { + destroyed = true; + return Promise.resolve(); + }, + }), + oneTimeId: 'run-2', + source: 'def add(a, b): return a + b', + contract: { functionName: 'add', maxScore: 10 }, + args: [1, 2], + }); + + assertEquals(result, { + status: 'failed', + kind: 'runner', + message: 'Python returned malformed JSON.', + }); + assertEquals(destroyed, true); +}); + +Deno.test('executePythonFunction reports sandbox cleanup failure as a runner failure', async () => { + const result = await executePythonFunction({ + sandboxFactory: fakeSandboxFactory({ + writeFile() { + return Promise.resolve(); + }, + exec() { + return Promise.resolve({ exitCode: 0, stdout: '{"ok":true,"value":3}', stderr: '' }); + }, + destroy() { + return Promise.reject(new Error('sandbox destroy failed')); + }, + }), + oneTimeId: 'run-3', + source: 'def add(a, b): return a + b', + contract: { functionName: 'add', maxScore: 10 }, + args: [1, 2], + }); + + assertEquals(result, { + status: 'failed', + kind: 'runner', + message: 'sandbox destroy failed', + }); +}); + +function fakeSandboxFactory(sandbox: PythonGraderSandbox): PythonGraderSandboxFactory { + return { + create() { + return Promise.resolve(sandbox); + }, + }; +} diff --git a/src/python_lab/runtime_submission.ts b/src/python_lab/runtime_submission.ts new file mode 100644 index 0000000..4a49947 --- /dev/null +++ b/src/python_lab/runtime_submission.ts @@ -0,0 +1,324 @@ +import type { GatewayCodeSubmissionAcceptedResult } from '../../sdk/app-sdk.ts'; +import type { LanternLtiToolContext } from '../lti/tool_provider.ts'; +import type { RuntimeSessionRecord } from '../lti/types.ts'; +import type { PackageReviewRepository } from '../package_review/repository.ts'; +import type { AttemptRecord, PackageVersionRecord } from '../package_review/types.ts'; +import { requireRuntimeAttempt, requireRuntimePackageVersion } from '../runtime/gateway_context.ts'; +import { denyRuntimeBroker, errorMessage } from '../runtime/gateway_errors.ts'; +import { requireRuntimeCapability } from '../runtime/gateway_parsing.ts'; +import type { PythonLabArtifactStore } from './artifact_store.ts'; +import { buildPythonSubmissionArtifactKey } from './artifact_store.ts'; +import type { PythonLabRepository } from './repository.ts'; +import type { CodeSubmissionRunRecord } from './repository.ts'; +import { evaluatePythonSubmission } from './service.ts'; +import type { PythonGraderSandboxFactory } from './runner.ts'; + +export interface RuntimeCodeSubmissionResult extends GatewayCodeSubmissionAcceptedResult { + readonly attempt: AttemptRecord; +} + +export async function runRuntimeCodeSubmission(input: { + readonly repository: PackageReviewRepository; + readonly pythonLabRepository: PythonLabRepository; + readonly pythonLabArtifactStore: PythonLabArtifactStore; + readonly sandboxFactory: PythonGraderSandboxFactory; + readonly session: RuntimeSessionRecord; + readonly payload: unknown; + readonly getLtiToolContext?: () => Promise; + readonly createId: () => string; + readonly now?: () => Date; +}): Promise { + const now = input.now ?? (() => new Date()); + requireRuntimeCapability(input.session, 'run_code_submission'); + const request = parseCodeSubmissionRequest(input.payload); + const attempt = await requireRuntimeAttempt(input.repository, input.session); + const packageVersion = await requireRuntimePackageVersion(input.repository, input.session); + requireSandboxAssignment(packageVersion); + const definition = await input.pythonLabRepository.getCodeAssignmentDefinitionByPackageVersionId( + packageVersion.id, + ); + + if (definition === null) { + throw new Error('Python assignment definition was not found for this package version.'); + } + + const privateArtifact = await input.pythonLabArtifactStore.readPrivateAssignment( + definition.privateArtifactKey, + ); + + if (privateArtifact.functionName !== definition.functionName) { + throw new Error('Python assignment definition did not match its private artifact.'); + } + + if (request.phase === 'final') { + const existing = await input.pythonLabRepository.getFinalCodeSubmissionRunByAttemptId( + attempt.attemptId, + ); + + if (existing !== null) { + const run = await requirePersistedFinalRun({ + run: existing, + pythonLabRepository: input.pythonLabRepository, + pythonLabArtifactStore: input.pythonLabArtifactStore, + now, + }); + + return await finalizeTrustedRun({ + repository: input.repository, + session: input.session, + attempt, + packageVersion, + run, + ...(input.getLtiToolContext === undefined + ? {} + : { getLtiToolContext: input.getLtiToolContext }), + now, + }); + } + } + + const evaluation = await evaluatePythonSubmission({ + source: request.source, + contract: { functionName: definition.functionName, maxScore: 10 }, + cases: privateArtifact.cases, + phase: request.phase, + sandboxFactory: input.sandboxFactory, + createOneTimeId: input.createId, + }); + + if (evaluation.runnerFailed) { + throw new Error('Python execution infrastructure did not finish the submission.'); + } + + if (request.phase === 'practice') { + await input.repository.recordAuditEvent({ + eventType: 'python_submission.practice', + actorType: 'system', + actorId: null, + packageVersionId: input.session.packageVersionId, + attemptId: attempt.attemptId, + status: 'accepted', + summary: 'Ran public Python tests without finalizing the attempt.', + detail: { publicCaseCount: evaluation.publicOutcomes.length }, + occurredAt: now().toISOString(), + }); + + return { + accepted: true, + phase: 'practice', + publicOutcomes: [...evaluation.publicOutcomes], + hiddenSummary: null, + scoreGiven: null, + scoreMaximum: null, + alreadyFinalized: false, + gradePublished: false, + attempt, + }; + } + + if ( + evaluation.hiddenSummary === null || evaluation.scoreGiven === null || + evaluation.scoreMaximum === null + ) { + throw new Error('Final Python evaluation did not produce a trusted score.'); + } + + const runId = `code-run-${input.createId()}`; + const sourceArtifactKey = buildPythonSubmissionArtifactKey({ + attemptId: attempt.attemptId, + runId, + }); + const occurredAt = now().toISOString(); + const reservation = await input.pythonLabRepository.reserveCodeSubmissionRun({ + runId, + attemptId: attempt.attemptId, + sourceArtifactKey, + phase: 'final', + publicOutcomes: evaluation.publicOutcomes, + hiddenSummary: evaluation.hiddenSummary, + scoreGiven: evaluation.scoreGiven, + scoreMaximum: evaluation.scoreMaximum, + createdAt: occurredAt, + sourcePersistedAt: null, + finalizedAt: occurredAt, + }); + + if (reservation.status === 'existing') { + const run = await requirePersistedFinalRun({ + run: reservation.run, + pythonLabRepository: input.pythonLabRepository, + pythonLabArtifactStore: input.pythonLabArtifactStore, + now, + }); + + return await finalizeTrustedRun({ + repository: input.repository, + session: input.session, + attempt, + packageVersion, + run, + ...(input.getLtiToolContext === undefined + ? {} + : { getLtiToolContext: input.getLtiToolContext }), + now, + }); + } + + let sourceWritten = false; + let run: CodeSubmissionRunRecord; + + try { + await input.pythonLabArtifactStore.writeSubmissionSource(sourceArtifactKey, request.source); + sourceWritten = true; + run = await input.pythonLabRepository.markCodeSubmissionSourcePersisted({ + runId, + sourcePersistedAt: occurredAt, + }); + } catch (error) { + if (!sourceWritten) { + await input.pythonLabRepository.deleteUnpersistedCodeSubmissionRun(runId); + } + throw error; + } + + return await finalizeTrustedRun({ + repository: input.repository, + session: input.session, + attempt, + packageVersion, + run, + ...(input.getLtiToolContext === undefined + ? {} + : { getLtiToolContext: input.getLtiToolContext }), + now, + }); +} + +async function requirePersistedFinalRun(input: { + readonly run: CodeSubmissionRunRecord; + readonly pythonLabRepository: PythonLabRepository; + readonly pythonLabArtifactStore: PythonLabArtifactStore; + readonly now: () => Date; +}): Promise { + if (input.run.sourcePersistedAt !== null) { + return input.run; + } + + if (!(await input.pythonLabArtifactStore.submissionSourceExists(input.run.sourceArtifactKey))) { + throw new Error('A final Python submission is still being recorded. Retry shortly.'); + } + + return await input.pythonLabRepository.markCodeSubmissionSourcePersisted({ + runId: input.run.runId, + sourcePersistedAt: input.now().toISOString(), + }); +} + +async function finalizeTrustedRun(input: { + readonly repository: PackageReviewRepository; + readonly session: RuntimeSessionRecord; + readonly attempt: AttemptRecord; + readonly packageVersion: PackageVersionRecord; + readonly run: CodeSubmissionRunRecord; + readonly getLtiToolContext?: () => Promise; + readonly now: () => Date; +}): Promise { + const finalizedNow = input.attempt.finalizedAt === null; + const finalizedAttempt = finalizedNow + ? await input.repository.finalizeAttempt({ + attemptId: input.attempt.attemptId, + status: 'completed', + completionState: 'completed', + finalizedAt: input.now().toISOString(), + }) + : input.attempt; + + if (finalizedNow) { + await input.repository.recordAuditEvent({ + eventType: 'python_submission.finalized', + actorType: 'system', + actorId: null, + packageVersionId: input.session.packageVersionId, + attemptId: finalizedAttempt.attemptId, + status: 'accepted', + summary: 'Finalized a Python submission from a stored server evaluation.', + detail: { + runId: input.run.runId, + scoreGiven: input.run.scoreGiven, + scoreMaximum: input.run.scoreMaximum, + hiddenPassed: input.run.hiddenSummary.passed, + hiddenTotal: input.run.hiddenSummary.total, + }, + occurredAt: input.now().toISOString(), + }); + } + + return { + accepted: true, + phase: 'final', + publicOutcomes: [...input.run.publicOutcomes], + hiddenSummary: input.run.hiddenSummary, + scoreGiven: input.run.scoreGiven, + scoreMaximum: input.run.scoreMaximum, + alreadyFinalized: !finalizedNow, + gradePublished: false, + attempt: finalizedAttempt, + }; +} + +function parseCodeSubmissionRequest( + input: unknown, +): { phase: 'practice' | 'final'; source: string } { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + denyRuntimeBroker({ + category: 'specInvalid', + code: 'invalid_code_submission_request', + message: 'Code submission must be an object.', + capability: 'run_code_submission', + detail: {}, + }); + } + + const record = input as Record; + + if (record.phase !== 'practice' && record.phase !== 'final') { + denyRuntimeBroker({ + category: 'specInvalid', + code: 'invalid_code_submission_phase', + message: 'Code submission phase must be practice or final.', + capability: 'run_code_submission', + detail: {}, + }); + } + + if ( + typeof record.source !== 'string' || record.source.trim() === '' || + record.source.length > 32_000 + ) { + denyRuntimeBroker({ + category: 'specInvalid', + code: 'invalid_code_submission_source', + message: 'Python source is required and must contain at most 32000 characters.', + capability: 'run_code_submission', + detail: {}, + }); + } + + return { phase: record.phase, source: record.source }; +} + +function requireSandboxAssignment(packageVersion: PackageVersionRecord): void { + if (packageVersion.grading.mode !== 'sandbox') { + denyRuntimeBroker({ + category: 'policyDenied', + code: 'code_submission_not_configured', + message: 'This reviewed package does not use Lantern sandbox grading.', + capability: 'run_code_submission', + detail: {}, + }); + } +} + +export function codeSubmissionFailureMessage(error: unknown): string { + return error instanceof Error ? error.message : errorMessage(error); +} diff --git a/src/python_lab/runtime_submission_test.ts b/src/python_lab/runtime_submission_test.ts new file mode 100644 index 0000000..e619ee9 --- /dev/null +++ b/src/python_lab/runtime_submission_test.ts @@ -0,0 +1,380 @@ +import { assertEquals, assertRejects } from '@std/assert'; +import { + buildAttemptRecord, + buildPackageVersionRecord, + createInMemoryPackageReviewRepository, +} from '../test_helpers/package_review.ts'; +import { buildRuntimeSessionRecord } from '../test_helpers/lti.ts'; +import type { PythonLabArtifactStore, PythonLabPrivateArtifact } from './artifact_store.ts'; +import type { PythonLabRepository } from './repository.ts'; +import { createInMemoryPythonLabRepository } from './repository_memory.ts'; +import type { PythonGraderSandboxFactory } from './runner.ts'; +import { runRuntimeCodeSubmission } from './runtime_submission.ts'; + +Deno.test('Python runtime submissions keep practice unfinalized and make final runs idempotent', async () => { + const repository = createRepository(); + const pythonLabRepository = createInMemoryPythonLabRepository({ + definitions: [{ + packageVersionId: 1, + functionName: 'add', + privateArtifactKey: 'var/private-python-lab/lab-123/assignment.json', + createdAt: '2026-07-13T00:00:00Z', + }], + }); + const artifacts = createArtifactStore(privateArtifact()); + const session = runtimeSession(); + + const practice = await runRuntimeCodeSubmission({ + repository, + pythonLabRepository, + pythonLabArtifactStore: artifacts, + sandboxFactory: functionSandboxFactory(), + session, + payload: { phase: 'practice', source: 'def add(left, right):\n return left + right\n' }, + createId: createIds(), + }); + + assertEquals(practice.phase, 'practice'); + assertEquals(practice.scoreGiven, null); + assertEquals((await repository.getAttemptById('attempt-123'))?.finalizedAt, null); + + const firstFinal = await runRuntimeCodeSubmission({ + repository, + pythonLabRepository, + pythonLabArtifactStore: artifacts, + sandboxFactory: functionSandboxFactory(), + session, + payload: { phase: 'final', source: 'def add(left, right):\n return left + right\n' }, + createId: createIds(), + }); + const repeatedFinal = await runRuntimeCodeSubmission({ + repository, + pythonLabRepository, + pythonLabArtifactStore: artifacts, + sandboxFactory: functionSandboxFactory(), + session, + payload: { phase: 'final', source: 'def add(left, right):\n return 0\n' }, + createId: createIds(), + }); + + assertEquals(firstFinal.scoreGiven, 10); + assertEquals(firstFinal.scoreMaximum, 10); + assertEquals(firstFinal.gradePublished, false); + assertEquals(repeatedFinal.alreadyFinalized, true); + assertEquals(repeatedFinal.scoreGiven, 10); + assertEquals( + (await pythonLabRepository.getFinalCodeSubmissionRunByAttemptId('attempt-123'))?.scoreGiven, + 10, + ); + assertEquals((await repository.getAttemptById('attempt-123'))?.status, 'completed'); +}); + +Deno.test('Python runtime submission never finalizes or publishes a grade when the runner fails', async () => { + const repository = createRepository(); + const pythonLabRepository = createInMemoryPythonLabRepository({ + definitions: [{ + packageVersionId: 1, + functionName: 'add', + privateArtifactKey: 'var/private-python-lab/lab-123/assignment.json', + createdAt: '2026-07-13T00:00:00Z', + }], + }); + + await assertRejects( + () => + runRuntimeCodeSubmission({ + repository, + pythonLabRepository, + pythonLabArtifactStore: createArtifactStore(privateArtifact()), + sandboxFactory: { + create() { + return Promise.reject(new Error('sandbox unavailable')); + }, + }, + session: runtimeSession(), + payload: { phase: 'final', source: 'def add(left, right):\n return left + right\n' }, + createId: createIds(), + }), + Error, + 'Python execution infrastructure did not finish', + ); + + assertEquals((await repository.getAttemptById('attempt-123'))?.finalizedAt, null); + assertEquals(await pythonLabRepository.getFinalCodeSubmissionRunByAttemptId('attempt-123'), null); +}); + +Deno.test('concurrent final submissions reserve one source artifact and recover after the winner finishes', async () => { + const repository = createRepository(); + const pythonLabRepository = createInMemoryPythonLabRepository({ definitions: [definition()] }); + const blockingArtifacts = createBlockingArtifactStore(privateArtifact()); + const first = runRuntimeCodeSubmission({ + repository, + pythonLabRepository, + pythonLabArtifactStore: blockingArtifacts.store, + sandboxFactory: functionSandboxFactory(), + session: runtimeSession(), + payload: { phase: 'final', source: 'def add(left, right):\n return left + right\n' }, + createId: createIds(), + }); + + await blockingArtifacts.sourceWriteStarted; + + await assertRejects( + () => + runRuntimeCodeSubmission({ + repository, + pythonLabRepository, + pythonLabArtifactStore: blockingArtifacts.store, + sandboxFactory: functionSandboxFactory(), + session: runtimeSession(), + payload: { phase: 'final', source: 'def add(left, right):\n return 0\n' }, + createId: createIds(), + }), + Error, + 'still being recorded', + ); + + blockingArtifacts.releaseSourceWrite(); + const winner = await first; + const retry = await runRuntimeCodeSubmission({ + repository, + pythonLabRepository, + pythonLabArtifactStore: blockingArtifacts.store, + sandboxFactory: functionSandboxFactory(), + session: runtimeSession(), + payload: { phase: 'final', source: 'def add(left, right):\n return 0\n' }, + createId: createIds(), + }); + + assertEquals(winner.scoreGiven, 10); + assertEquals(retry.alreadyFinalized, true); + assertEquals(blockingArtifacts.sourceKeys.size, 1); + assertEquals( + (await pythonLabRepository.getFinalCodeSubmissionRunByAttemptId('attempt-123')) + ?.sourcePersistedAt === null, + false, + ); +}); + +Deno.test('a final run whose source was stored before its database update is recovered on retry', async () => { + const repository = createRepository(); + const baseRepository = createInMemoryPythonLabRepository({ definitions: [definition()] }); + let failPersistence = true; + const pythonLabRepository: PythonLabRepository = { + ...baseRepository, + markCodeSubmissionSourcePersisted(input) { + if (failPersistence) { + failPersistence = false; + return Promise.reject(new Error('D1 update interrupted')); + } + return baseRepository.markCodeSubmissionSourcePersisted(input); + }, + }; + const artifacts = createArtifactStore(privateArtifact()); + + await assertRejects( + () => + runRuntimeCodeSubmission({ + repository, + pythonLabRepository, + pythonLabArtifactStore: artifacts, + sandboxFactory: functionSandboxFactory(), + session: runtimeSession(), + payload: { phase: 'final', source: 'def add(left, right):\n return left + right\n' }, + createId: createIds(), + }), + Error, + 'D1 update interrupted', + ); + + const recovered = await runRuntimeCodeSubmission({ + repository, + pythonLabRepository, + pythonLabArtifactStore: artifacts, + sandboxFactory: functionSandboxFactory(), + session: runtimeSession(), + payload: { phase: 'final', source: 'def add(left, right):\n return 0\n' }, + createId: createIds(), + }); + + assertEquals(recovered.scoreGiven, 10); + assertEquals(recovered.alreadyFinalized, false); + assertEquals((await repository.getAttemptById('attempt-123'))?.status, 'completed'); +}); + +function createRepository() { + return createInMemoryPackageReviewRepository({ + packageVersions: [ + buildPackageVersionRecord({ + appId: 'python-lab-lab-123', + version: '1.0.0', + capabilities: [ + 'read_launch_context', + 'read_activity_content', + 'run_code_submission', + ], + grading: { mode: 'sandbox', rubricFile: null, maxScore: 10 }, + catalogVisibility: 'python_lab_trial', + }), + ], + attempts: [ + buildAttemptRecord({ + appId: 'python-lab-lab-123', + packageVersion: '1.0.0', + }), + ], + }); +} + +function definition() { + return { + packageVersionId: 1, + functionName: 'add', + privateArtifactKey: 'var/private-python-lab/lab-123/assignment.json', + createdAt: '2026-07-13T00:00:00Z', + }; +} + +function runtimeSession() { + return buildRuntimeSessionRecord({ + appId: 'python-lab-lab-123', + packageVersion: '1.0.0', + capabilities: [ + 'read_launch_context', + 'read_activity_content', + 'run_code_submission', + ], + preview: { previewSessionId: 'preview-session-123' }, + }); +} + +function privateArtifact(): PythonLabPrivateArtifact { + return { + kind: 'assignment', + functionName: 'add', + referenceCode: 'def add(left, right):\n return left + right\n', + cases: [ + { label: 'one plus two', visibility: 'public', args: [1, 2], expected: 3 }, + { label: 'zero plus four', visibility: 'public', args: [0, 4], expected: 4 }, + { label: 'negative plus five', visibility: 'public', args: [-1, 5], expected: 4 }, + { label: 'large', visibility: 'hidden', args: [500, 700], expected: 1200 }, + { label: 'negative', visibility: 'hidden', args: [-2, -3], expected: -5 }, + { label: 'mixed', visibility: 'hidden', args: [-3, 9], expected: 6 }, + { label: 'identity', visibility: 'hidden', args: [0, 0], expected: 0 }, + { label: 'boundary', visibility: 'hidden', args: [999, 1], expected: 1000 }, + ], + }; +} + +function createArtifactStore(artifact: PythonLabPrivateArtifact): PythonLabArtifactStore { + const submittedSources = new Map(); + + return { + writePendingDraft() { + return Promise.resolve(); + }, + readPendingDraft() { + return Promise.reject(new Error('not used by runtime submissions')); + }, + writePrivateAssignment() { + return Promise.resolve(); + }, + readPrivateAssignment() { + return Promise.resolve(structuredClone(artifact)); + }, + writeSubmissionSource(key, source) { + submittedSources.set(key, source); + return Promise.resolve(); + }, + submissionSourceExists(key) { + return Promise.resolve(submittedSources.has(key)); + }, + delete(key) { + submittedSources.delete(key); + return Promise.resolve(); + }, + }; +} + +function createBlockingArtifactStore(artifact: PythonLabPrivateArtifact): { + readonly store: PythonLabArtifactStore; + readonly sourceWriteStarted: Promise; + readonly releaseSourceWrite: () => void; + readonly sourceKeys: ReadonlySet; +} { + const sourceKeys = new Set(); + let notifySourceWriteStarted: () => void = () => undefined; + const sourceWriteStarted = new Promise((resolve) => { + notifySourceWriteStarted = resolve; + }); + let releaseSourceWrite: () => void = () => undefined; + const sourceWriteReleased = new Promise((resolve) => { + releaseSourceWrite = resolve; + }); + + return { + store: { + writePendingDraft() { + return Promise.resolve(); + }, + readPendingDraft() { + return Promise.reject(new Error('not used by runtime submissions')); + }, + writePrivateAssignment() { + return Promise.resolve(); + }, + readPrivateAssignment() { + return Promise.resolve(structuredClone(artifact)); + }, + async writeSubmissionSource(key) { + notifySourceWriteStarted(); + await sourceWriteReleased; + sourceKeys.add(key); + }, + submissionSourceExists(key) { + return Promise.resolve(sourceKeys.has(key)); + }, + delete(key) { + sourceKeys.delete(key); + return Promise.resolve(); + }, + }, + sourceWriteStarted, + releaseSourceWrite, + sourceKeys, + }; +} + +function functionSandboxFactory(): PythonGraderSandboxFactory { + return { + create() { + let source = ''; + let request = ''; + + return Promise.resolve({ + writeFile(path, contents) { + if (path === '/workspace/solution.py') source = contents; + if (path === '/workspace/request.json') request = contents; + return Promise.resolve(); + }, + exec() { + const args = (JSON.parse(request) as { args: number[] }).args; + const value = source.includes('return 0') ? 0 : args[0]! + args[1]!; + return Promise.resolve({ + exitCode: 0, + stdout: JSON.stringify({ ok: true, value }), + stderr: '', + }); + }, + destroy() { + return Promise.resolve(); + }, + }); + }, + }; +} + +function createIds(): () => string { + let value = 0; + return () => `test-${++value}`; +} diff --git a/src/python_lab/service.ts b/src/python_lab/service.ts new file mode 100644 index 0000000..dac6af1 --- /dev/null +++ b/src/python_lab/service.ts @@ -0,0 +1,270 @@ +import { + buildPublicPythonAssignmentContent, + type EvaluatedCodeTestCase, + type GeneratedCodeTestInput, + type JsonValue, + parseGeneratedCodeTestInputs, + type PublicPythonAssignmentContent, + PYTHON_ASSIGNMENT_MAX_SCORE, + type PythonAssignmentDraft, +} from './contract.ts'; +import { + executePythonFunction, + type PythonExecutionOutcome, + type PythonGraderSandboxFactory, +} from './runner.ts'; + +export type CodeRunPhase = 'practice' | 'final' | 'candidate'; +export type CodeTestStatus = 'passed' | 'failed'; +export const PYTHON_SANDBOX_CONCURRENCY = 3; + +export interface CodeTestOutcome { + readonly label: string; + readonly status: CodeTestStatus; + readonly message: string | null; +} + +export interface CodeSubmissionEvaluation { + readonly phase: CodeRunPhase; + readonly publicOutcomes: readonly CodeTestOutcome[]; + readonly hiddenSummary: { readonly passed: number; readonly total: number } | null; + readonly scoreGiven: number | null; + readonly scoreMaximum: number | null; + readonly runnerFailed: boolean; +} + +export interface EvaluatedPythonAssignmentDefinition { + readonly publicContent: PublicPythonAssignmentContent; + readonly privateCases: readonly EvaluatedCodeTestCase[]; +} + +export interface PythonTestVectorGenerator { + generate(input: { + readonly title: string; + readonly prompt: string; + readonly functionName: string; + }): Promise; +} + +export class PythonAssignmentValidationError extends Error { + readonly _tag = 'PythonAssignmentValidationError' as const; + + constructor(message: string) { + super(message); + } +} + +export async function generateAndValidatePythonAssignment(input: { + readonly draft: PythonAssignmentDraft; + readonly generator: PythonTestVectorGenerator; + readonly sandboxFactory: PythonGraderSandboxFactory; + readonly createOneTimeId: () => string; +}): Promise { + const generated = parseGeneratedCodeTestInputs( + await input.generator.generate({ + title: input.draft.title, + prompt: input.draft.prompt, + functionName: input.draft.contract.functionName, + }), + ); + const privateCases = await deriveExpectedCases({ + source: input.draft.referenceCode, + contract: input.draft.contract, + cases: generated, + sandboxFactory: input.sandboxFactory, + createOneTimeId: input.createOneTimeId, + }); + const starterEvaluation = await evaluatePythonSubmission({ + source: input.draft.starterCode, + contract: input.draft.contract, + cases: privateCases, + phase: 'practice', + sandboxFactory: input.sandboxFactory, + createOneTimeId: input.createOneTimeId, + }); + + if (starterEvaluation.runnerFailed) { + throw new PythonAssignmentValidationError( + 'Starter code could not be validated because the Python runner did not complete.', + ); + } + + if (starterEvaluation.publicOutcomes.every((outcome) => outcome.status === 'passed')) { + throw new PythonAssignmentValidationError( + 'Starter code must fail at least one public test before the Lab can be created.', + ); + } + + return { + publicContent: buildPublicPythonAssignmentContent({ draft: input.draft, cases: privateCases }), + privateCases, + }; +} + +export async function deriveExpectedCases(input: { + readonly source: string; + readonly contract: PythonAssignmentDraft['contract']; + readonly cases: readonly GeneratedCodeTestInput[]; + readonly sandboxFactory: PythonGraderSandboxFactory; + readonly createOneTimeId: () => string; +}): Promise { + const executions = await mapWithConcurrency( + input.cases, + PYTHON_SANDBOX_CONCURRENCY, + async (testCase) => + await executePythonFunction({ + sandboxFactory: input.sandboxFactory, + oneTimeId: input.createOneTimeId(), + source: input.source, + contract: input.contract, + args: testCase.args, + }), + ); + + return input.cases.map((testCase, index) => { + const execution = executions[index]!; + if (execution.status === 'failed') { + throw new PythonAssignmentValidationError( + `Reference solution failed ${testCase.label}: ${execution.message}`, + ); + } + + return { ...testCase, expected: execution.value }; + }); +} + +export async function evaluatePythonSubmission(input: { + readonly source: string; + readonly contract: PythonAssignmentDraft['contract']; + readonly cases: readonly EvaluatedCodeTestCase[]; + readonly phase: CodeRunPhase; + readonly sandboxFactory: PythonGraderSandboxFactory; + readonly createOneTimeId: () => string; +}): Promise { + const cases = input.phase === 'practice' + ? input.cases.filter((testCase) => testCase.visibility === 'public') + : input.cases; + const executions = await mapWithConcurrency( + cases, + PYTHON_SANDBOX_CONCURRENCY, + async (testCase) => + await executePythonFunction({ + sandboxFactory: input.sandboxFactory, + oneTimeId: input.createOneTimeId(), + source: input.source, + contract: input.contract, + args: testCase.args, + }), + ); + const outcomes = cases.map((testCase, index) => { + const execution = executions[index]!; + return { + visibility: testCase.visibility, + ...toCodeTestOutcome(testCase, execution), + }; + }); + const runnerFailed = executions.some( + (execution) => execution.status === 'failed' && execution.kind === 'runner', + ); + + const publicOutcomes = outcomes.filter((outcome) => outcome.visibility === 'public').map( + ({ label, status, message }) => ({ label, status, message }), + ); + const hiddenOutcomes = outcomes.filter((outcome) => outcome.visibility === 'hidden'); + const passed = outcomes.filter((outcome) => outcome.status === 'passed').length; + const isScored = input.phase !== 'practice'; + + return { + phase: input.phase, + publicOutcomes, + hiddenSummary: input.phase === 'practice' ? null : { + passed: hiddenOutcomes.filter((outcome) => outcome.status === 'passed').length, + total: hiddenOutcomes.length, + }, + scoreGiven: isScored ? calculatePythonScore(passed, outcomes.length) : null, + scoreMaximum: isScored ? PYTHON_ASSIGNMENT_MAX_SCORE : null, + runnerFailed, + }; +} + +async function mapWithConcurrency( + values: readonly T[], + concurrency: number, + map: (value: T) => Promise, +): Promise { + const results = new Array(values.length); + let nextIndex = 0; + const workerCount = Math.min(concurrency, values.length); + + await Promise.all(Array.from({ length: workerCount }, async () => { + while (true) { + const index = nextIndex; + nextIndex += 1; + + if (index >= values.length) return; + results[index] = await map(values[index]!); + } + })); + + return results; +} + +export function calculatePythonScore(passed: number, total: number): number { + if ( + !Number.isInteger(passed) || !Number.isInteger(total) || total <= 0 || passed < 0 || + passed > total + ) { + throw new PythonAssignmentValidationError('Python test score inputs are invalid.'); + } + + return Math.round((passed / total) * PYTHON_ASSIGNMENT_MAX_SCORE * 100) / 100; +} + +function toCodeTestOutcome( + testCase: EvaluatedCodeTestCase, + execution: PythonExecutionOutcome, +): CodeTestOutcome { + if (execution.status === 'failed') { + return { label: testCase.label, status: 'failed', message: execution.message }; + } + + if (!jsonValuesEqual(execution.value, testCase.expected)) { + return { + label: testCase.label, + status: 'failed', + message: testCase.visibility === 'public' + ? 'Returned a different value than expected.' + : 'Hidden test failed.', + }; + } + + return { label: testCase.label, status: 'passed', message: null }; +} + +function jsonValuesEqual(left: JsonValue, right: JsonValue): boolean { + if (left === right) { + return true; + } + + if (typeof left !== typeof right || left === null || right === null) { + return false; + } + + if (Array.isArray(left) || Array.isArray(right)) { + return Array.isArray(left) && Array.isArray(right) && + left.length === right.length && left.every((value, index) => + jsonValuesEqual(value, right[index]!) + ); + } + + if (typeof left === 'object' && typeof right === 'object') { + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + + return leftKeys.length === rightKeys.length && leftKeys.every( + (key, index) => key === rightKeys[index] && jsonValuesEqual(left[key]!, right[key]!), + ); + } + + return false; +} diff --git a/src/python_lab/service_test.ts b/src/python_lab/service_test.ts new file mode 100644 index 0000000..ced1854 --- /dev/null +++ b/src/python_lab/service_test.ts @@ -0,0 +1,220 @@ +import { assertEquals, assertRejects } from '@std/assert'; +import { type JsonValue } from './contract.ts'; +import { + calculatePythonScore, + evaluatePythonSubmission, + generateAndValidatePythonAssignment, + PYTHON_SANDBOX_CONCURRENCY, + PythonAssignmentValidationError, +} from './service.ts'; +import type { PythonGraderSandbox, PythonGraderSandboxFactory } from './runner.ts'; + +Deno.test('generateAndValidatePythonAssignment derives expected values and requires diagnostic starter code', async () => { + const assignment = await generateAndValidatePythonAssignment({ + draft: draft(), + generator: { generate: () => Promise.resolve(generatedCases()) }, + sandboxFactory: functionSandboxFactory(), + createOneTimeId: createIds(), + }); + + assertEquals(assignment.publicContent.publicCases.length, 3); + assertEquals(assignment.privateCases.length, 8); + assertEquals(assignment.privateCases[0]?.expected, 3); +}); + +Deno.test('generateAndValidatePythonAssignment rejects starter code that passes all public cases', async () => { + await assertRejects( + () => + generateAndValidatePythonAssignment({ + draft: { ...draft(), starterCode: 'def add(left, right):\n return left + right\n' }, + generator: { generate: () => Promise.resolve(generatedCases()) }, + sandboxFactory: functionSandboxFactory(), + createOneTimeId: createIds(), + }), + PythonAssignmentValidationError, + 'Starter code must fail', + ); +}); + +Deno.test('generateAndValidatePythonAssignment rejects an unavailable runner instead of accepting the starter failure', async () => { + await assertRejects( + () => + generateAndValidatePythonAssignment({ + draft: draft(), + generator: { generate: () => Promise.resolve(generatedCases()) }, + sandboxFactory: { + create() { + return Promise.reject(new Error('sandbox unavailable')); + }, + }, + createOneTimeId: createIds(), + }), + PythonAssignmentValidationError, + 'Reference solution failed', + ); +}); + +Deno.test('evaluatePythonSubmission redacts hidden detail and calculates a ten point final score', async () => { + const assignment = await generateAndValidatePythonAssignment({ + draft: draft(), + generator: { generate: () => Promise.resolve(generatedCases()) }, + sandboxFactory: functionSandboxFactory(), + createOneTimeId: createIds(), + }); + const result = await evaluatePythonSubmission({ + source: 'def add(left, right):\n return left + right\n', + contract: draft().contract, + cases: assignment.privateCases, + phase: 'final', + sandboxFactory: functionSandboxFactory(), + createOneTimeId: createIds(), + }); + + assertEquals(result.publicOutcomes.length, 3); + assertEquals(result.hiddenSummary, { passed: 5, total: 5 }); + assertEquals(result.scoreGiven, 10); + assertEquals(result.scoreMaximum, 10); + assertEquals(calculatePythonScore(7, 8), 8.75); +}); + +Deno.test('evaluatePythonSubmission bounds concurrent Sandbox execution', async () => { + const assignment = await generateAndValidatePythonAssignment({ + draft: draft(), + generator: { generate: () => Promise.resolve(generatedCases()) }, + sandboxFactory: functionSandboxFactory(), + createOneTimeId: createIds(), + }); + const tracker = createConcurrencyTrackingSandboxFactory(); + const result = await evaluatePythonSubmission({ + source: 'def add(left, right):\n return left + right\n', + contract: draft().contract, + cases: assignment.privateCases, + phase: 'final', + sandboxFactory: tracker.factory, + createOneTimeId: createIds(), + }); + + assertEquals(result.runnerFailed, false); + assertEquals(tracker.maxActive, PYTHON_SANDBOX_CONCURRENCY); + assertEquals(tracker.active, 0); +}); + +function draft() { + return { + title: 'Add numbers', + prompt: 'Write a function that returns the sum of two integers.', + starterCode: 'def add(left, right):\n return 0\n', + referenceCode: 'def add(left, right):\n return left + right\n', + contract: { functionName: 'add', maxScore: 10 as const }, + }; +} + +function generatedCases() { + return { + cases: [ + { label: 'one plus two', visibility: 'public', args: [1, 2] }, + { label: 'zero plus four', visibility: 'public', args: [0, 4] }, + { label: 'negative plus five', visibility: 'public', args: [-1, 5] }, + { label: 'large numbers', visibility: 'hidden', args: [500, 700] }, + { label: 'negative numbers', visibility: 'hidden', args: [-2, -3] }, + { label: 'mixed signs', visibility: 'hidden', args: [-3, 9] }, + { label: 'identity', visibility: 'hidden', args: [0, 0] }, + { label: 'boundary', visibility: 'hidden', args: [999, 1] }, + ], + }; +} + +function functionSandboxFactory(): PythonGraderSandboxFactory { + return { + create() { + let source = ''; + let request = ''; + + return Promise.resolve( + { + writeFile(path, contents) { + if (path === '/workspace/solution.py') { + source = contents; + } + + if (path === '/workspace/request.json') { + request = contents; + } + + return Promise.resolve(); + }, + exec() { + const payload = JSON.parse(request) as { args: JsonValue[] }; + const left = payload.args[0]; + const right = payload.args[1]; + const value = source.includes('return 0') + ? 0 + : typeof left === 'number' && typeof right === 'number' + ? left + right + : null; + return Promise.resolve({ + exitCode: 0, + stdout: JSON.stringify({ ok: true, value }), + stderr: '', + }); + }, + destroy() { + return Promise.resolve(); + }, + } satisfies PythonGraderSandbox, + ); + }, + }; +} + +function createConcurrencyTrackingSandboxFactory(): { + readonly factory: PythonGraderSandboxFactory; + readonly maxActive: number; + readonly active: number; +} { + let active = 0; + let maxActive = 0; + + return { + get maxActive() { + return maxActive; + }, + get active() { + return active; + }, + factory: { + create() { + active += 1; + maxActive = Math.max(maxActive, active); + let request = ''; + + return Promise.resolve( + { + writeFile(path, contents) { + if (path === '/workspace/request.json') request = contents; + return Promise.resolve(); + }, + async exec() { + await new Promise((resolve) => setTimeout(resolve, 1)); + const args = (JSON.parse(request) as { args: number[] }).args; + return { + exitCode: 0, + stdout: JSON.stringify({ ok: true, value: args[0]! + args[1]! }), + stderr: '', + }; + }, + destroy() { + active -= 1; + return Promise.resolve(); + }, + } satisfies PythonGraderSandbox, + ); + }, + }, + }; +} + +function createIds(): () => string { + let next = 0; + return () => `test-run-${++next}`; +} diff --git a/src/python_lab/turnstile.ts b/src/python_lab/turnstile.ts new file mode 100644 index 0000000..e8cb277 --- /dev/null +++ b/src/python_lab/turnstile.ts @@ -0,0 +1,44 @@ +export interface PythonLabHumanVerifier { + verify(input: { readonly token: string; readonly remoteIp: string | null }): Promise; +} + +export function createTurnstilePythonLabHumanVerifier(secret: string): PythonLabHumanVerifier { + return { + async verify(input) { + if (input.token.trim() === '') { + throw new Error('Complete the verification check before creating a Python Lab.'); + } + + const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + secret, + response: input.token, + ...(input.remoteIp === null ? {} : { remoteip: input.remoteIp }), + }), + }); + + if (!response.ok) { + throw new Error('Verification service was unavailable.'); + } + + const body = await response.json(); + + if ( + body === null || typeof body !== 'object' || + (body as Record).success !== true + ) { + throw new Error('Verification was not accepted.'); + } + }, + }; +} + +export function createUnavailablePythonLabHumanVerifier(message: string): PythonLabHumanVerifier { + return { + verify() { + return Promise.reject(new Error(message)); + }, + }; +} diff --git a/src/python_lab/views.ts b/src/python_lab/views.ts new file mode 100644 index 0000000..dbfe88b --- /dev/null +++ b/src/python_lab/views.ts @@ -0,0 +1,97 @@ +import type { PythonLabRecord } from './repository.ts'; +import type { verifyPythonLabCandidate } from './lab_service.ts'; + +export const LAB_PATH = '/autograder'; + +export function labUrl(labId: string): string { + return `${LAB_PATH}/labs/${encodeURIComponent(labId)}`; +} + +export function renderLabCreateForm(turnstileSiteKey: string): string { + const widget = turnstileSiteKey === '' + ? `` + : `
    `; + return `
    +
    +
    +
    +
    +
    + ${widget} +
    `; +} + +export function renderLabStatus(lab: PythonLabRecord): string { + if (lab.state === 'generating') { + return `

    Your test inputs are being prepared. Refresh this page shortly.

    `; + } + if (lab.state === 'failed') { + return `

    ${ + escapeHtml(lab.failureMessage ?? 'Python Lab generation failed.') + }

    `; + } + if (lab.state !== 'ready') { + return `

    This Python Lab has expired.

    `; + } + return `

    Python function contract: ${ + escapeHtml(lab.authorDraft.functionName) + }(...). Public tests and hidden final checks are ready.

    +
    Verify candidate
    + ${renderCandidatePage(lab, lab.authorDraft.starterCode, null)} +

    Canvas access

    `; +} + +export function renderCandidatePage( + lab: PythonLabRecord, + source: string, + result: Awaited> | null, +): string { + const resultHtml = result === null + ? '' + : `
      ${ + result.publicOutcomes.map((outcome) => + `
    • ${escapeHtml(outcome.label)}: ${escapeHtml(outcome.status)}${ + outcome.message === null ? '' : ` (${escapeHtml(outcome.message)})` + }
    • ` + ).join('') + }

    Hidden tests: ${result.hiddenSummary?.passed ?? 0} / ${ + result.hiddenSummary?.total ?? 0 + }. Score: ${result.scoreGiven ?? 0} / ${result.scoreMaximum ?? 10}.

    `; + return `

    Verify a candidate submission

    ${resultHtml}
    `; +} + +export function renderPythonLabPage(input: { + readonly title: string; + readonly heading: string; + readonly body: string; + readonly notice?: string; +}): string { + return `${ + escapeHtml(input.title) + }

    Lantern Lab

    ${ + escapeHtml(input.heading) + }

    ${ + input.notice ? `

    ${escapeHtml(input.notice)}

    ` : '' + }${input.body}
    `; +} + +export function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/src/python_lab/workflow.ts b/src/python_lab/workflow.ts new file mode 100644 index 0000000..702f9d4 --- /dev/null +++ b/src/python_lab/workflow.ts @@ -0,0 +1,46 @@ +export interface PythonLabGenerationWorkflowBinding { + create(input: { + readonly id: string; + readonly params: PythonLabGenerationWorkflowParams; + }): Promise<{ readonly id?: string }>; +} + +export interface PythonLabGenerationWorkflowParams { + readonly labId: string; +} + +export interface PythonLabGenerationScheduler { + schedule( + input: PythonLabGenerationWorkflowParams, + ): Promise<{ readonly workflowInstanceId: string | null }>; +} + +export function createCloudflarePythonLabGenerationScheduler( + workflow: PythonLabGenerationWorkflowBinding, +): PythonLabGenerationScheduler { + return { + async schedule(input) { + const instance = await workflow.create({ id: input.labId, params: input }); + return { workflowInstanceId: typeof instance.id === 'string' ? instance.id : null }; + }, + }; +} + +export function createUnavailablePythonLabGenerationScheduler( + message: string, +): PythonLabGenerationScheduler { + return { + schedule() { + return Promise.reject(new Error(message)); + }, + }; +} + +export function isPythonLabGenerationWorkflowBinding( + value: unknown, +): value is PythonLabGenerationWorkflowBinding { + return ( + typeof value === 'object' && value !== null && + typeof (value as Partial).create === 'function' + ); +} diff --git a/src/python_lab/workflow_runner.ts b/src/python_lab/workflow_runner.ts new file mode 100644 index 0000000..25d23c8 --- /dev/null +++ b/src/python_lab/workflow_runner.ts @@ -0,0 +1,39 @@ +import { + type PythonGraderSandboxFactoryProvider, + resolveWorkerServices, + type WorkerBindings, +} from '../app_worker_services.ts'; +import { createObjectEnvReader } from '../platform/env.ts'; +import { generatePythonLab } from './lab_service.ts'; +import type { PythonLabGenerationWorkflowParams } from './workflow.ts'; + +export async function runPythonLabGenerationWorkflow(input: { + readonly bindings: WorkerBindings; + readonly params: PythonLabGenerationWorkflowParams; + readonly createPythonSandboxFactory?: PythonGraderSandboxFactoryProvider; +}): Promise<{ readonly labId: string; readonly state: string }> { + const services = resolveWorkerServices( + input.bindings, + createObjectEnvReader(input.bindings), + undefined, + input.createPythonSandboxFactory, + ); + const lab = await services.pythonLabRepository.getLabById(input.params.labId); + + if (lab === null) { + throw new Error('Python Lab generation target was not found.'); + } + + const completed = await generatePythonLab({ + lab, + repository: services.getRepository(), + pythonLabRepository: services.pythonLabRepository, + pythonLabArtifactStore: services.pythonLabArtifactStore, + sandboxFactory: services.pythonGraderSandboxFactory, + testVectorGenerator: services.pythonTestVectorGenerator, + importPackageFromSource: services.importPackageFromSource, + createId: () => crypto.randomUUID().replaceAll('-', ''), + }); + + return { labId: completed.labId, state: completed.state }; +} diff --git a/src/reports/instructor_report.ts b/src/reports/instructor_report.ts deleted file mode 100644 index f689c16..0000000 --- a/src/reports/instructor_report.ts +++ /dev/null @@ -1,390 +0,0 @@ -import type { - AttemptEventRecord, - AttemptRecord, - GradePublicationRecord, -} from '../package_review/types.ts'; -import type { PackageReviewRepository } from '../package_review/repository.ts'; - -export interface InstructorReport { - appId: string; - generatedAt: string; - summary: InstructorReportSummary; - dailyActivity: InstructorReportDailyActivity[]; - eventMix: InstructorReportEventMix[]; - scoreDistribution: InstructorReportScoreBucket[]; - itemActivity: InstructorReportItemActivity[]; - students: InstructorReportStudent[]; - followUpStudents: InstructorReportStudent[]; - recentAttempts: InstructorReportAttempt[]; -} - -export interface InstructorReportSummary { - learnerCount: number; - attemptCount: number; - completedAttemptCount: number; - inProgressAttemptCount: number; - answerEventCount: number; - progressEventCount: number; - completeEventCount: number; - totalEventCount: number; - scoredAttemptCount: number; - completionRatePercent: number; - averageScorePercent: number | null; - latestActivityAt: string | null; -} - -export interface InstructorReportDailyActivity { - day: string; - attemptCount: number; - completedAttemptCount: number; -} - -export interface InstructorReportEventMix { - label: string; - count: number; -} - -export interface InstructorReportScoreBucket { - label: string; - count: number; -} - -export interface InstructorReportItemActivity { - questionId: string; - answerEventCount: number; - latestAnsweredAt: string; -} - -export type InstructorReportStudentSignal = 'on_track' | 'in_progress' | 'needs_follow_up'; - -export interface InstructorReportStudent { - userId: string; - displayName: string; - email: string | null; - login: string | null; - attemptCount: number; - completedAttemptCount: number; - answerEventCount: number; - progressEventCount: number; - completeEventCount: number; - totalEventCount: number; - completionRatePercent: number; - latestActivityAt: string | null; - latestScorePercent: number | null; - latestAttemptStatus: AttemptRecord['status']; - signal: InstructorReportStudentSignal; -} - -export interface InstructorReportAttempt { - attempt: AttemptRecord; - eventCount: number; - answerEventCount: number; - progressEventCount: number; - completeEventCount: number; - answeredQuestionIds: string[]; - latestEventAt: string | null; - gradePublication: GradePublicationRecord | null; - scorePercent: number | null; -} - -export async function buildInstructorReport(input: { - repository: PackageReviewRepository; - appId: string; - generatedAt: string; -}): Promise { - const attempts = (await input.repository.listAttemptsByApp(input.appId)).filter( - (attempt) => attempt.userRole === 'learner', - ); - const reportAttempts = await Promise.all( - attempts.map(async (attempt) => { - const [events, gradePublication] = await Promise.all([ - input.repository.listAttemptEvents(attempt.attemptId), - input.repository.getGradePublicationByAttemptId(attempt.attemptId), - ]); - - return summarizeAttempt(attempt, events, gradePublication); - }), - ); - - const students = summarizeStudents(reportAttempts); - - return { - appId: input.appId, - generatedAt: input.generatedAt, - summary: summarizeReport(reportAttempts), - dailyActivity: summarizeDailyActivity(reportAttempts), - eventMix: summarizeEventMix(reportAttempts), - scoreDistribution: summarizeScoreDistribution(reportAttempts), - itemActivity: summarizeItemActivity(reportAttempts), - students, - followUpStudents: students - .filter((student) => student.signal === 'needs_follow_up') - .slice(0, 6), - recentAttempts: reportAttempts.slice(0, 12), - }; -} - -function summarizeAttempt( - attempt: AttemptRecord, - events: AttemptEventRecord[], - gradePublication: GradePublicationRecord | null, -): InstructorReportAttempt { - const answerEventCount = events.filter((event) => event.learningVerb === 'answered').length; - const progressEventCount = events.filter((event) => event.learningVerb === 'progressed').length; - const completeEventCount = events.filter((event) => event.learningVerb === 'completed').length; - const answeredQuestionIds = events.flatMap((event) => - event.learningVerb === 'answered' && event.objectType === 'question' ? [event.objectId] : [] - ); - const latestEventAt = events.length === 0 ? null : events.reduce( - (latest, event) => event.receivedAt.localeCompare(latest) > 0 ? event.receivedAt : latest, - events[0]!.receivedAt, - ); - - return { - attempt, - eventCount: events.length, - answerEventCount, - progressEventCount, - completeEventCount, - answeredQuestionIds, - latestEventAt, - gradePublication, - scorePercent: scorePercent(gradePublication), - }; -} - -function summarizeReport(attempts: InstructorReportAttempt[]): InstructorReportSummary { - const scores = attempts - .map((attempt) => attempt.scorePercent) - .filter((score): score is number => score !== null); - - return { - learnerCount: new Set(attempts.map((attempt) => attempt.attempt.userId)).size, - attemptCount: attempts.length, - completedAttemptCount: attempts.filter((attempt) => attempt.attempt.status === 'completed') - .length, - inProgressAttemptCount: attempts.filter((attempt) => attempt.attempt.status === 'in_progress') - .length, - answerEventCount: sum(attempts.map((attempt) => attempt.answerEventCount)), - progressEventCount: sum(attempts.map((attempt) => attempt.progressEventCount)), - completeEventCount: sum(attempts.map((attempt) => attempt.completeEventCount)), - totalEventCount: sum(attempts.map((attempt) => attempt.eventCount)), - scoredAttemptCount: scores.length, - completionRatePercent: attempts.length === 0 - ? 0 - : (attempts.filter((attempt) => attempt.attempt.status === 'completed').length / - attempts.length) * - 100, - averageScorePercent: scores.length === 0 - ? null - : scores.reduce((total, score) => total + score, 0) / scores.length, - latestActivityAt: maxTimestamp( - attempts.flatMap((attempt) => [ - attempt.attempt.startedAt, - attempt.attempt.finalizedAt, - attempt.latestEventAt, - ]), - ), - }; -} - -function summarizeDailyActivity( - attempts: InstructorReportAttempt[], -): InstructorReportDailyActivity[] { - const byDay = new Map(); - - for (const attempt of attempts) { - const day = attempt.attempt.startedAt.slice(0, 10); - const existing = byDay.get(day) ?? { - day, - attemptCount: 0, - completedAttemptCount: 0, - }; - - byDay.set(day, { - ...existing, - attemptCount: existing.attemptCount + 1, - completedAttemptCount: existing.completedAttemptCount + - (attempt.attempt.status === 'completed' ? 1 : 0), - }); - } - - return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day)).slice(-14); -} - -function summarizeEventMix(attempts: InstructorReportAttempt[]): InstructorReportEventMix[] { - return [ - { - label: 'Answers', - count: sum(attempts.map((attempt) => attempt.answerEventCount)), - }, - { - label: 'Progress', - count: sum(attempts.map((attempt) => attempt.progressEventCount)), - }, - { - label: 'Completion', - count: sum(attempts.map((attempt) => attempt.completeEventCount)), - }, - ]; -} - -function summarizeScoreDistribution( - attempts: InstructorReportAttempt[], -): InstructorReportScoreBucket[] { - const buckets: InstructorReportScoreBucket[] = [ - { label: '80-100%', count: 0 }, - { label: '60-79%', count: 0 }, - { label: '0-59%', count: 0 }, - { label: 'Unscored', count: 0 }, - ]; - - for (const attempt of attempts) { - if (attempt.scorePercent === null) { - buckets[3]!.count += 1; - } else if (attempt.scorePercent >= 80) { - buckets[0]!.count += 1; - } else if (attempt.scorePercent >= 60) { - buckets[1]!.count += 1; - } else { - buckets[2]!.count += 1; - } - } - - return buckets; -} - -function summarizeItemActivity( - attempts: InstructorReportAttempt[], -): InstructorReportItemActivity[] { - const byQuestion = new Map(); - - for (const attempt of attempts) { - for (const questionId of attempt.answeredQuestionIds) { - const existing = byQuestion.get(questionId); - byQuestion.set(questionId, { - questionId, - answerEventCount: (existing?.answerEventCount ?? 0) + 1, - latestAnsweredAt: - maxTimestamp([existing?.latestAnsweredAt ?? null, attempt.latestEventAt]) ?? - attempt.attempt.startedAt, - }); - } - } - - return [...byQuestion.values()] - .sort( - (left, right) => - right.answerEventCount - left.answerEventCount || - right.latestAnsweredAt.localeCompare(left.latestAnsweredAt) || - left.questionId.localeCompare(right.questionId), - ) - .slice(0, 8); -} - -function summarizeStudents(attempts: InstructorReportAttempt[]): InstructorReportStudent[] { - const byUser = new Map(); - - for (const attempt of attempts) { - const current = byUser.get(attempt.attempt.userId) ?? []; - current.push(attempt); - byUser.set(attempt.attempt.userId, current); - } - - return [...byUser.entries()] - .map(([userId, userAttempts]) => summarizeStudent(userId, userAttempts)) - .sort( - (left, right) => - (right.latestActivityAt ?? '').localeCompare(left.latestActivityAt ?? '') || - left.displayName.localeCompare(right.displayName), - ); -} - -function summarizeStudent( - userId: string, - attempts: InstructorReportAttempt[], -): InstructorReportStudent { - const sortedAttempts = attempts.toSorted((left, right) => - right.attempt.startedAt.localeCompare(left.attempt.startedAt) - ); - const latestAttempt = sortedAttempts[0]!; - const latestScoredAttempt = sortedAttempts.find((attempt) => attempt.scorePercent !== null) ?? - null; - const completedAttemptCount = attempts.filter( - (attempt) => attempt.attempt.status === 'completed', - ).length; - - return { - userId, - displayName: latestAttempt.attempt.userDisplayName ?? - latestAttempt.attempt.userLogin ?? - latestAttempt.attempt.userEmail ?? - userId, - email: latestAttempt.attempt.userEmail, - login: latestAttempt.attempt.userLogin, - attemptCount: attempts.length, - completedAttemptCount, - answerEventCount: sum(attempts.map((attempt) => attempt.answerEventCount)), - progressEventCount: sum(attempts.map((attempt) => attempt.progressEventCount)), - completeEventCount: sum(attempts.map((attempt) => attempt.completeEventCount)), - totalEventCount: sum(attempts.map((attempt) => attempt.eventCount)), - completionRatePercent: attempts.length === 0 - ? 0 - : (completedAttemptCount / attempts.length) * 100, - latestActivityAt: maxTimestamp( - attempts.flatMap((attempt) => [ - attempt.attempt.startedAt, - attempt.attempt.finalizedAt, - attempt.latestEventAt, - ]), - ), - latestScorePercent: latestScoredAttempt?.scorePercent ?? null, - latestAttemptStatus: latestAttempt.attempt.status, - signal: classifyStudentSignal({ - completedAttemptCount, - latestScorePercent: latestScoredAttempt?.scorePercent ?? null, - latestAttemptStatus: latestAttempt.attempt.status, - }), - }; -} - -function classifyStudentSignal(input: { - completedAttemptCount: number; - latestScorePercent: number | null; - latestAttemptStatus: AttemptRecord['status']; -}): InstructorReportStudentSignal { - if ( - (input.latestScorePercent !== null && input.latestScorePercent < 70) || - (input.completedAttemptCount === 0 && input.latestAttemptStatus !== 'completed') - ) { - return 'needs_follow_up'; - } - - if (input.latestAttemptStatus === 'in_progress') { - return 'in_progress'; - } - - return 'on_track'; -} - -function scorePercent(gradePublication: GradePublicationRecord | null): number | null { - if (gradePublication === null || gradePublication.scoreMaximum <= 0) { - return null; - } - - return (gradePublication.scoreGiven / gradePublication.scoreMaximum) * 100; -} - -function sum(values: number[]): number { - return values.reduce((total, value) => total + value, 0); -} - -function maxTimestamp(values: Array): string | null { - const timestamps = values.filter((value): value is string => value !== null); - - if (timestamps.length === 0) { - return null; - } - - return timestamps.reduce((latest, value) => (value.localeCompare(latest) > 0 ? value : latest)); -} diff --git a/src/runtime/artifact_store.ts b/src/runtime/artifact_store.ts index 0ddd2ac..7fae878 100644 --- a/src/runtime/artifact_store.ts +++ b/src/runtime/artifact_store.ts @@ -21,6 +21,7 @@ export interface RuntimeArtifactBucketListResult { export interface RuntimeArtifactBucket { get(key: string): Promise; put?(key: string, value: string | ArrayBuffer | ArrayBufferView): Promise; + delete?(key: string): Promise; list?(options: { prefix: string; cursor?: string }): Promise; } diff --git a/src/runtime/delivery.ts b/src/runtime/delivery.ts index e5eb60b..2ecbba9 100644 --- a/src/runtime/delivery.ts +++ b/src/runtime/delivery.ts @@ -32,10 +32,17 @@ export interface RuntimeDeliveryDescriptor { } export interface ReviewedRuntimeDeliveryContext { - session: RuntimeSessionRecord; + session: Pick; reviewedPackage: Pick< PackageVersionRecord, - 'id' | 'version' | 'artifact' | 'runtimeContractSignature' | 'grading' | 'manifestJson' + | 'id' + | 'appId' + | 'version' + | 'runtime' + | 'artifact' + | 'runtimeContractSignature' + | 'grading' + | 'manifestJson' >; } @@ -52,6 +59,11 @@ export interface RuntimeDelivery { assetPath: string; }, ): Promise; + invokeReviewedWorker( + input: ReviewedRuntimeDeliveryContext & { + request: Request; + }, + ): Promise; } export function createDirectRuntimeDelivery(artifactStore: RuntimeArtifactStore): RuntimeDelivery { @@ -128,6 +140,11 @@ export function createDirectRuntimeDelivery(artifactStore: RuntimeArtifactStore) contentType: contentTypeForRuntimePath(relativePath), }; }, + invokeReviewedWorker(): Promise { + return Promise.reject( + new Error('Reviewed Worker apps require Cloudflare Dynamic Worker delivery.'), + ); + }, }; } @@ -146,6 +163,9 @@ export function createUnsupportedRuntimeDelivery(message: string): RuntimeDelive loadBrowserGraderAsset(): Promise { return Promise.reject(new Error(message)); }, + invokeReviewedWorker(): Promise { + return Promise.reject(new Error(message)); + }, }; } @@ -209,7 +229,7 @@ export function classifyRuntimeArtifactFailureCode(error: unknown): RuntimeArtif type RuntimeBytesReader = (relativePath: string) => Promise; async function readRuntimeBytes(input: { - session: RuntimeSessionRecord; + session: Pick; relativePath: string; readBytes: RuntimeBytesReader; }): Promise { diff --git a/src/runtime/dynamic_worker_delivery.ts b/src/runtime/dynamic_worker_delivery.ts index 0834eb5..4980291 100644 --- a/src/runtime/dynamic_worker_delivery.ts +++ b/src/runtime/dynamic_worker_delivery.ts @@ -18,6 +18,8 @@ const DYNAMIC_WORKER_COMPATIBILITY_DATE = '2026-04-06'; const REVIEWED_RUNTIME_ENVELOPE_VERSION = 'v1'; const INTERNAL_BROWSER_GRADER_PREFIX = '/_lantern_internal/browser-grader'; const INTERNAL_RUNTIME_ORIGIN = 'https://reviewed-runtime.internal'; +const REVIEWED_WORKER_CPU_MS = 50; +const REVIEWED_WORKER_SUBREQUESTS = 8; const textEncoder = new TextEncoder(); @@ -26,7 +28,15 @@ export interface DynamicWorkerEntrypoint { } export interface DynamicWorkerInstance { - getEntrypoint(): DynamicWorkerEntrypoint; + getEntrypoint( + name?: string | null, + options?: { + readonly limits?: { + readonly cpuMs: number; + readonly subRequests: number; + }; + }, + ): DynamicWorkerEntrypoint; } export interface DynamicWorkerCode { @@ -34,6 +44,11 @@ export interface DynamicWorkerCode { mainModule: string; modules: Record; globalOutbound: null; + limits: { + cpuMs: number; + subRequests: number; + }; + env?: Record; } export interface DynamicWorkerLoader { @@ -51,6 +66,10 @@ interface ImmutableRuntimeAsset { export function createDynamicWorkerRuntimeDelivery(input: { loader: DynamicWorkerLoader; snapshotStore: PackageSnapshotStore; + createLmsBinding?: (identity: { + readonly appId: string; + readonly packageVersionId: number; + }) => unknown; envelopeVersion?: string; }): RuntimeDelivery { const envelopeVersion = input.envelopeVersion ?? REVIEWED_RUNTIME_ENVELOPE_VERSION; @@ -111,6 +130,36 @@ export function createDynamicWorkerRuntimeDelivery(input: { relativePath: delivery.assetPath, }); }, + async invokeReviewedWorker(delivery) { + if (delivery.reviewedPackage.runtime.kind !== 'worker') { + throw new Error('Only reviewed Worker packages can be invoked as server apps.'); + } + if (input.createLmsBinding === undefined) { + throw new Error('Reviewed Worker apps require the Lantern LMS capability binding.'); + } + const workerId = buildReviewedRuntimeWorkerId({ + runtimeContractSignature: delivery.reviewedPackage.runtimeContractSignature, + envelopeVersion, + }); + const worker = input.loader.get( + workerId, + () => + buildReviewedAppWorkerCode({ + ...delivery, + snapshotStore: input.snapshotStore, + lmsBinding: input.createLmsBinding!({ + appId: delivery.reviewedPackage.appId, + packageVersionId: delivery.reviewedPackage.id, + }), + }), + ); + return await worker.getEntrypoint(null, { + limits: { + cpuMs: REVIEWED_WORKER_CPU_MS, + subRequests: REVIEWED_WORKER_SUBREQUESTS, + }, + }).fetch(delivery.request); + }, }; } @@ -138,6 +187,45 @@ export async function buildReviewedRuntimeWorkerCode( 'index.js': buildReviewedRuntimeWorkerSource(assets), }, globalOutbound: null, + limits: { + cpuMs: REVIEWED_WORKER_CPU_MS, + subRequests: REVIEWED_WORKER_SUBREQUESTS, + }, + }; +} + +export async function buildReviewedAppWorkerCode( + input: ReviewedRuntimeDeliveryContext & { + snapshotStore: PackageSnapshotStore; + lmsBinding: unknown; + }, +): Promise { + if (input.reviewedPackage.runtime.kind !== 'worker') { + throw new Error('Reviewed app Worker code requires a worker runtime contract.'); + } + const relativePath = requireRelativeSnapshotPath( + trimLeadingSlash(input.reviewedPackage.runtime.entrypoint), + 'Reviewed Worker entrypoint must stay inside the package snapshot.', + ); + const bytes = await input.snapshotStore.readBytes(input.session.snapshotRoot, relativePath); + let source: string; + try { + source = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + throw new Error('Reviewed Worker entrypoint must be valid UTF-8 JavaScript.'); + } + return { + compatibilityDate: DYNAMIC_WORKER_COMPATIBILITY_DATE, + mainModule: 'index.js', + modules: { 'index.js': source }, + globalOutbound: null, + limits: { + cpuMs: REVIEWED_WORKER_CPU_MS, + subRequests: REVIEWED_WORKER_SUBREQUESTS, + }, + env: { + LANTERN_LMS: input.lmsBinding, + }, }; } diff --git a/src/runtime/dynamic_worker_delivery_test.ts b/src/runtime/dynamic_worker_delivery_test.ts index 954d15e..b2ad63b 100644 --- a/src/runtime/dynamic_worker_delivery_test.ts +++ b/src/runtime/dynamic_worker_delivery_test.ts @@ -1,9 +1,11 @@ import { assertEquals, assertNotEquals, assertRejects, assertStringIncludes } from '@std/assert'; +import { Miniflare } from 'miniflare'; import { buildReviewedRuntimeAssetMap, buildReviewedRuntimeWorkerCode, buildReviewedRuntimeWorkerId, createDynamicWorkerRuntimeDelivery, + type DynamicWorkerCode, } from './dynamic_worker_delivery.ts'; import { buildPackageVersionRecord, @@ -140,6 +142,123 @@ Deno.test('Dynamic Worker delivery code blocks outbound access and carries no ex assertEquals((code.modules['index.js'] ?? '').includes('globalThis.Deno'), false); }); +Deno.test('reviewed app Workers receive only the LMS binding and hard invocation limits', async () => { + let loadedCode: DynamicWorkerCode | null = null; + let invocationLimits: { readonly cpuMs: number; readonly subRequests: number } | null = null; + const lmsBinding = { listRoster: () => Promise.resolve({ ok: true }) }; + const delivery = createDynamicWorkerRuntimeDelivery({ + loader: { + get(_id, callback) { + return { + getEntrypoint(_name, options) { + invocationLimits = options?.limits ?? null; + return { + async fetch() { + loadedCode = await callback(); + return new Response('Roster', { + headers: { 'content-type': 'text/html' }, + }); + }, + }; + }, + }; + }, + }, + snapshotStore: createMemorySnapshotStore({ + 'dist/worker.js': 'export default { fetch() { return new Response("ok"); } };', + }), + createLmsBinding(identity) { + assertEquals(identity, { appId: 'photo-roster', packageVersionId: 7 }); + return lmsBinding; + }, + }); + const reviewedPackage = buildPackageVersionRecord({ + id: 7, + appId: 'photo-roster', + runtime: { kind: 'worker', entrypoint: '/dist/worker.js' }, + capabilities: ['lms.roster.members.read'], + approvalStatus: 'approved', + }); + + const response = await delivery.invokeReviewedWorker({ + session: { snapshotRoot: reviewedPackage.artifact.snapshotRoot }, + reviewedPackage, + request: new Request('https://reviewed-app.internal/'), + }); + const code = loadedCode as DynamicWorkerCode | null; + if (code === null) { + throw new Error('Expected the reviewed Worker code to load.'); + } + + assertEquals(response.status, 200); + assertEquals(invocationLimits, { cpuMs: 50, subRequests: 8 }); + assertEquals(code.globalOutbound, null); + assertEquals(code.env, { LANTERN_LMS: lmsBinding }); + assertEquals(Object.keys(code.env ?? {}), ['LANTERN_LMS']); +}); + +Deno.test('Photo Roster bundle runs in workerd and reaches roster data only through RPC', async () => { + const appScript = await Deno.readTextFile('examples/apps/photo-roster/dist/worker.js'); + const common = { modules: true, compatibilityDate: '2026-07-15' } as const; + const miniflare = new Miniflare({ + workers: [ + { + ...common, + name: 'photo-roster', + script: appScript, + serviceBindings: { + LANTERN_LMS: { name: 'lantern-lms', entrypoint: 'LanternLms' }, + }, + }, + { + ...common, + name: 'lantern-lms', + script: ` + import { WorkerEntrypoint } from 'cloudflare:workers'; + export class LanternLms extends WorkerEntrypoint { + listRoster(input) { + if (input.runtimeSessionId !== 'session-1' || input.runtimeSessionToken !== 'token-1') { + return { ok: false, error: { code: 'session_expired', traceId: 'trace-denied' } }; + } + return { + ok: true, + value: { + observedAt: '2026-07-20T15:00:00.000Z', + members: [{ + subjectId: 'subject-1', + displayName: 'Ada Lovelace', + roles: ['learner'], + status: 'active', + email: 'ada@example.edu' + }] + } + }; + } + } + export default { fetch() { return new Response('RPC only', { status: 405 }); } }; + `, + }, + ], + }); + try { + const response = await miniflare.dispatchFetch('https://photo-roster.internal/', { + headers: { + 'x-lantern-runtime-session-id': 'session-1', + 'x-lantern-runtime-session-token': 'token-1', + 'x-lantern-trace-id': 'trace-1', + }, + }); + const html = await response.text(); + + assertEquals(response.status, 200); + assertEquals(response.headers.get('cache-control'), 'private, no-store'); + assertStringIncludes(html, 'Ada Lovelace'); + assertStringIncludes(html, 'ada@example.edu'); + } finally { + await miniflare.dispose(); + } +}); + Deno.test('Dynamic Worker delivery keeps missing reviewed assets distinct from loader delivery failures', async () => { const session = buildRuntimeSessionRecord({ snapshotRoot: 'var/packages/chapter-4-asteroids/0.1.0', diff --git a/src/runtime/gateway.ts b/src/runtime/gateway.ts index dcc4b58..697b259 100644 --- a/src/runtime/gateway.ts +++ b/src/runtime/gateway.ts @@ -1,4 +1,4 @@ -import { type AttemptScoreResult, loadReviewedRubric, scoreAttempt } from '../grading/service.ts'; +import type { AttemptScoreResult } from '../grading/service.ts'; import type { BrowserGraderResult, GatewayEvidenceArtifactAcceptedResult, @@ -30,7 +30,6 @@ import { parseScoreProposal, requireRuntimeCapability, } from './gateway_parsing.ts'; -import { publishRuntimeAttemptScore } from './gateway_publication.ts'; import { readReviewedBrowserGraderConfig } from './browser_grader.ts'; import { normalizeAttemptEvent } from './attempt_event_normalization.ts'; import type { @@ -141,10 +140,8 @@ export async function submitEvidenceArtifact(input: { eventType: 'attempt.evidence_artifact.submitted', actorType: 'system', actorId: null, - deploymentRecordId: input.session.deploymentRecordId, packageVersionId: input.session.packageVersionId, attemptId: attempt.attemptId, - lineItemBindingId: null, status: 'accepted', summary: 'Stored an anonymous evidence artifact through the runtime gateway.', detail: { @@ -358,76 +355,15 @@ export async function finalizeRuntimeAttempt(input: { submissionMode: input.session.launch.submissionMode, evidenceArtifacts, finalizedNow, - lineItemBinding: null, - gradePublication: null, - gradePublishedNow: false, - publishError: null, }; } - - if (packageVersion.grading.mode === 'manual') { - denyRuntimeBroker({ - category: 'policyDenied', - code: 'manual_grading_requires_operator', - message: 'Manual grading cannot be finalized automatically in Phase 3.', - capability: 'finalize_attempt', - detail: {}, - }); - } - - const finalizedNow = attempt.finalizedAt === null; - const finalizedAttempt = finalizedNow - ? await input.repository.finalizeAttempt({ - attemptId: attempt.attemptId, - status: completionStateToAttemptStatus(finalizeInput.completionState), - completionState: finalizeInput.completionState, - finalizedAt: now().toISOString(), - }) - : attempt; - const events = await input.repository.listAttemptEvents(finalizedAttempt.attemptId); - const rubric = packageVersion.grading.mode === 'declarative' - ? await loadReviewedRubric({ - snapshotRoot: packageVersion.artifact.snapshotRoot, - rubricFile: packageVersion.grading.rubricFile, - artifactStore: input.artifactStore, - }) - : undefined; - const browserGraderResult = packageVersion.grading.mode === 'browser' - ? validateBrowserGraderResult({ - browserGraderResult: finalizeInput.browserGraderResult, - packageVersion, - }) - : null; - const score = browserGraderResult === null - ? scoreAttempt({ - attempt: finalizedAttempt, - events, - grading: packageVersion.grading, - ...(rubric === undefined ? {} : { rubric }), - }) - : { - scoreGiven: browserGraderResult.scoreGiven, - scoreMaximum: browserGraderResult.scoreMaximum, - }; - const publishResult = await publishRuntimeAttemptScore({ - repository: input.repository, - session: input.session, - attempt: finalizedAttempt, - packageVersion, - score, - getLtiToolContext: requireLtiToolContext(input.getLtiToolContext), - now, + denyRuntimeBroker({ + category: 'policyDenied', + code: 'live_grading_not_available', + message: 'Live LMS grading is outside the governed primitive kernel.', + capability: 'finalize_attempt', + detail: {}, }); - - return { - attempt: finalizedAttempt, - score, - browserGraderResult, - submissionMode: input.session.launch.submissionMode, - evidenceArtifacts, - finalizedNow, - ...publishResult, - }; } catch (error) { if (previewSession !== null && !previewBlockEvidenceRecorded) { await input.repository.appendPreviewEvidence({ @@ -447,16 +383,6 @@ export async function finalizeRuntimeAttempt(input: { } } -function requireLtiToolContext( - getLtiToolContext: (() => Promise) | undefined, -): () => Promise { - if (getLtiToolContext === undefined) { - throw new Error('LTI tool context is required for live score publication.'); - } - - return getLtiToolContext; -} - function validateBrowserGraderResult(input: { browserGraderResult: BrowserGraderResult | null; packageVersion: Pick; diff --git a/src/runtime/gateway_attempt_events_test.ts b/src/runtime/gateway_attempt_events_test.ts index 6b031e2..05ea4dd 100644 --- a/src/runtime/gateway_attempt_events_test.ts +++ b/src/runtime/gateway_attempt_events_test.ts @@ -1,11 +1,12 @@ import { assertEquals, assertObjectMatch, assertRejects } from '@std/assert'; -import { createApp } from '../app.ts'; +import { createTestApp as createApp } from '../test_helpers/app.ts'; import { withRuntimeOriginEnv } from '../app_test_support.ts'; import { acceptAttemptEvent, parseAttemptEvent, requireRuntimeCapability } from './gateway.ts'; import { RuntimeBrokerDenialError } from './gateway_errors.ts'; import { buildRuntimeSessionRecord } from '../test_helpers/lti.ts'; import { buildAttemptRecord, + buildPreviewSessionRecord, createInMemoryPackageReviewRepository, } from '../test_helpers/package_review.ts'; @@ -13,6 +14,7 @@ Deno.test('runtime gateway accepts authenticated attempt-event writes and persis await withRuntimeOriginEnv(async () => { const repository = createInMemoryPackageReviewRepository({ attempts: [buildAttemptRecord()], + previewSessions: [buildPreviewSessionRecord({ fakeAttemptId: 'attempt-123' })], runtimeSessions: [ buildRuntimeSessionRecord({ expiresAt: '2099-03-26T02:45:00Z', @@ -63,6 +65,7 @@ Deno.test('runtime gateway blocks missing capability, bad payloads, and bad toke await withRuntimeOriginEnv(async () => { const repository = createInMemoryPackageReviewRepository({ attempts: [buildAttemptRecord()], + previewSessions: [buildPreviewSessionRecord({ fakeAttemptId: 'attempt-123' })], runtimeSessions: [ buildRuntimeSessionRecord({ capabilities: ['read_launch_context'], @@ -181,6 +184,7 @@ Deno.test('runtime gateway validates attempt event payloads and capabilities', a Deno.test('runtime gateway helper appends attempt events directly against the durable ledger', async () => { const repository = createInMemoryPackageReviewRepository({ attempts: [buildAttemptRecord()], + previewSessions: [buildPreviewSessionRecord({ fakeAttemptId: 'attempt-123' })], runtimeSessions: [ buildRuntimeSessionRecord({ expiresAt: '2099-03-26T02:45:00Z', diff --git a/src/runtime/gateway_broker_denials_test.ts b/src/runtime/gateway_broker_denials_test.ts index c0ab766..fb22163 100644 --- a/src/runtime/gateway_broker_denials_test.ts +++ b/src/runtime/gateway_broker_denials_test.ts @@ -61,7 +61,6 @@ Deno.test('runtime broker accepts score proposals without creating a direct grad scoreMaximum: 10, }, }); - assertEquals(await repository.getGradePublicationByAttemptId('attempt-123'), null); assertEquals((await repository.getAttemptById('attempt-123'))?.finalizedAt, null); }); diff --git a/src/runtime/gateway_context.ts b/src/runtime/gateway_context.ts index c7f24e2..4037fb5 100644 --- a/src/runtime/gateway_context.ts +++ b/src/runtime/gateway_context.ts @@ -1,7 +1,6 @@ import type { PackageReviewRepository } from '../package_review/repository.ts'; import type { AttemptRecord, - DeploymentRecord, PackageVersionRecord, PreviewSessionRecord, } from '../package_review/types.ts'; @@ -10,11 +9,7 @@ import type { RuntimeSessionRecord } from '../lti/types.ts'; export async function resolvePreviewSession( repository: PackageReviewRepository, session: RuntimeSessionRecord, -): Promise { - if (session.preview === undefined) { - return null; - } - +): Promise { const previewSession = await repository.getPreviewSessionById(session.preview.previewSessionId); if (previewSession === null) { @@ -50,7 +45,7 @@ export async function requireRuntimeAttempt( } if ( - attempt.deploymentRecordId !== session.deploymentRecordId || + attempt.previewSessionId !== session.preview.previewSessionId || attempt.packageVersionId !== session.packageVersionId || attempt.appId !== session.appId ) { @@ -79,23 +74,6 @@ export async function requireRuntimePackageVersion( return packageVersion; } -export async function requireRuntimeDeployment( - repository: PackageReviewRepository, - session: RuntimeSessionRecord, -): Promise { - const deployment = await repository.getDeploymentBySlug(session.deploymentSlug); - - if (!deployment) { - throw new Error(`Deployment ${session.deploymentSlug} was not found for finalize.`); - } - - if (deployment.id !== session.deploymentRecordId) { - throw new Error(`Deployment ${deployment.slug} did not match the runtime session context.`); - } - - return deployment; -} - function matchesPreviewAttemptId(attemptId: string, previewSession: PreviewSessionRecord): boolean { return ( attemptId === previewSession.fakeAttemptId || diff --git a/src/runtime/gateway_evidence_test.ts b/src/runtime/gateway_evidence_test.ts index f352488..e5258e8 100644 --- a/src/runtime/gateway_evidence_test.ts +++ b/src/runtime/gateway_evidence_test.ts @@ -25,7 +25,11 @@ Deno.test('runtime gateway accepts an allowlisted evidence artifact and records ], }), ], - attempts: [buildAttemptRecord()], + attempts: [ + buildAttemptRecord({ + previewSessionId: 'preview-session-evidence', + }), + ], }); const session = buildRuntimeSessionRecord({ capabilities: [ @@ -124,7 +128,11 @@ Deno.test('runtime gateway accepts screenshot evidence through the same governed ], }), ], - attempts: [buildAttemptRecord()], + attempts: [ + buildAttemptRecord({ + previewSessionId: 'preview-session-screenshot', + }), + ], }); const session = buildRuntimeSessionRecord({ capabilities: [ diff --git a/src/runtime/gateway_finalize_edge_cases_test.ts b/src/runtime/gateway_finalize_edge_cases_test.ts deleted file mode 100644 index c1a919f..0000000 --- a/src/runtime/gateway_finalize_edge_cases_test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { assertEquals, assertRejects } from '@std/assert'; -import { finalizeRuntimeAttempt } from './gateway.ts'; -import { RuntimeBrokerDenialError } from './gateway_errors.ts'; -import { - buildDeploymentBinding, - buildRuntimeSessionRecord, - getTestToolPrivateJwkEnvValue, -} from '../test_helpers/lti.ts'; -import { - buildAttemptEventRecord, - buildAttemptRecord, - buildDeploymentRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from '../test_helpers/package_review.ts'; -import { - EXAMPLE_SNAPSHOT_ROOT, - FILE_SYSTEM_RUNTIME_ARTIFACT_STORE, - restoreEnv, - TEST_RUNTIME_ENV, - withFetchStub, -} from './gateway_test_helpers.ts'; -import { createTestLtiToolContext } from '../test_helpers/lti_tool_context.ts'; - -Deno.test('runtime gateway surfaces lti-tool score failures clearly after the durable attempt is finalized', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - artifact: { - snapshotRoot: EXAMPLE_SNAPSHOT_ROOT, - manifestPath: `${EXAMPLE_SNAPSHOT_ROOT}/manifest.json`, - entrypointPath: `${EXAMPLE_SNAPSHOT_ROOT}/dist/index.html`, - digest: 'sha256:example-snapshot', - }, - }), - ], - deployments: [ - buildDeploymentRecord({ - binding: buildDeploymentBinding(), - }), - ], - attempts: [buildAttemptRecord()], - attemptEvents: [ - buildAttemptEventRecord({ - id: 1, - sequence: 1, - event: { - type: 'answer', - questionId: 'q1', - answer: 'resistance to a change in motion', - timestamp: '2026-03-24T02:30:00Z', - }, - }), - ], - }); - const session = buildRuntimeSessionRecord({ - ltiToolSessionId: 'lti-tool-session-123', - expiresAt: '2099-03-26T02:45:00Z', - }); - const ltiToolContext = createTestLtiToolContext({ - submitScoreSuccess: false, - submitScoreMessage: 'score publish failed', - }); - - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - const result = await withFetchStub( - () => - new Response( - JSON.stringify({ - error: 'invalid_client', - }), - { - status: 401, - headers: { - 'content-type': 'application/json', - }, - }, - ), - async () => - await finalizeRuntimeAttempt({ - repository, - session, - payload: { - completionState: 'completed', - }, - env: TEST_RUNTIME_ENV, - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - artifactStore: FILE_SYSTEM_RUNTIME_ARTIFACT_STORE, - now: () => new Date('2026-03-24T02:35:00Z'), - }), - ); - - assertEquals(result.finalizedNow, true); - assertEquals(result.gradePublishedNow, false); - assertEquals(result.publishError?.code, 'score_publish_failed'); - assertEquals(result.gradePublication?.status, 'failed'); - - const attempt = await repository.getAttemptById('attempt-123'); - - assertEquals(attempt?.status, 'completed'); - assertEquals(attempt?.finalizedAt, '2026-03-24T02:35:00.000Z'); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } -}); - -Deno.test('runtime gateway fails clearly for manual grading finalize requests and leaves the attempt open', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - grading: { - mode: 'manual', - rubricFile: null, - maxScore: null, - }, - }), - ], - attempts: [buildAttemptRecord()], - }); - const session = buildRuntimeSessionRecord({ - expiresAt: '2099-03-26T02:45:00Z', - }); - - const error = (await assertRejects( - () => - finalizeRuntimeAttempt({ - repository, - session, - payload: { - completionState: 'completed', - }, - env: TEST_RUNTIME_ENV, - artifactStore: FILE_SYSTEM_RUNTIME_ARTIFACT_STORE, - now: () => new Date('2026-03-24T02:35:00Z'), - }), - RuntimeBrokerDenialError, - )) as RuntimeBrokerDenialError; - - assertEquals(error.category, 'policyDenied'); - assertEquals(error.code, 'manual_grading_requires_operator'); - - const attempt = await repository.getAttemptById('attempt-123'); - - assertEquals(attempt?.status, 'in_progress'); - assertEquals(attempt?.finalizedAt, null); -}); diff --git a/src/runtime/gateway_finalize_test.ts b/src/runtime/gateway_finalize_test.ts deleted file mode 100644 index 8270066..0000000 --- a/src/runtime/gateway_finalize_test.ts +++ /dev/null @@ -1,651 +0,0 @@ -import { assertEquals, assertRejects } from '@std/assert'; -import { finalizeRuntimeAttempt, parseFinalizeAttemptInput } from './gateway.ts'; -import { RuntimeBrokerDenialError } from './gateway_errors.ts'; -import { - buildDeploymentBinding, - buildRuntimeSessionRecord, - getTestToolPrivateJwkEnvValue, -} from '../test_helpers/lti.ts'; -import { - buildAttemptEventRecord, - buildAttemptEvidenceArtifactRecord, - buildAttemptRecord, - buildDeploymentRecord, - buildPackageVersionRecord, - createInMemoryPackageReviewRepository, -} from '../test_helpers/package_review.ts'; -import { - EXAMPLE_SNAPSHOT_ROOT, - FILE_SYSTEM_RUNTIME_ARTIFACT_STORE, - restoreEnv, - TEST_RUNTIME_ENV, - withFetchStub, -} from './gateway_test_helpers.ts'; -import { createTestLtiToolContext } from '../test_helpers/lti_tool_context.ts'; - -const WEB_CHECKUP_SNAPSHOT_ROOT = 'examples/apps/web-checkup'; - -Deno.test('runtime gateway requires explicit finalize completion state', async () => { - const error = (await assertRejects( - () => Promise.resolve().then(() => parseFinalizeAttemptInput({})), - RuntimeBrokerDenialError, - )) as RuntimeBrokerDenialError; - - assertEquals(error.code, 'invalid_finalize_request'); - assertEquals(error.capability, 'finalize_attempt'); -}); - -Deno.test('runtime gateway finalizes declarative attempts from the reviewed rubric and stays idempotent', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - artifact: { - snapshotRoot: EXAMPLE_SNAPSHOT_ROOT, - manifestPath: `${EXAMPLE_SNAPSHOT_ROOT}/manifest.json`, - entrypointPath: `${EXAMPLE_SNAPSHOT_ROOT}/dist/index.html`, - digest: 'sha256:example-snapshot', - }, - }), - ], - deployments: [ - buildDeploymentRecord({ - binding: buildDeploymentBinding(), - }), - ], - attempts: [buildAttemptRecord()], - attemptEvents: [ - buildAttemptEventRecord({ - id: 1, - sequence: 1, - event: { - type: 'answer', - questionId: 'q1', - answer: 'resistance to a change in motion', - timestamp: '2026-03-24T02:30:00Z', - }, - }), - buildAttemptEventRecord({ - id: 2, - sequence: 2, - event: { - type: 'answer', - questionId: 'q2', - answer: 'speed with direction', - timestamp: '2026-03-24T02:31:00Z', - }, - }), - ], - }); - const session = buildRuntimeSessionRecord({ - ltiToolSessionId: 'lti-tool-session-123', - expiresAt: '2099-03-26T02:45:00Z', - }); - const ltiToolContext = createTestLtiToolContext(); - - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - await withFetchStub( - (input, init) => { - const url = String(input); - - if (url === 'https://sso.canvaslms.com/login/oauth2/token') { - return new Response( - JSON.stringify({ - access_token: 'canvas-access-token', - token_type: 'bearer', - }), - { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }, - ); - } - - assertEquals(url, 'https://canvas.example/api/lti/courses/42/line_items/9/scores'); - assertEquals(init?.method, 'POST'); - - return new Response(null, { status: 202 }); - }, - async () => { - const firstResult = await finalizeRuntimeAttempt({ - repository, - session, - payload: { - completionState: 'completed', - }, - env: TEST_RUNTIME_ENV, - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - artifactStore: FILE_SYSTEM_RUNTIME_ARTIFACT_STORE, - now: () => new Date('2026-03-24T02:35:00Z'), - }); - const secondResult = await finalizeRuntimeAttempt({ - repository, - session, - payload: { - completionState: 'abandoned', - }, - env: TEST_RUNTIME_ENV, - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - artifactStore: FILE_SYSTEM_RUNTIME_ARTIFACT_STORE, - now: () => new Date('2026-03-24T02:40:00Z'), - }); - - assertEquals(firstResult.finalizedNow, true); - assertEquals(firstResult.attempt.status, 'completed'); - assertEquals(firstResult.score, { - scoreGiven: 100, - scoreMaximum: 100, - }); - assertEquals(firstResult.gradePublishedNow, true); - assertEquals(firstResult.gradePublication?.status, 'published'); - assertEquals( - firstResult.lineItemBinding?.lineItemUrl, - 'https://canvas.example/api/lti/courses/42/line_items/9', - ); - assertEquals(secondResult.finalizedNow, false); - assertEquals(secondResult.attempt.status, 'completed'); - assertEquals(secondResult.attempt.completionState, 'completed'); - assertEquals(secondResult.attempt.finalizedAt, '2026-03-24T02:35:00.000Z'); - assertEquals(secondResult.gradePublishedNow, false); - assertEquals(secondResult.gradePublication?.status, 'published'); - }, - ); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } -}); - -Deno.test('runtime gateway finalizes browser grading through the reviewed browser grader result and still publishes through AGS', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - appId: 'web-checkup', - title: 'Web Checkup', - grading: { - mode: 'browser', - rubricFile: null, - maxScore: 100, - }, - manifestJson: { - app_id: 'web-checkup', - version: '0.1.0', - title: 'Web Checkup', - grading: { - mode: 'browser', - max_score: 100, - }, - authoring: { - kind: 'browser_autograder', - grader_spec_files: [ - '/grading/specs/structure.spec.js', - '/grading/specs/behavior.spec.js', - ], - evidence_example_file: '/evidence/example-output.json', - }, - }, - artifact: { - snapshotRoot: WEB_CHECKUP_SNAPSHOT_ROOT, - manifestPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/manifest.json`, - entrypointPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/dist/index.html`, - digest: 'sha256:web-checkup-snapshot', - }, - }), - ], - deployments: [ - buildDeploymentRecord({ - appId: 'web-checkup', - slug: 'web-checkup-pilot', - label: 'Web Checkup Pilot', - binding: buildDeploymentBinding(), - }), - ], - attempts: [ - buildAttemptRecord({ - appId: 'web-checkup', - deploymentSlug: 'web-checkup-pilot', - }), - ], - }); - const session = buildRuntimeSessionRecord({ - ltiToolSessionId: 'lti-tool-session-123', - appId: 'web-checkup', - deploymentSlug: 'web-checkup-pilot', - snapshotRoot: WEB_CHECKUP_SNAPSHOT_ROOT, - entrypointPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/dist/index.html`, - contentPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/content/activity.json`, - expiresAt: '2099-03-26T02:45:00Z', - }); - const ltiToolContext = createTestLtiToolContext(); - - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - await withFetchStub( - (input, init) => { - const url = String(input); - - if (url === 'https://sso.canvaslms.com/login/oauth2/token') { - return new Response( - JSON.stringify({ - access_token: 'canvas-access-token', - token_type: 'bearer', - }), - { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }, - ); - } - - assertEquals(url, 'https://canvas.example/api/lti/courses/42/line_items/9/scores'); - assertEquals(init?.method, 'POST'); - - return new Response(null, { status: 202 }); - }, - async () => { - const result = await finalizeRuntimeAttempt({ - repository, - session, - payload: { - completionState: 'completed', - browserGraderResult: { - scoreGiven: 100, - scoreMaximum: 100, - specResults: [ - { - source: '/grading/specs/structure.spec.js', - result: 'passed', - failures: [], - }, - { - source: '/grading/specs/behavior.spec.js', - result: 'passed', - failures: [], - }, - ], - }, - }, - env: TEST_RUNTIME_ENV, - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - artifactStore: FILE_SYSTEM_RUNTIME_ARTIFACT_STORE, - now: () => new Date('2026-04-08T18:05:00Z'), - }); - - assertEquals(result.finalizedNow, true); - assertEquals(result.score, { - scoreGiven: 100, - scoreMaximum: 100, - }); - assertEquals(result.browserGraderResult?.specResults.length, 2); - assertEquals(result.gradePublishedNow, true); - assertEquals(result.gradePublication?.status, 'published'); - }, - ); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } -}); - -Deno.test('runtime gateway rejects anonymous finalize when no evidence artifacts are stored', async () => { - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - appId: 'web-checkup', - title: 'Web Checkup', - capabilities: [ - 'read_launch_context', - 'read_activity_content', - 'submit_attempt_event', - 'submit_evidence_artifact', - 'finalize_attempt', - ], - grading: { - mode: 'browser', - rubricFile: null, - maxScore: 100, - }, - manifestJson: { - app_id: 'web-checkup', - version: '0.1.0', - title: 'Web Checkup', - grading: { - mode: 'browser', - max_score: 100, - }, - authoring: { - kind: 'browser_autograder', - grader_spec_files: [ - '/grading/specs/structure.spec.js', - '/grading/specs/behavior.spec.js', - ], - evidence_example_file: '/evidence/example-output.json', - }, - }, - }), - ], - attempts: [ - buildAttemptRecord({ - appId: 'web-checkup', - deploymentSlug: 'web-checkup-pilot', - }), - ], - }); - const session = buildRuntimeSessionRecord({ - ltiToolSessionId: 'lti-tool-session-123', - appId: 'web-checkup', - deploymentSlug: 'web-checkup-pilot', - capabilities: [ - 'read_launch_context', - 'read_activity_content', - 'submit_attempt_event', - 'submit_evidence_artifact', - 'finalize_attempt', - ], - snapshotRoot: WEB_CHECKUP_SNAPSHOT_ROOT, - entrypointPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/dist/index.html`, - contentPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/content/activity.json`, - expiresAt: '2099-03-26T02:45:00Z', - }); - - const error = (await assertRejects( - () => - finalizeRuntimeAttempt({ - repository, - session, - payload: { - completionState: 'completed', - browserGraderResult: { - scoreGiven: 100, - scoreMaximum: 100, - specResults: [ - { - source: '/grading/specs/structure.spec.js', - result: 'passed', - failures: [], - }, - { - source: '/grading/specs/behavior.spec.js', - result: 'passed', - failures: [], - }, - ], - }, - }, - env: TEST_RUNTIME_ENV, - artifactStore: FILE_SYSTEM_RUNTIME_ARTIFACT_STORE, - now: () => new Date('2026-04-08T19:00:00Z'), - }), - RuntimeBrokerDenialError, - )) as RuntimeBrokerDenialError; - - assertEquals(error.category, 'policyDenied'); - assertEquals(error.code, 'anonymous_evidence_required'); - assertEquals(error.capability, 'finalize_attempt'); -}); - -Deno.test('runtime gateway finalizes anonymous browser attempts only after evidence artifacts are already stored', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - appId: 'web-checkup', - title: 'Web Checkup', - capabilities: [ - 'read_launch_context', - 'read_activity_content', - 'submit_attempt_event', - 'submit_evidence_artifact', - 'finalize_attempt', - ], - grading: { - mode: 'browser', - rubricFile: null, - maxScore: 100, - }, - manifestJson: { - app_id: 'web-checkup', - version: '0.1.0', - title: 'Web Checkup', - grading: { - mode: 'browser', - max_score: 100, - }, - authoring: { - kind: 'browser_autograder', - grader_spec_files: [ - '/grading/specs/structure.spec.js', - '/grading/specs/behavior.spec.js', - ], - evidence_example_file: '/evidence/example-output.json', - }, - }, - artifact: { - snapshotRoot: WEB_CHECKUP_SNAPSHOT_ROOT, - manifestPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/manifest.json`, - entrypointPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/dist/index.html`, - digest: 'sha256:web-checkup-snapshot', - }, - }), - ], - deployments: [ - buildDeploymentRecord({ - appId: 'web-checkup', - slug: 'web-checkup-pilot', - label: 'Web Checkup Pilot', - binding: buildDeploymentBinding(), - }), - ], - attempts: [ - buildAttemptRecord({ - appId: 'web-checkup', - deploymentSlug: 'web-checkup-pilot', - }), - ], - attemptEvidenceArtifacts: [ - buildAttemptEvidenceArtifactRecord({ - artifactId: 'artifact-001', - attemptId: 'attempt-123', - kind: 'screenshot_png', - contentType: 'image/png', - fileName: 'submission.png', - storageKey: 'var/attempt-evidence/attempt-123/artifact-001-submission.png', - byteSize: 2048, - sha256: 'sha256:screenshot-artifact-001', - }), - ], - }); - const session = buildRuntimeSessionRecord({ - ltiToolSessionId: 'lti-tool-session-123', - appId: 'web-checkup', - deploymentSlug: 'web-checkup-pilot', - capabilities: [ - 'read_launch_context', - 'read_activity_content', - 'submit_attempt_event', - 'submit_evidence_artifact', - 'finalize_attempt', - ], - snapshotRoot: WEB_CHECKUP_SNAPSHOT_ROOT, - entrypointPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/dist/index.html`, - contentPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/content/activity.json`, - expiresAt: '2099-03-26T02:45:00Z', - }); - const ltiToolContext = createTestLtiToolContext(); - - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - await withFetchStub( - (input, init) => { - const url = String(input); - - if (url === 'https://sso.canvaslms.com/login/oauth2/token') { - return new Response( - JSON.stringify({ - access_token: 'canvas-access-token', - token_type: 'bearer', - }), - { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }, - ); - } - - assertEquals(url, 'https://canvas.example/api/lti/courses/42/line_items/9/scores'); - assertEquals(init?.method, 'POST'); - - return new Response(null, { status: 202 }); - }, - async () => { - const result = await finalizeRuntimeAttempt({ - repository, - session, - payload: { - completionState: 'completed', - browserGraderResult: { - scoreGiven: 100, - scoreMaximum: 100, - specResults: [ - { - source: '/grading/specs/structure.spec.js', - result: 'passed', - failures: [], - }, - { - source: '/grading/specs/behavior.spec.js', - result: 'passed', - failures: [], - }, - ], - }, - }, - env: TEST_RUNTIME_ENV, - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - artifactStore: FILE_SYSTEM_RUNTIME_ARTIFACT_STORE, - now: () => new Date('2026-04-08T19:02:00Z'), - }); - - assertEquals(result.finalizedNow, true); - assertEquals(result.submissionMode, 'anonymous_submission'); - assertEquals(result.evidenceArtifacts.length, 1); - assertEquals(result.evidenceArtifacts[0]?.artifactId, 'artifact-001'); - assertEquals(result.evidenceArtifacts[0]?.kind, 'screenshot_png'); - assertEquals(result.evidenceArtifacts[0]?.fileName, 'submission.png'); - assertEquals(result.gradePublishedNow, true); - }, - ); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } -}); - -Deno.test('runtime gateway keeps the AGS line-item resource id aligned with the reviewed activity', async () => { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - const repository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPackageVersionRecord({ - artifact: { - snapshotRoot: EXAMPLE_SNAPSHOT_ROOT, - manifestPath: `${EXAMPLE_SNAPSHOT_ROOT}/manifest.json`, - entrypointPath: `${EXAMPLE_SNAPSHOT_ROOT}/dist/index.html`, - digest: 'sha256:example-snapshot', - }, - }), - ], - deployments: [ - buildDeploymentRecord({ - binding: buildDeploymentBinding(), - }), - ], - attempts: [ - buildAttemptRecord({ - activityId: '/content/bonus.json', - }), - ], - attemptEvents: [ - buildAttemptEventRecord({ - id: 1, - sequence: 1, - event: { - type: 'answer', - questionId: 'q1', - answer: 'resistance to a change in motion', - timestamp: '2026-03-24T02:30:00Z', - }, - }), - ], - }); - const session = buildRuntimeSessionRecord({ - ltiToolSessionId: 'lti-tool-session-123', - contentPath: `${EXAMPLE_SNAPSHOT_ROOT}/content/bonus.json`, - launch: { - userRole: 'learner', - courseId: 'course-42', - assignmentId: 'assignment-9', - activityId: '/content/bonus.json', - submissionMode: 'standard', - }, - expiresAt: '2099-03-26T02:45:00Z', - }); - const ltiToolContext = createTestLtiToolContext(); - - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); - - try { - await withFetchStub( - (input, init) => { - const url = String(input); - - if (url === 'https://sso.canvaslms.com/login/oauth2/token') { - return new Response( - JSON.stringify({ - access_token: 'canvas-access-token', - token_type: 'bearer', - }), - { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }, - ); - } - - assertEquals(url, 'https://canvas.example/api/lti/courses/42/line_items/9/scores'); - assertEquals(init?.method, 'POST'); - - return new Response(null, { status: 202 }); - }, - async () => { - const result = await finalizeRuntimeAttempt({ - repository, - session, - payload: { - completionState: 'completed', - }, - env: TEST_RUNTIME_ENV, - getLtiToolContext: () => Promise.resolve(ltiToolContext.context), - artifactStore: FILE_SYSTEM_RUNTIME_ARTIFACT_STORE, - now: () => new Date('2026-03-24T02:35:00Z'), - }); - - assertEquals( - result.lineItemBinding?.resourceId, - 'lantern:chapter-4-asteroids:0.1.0:/content/bonus.json', - ); - }, - ); - } finally { - restoreEnv('LTI_TOOL_PRIVATE_JWK', previousToolKey); - } -}); diff --git a/src/runtime/gateway_preview_test.ts b/src/runtime/gateway_preview_test.ts index 0dd7321..1bbd59d 100644 --- a/src/runtime/gateway_preview_test.ts +++ b/src/runtime/gateway_preview_test.ts @@ -40,7 +40,7 @@ Deno.test('preview gateway finalize returns fake scoring and never calls Canvas attempts: [ buildAttemptRecord({ attemptId: 'preview-attempt-123', - deploymentRecordId: 0, + previewSessionId: 'preview-session-finalize-fake', }), ], attemptEvents: [ @@ -55,8 +55,6 @@ Deno.test('preview gateway finalize returns fake scoring and never calls Canvas }); const session = buildRuntimeSessionRecord({ attemptId: 'preview-attempt-123', - deploymentRecordId: 0, - deploymentSlug: 'chapter-4-asteroids-preview', services: { ags: null, nrps: null, @@ -87,10 +85,6 @@ Deno.test('preview gateway finalize returns fake scoring and never calls Canvas scoreGiven: 0, scoreMaximum: 42, }); - assertEquals(result.lineItemBinding, null); - assertEquals(result.gradePublication, null); - assertEquals(result.gradePublishedNow, false); - assertEquals(result.publishError, null); }, ); }); @@ -106,14 +100,12 @@ Deno.test('preview gateway enforces declared capabilities and records bounded bl attempts: [ buildAttemptRecord({ attemptId: 'preview-attempt-123', - deploymentRecordId: 0, + previewSessionId: 'preview-session-capability-block', }), ], }); const session = buildRuntimeSessionRecord({ attemptId: 'preview-attempt-123', - deploymentRecordId: 0, - deploymentSlug: 'chapter-4-asteroids-preview', capabilities: ['read_launch_context'], services: { ags: null, @@ -191,8 +183,7 @@ Deno.test('preview gateway records allowed quick-study attempt events through th attempts: [ buildAttemptRecord({ attemptId: 'preview-attempt-123', - deploymentRecordId: 0, - deploymentSlug: 'quick-study-preview', + previewSessionId: 'preview-session-quick-study-allowed', appId: 'quick-study', activityId: 'quick-study', }), @@ -200,8 +191,6 @@ Deno.test('preview gateway records allowed quick-study attempt events through th }); const session = buildRuntimeSessionRecord({ attemptId: 'preview-attempt-123', - deploymentRecordId: 0, - deploymentSlug: 'quick-study-preview', appId: 'quick-study', snapshotRoot, entrypointPath: `${snapshotRoot}/dist/index.html`, @@ -262,14 +251,12 @@ Deno.test('preview gateway blocks live-service finalize paths clearly and record attempts: [ buildAttemptRecord({ attemptId: 'preview-attempt-123', - deploymentRecordId: 0, + previewSessionId: 'preview-session-live-service-block', }), ], }); const session = buildRuntimeSessionRecord({ attemptId: 'preview-attempt-123', - deploymentRecordId: 0, - deploymentSlug: 'chapter-4-asteroids-preview', preview: { previewSessionId: 'preview-session-live-service-block', }, @@ -353,8 +340,7 @@ Deno.test('preview gateway records browser grader evidence instead of fake scori attempts: [ buildAttemptRecord({ attemptId: 'preview-attempt-123', - deploymentRecordId: 0, - deploymentSlug: 'web-checkup-preview', + previewSessionId: 'preview-session-browser-grading', appId: 'web-checkup', }), ], @@ -369,8 +355,6 @@ Deno.test('preview gateway records browser grader evidence instead of fake scori }); const session = buildRuntimeSessionRecord({ attemptId: 'preview-attempt-123', - deploymentRecordId: 0, - deploymentSlug: 'web-checkup-preview', appId: 'web-checkup', snapshotRoot: WEB_CHECKUP_SNAPSHOT_ROOT, entrypointPath: `${WEB_CHECKUP_SNAPSHOT_ROOT}/dist/index.html`, diff --git a/src/runtime/gateway_publication.ts b/src/runtime/gateway_publication.ts deleted file mode 100644 index 35b7ce6..0000000 --- a/src/runtime/gateway_publication.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { type AttemptScoreResult } from '../grading/service.ts'; -import type { LanternLtiToolContext } from '../lti/tool_provider.ts'; -import type { LTISession } from '@longsightgroup/lti-tool'; -import { - LTI_AGS_LINEITEM_SCOPE, - LTI_AGS_SCORE_SCOPE, - type RuntimeSessionRecord, -} from '../lti/types.ts'; -import { requireVerifiedLtiSessionWithContext } from '../lti/session_boundary.ts'; -import type { PackageReviewRepository } from '../package_review/repository.ts'; -import type { AttemptRecord, PackageVersionRecord } from '../package_review/types.ts'; -import { requireRuntimeDeployment } from './gateway_context.ts'; -import { - buildFinalGradeLineItemSpec, - resolveActivityProgress, -} from './gateway_publication_support.ts'; -import type { FinalizeAttemptResult } from './gateway_types.ts'; - -export async function publishRuntimeAttemptScore(input: { - repository: PackageReviewRepository; - session: RuntimeSessionRecord; - attempt: AttemptRecord; - packageVersion: PackageVersionRecord; - score: AttemptScoreResult; - getLtiToolContext: () => Promise; - now: () => Date; -}): Promise< - Pick< - FinalizeAttemptResult, - 'lineItemBinding' | 'gradePublication' | 'gradePublishedNow' | 'publishError' - > -> { - const deployment = await requireRuntimeDeployment(input.repository, input.session); - - if (deployment.binding === null) { - return { - lineItemBinding: null, - gradePublication: null, - gradePublishedNow: false, - publishError: { - code: 'missing_binding', - message: 'Deployment binding is required before score publish can continue.', - detail: { - deploymentSlug: deployment.slug, - }, - }, - }; - } - - if (input.session.ltiToolSessionId === null) { - return { - lineItemBinding: null, - gradePublication: null, - gradePublishedNow: false, - publishError: { - code: 'lti_session_missing', - message: 'A verified lti-tool session is required before score publish can continue.', - detail: { - attemptId: input.attempt.attemptId, - }, - }, - }; - } - - const ags = input.session.services.ags; - - if (ags === null) { - return { - lineItemBinding: null, - gradePublication: null, - gradePublishedNow: false, - publishError: { - code: 'missing_ags_context', - message: 'Launch did not provide AGS service context for this attempt.', - detail: { - attemptId: input.attempt.attemptId, - }, - }, - }; - } - - const existingBinding = await input.repository.getLineItemBinding({ - deploymentRecordId: input.session.deploymentRecordId, - packageVersionId: input.session.packageVersionId, - contextId: input.attempt.contextId, - resourceLinkId: input.attempt.resourceLinkId, - activityId: input.attempt.activityId, - }); - const hasScoreScope = ags.scope.includes(LTI_AGS_SCORE_SCOPE); - const requiresLineitemScope = existingBinding === null && ags.lineitemUrl === null; - - if (!hasScoreScope || (requiresLineitemScope && !ags.scope.includes(LTI_AGS_LINEITEM_SCOPE))) { - return { - lineItemBinding: existingBinding, - gradePublication: null, - gradePublishedNow: false, - publishError: { - code: 'missing_ags_scope', - message: 'Launch did not grant the AGS scopes Lantern needs to publish the final score.', - detail: { - scopes: ags.scope, - }, - }, - }; - } - - return await publishRuntimeAttemptScoreWithLtiTool({ - ...input, - existingBinding, - }); -} - -async function publishRuntimeAttemptScoreWithLtiTool(input: { - repository: PackageReviewRepository; - session: RuntimeSessionRecord; - attempt: AttemptRecord; - packageVersion: PackageVersionRecord; - score: AttemptScoreResult; - getLtiToolContext: () => Promise; - existingBinding: Awaited>; - now: () => Date; -}): Promise< - Pick< - FinalizeAttemptResult, - 'lineItemBinding' | 'gradePublication' | 'gradePublishedNow' | 'publishError' - > -> { - const ags = input.session.services.ags; - - if (ags === null) { - return { - lineItemBinding: input.existingBinding, - gradePublication: null, - gradePublishedNow: false, - publishError: { - code: 'missing_ags_context', - message: 'Launch did not provide AGS service context for this attempt.', - detail: { - attemptId: input.attempt.attemptId, - }, - }, - }; - } - - let ltiContext: LanternLtiToolContext; - let ltiSession: LTISession; - - try { - const loaded = await requireVerifiedLtiSessionWithContext({ - getContext: input.getLtiToolContext, - sessionId: input.session.ltiToolSessionId, - missingMessage: 'A verified lti-tool session is required before score publish can continue.', - notFoundMessage: 'The verified LTI session for this runtime attempt was not found.', - }); - - ltiContext = loaded.context; - ltiSession = loaded.session; - } catch (error) { - return { - lineItemBinding: input.existingBinding, - gradePublication: null, - gradePublishedNow: false, - publishError: { - code: 'lti_session_missing', - message: error instanceof Error - ? error.message - : 'The verified LTI session is unavailable.', - detail: { - ltiToolSessionId: input.session.ltiToolSessionId, - }, - }, - }; - } - - const lineItemBinding = input.existingBinding ?? - await createLineItemBindingWithLtiTool({ - ...input, - ltiContext, - ltiSession, - }); - - if ('publishError' in lineItemBinding) { - return lineItemBinding; - } - - const existingPublication = await input.repository.getGradePublicationByAttemptId( - input.attempt.attemptId, - ); - - if (existingPublication?.status === 'published') { - return { - lineItemBinding, - gradePublication: existingPublication, - gradePublishedNow: false, - publishError: null, - }; - } - - const gradePublication = existingPublication ?? - await input.repository.createGradePublication({ - attemptId: input.attempt.attemptId, - lineItemBindingId: lineItemBinding.id, - lineItemUrl: lineItemBinding.lineItemUrl, - platformUserId: input.attempt.userId, - scoreGiven: input.score.scoreGiven, - scoreMaximum: input.score.scoreMaximum, - activityProgress: resolveActivityProgress(input.attempt), - gradingProgress: 'Pending', - status: 'pending', - createdAt: input.now().toISOString(), - updatedAt: input.now().toISOString(), - publishedAt: null, - errorCode: null, - errorDetail: null, - }); - const result = await ltiContext.tool.createAdvantage(ltiSession).submitScore( - { - userId: gradePublication.platformUserId, - scoreGiven: gradePublication.scoreGiven, - scoreMaximum: gradePublication.scoreMaximum, - activityProgress: gradePublication.activityProgress, - gradingProgress: 'FullyGraded', - timestamp: input.now().toISOString(), - }, - { lineItemUrl: lineItemBinding.lineItemUrl }, - ); - - if (!result.success) { - return { - lineItemBinding, - gradePublication: await input.repository.updateGradePublication({ - attemptId: input.attempt.attemptId, - status: 'failed', - updatedAt: input.now().toISOString(), - publishedAt: null, - errorCode: 'score_publish_failed', - errorDetail: { - message: result.error.message, - code: result.error.code, - serviceKind: result.error.serviceKind, - operation: result.error.operation, - }, - }), - gradePublishedNow: false, - publishError: { - code: 'score_publish_failed', - message: result.error.message, - detail: { - lineItemUrl: lineItemBinding.lineItemUrl, - serviceKind: result.error.serviceKind, - operation: result.error.operation, - }, - }, - }; - } - - return { - lineItemBinding, - gradePublication: await input.repository.updateGradePublication({ - attemptId: input.attempt.attemptId, - status: 'published', - updatedAt: input.now().toISOString(), - publishedAt: input.now().toISOString(), - errorCode: null, - errorDetail: null, - }), - gradePublishedNow: true, - publishError: null, - }; -} - -async function createLineItemBindingWithLtiTool(input: { - repository: PackageReviewRepository; - session: RuntimeSessionRecord; - attempt: AttemptRecord; - packageVersion: PackageVersionRecord; - score: AttemptScoreResult; - ltiContext: LanternLtiToolContext; - ltiSession: LTISession; - now: () => Date; -}): Promise< - | Awaited> - | Pick< - FinalizeAttemptResult, - 'lineItemBinding' | 'gradePublication' | 'gradePublishedNow' | 'publishError' - > -> { - const spec = buildFinalGradeLineItemSpec({ - session: input.session, - packageVersion: input.packageVersion, - scoreMaximum: input.score.scoreMaximum, - }); - const result = await input.ltiContext.tool.createAdvantage(input.ltiSession).findOrCreateLineItem( - { - resourceLinkId: input.attempt.resourceLinkId, - resourceId: spec.resourceId, - tag: spec.tag, - create: { - label: spec.label, - scoreMaximum: spec.scoreMaximum, - }, - }, - ); - - if (!result.success) { - return { - lineItemBinding: null, - gradePublication: null, - gradePublishedNow: false, - publishError: { - code: 'line_item_failed', - message: result.error.message, - detail: { - attemptId: input.attempt.attemptId, - serviceKind: result.error.serviceKind, - operation: result.error.operation, - }, - }, - }; - } - - return await input.repository.saveLineItemBinding({ - deploymentRecordId: input.session.deploymentRecordId, - packageVersionId: input.session.packageVersionId, - contextId: input.attempt.contextId, - resourceLinkId: input.attempt.resourceLinkId, - activityId: input.attempt.activityId, - lineItemsUrl: input.session.services.ags?.lineitemsUrl ?? '', - lineItemUrl: result.data.id, - resourceId: result.data.resourceId ?? spec.resourceId, - tag: result.data.tag ?? spec.tag, - label: result.data.label, - scoreMaximum: result.data.scoreMaximum, - createdAt: input.now().toISOString(), - updatedAt: input.now().toISOString(), - }); -} diff --git a/src/runtime/gateway_publication_support.ts b/src/runtime/gateway_publication_support.ts deleted file mode 100644 index 0533dad..0000000 --- a/src/runtime/gateway_publication_support.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - buildLtiActivityResourceId, - type LmsType, - type RuntimeSessionRecord, -} from '../lti/types.ts'; -import type { - AttemptRecord, - GradePublicationRecord, - PackageVersionRecord, -} from '../package_review/types.ts'; - -export interface ManagedLineItemSpec { - resourceId: string; - tag: string; - label: string; - scoreMaximum: number; -} - -export function buildFinalGradeLineItemSpec(input: { - session: RuntimeSessionRecord; - packageVersion: Pick; - scoreMaximum: number; -}): ManagedLineItemSpec { - return { - resourceId: buildLineItemResourceId(input.session), - tag: 'final-grade', - label: `${input.packageVersion.title} Final Grade`, - scoreMaximum: input.scoreMaximum, - }; -} - -export function buildSmokeVerificationLineItemSpec(input: { - appId: string; - appTitle: string; - lms: LmsType; -}): ManagedLineItemSpec { - return { - resourceId: `lantern:${input.appId}:${input.lms}:smoke`, - tag: 'smoke-verification', - label: `${input.appTitle} Smoke Verification`, - scoreMaximum: 1, - }; -} - -export function resolveActivityProgress( - attempt: AttemptRecord, -): GradePublicationRecord['activityProgress'] { - return attempt.completionState === 'completed' ? 'Completed' : 'InProgress'; -} - -function buildLineItemResourceId(session: RuntimeSessionRecord): string { - return buildLtiActivityResourceId({ - appId: session.appId, - packageVersion: session.packageVersion, - activityId: session.launch.activityId, - }); -} diff --git a/src/runtime/gateway_types.ts b/src/runtime/gateway_types.ts index 0160b92..c8c2395 100644 --- a/src/runtime/gateway_types.ts +++ b/src/runtime/gateway_types.ts @@ -1,10 +1,5 @@ import type { AttemptScoreResult } from '../grading/service.ts'; -import type { - AttemptEvidenceArtifactRecord, - AttemptRecord, - GradePublicationRecord, - LineItemBindingRecord, -} from '../package_review/types.ts'; +import type { AttemptEvidenceArtifactRecord, AttemptRecord } from '../package_review/types.ts'; import type { BrowserGraderResult, EvidenceArtifactContentType, @@ -60,12 +55,4 @@ export interface FinalizeAttemptResult { submissionMode: SubmissionMode; evidenceArtifacts: Array>; finalizedNow: boolean; - lineItemBinding: LineItemBindingRecord | null; - gradePublication: GradePublicationRecord | null; - gradePublishedNow: boolean; - publishError: { - code: string; - message: string; - detail: Record; - } | null; } diff --git a/src/runtime/reviewed_worker_http.ts b/src/runtime/reviewed_worker_http.ts new file mode 100644 index 0000000..46ad721 --- /dev/null +++ b/src/runtime/reviewed_worker_http.ts @@ -0,0 +1,87 @@ +import type { Context } from '@hono/hono'; + +/** Builds the sanitized internal request passed to an immutable reviewed Worker. */ +export function buildReviewedWorkerRequest( + context: Context, + session: { + readonly sessionId: string; + readonly sessionToken: string; + }, + traceId?: string, +): Request { + const sourceUrl = new URL(context.req.url); + const targetUrl = new URL('https://reviewed-app.internal/'); + const cursor = sourceUrl.searchParams.get('cursor'); + if (cursor !== null) { + targetUrl.searchParams.set('cursor', cursor); + } + const headers = new Headers({ + accept: 'text/html', + 'x-lantern-runtime-session-id': session.sessionId, + 'x-lantern-runtime-session-token': session.sessionToken, + }); + if (traceId !== undefined) { + headers.set('x-lantern-trace-id', traceId); + } + const language = context.req.header('accept-language'); + if (language !== undefined) { + headers.set('accept-language', language.slice(0, 160)); + } + return new Request(targetUrl, { method: 'GET', headers }); +} + +/** Enforces Lantern-owned headers on a reviewed Worker HTML response. */ +export function sanitizeReviewedWorkerResponse(response: Response): Response { + const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; + if (!contentType.startsWith('text/html')) { + throw new Error('Reviewed Worker apps must return an HTML response.'); + } + if ( + response.status < 200 || + response.status > 499 || + (response.status >= 300 && response.status < 400) + ) { + throw new Error('Reviewed Worker app returned an unsupported status.'); + } + const headers = new Headers({ + 'content-type': 'text/html; charset=UTF-8', + }); + const contentLanguage = response.headers.get('content-language'); + if (contentLanguage !== null) { + headers.set('content-language', contentLanguage.slice(0, 64)); + } + applyRuntimeDocumentHeaders(headers); + headers.set('cache-control', 'private, no-store'); + return new Response(response.body, { + status: response.status, + headers, + }); +} + +function applyRuntimeDocumentHeaders(headers: Headers): void { + headers.set('cache-control', 'private, no-store'); + headers.set('cross-origin-resource-policy', 'same-origin'); + headers.set('referrer-policy', 'no-referrer'); + headers.set('x-content-type-options', 'nosniff'); + headers.set( + 'content-security-policy', + [ + "default-src 'none'", + "style-src 'unsafe-inline'", + "img-src 'self' data:", + "base-uri 'none'", + "form-action 'self'", + "frame-ancestors 'self'", + ].join('; '), + ); + headers.set( + 'permissions-policy', + [ + 'camera=()', + 'geolocation=()', + 'microphone=()', + 'payment=()', + 'usb=()', + ].join(', '), + ); +} diff --git a/src/runtime/route_commands.ts b/src/runtime/route_commands.ts index e533f7f..168527a 100644 --- a/src/runtime/route_commands.ts +++ b/src/runtime/route_commands.ts @@ -32,10 +32,8 @@ export async function acceptRuntimeAttemptEvent(input: { eventType: 'attempt.submitted', actorType: 'system', actorId: null, - deploymentRecordId: input.session.deploymentRecordId, packageVersionId: input.session.packageVersionId, attemptId: input.session.attemptId, - lineItemBindingId: null, status: 'accepted', summary: 'Accepted attempt submission through the runtime gateway.', detail: { @@ -116,10 +114,8 @@ export async function finalizeRuntimeSession(input: { eventType: 'attempt.finalized', actorType: 'system', actorId: null, - deploymentRecordId: input.session.deploymentRecordId, packageVersionId: input.session.packageVersionId, attemptId: input.session.attemptId, - lineItemBindingId: null, status: 'accepted', summary: 'Finalized the durable attempt inside the runtime gateway.', detail: buildFinalizeAuditDetail(result), @@ -133,7 +129,7 @@ export async function finalizeRuntimeSession(input: { completionState: result.attempt.completionState, scoreGiven: result.score.scoreGiven, scoreMaximum: result.score.scoreMaximum, - gradePublished: result.gradePublication?.status === 'published', + gradePublished: false, submissionMode: result.submissionMode, evidenceArtifactCount: result.evidenceArtifacts.length, evidenceArtifacts: result.evidenceArtifacts, @@ -143,47 +139,6 @@ export async function finalizeRuntimeSession(input: { }); } - if (result.gradePublishedNow && result.gradePublication !== null) { - await input.repository.recordAuditEvent({ - eventType: 'grade_publish.succeeded', - actorType: 'system', - actorId: null, - deploymentRecordId: input.session.deploymentRecordId, - packageVersionId: input.session.packageVersionId, - attemptId: input.session.attemptId, - lineItemBindingId: result.lineItemBinding?.id ?? null, - status: 'succeeded', - summary: 'Published the final score to Canvas through AGS.', - detail: { - lineItemUrl: result.gradePublication.lineItemUrl, - scoreGiven: result.gradePublication.scoreGiven, - scoreMaximum: result.gradePublication.scoreMaximum, - }, - occurredAt, - }); - } - - if (result.publishError !== null) { - await input.repository.recordAuditEvent({ - eventType: 'grade_publish.failed', - actorType: 'system', - actorId: null, - deploymentRecordId: input.session.deploymentRecordId, - packageVersionId: input.session.packageVersionId, - attemptId: input.session.attemptId, - lineItemBindingId: result.lineItemBinding?.id ?? null, - status: 'failed', - summary: 'Canvas AGS score publish failed.', - detail: { - code: result.publishError.code, - message: result.publishError.message, - request: input.request, - ...result.publishError.detail, - }, - occurredAt, - }); - } - return result; } diff --git a/src/runtime/session.ts b/src/runtime/session.ts index 2978a6b..3beb58f 100644 --- a/src/runtime/session.ts +++ b/src/runtime/session.ts @@ -104,7 +104,7 @@ export async function renderRuntimeSessionPage( buildRuntimeBootstrapScript({ bootstrap, runtimeBaseUrl, - previewSessionId: session.preview?.previewSessionId ?? null, + previewSessionId: session.preview.previewSessionId, }) }`; diff --git a/src/runtime/session_html.ts b/src/runtime/session_html.ts index f0beb26..26b721b 100644 --- a/src/runtime/session_html.ts +++ b/src/runtime/session_html.ts @@ -15,6 +15,7 @@ export function buildRuntimeBootstrapScript(input: { ); const scoreProposalUrl = serializeForInlineScript(`${input.runtimeBaseUrl}/score-proposal`); const finalizeUrl = serializeForInlineScript(`${input.runtimeBaseUrl}/finalize`); + const codeRunsUrl = serializeForInlineScript(`${input.runtimeBaseUrl}/code-runs`); const browserGraderUrls = buildBrowserGraderAssetUrls({ runtimeBaseUrl: input.runtimeBaseUrl, }); @@ -184,6 +185,18 @@ window.GatewayApp = { const runner = await ensureBrowserGraderLoaded(); return await runner.run(); }, + async runCodeSubmission(input) { + const response = await fetch(${codeRunsUrl}, { + method: 'POST', + headers: { + Authorization: 'Bearer ' + window.GatewayBootstrap.session.token, + 'content-type': 'application/json', + }, + body: JSON.stringify(input ?? {}), + }); + + return await readGatewayMutationResponse(response, 'Python code submission'); + }, async finalizeAttempt(input) { const response = await fetch(${finalizeUrl}, { method: 'POST', diff --git a/src/test_helpers/admin_auth.ts b/src/test_helpers/admin_auth.ts index 32baaa8..9f22f8c 100644 --- a/src/test_helpers/admin_auth.ts +++ b/src/test_helpers/admin_auth.ts @@ -10,7 +10,7 @@ import { expiredAdminSessionCookieValue, readAdminSessionCookie, } from '../admin_auth/service.ts'; -import { createApp } from '../app.ts'; +import { createTestApp as createApp } from './app.ts'; import type { AppServices } from '../app_services.ts'; import { createObjectEnvReader } from '../platform/env.ts'; diff --git a/src/test_helpers/app.ts b/src/test_helpers/app.ts new file mode 100644 index 0000000..d9957b1 --- /dev/null +++ b/src/test_helpers/app.ts @@ -0,0 +1,12 @@ +import type { Hono } from '@hono/hono'; +import { buildApp } from '../app_core.ts'; +import { type AppServices, resolveTestServices } from '../app_services.ts'; +import { installDenoEnvReader } from '../platform/deno_env.ts'; + +installDenoEnvReader(); + +export function createTestApp(services: Partial = {}): Hono { + return buildApp(resolveTestServices(services), { + kernelAdminContext: services.getLmsKernelRepository !== undefined, + }); +} diff --git a/src/test_helpers/app_writer_generated_package.ts b/src/test_helpers/app_writer_generated_package.ts index 3cd3063..ecea629 100644 --- a/src/test_helpers/app_writer_generated_package.ts +++ b/src/test_helpers/app_writer_generated_package.ts @@ -8,7 +8,7 @@ export function buildValidSimpleActivityFiles(): AppWriterWorkspaceFile[] { path: 'manifest.json', contents: JSON.stringify( { - schema_version: '1', + schema_version: '2', app_id: 'phonics-match', version: '0.1.0', title: 'Phonics Match', @@ -16,7 +16,7 @@ export function buildValidSimpleActivityFiles(): AppWriterWorkspaceFile[] { type: 'user', id: 'instructor-1', }, - entrypoint: '/dist/index.html', + runtime: { kind: 'browser', entrypoint: '/dist/index.html' }, roles: ['learner', 'instructor'], install_scope: 'course', capabilities: ['read_activity_content', 'submit_attempt_event', 'finalize_attempt'], @@ -98,7 +98,7 @@ export function buildValidBrowserAutograderFiles(): AppWriterWorkspaceFile[] { path: 'manifest.json', contents: JSON.stringify( { - schema_version: '1', + schema_version: '2', app_id: 'web-check', version: '0.1.0', title: 'Web Check', @@ -106,7 +106,7 @@ export function buildValidBrowserAutograderFiles(): AppWriterWorkspaceFile[] { type: 'user', id: 'instructor-1', }, - entrypoint: '/dist/index.html', + runtime: { kind: 'browser', entrypoint: '/dist/index.html' }, roles: ['learner', 'instructor'], install_scope: 'assignment', capabilities: ['read_activity_content', 'submit_evidence_artifact', 'finalize_attempt'], diff --git a/src/test_helpers/lti_session_builders.ts b/src/test_helpers/lti_session_builders.ts index 06aa887..2156b86 100644 --- a/src/test_helpers/lti_session_builders.ts +++ b/src/test_helpers/lti_session_builders.ts @@ -1,26 +1,13 @@ import { resolveSubmissionMode } from '../../sdk/app-sdk.ts'; import type { LTISession } from '@longsightgroup/lti-tool'; -import type { - DeepLinkingSessionRecord, - LmsType, - RuntimeSessionRecord, - ValidatedDeepLinkingRequest, - ValidatedLaunch, -} from '../lti/types.ts'; -import { LTI_ASSIGNMENT_SELECTION_PLACEMENT as DEFAULT_ASSIGNMENT_SELECTION_PLACEMENT } from '../lti/types.ts'; -import { - buildLaunchServiceClaims, - buildPlatformIdentity, - TEST_NOW, -} from './lti_identity_builders.ts'; +import type { LmsType, RuntimeSessionRecord } from '../lti/types.ts'; +import { buildLaunchServiceClaims, TEST_NOW } from './lti_identity_builders.ts'; export function buildTestLtiJwtPayload( input: { - messageType?: 'LtiResourceLinkRequest' | 'LtiDeepLinkingRequest'; targetLinkUri?: string; } = {}, ): LTISession['jwtPayload'] { - const messageType = input.messageType ?? 'LtiResourceLinkRequest'; const targetLinkUri = input.targetLinkUri ?? 'http://localhost:8417/lti/launch'; const base = { iss: 'https://canvas.instructure.com', @@ -33,14 +20,6 @@ export function buildTestLtiJwtPayload( 'https://purl.imsglobal.org/spec/lti/claim/target_link_uri': targetLinkUri, } as const; - if (messageType === 'LtiDeepLinkingRequest') { - return { - ...base, - sub: 'canvas-user-123', - 'https://purl.imsglobal.org/spec/lti/claim/message_type': 'LtiDeepLinkingRequest', - }; - } - return { ...base, sub: 'canvas-user-123', @@ -48,105 +27,6 @@ export function buildTestLtiJwtPayload( }; } -export function buildValidatedLaunch(overrides: Partial = {}): ValidatedLaunch { - const identity = buildPlatformIdentity({ - ...(overrides.lms === undefined ? {} : { lms: overrides.lms }), - ...(overrides.canvasEnvironment === undefined - ? {} - : { canvasEnvironment: overrides.canvasEnvironment }), - ...(overrides.issuer === undefined ? {} : { issuer: overrides.issuer }), - ...(overrides.clientId === undefined ? {} : { clientId: overrides.clientId }), - ...(overrides.deploymentId === undefined ? {} : { deploymentId: overrides.deploymentId }), - }); - - return { - internalDeploymentId: overrides.internalDeploymentId ?? 1, - internalDeploymentSlug: overrides.internalDeploymentSlug ?? 'chapter-4-asteroids-pilot', - appId: overrides.appId ?? 'chapter-4-asteroids', - packageVersionId: overrides.packageVersionId ?? 1, - packageVersion: overrides.packageVersion ?? '0.1.0', - contentPath: overrides.contentPath ?? '/content/activity.json', - attemptId: overrides.attemptId ?? 'attempt-123', - userId: overrides.userId ?? 'canvas-user-123', - userDisplayName: overrides.userDisplayName ?? null, - userEmail: overrides.userEmail ?? null, - userLogin: overrides.userLogin ?? null, - userRole: overrides.userRole ?? 'learner', - resourceLinkId: overrides.resourceLinkId ?? 'resource-link-123', - resourceLinkTitle: overrides.resourceLinkTitle ?? 'Chapter 4 Asteroids', - contextId: overrides.contextId ?? 'course-42', - contextTitle: overrides.contextTitle ?? 'Physics 101', - targetLinkUri: overrides.targetLinkUri ?? 'http://localhost:8417/lti/launch', - returnUrl: overrides.returnUrl ?? 'https://canvas.example/return', - activityId: overrides.activityId ?? 'activity-123', - services: overrides.services ?? - buildLaunchServiceClaims({ - lms: identity.lms, - }), - issuedAt: overrides.issuedAt ?? TEST_NOW, - ...identity, - }; -} - -export function buildDeepLinkingSettingsClaimValue( - input: { - acceptTypes?: string[] | undefined; - acceptMultiple?: boolean | undefined; - acceptPresentationDocumentTargets?: string[] | undefined; - acceptLineItem?: boolean | undefined; - deepLinkReturnUrl?: string | undefined; - data?: string | null | undefined; - } = {}, -): Record { - return { - accept_types: input.acceptTypes ?? ['ltiResourceLink'], - accept_multiple: input.acceptMultiple ?? false, - accept_presentation_document_targets: input.acceptPresentationDocumentTargets ?? ['iframe'], - accept_lineitem: input.acceptLineItem ?? false, - deep_link_return_url: input.deepLinkReturnUrl ?? - 'https://canvas.example/courses/42/deep_link_return', - ...(input.data === undefined ? {} : { data: input.data }), - }; -} - -export function buildValidatedDeepLinkingRequest( - overrides: Partial = {}, -): ValidatedDeepLinkingRequest { - const identity = buildPlatformIdentity({ - ...(overrides.lms === undefined ? {} : { lms: overrides.lms }), - ...(overrides.canvasEnvironment === undefined - ? {} - : { canvasEnvironment: overrides.canvasEnvironment }), - ...(overrides.issuer === undefined ? {} : { issuer: overrides.issuer }), - ...(overrides.clientId === undefined ? {} : { clientId: overrides.clientId }), - ...(overrides.deploymentId === undefined ? {} : { deploymentId: overrides.deploymentId }), - }); - - return { - ltiToolSessionId: overrides.ltiToolSessionId ?? null, - internalDeploymentId: overrides.internalDeploymentId ?? 1, - internalDeploymentSlug: overrides.internalDeploymentSlug ?? 'chapter-4-asteroids-pilot', - appId: overrides.appId ?? 'chapter-4-asteroids', - userId: overrides.userId ?? 'canvas-user-123', - userRole: overrides.userRole ?? 'instructor', - contextId: overrides.contextId ?? 'course-42', - contextTitle: overrides.contextTitle ?? 'Physics 101', - targetLinkUri: overrides.targetLinkUri ?? 'http://localhost:8417/lti/deep-linking', - deepLinkReturnUrl: overrides.deepLinkReturnUrl ?? - 'https://canvas.example/courses/42/deep_link_return', - data: overrides.data ?? 'deep-linking-state-token', - placement: overrides.placement ?? DEFAULT_ASSIGNMENT_SELECTION_PLACEMENT, - settings: overrides.settings ?? { - acceptTypes: ['ltiResourceLink'], - acceptMultiple: false, - acceptPresentationDocumentTargets: ['iframe'], - acceptLineItem: false, - }, - issuedAt: overrides.issuedAt ?? TEST_NOW, - ...identity, - }; -} - export function buildRuntimeSessionRecord( overrides: Partial & { servicesLms?: LmsType } = {}, ): RuntimeSessionRecord { @@ -157,8 +37,6 @@ export function buildRuntimeSessionRecord( sessionToken: sessionOverrides.sessionToken ?? 'runtime-token-123', ltiToolSessionId: sessionOverrides.ltiToolSessionId ?? null, attemptId: sessionOverrides.attemptId ?? 'attempt-123', - deploymentRecordId: sessionOverrides.deploymentRecordId ?? 1, - deploymentSlug: sessionOverrides.deploymentSlug ?? 'chapter-4-asteroids-pilot', appId: sessionOverrides.appId ?? 'chapter-4-asteroids', packageVersionId: sessionOverrides.packageVersionId ?? 1, packageVersion: sessionOverrides.packageVersion ?? '0.1.0', @@ -195,37 +73,10 @@ export function buildRuntimeSessionRecord( ], ), }, - ...(sessionOverrides.preview === undefined ? {} : { preview: sessionOverrides.preview }), + preview: sessionOverrides.preview ?? { + previewSessionId: 'preview-session-123', + }, createdAt: sessionOverrides.createdAt ?? TEST_NOW, expiresAt: sessionOverrides.expiresAt ?? '2099-03-26T22:47:00Z', }; } - -export function buildDeepLinkingSessionRecord( - overrides: Partial = {}, -): DeepLinkingSessionRecord { - return { - sessionId: overrides.sessionId ?? 'deep-linking-session-123', - sessionToken: overrides.sessionToken ?? 'deep-linking-token-123', - ltiToolSessionId: overrides.ltiToolSessionId ?? null, - deploymentRecordId: overrides.deploymentRecordId ?? 1, - deploymentSlug: overrides.deploymentSlug ?? 'chapter-4-asteroids-pilot', - appId: overrides.appId ?? 'chapter-4-asteroids', - userId: overrides.userId ?? 'canvas-user-123', - userRole: overrides.userRole ?? 'instructor', - contextId: overrides.contextId ?? 'course-42', - contextTitle: overrides.contextTitle ?? 'Physics 101', - deepLinkReturnUrl: overrides.deepLinkReturnUrl ?? - 'https://canvas.example/courses/42/deep_link_return', - data: overrides.data ?? 'deep-linking-state-token', - placement: overrides.placement ?? DEFAULT_ASSIGNMENT_SELECTION_PLACEMENT, - acceptTypes: overrides.acceptTypes ?? ['ltiResourceLink'], - acceptMultiple: overrides.acceptMultiple ?? false, - acceptPresentationDocumentTargets: overrides.acceptPresentationDocumentTargets ?? ['iframe'], - acceptLineItem: overrides.acceptLineItem ?? false, - selection: overrides.selection ?? null, - createdAt: overrides.createdAt ?? TEST_NOW, - expiresAt: overrides.expiresAt ?? '2026-03-26T22:50:00Z', - usedAt: overrides.usedAt ?? null, - }; -} diff --git a/src/test_helpers/lti_signing.ts b/src/test_helpers/lti_signing.ts index 50cd7f8..1cd87f8 100644 --- a/src/test_helpers/lti_signing.ts +++ b/src/test_helpers/lti_signing.ts @@ -1,6 +1,5 @@ import { importJWK, SignJWT } from 'jose'; -import type { CanvasDeploymentBinding, DeploymentBinding } from '../lti/types.ts'; -import { LTI_DEEP_LINKING_REQUEST_MESSAGE_TYPE as DEFAULT_DEEP_LINKING_REQUEST_MESSAGE_TYPE } from '../lti/types.ts'; +import type { CanvasDeploymentBinding } from '../lti/types.ts'; import { type AgsShape, buildAgsLaunchClaimValue, @@ -8,8 +7,6 @@ import { buildNrpsLaunchClaimValue, TEST_NOW, } from './lti_identity_builders.ts'; -import { buildDeepLinkingSettingsClaimValue } from './lti_session_builders.ts'; -import { createMemoryLtiStorage, syncLtiToolDeploymentBinding } from '../lti/tool_provider.ts'; export const TEST_TOOL_PRIVATE_JWK = { kty: 'RSA', @@ -74,12 +71,6 @@ export interface CanvasLaunchTokenInput { contextMembershipsUrl?: string; serviceVersions?: string[]; } | null; - deepLinkReturnUrl?: string; - deepLinkData?: string | null; - deepLinkAcceptTypes?: string[]; - deepLinkAcceptMultiple?: boolean; - deepLinkAcceptPresentationDocumentTargets?: string[]; - deepLinkAcceptLineItem?: boolean; custom?: Record; } @@ -98,19 +89,6 @@ export async function signCanvasIdToken(input: CanvasLaunchTokenInput = {}): Pro ? null : buildAgsLaunchClaimValue(input.ags ?? {}, input.agsShape); const nrpsClaim = input.nrps === null ? null : buildNrpsLaunchClaimValue(input.nrps ?? {}); - const deepLinkingSettings = input.messageType === DEFAULT_DEEP_LINKING_REQUEST_MESSAGE_TYPE || - input.deepLinkReturnUrl !== undefined || - input.deepLinkAcceptTypes !== undefined || - input.deepLinkAcceptPresentationDocumentTargets !== undefined - ? buildDeepLinkingSettingsClaimValue({ - acceptTypes: input.deepLinkAcceptTypes, - acceptMultiple: input.deepLinkAcceptMultiple, - acceptPresentationDocumentTargets: input.deepLinkAcceptPresentationDocumentTargets, - acceptLineItem: input.deepLinkAcceptLineItem, - deepLinkReturnUrl: input.deepLinkReturnUrl, - data: input.deepLinkData, - }) - : null; const claims = { ...(input.nonce === null ? {} : { nonce: input.nonce ?? 'nonce-123' }), ...(input.name === undefined ? {} : { name: input.name }), @@ -150,9 +128,6 @@ export async function signCanvasIdToken(input: CanvasLaunchTokenInput = {}): Pro ...(nrpsClaim === null ? {} : { 'https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice': nrpsClaim, }), - ...(deepLinkingSettings === null ? {} : { - 'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings': deepLinkingSettings, - }), }; const signingKey = await importJWK(TEST_CANVAS_PRIVATE_JWK, 'ES256'); let token = new SignJWT(claims) @@ -192,24 +167,6 @@ export async function signLtiToolState(input: { .sign(new TextEncoder().encode(TEST_LTI_STATE_SECRET)); } -export async function createSyncedTestLtiStorage(input: { - binding?: DeploymentBinding; - appTitle?: string; - deploymentSlug?: string; -} = {}) { - const binding = input.binding ?? buildDeploymentBinding(); - const storage = createMemoryLtiStorage(); - - await syncLtiToolDeploymentBinding({ - storage, - appTitle: input.appTitle ?? 'Chapter 4 Asteroids', - deploymentSlug: input.deploymentSlug ?? 'chapter-4-asteroids-pilot', - binding, - }); - - return storage; -} - export function getTestCanvasJwks(): { keys: [ { diff --git a/src/test_helpers/package_review.ts b/src/test_helpers/package_review.ts index 642b9ab..787dbb5 100644 --- a/src/test_helpers/package_review.ts +++ b/src/test_helpers/package_review.ts @@ -2,6 +2,5 @@ export * from './package_review_in_memory_repository.ts'; export * from './package_review_in_memory_authoring.ts'; export * from './package_review_in_memory_shared.ts'; export * from './package_review_test_builder_base.ts'; -export * from './package_review_test_builder_ops.ts'; export * from './package_review_test_builder_preview.ts'; export * from './lti_session_builders.ts'; diff --git a/src/test_helpers/package_review_in_memory_attempts.ts b/src/test_helpers/package_review_in_memory_attempts.ts index 9fdfe37..030f59f 100644 --- a/src/test_helpers/package_review_in_memory_attempts.ts +++ b/src/test_helpers/package_review_in_memory_attempts.ts @@ -14,11 +14,6 @@ type AttemptRepository = Pick< | 'listAttemptEvents' | 'finalizeAttempt' | 'writeAttemptLocalState' - | 'getLineItemBinding' - | 'saveLineItemBinding' - | 'getGradePublicationByAttemptId' - | 'createGradePublication' - | 'updateGradePublication' | 'recordAuditEvent' | 'listAuditEventsByAttemptId' | 'listAuditEventsByEventType' @@ -47,13 +42,6 @@ export function createInMemoryAttemptRepository(state: InMemoryRepositoryState): return Promise.resolve( state.attempts .filter((candidate) => candidate.appId === appId) - .filter((candidate) => { - const deployment = state.deployments.find( - (deploymentRecord) => deploymentRecord.id === candidate.deploymentRecordId, - ); - - return deployment?.lmsType !== 'preview'; - }) .sort( (left, right) => right.startedAt.localeCompare(left.startedAt) || right.id - left.id, ) @@ -192,90 +180,6 @@ export function createInMemoryAttemptRepository(state: InMemoryRepositoryState): return Promise.resolve(cloneRecord(nextRecord)); }, - getLineItemBinding(input) { - const record = state.lineItemBindings.find( - (candidate) => - candidate.deploymentRecordId === input.deploymentRecordId && - candidate.packageVersionId === input.packageVersionId && - candidate.contextId === input.contextId && - candidate.resourceLinkId === input.resourceLinkId && - candidate.activityId === input.activityId, - ); - - return Promise.resolve(record ? cloneRecord(record) : null); - }, - - saveLineItemBinding(record) { - const existing = state.lineItemBindings.find( - (candidate) => - candidate.deploymentRecordId === record.deploymentRecordId && - candidate.packageVersionId === record.packageVersionId && - candidate.contextId === record.contextId && - candidate.resourceLinkId === record.resourceLinkId && - candidate.activityId === record.activityId, - ); - - if (existing) { - return Promise.resolve(cloneRecord(existing)); - } - - const nextRecord = cloneRecord({ - ...record, - id: nextId(state.lineItemBindings), - }); - state.lineItemBindings.push(nextRecord); - return Promise.resolve(cloneRecord(nextRecord)); - }, - - getGradePublicationByAttemptId(attemptId) { - const record = state.gradePublications.find((candidate) => candidate.attemptId === attemptId); - return Promise.resolve(record ? cloneRecord(record) : null); - }, - - createGradePublication(record) { - const existing = state.gradePublications.find( - (candidate) => candidate.attemptId === record.attemptId, - ); - - if (existing) { - return Promise.resolve(cloneRecord(existing)); - } - - const nextRecord = cloneRecord({ - ...record, - id: nextId(state.gradePublications), - }); - state.gradePublications.push(nextRecord); - return Promise.resolve(cloneRecord(nextRecord)); - }, - - updateGradePublication(input) { - const index = state.gradePublications.findIndex( - (candidate) => candidate.attemptId === input.attemptId, - ); - - if (index < 0) { - throw new Error(`Grade publication for attempt ${input.attemptId} was not found.`); - } - - const existing = state.gradePublications[index]; - - if (!existing) { - throw new Error(`Grade publication for attempt ${input.attemptId} was not found.`); - } - - const nextRecord = cloneRecord({ - ...existing, - status: input.status, - updatedAt: input.updatedAt, - publishedAt: input.publishedAt, - errorCode: input.errorCode, - errorDetail: input.errorDetail, - }); - state.gradePublications.splice(index, 1, nextRecord); - return Promise.resolve(cloneRecord(nextRecord)); - }, - recordAuditEvent(record) { const nextRecord = cloneRecord({ ...record, @@ -283,29 +187,6 @@ export function createInMemoryAttemptRepository(state: InMemoryRepositoryState): }); state.auditEvents.push(nextRecord); - if ( - nextRecord.eventType === 'deployment.ags_smoke_verified' && - nextRecord.deploymentRecordId !== null - ) { - state.controlPlaneDeploymentDetails = state.controlPlaneDeploymentDetails.map((detail) => - detail.inventory.deploymentId === nextRecord.deploymentRecordId - ? { - ...detail, - latestAgsSmoke: { - status: nextRecord.status === 'succeeded' ? 'succeeded' : 'failed', - occurredAt: nextRecord.occurredAt, - summary: nextRecord.summary, - attemptId: nextRecord.attemptId, - contextId: typeof nextRecord.detail.contextId === 'string' - ? nextRecord.detail.contextId - : null, - detail: cloneRecord(nextRecord.detail), - }, - } - : detail - ); - } - return Promise.resolve(cloneRecord(nextRecord)); }, diff --git a/src/test_helpers/package_review_in_memory_canvas_registrations.ts b/src/test_helpers/package_review_in_memory_canvas_registrations.ts deleted file mode 100644 index e663e71..0000000 --- a/src/test_helpers/package_review_in_memory_canvas_registrations.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { DeploymentRecord } from '../package_review/types.ts'; -import { DEFAULT_UPDATED_AT } from './package_review_test_defaults.ts'; -import type { InMemoryRepositoryState } from './package_review_in_memory_shared.ts'; -import { cloneRecord, nextId } from './package_review_in_memory_shared.ts'; -import { buildDeploymentRecord } from './package_review_test_builder_base.ts'; - -type PendingCanvasDeployment = DeploymentRecord & { - pendingCanvasIssuer?: string; - pendingCanvasClientId?: string; - pendingCanvasEnvironment?: 'production' | 'beta' | 'test'; -}; - -export function completePendingCanvasBinding( - state: InMemoryRepositoryState, - input: { - issuer: string; - clientId: string; - deploymentId: string; - }, -): DeploymentRecord | null { - const exactMatch = state.deployments.find( - (candidate) => - candidate.lmsType === 'canvas' && - candidate.binding?.lms === 'canvas' && - candidate.binding.issuer === input.issuer && - candidate.binding.clientId === input.clientId && - candidate.binding.deploymentId === input.deploymentId, - ); - - if (exactMatch) { - return cloneRecord(exactMatch); - } - - const pending = state.deployments.filter((candidate) => - isMatchingPendingCanvasDeployment(candidate, input) - ); - - if (pending.length === 0) { - return null; - } - - if (pending.length > 1) { - throw new Error( - `Multiple Canvas registrations matched issuer ${input.issuer} with client ${input.clientId}. Resolve the duplicate Canvas registrations before login can continue.`, - ); - } - - const deployment = pending[0]; - - if (!deployment) { - return null; - } - - const pendingCanvas = deployment as PendingCanvasDeployment; - const nextDeployment = buildDeploymentRecord({ - ...deployment, - binding: { - lms: 'canvas', - issuer: input.issuer, - clientId: input.clientId, - deploymentId: input.deploymentId, - canvasEnvironment: pendingCanvas.pendingCanvasEnvironment ?? 'production', - }, - updatedAt: DEFAULT_UPDATED_AT, - }); - const index = state.deployments.findIndex((candidate) => candidate.slug === deployment.slug); - state.deployments.splice(index, 1, nextDeployment); - - return cloneRecord(nextDeployment); -} - -export function saveCanvasRegistration( - state: InMemoryRepositoryState, - input: { - slug: string; - label: string; - appId: string; - canvasEnvironment: 'production' | 'beta' | 'test'; - issuer: string; - clientId: string; - }, -): DeploymentRecord { - const existing = state.deployments.find((candidate) => candidate.slug === input.slug); - const existingAppSlot = state.deployments.find( - (candidate) => - candidate.slug !== input.slug && - candidate.appId === input.appId && - candidate.lmsType === 'canvas', - ); - const conflicting = state.deployments.find((candidate) => { - const pendingCanvas = candidate as PendingCanvasDeployment; - - return ( - candidate.slug !== input.slug && - candidate.lmsType === 'canvas' && - candidate.binding === null && - pendingCanvas.pendingCanvasIssuer === input.issuer && - pendingCanvas.pendingCanvasClientId === input.clientId - ); - }); - - if (conflicting) { - throw new Error( - `Canvas registration ${input.clientId} is already reserved for another deployment.`, - ); - } - - if (existing && existing.appId !== input.appId) { - throw new Error(`Deployment ${input.slug} belongs to app ${existing.appId}.`); - } - - if (existing && existing.lmsType !== 'canvas') { - throw new Error( - `Deployment ${input.slug} is already bound as ${existing.lmsType} and cannot change to canvas.`, - ); - } - - if (existingAppSlot) { - throw new Error(`App ${input.appId} already has a canvas deployment.`); - } - - const nextDeployment = { - ...buildDeploymentRecord({ - id: existing?.id ?? nextId(state.deployments), - slug: input.slug, - label: input.label, - appId: input.appId, - enabledPackageVersionId: existing?.enabledPackageVersionId ?? null, - enabledPackageVersion: existing?.enabledPackageVersion ?? null, - lmsType: 'canvas', - binding: null, - ltiProfileOverride: existing?.ltiProfileOverride ?? null, - updatedAt: DEFAULT_UPDATED_AT, - }), - pendingCanvasIssuer: input.issuer, - pendingCanvasClientId: input.clientId, - pendingCanvasEnvironment: input.canvasEnvironment, - }; - - if (existing) { - const index = state.deployments.findIndex((candidate) => candidate.slug === input.slug); - state.deployments.splice(index, 1, nextDeployment); - } else { - state.deployments.push(nextDeployment); - } - - return cloneRecord(nextDeployment); -} - -function isMatchingPendingCanvasDeployment( - candidate: DeploymentRecord, - input: { - issuer: string; - clientId: string; - }, -): boolean { - const pendingCanvas = candidate as PendingCanvasDeployment; - - return ( - candidate.lmsType === 'canvas' && - candidate.binding === null && - candidate.slug !== '' && - candidate.appId !== '' && - pendingCanvas.pendingCanvasIssuer === input.issuer && - pendingCanvas.pendingCanvasClientId === input.clientId - ); -} diff --git a/src/test_helpers/package_review_in_memory_deployments.ts b/src/test_helpers/package_review_in_memory_deployments.ts deleted file mode 100644 index c811bd2..0000000 --- a/src/test_helpers/package_review_in_memory_deployments.ts +++ /dev/null @@ -1,277 +0,0 @@ -import type { PackageReviewRepository } from '../package_review/repository.ts'; -import { DEFAULT_UPDATED_AT } from './package_review_test_defaults.ts'; -import { - completePendingCanvasBinding, - saveCanvasRegistration, -} from './package_review_in_memory_canvas_registrations.ts'; -import type { InMemoryRepositoryState } from './package_review_in_memory_shared.ts'; -import { cloneRecord, nextId } from './package_review_in_memory_shared.ts'; -import { buildDeploymentRecord } from './package_review_test_builder_base.ts'; - -type DeploymentRepository = Pick< - PackageReviewRepository, - | 'getDeploymentBySlug' - | 'getLanternLtiProfileSettings' - | 'listDeploymentsByApp' - | 'getDeploymentByBinding' - | 'getDeploymentByPlatformIdentity' - | 'completePendingCanvasBinding' - | 'saveLanternDefaultLtiProfile' - | 'saveDeploymentLtiProfileOverride' - | 'saveDeploymentBinding' - | 'saveCanvasRegistration' - | 'pinDeploymentVersion' ->; - -export function createInMemoryDeploymentRepository( - state: InMemoryRepositoryState, -): DeploymentRepository { - return { - getDeploymentBySlug(slug) { - const deployment = state.deployments.find((candidate) => candidate.slug === slug); - return Promise.resolve(deployment ? cloneRecord(deployment) : null); - }, - - getLanternLtiProfileSettings() { - return Promise.resolve(cloneRecord(state.lanternLtiProfileSettings)); - }, - - listDeploymentsByApp(appId) { - return Promise.resolve( - state.deployments - .filter((candidate) => candidate.appId === appId && candidate.lmsType !== 'preview') - .map((deployment) => cloneRecord(deployment)), - ); - }, - - getDeploymentByBinding(binding) { - const deployment = state.deployments.find( - (candidate) => - candidate.binding?.lms === binding.lms && - candidate.binding?.issuer === binding.issuer && - candidate.binding?.clientId === binding.clientId && - candidate.binding?.deploymentId === binding.deploymentId, - ); - return Promise.resolve(deployment ? cloneRecord(deployment) : null); - }, - - getDeploymentByPlatformIdentity(input) { - const deployments = state.deployments.filter( - (candidate) => - candidate.binding?.issuer === input.issuer && - candidate.binding?.deploymentId === input.deploymentId, - ); - - const matchingDeployments = input.clientId === null - ? deployments - : deployments.filter((candidate) => candidate.binding?.clientId === input.clientId); - - if (matchingDeployments.length === 0) { - if (input.clientId !== null) { - const canvasMatches = state.deployments.filter( - (candidate) => - candidate.binding?.lms === 'canvas' && - candidate.binding.issuer === input.issuer && - candidate.binding.clientId === input.clientId, - ); - const savedCanvas = canvasMatches[0]; - - if ( - canvasMatches.length === 1 && - savedCanvas?.binding?.deploymentId !== undefined && - savedCanvas.binding.deploymentId !== input.deploymentId - ) { - throw new Error( - `Canvas sent deployment ${input.deploymentId} for issuer ${input.issuer} and client ${input.clientId}, but Lantern saved deployment ${savedCanvas.binding.deploymentId}. Update the saved Canvas binding or relaunch from the correct Canvas placement.`, - ); - } - } - - return Promise.resolve(null); - } - - if (matchingDeployments.length > 1) { - if (input.clientId === null) { - throw new Error( - `Multiple deployments matched issuer ${input.issuer} with deployment ${input.deploymentId}. Platform must send client_id or duplicate LMS bindings must be resolved before login can continue.`, - ); - } - - throw new Error( - `Multiple deployments matched issuer ${input.issuer} with client ${input.clientId} and deployment ${input.deploymentId}. Resolve the duplicate LMS bindings before login can continue.`, - ); - } - - const deployment = matchingDeployments[0]; - - if (!deployment) { - return Promise.resolve(null); - } - - return Promise.resolve(cloneRecord(deployment)); - }, - - completePendingCanvasBinding(input) { - return Promise.resolve(completePendingCanvasBinding(state, input)); - }, - - saveLanternDefaultLtiProfile(input) { - const nextRecord = cloneRecord({ - defaultLtiProfile: input.defaultLtiProfile, - updatedAt: DEFAULT_UPDATED_AT, - }); - state.lanternLtiProfileSettings = nextRecord; - return Promise.resolve(cloneRecord(nextRecord)); - }, - - saveDeploymentLtiProfileOverride(input) { - const index = state.deployments.findIndex((candidate) => candidate.id === input.deploymentId); - - if (index < 0) { - throw new Error(`Deployment id ${input.deploymentId} was not found.`); - } - - const existing = state.deployments[index]; - - if (!existing) { - throw new Error(`Deployment id ${input.deploymentId} was not found.`); - } - - const nextDeployment = buildDeploymentRecord({ - ...existing, - ltiProfileOverride: input.ltiProfileOverride, - updatedAt: DEFAULT_UPDATED_AT, - }); - state.deployments.splice(index, 1, nextDeployment); - return Promise.resolve(cloneRecord(nextDeployment)); - }, - - saveDeploymentBinding(input) { - const existing = state.deployments.find((candidate) => candidate.slug === input.slug); - const existingAppSlot = state.deployments.find( - (candidate) => - candidate.slug !== input.slug && - candidate.appId === input.appId && - candidate.binding?.lms === input.binding.lms, - ); - const conflicting = state.deployments.find( - (candidate) => - candidate.slug !== input.slug && - candidate.binding?.lms === input.binding.lms && - candidate.binding?.issuer === input.binding.issuer && - candidate.binding?.clientId === input.binding.clientId && - candidate.binding?.deploymentId === input.binding.deploymentId, - ); - - if (conflicting) { - throw new Error( - `${ - formatBindingLabel( - input.binding.lms, - ) - } ${input.binding.clientId} / ${input.binding.deploymentId} already belongs to another deployment.`, - ); - } - - if (existing && existing.appId !== input.appId) { - throw new Error(`Deployment ${input.slug} belongs to app ${existing.appId}.`); - } - - if (existing && existing.binding !== null && existing.binding.lms !== input.binding.lms) { - throw new Error( - `Deployment ${input.slug} is already bound as ${existing.binding.lms} and cannot change to ${input.binding.lms}.`, - ); - } - - if (existingAppSlot) { - throw new Error(`App ${input.appId} already has a ${input.binding.lms} deployment.`); - } - - const nextDeployment = buildDeploymentRecord({ - id: existing?.id ?? nextId(state.deployments), - slug: input.slug, - label: input.label, - appId: input.appId, - enabledPackageVersionId: existing?.enabledPackageVersionId ?? null, - enabledPackageVersion: existing?.enabledPackageVersion ?? null, - binding: cloneRecord(input.binding), - ltiProfileOverride: existing?.ltiProfileOverride ?? null, - updatedAt: DEFAULT_UPDATED_AT, - }); - - if (existing) { - const index = state.deployments.findIndex((candidate) => candidate.slug === input.slug); - state.deployments.splice(index, 1, nextDeployment); - } else { - state.deployments.push(nextDeployment); - } - - return Promise.resolve(cloneRecord(nextDeployment)); - }, - - saveCanvasRegistration(input) { - return Promise.resolve(saveCanvasRegistration(state, input)); - }, - - pinDeploymentVersion(input) { - const packageVersion = state.packageVersions.find( - (candidate) => candidate.id === input.packageVersionId, - ); - - if (!packageVersion) { - throw new Error(`Package version id ${input.packageVersionId} was not found.`); - } - - if ( - packageVersion.approvalStatus !== 'approved' && - !(input.lmsType === 'preview' && packageVersion.approvalStatus === 'pending') - ) { - throw new Error('Only approved package versions can be enabled.'); - } - - if (packageVersion.appId !== input.appId) { - throw new Error( - `Package version ${packageVersion.appId}@${packageVersion.version} does not belong to deployment app ${input.appId}.`, - ); - } - - const existing = state.deployments.find((candidate) => candidate.slug === input.slug); - - if (existing && existing.appId !== input.appId) { - throw new Error(`Deployment ${input.slug} belongs to app ${existing.appId}.`); - } - - if (existing && existing.lmsType !== input.lmsType) { - throw new Error( - `Deployment ${input.slug} is already reserved as ${existing.lmsType} and cannot change to ${input.lmsType}.`, - ); - } - - const nextDeployment = buildDeploymentRecord({ - id: existing?.id ?? nextId(state.deployments), - slug: input.slug, - label: input.label, - appId: input.appId, - enabledPackageVersionId: packageVersion.id, - enabledPackageVersion: packageVersion.version, - lmsType: input.lmsType, - binding: existing?.binding ?? null, - ltiProfileOverride: existing?.ltiProfileOverride ?? null, - updatedAt: DEFAULT_UPDATED_AT, - }); - - if (existing) { - const index = state.deployments.findIndex((candidate) => candidate.slug === input.slug); - state.deployments.splice(index, 1, nextDeployment); - } else { - state.deployments.push(nextDeployment); - } - - return Promise.resolve(cloneRecord(nextDeployment)); - }, - }; -} - -function formatBindingLabel(lms: 'canvas' | 'moodle' | 'sakai'): string { - return `${lms.slice(0, 1).toUpperCase()}${lms.slice(1)} binding`; -} diff --git a/src/test_helpers/package_review_in_memory_ops.ts b/src/test_helpers/package_review_in_memory_ops.ts deleted file mode 100644 index 30640c5..0000000 --- a/src/test_helpers/package_review_in_memory_ops.ts +++ /dev/null @@ -1,288 +0,0 @@ -import type { OfficialBrokerCertificationStatus } from '../ops/types.ts'; -import { assertBrokerVerificationRunInput } from '../ops/repository_mapping.ts'; -import type { - InMemoryOpsRepository, - InMemoryRepositoryState, -} from './package_review_in_memory_shared.ts'; -import { - cloneRecord, - getLatestBrokerVerificationRecord, -} from './package_review_in_memory_shared.ts'; -import { - applyOfficialVerificationToDeployment, - applyOfficialVerificationToDetail, - latestOfficialForScope, -} from './package_review_in_memory_ops_support.ts'; -import { - buildBrokerVerificationStatus, - buildDeploymentGradePublicationSnapshot, - buildRetryableGradePublicationLookup, - buildRetryRuntimeSessionLookup, -} from './package_review_test_builder_ops.ts'; - -type OpsRepository = Pick< - InMemoryOpsRepository, - | 'listControlPlaneDeployments' - | 'getControlPlaneDeploymentDetail' - | 'listControlPlaneDiagnostics' - | 'listCertificationWorkflowStatuses' - | 'getLatestOfficialCertificationEvidence' - | 'getLatestBrokerVerificationStatus' - | 'recordBrokerVerificationRun' - | 'getRetryableGradePublicationLookup' ->; - -export function createInMemoryOpsRepositorySection(state: InMemoryRepositoryState): OpsRepository { - return { - listControlPlaneDeployments() { - return Promise.resolve( - state.controlPlaneDeployments.map(cloneRecord).sort((left, right) => { - const updatedAt = right.updatedAt.localeCompare(left.updatedAt); - - if (updatedAt !== 0) { - return updatedAt; - } - - return right.deploymentId - left.deploymentId; - }), - ); - }, - - getControlPlaneDeploymentDetail(deploymentRecordId) { - const record = state.controlPlaneDeploymentDetails.find( - (candidate) => candidate.inventory.deploymentId === deploymentRecordId, - ); - - return Promise.resolve(record ? cloneRecord(record) : null); - }, - - listControlPlaneDiagnostics(deploymentRecordId) { - return Promise.resolve( - state.controlPlaneDiagnostics - .filter((candidate) => candidate.deploymentRecordId === deploymentRecordId) - .map(cloneRecord) - .sort((left, right) => { - const occurredAt = right.occurredAt.localeCompare(left.occurredAt); - - if (occurredAt !== 0) { - return occurredAt; - } - - return right.id - left.id; - }), - ); - }, - - listCertificationWorkflowStatuses() { - return Promise.resolve(state.certificationWorkflowStatuses.map(cloneRecord)); - }, - - getLatestOfficialCertificationEvidence() { - return Promise.resolve( - state.latestOfficialCertificationEvidence - ? cloneRecord(state.latestOfficialCertificationEvidence) - : null, - ); - }, - - getLatestBrokerVerificationStatus() { - const record = getLatestBrokerVerificationRecord(state.brokerVerifications); - return Promise.resolve(record ? cloneRecord(record) : null); - }, - - recordBrokerVerificationRun(input) { - assertBrokerVerificationRunInput(input); - const official: OfficialBrokerCertificationStatus = input.source === '1edtech' - ? { - state: input.certificationState ?? 'notCertified', - checkedAt: input.checkedAt, - directoryUrl: input.detailUrl, - } - : latestOfficialForScope(state, input.scope); - const nextRecord = input.source === '1edtech' - ? buildBrokerVerificationStatus({ - supportedPath: input.scope, - internal: null, - official, - }) - : buildBrokerVerificationStatus({ - supportedPath: input.scope, - internal: { - source: input.source, - status: input.status as 'failed' | 'passed' | 'pending', - checkedAt: input.checkedAt, - summary: input.summary, - evidenceUrl: input.detailUrl, - }, - official, - }); - - state.brokerVerifications.push(cloneRecord(nextRecord)); - - if (input.source === '1edtech') { - const nextOfficialEvidence = { - workflowKey: input.workflowKey, - state: (input.certificationState ?? - 'notCertified') as OfficialBrokerCertificationStatus['state'], - checkedAt: input.checkedAt, - summary: input.summary, - directoryUrl: input.detailUrl, - }; - - if ( - shouldReplaceLatestOfficialEvidence( - state.latestOfficialCertificationEvidence, - nextOfficialEvidence.checkedAt, - ) - ) { - state.latestOfficialCertificationEvidence = cloneRecord(nextOfficialEvidence); - } - } else if (input.deploymentRecordId !== null) { - const deploymentRecordId = input.deploymentRecordId; - - state.certificationWorkflowStatuses = state.certificationWorkflowStatuses - .map((workflowStatus) => - workflowStatus.workflowKey === input.workflowKey && - shouldReplaceWorkflowEvidence( - workflowStatus.latestInternal?.checkedAt ?? null, - input.checkedAt, - ) - ? { - workflowKey: workflowStatus.workflowKey, - latestInternal: { - deploymentRecordId, - deploymentLabel: resolveDeploymentLabel(state, deploymentRecordId), - status: input.status as 'failed' | 'passed' | 'pending', - checkedAt: input.checkedAt, - summary: input.summary, - evidenceUrl: input.detailUrl, - }, - } - : workflowStatus - ) - .map(cloneRecord); - } - - if (input.source === '1edtech') { - state.controlPlaneDeployments = state.controlPlaneDeployments.map((deployment) => - applyOfficialVerificationToDeployment(deployment, input.scope, official) - ); - state.controlPlaneDeploymentDetails = state.controlPlaneDeploymentDetails.map((detail) => - applyOfficialVerificationToDetail(detail, input.scope, official) - ); - } else if (input.deploymentRecordId !== null) { - state.controlPlaneDeployments = state.controlPlaneDeployments.map((deployment) => - deployment.deploymentId === input.deploymentRecordId - ? { - ...deployment, - brokerVerification: cloneRecord(nextRecord), - } - : deployment - ); - state.controlPlaneDeploymentDetails = state.controlPlaneDeploymentDetails.map((detail) => - detail.inventory.deploymentId === input.deploymentRecordId - ? { - ...detail, - inventory: { - ...detail.inventory, - brokerVerification: cloneRecord(nextRecord), - }, - brokerVerification: cloneRecord(nextRecord), - } - : detail - ); - } - - return Promise.resolve(); - }, - - getRetryableGradePublicationLookup(attemptId) { - const seededRecord = state.retryableGradePublications.find( - (candidate) => candidate.attemptId === attemptId, - ); - - if (seededRecord) { - return Promise.resolve(cloneRecord(seededRecord)); - } - - const publication = state.gradePublications.find( - (candidate) => candidate.attemptId === attemptId && candidate.status === 'failed', - ); - const attempt = state.attempts.find((candidate) => candidate.attemptId === attemptId); - - if (!publication || !attempt) { - return Promise.resolve(null); - } - - const deployment = state.deployments.find( - (candidate) => candidate.id === attempt.deploymentRecordId, - ); - const runtimeSession = [...state.runtimeSessions] - .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) - .find((candidate) => candidate.attemptId === attemptId); - - return Promise.resolve( - buildRetryableGradePublicationLookup({ - attemptId, - deploymentRecordId: attempt.deploymentRecordId, - deploymentSlug: attempt.deploymentSlug, - publication: buildDeploymentGradePublicationSnapshot({ - attemptId: publication.attemptId, - status: publication.status, - lineItemUrl: publication.lineItemUrl, - platformUserId: publication.platformUserId, - scoreGiven: publication.scoreGiven, - scoreMaximum: publication.scoreMaximum, - activityProgress: publication.activityProgress, - gradingProgress: publication.gradingProgress, - publishedAt: publication.publishedAt, - updatedAt: publication.updatedAt, - errorCode: publication.errorCode, - errorDetail: publication.errorDetail, - }), - binding: deployment?.binding ?? null, - runtimeSession: runtimeSession - ? buildRetryRuntimeSessionLookup({ - sessionId: runtimeSession.sessionId, - ltiToolSessionId: runtimeSession.ltiToolSessionId, - attemptId: runtimeSession.attemptId, - deploymentRecordId: runtimeSession.deploymentRecordId, - deploymentSlug: runtimeSession.deploymentSlug, - appId: runtimeSession.appId, - packageVersionId: runtimeSession.packageVersionId, - packageVersion: runtimeSession.packageVersion, - services: runtimeSession.services, - createdAt: runtimeSession.createdAt, - expiresAt: runtimeSession.expiresAt, - }) - : null, - }), - ); - }, - }; -} - -function shouldReplaceWorkflowEvidence( - currentCheckedAt: string | null, - nextCheckedAt: string, -): boolean { - return currentCheckedAt === null || nextCheckedAt >= currentCheckedAt; -} - -function shouldReplaceLatestOfficialEvidence( - current: InMemoryRepositoryState['latestOfficialCertificationEvidence'] | null, - nextCheckedAt: string, -): boolean { - return current === null || nextCheckedAt >= current.checkedAt; -} - -function resolveDeploymentLabel( - state: InMemoryRepositoryState, - deploymentRecordId: number, -): string { - return ( - state.controlPlaneDeployments.find( - (deployment) => deployment.deploymentId === deploymentRecordId, - )?.deploymentLabel ?? `Deployment ${deploymentRecordId}` - ); -} diff --git a/src/test_helpers/package_review_in_memory_ops_support.ts b/src/test_helpers/package_review_in_memory_ops_support.ts deleted file mode 100644 index fd23ae0..0000000 --- a/src/test_helpers/package_review_in_memory_ops_support.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { - BrokerVerificationStatus, - ControlPlaneDeploymentDetailSnapshot, - ControlPlaneDeploymentInventoryRow, - OfficialBrokerCertificationStatus, -} from '../ops/types.ts'; -import { resolveSupportedPathForDeployment } from '../ops/broker_verification_paths.ts'; -import type { InMemoryRepositoryState } from './package_review_in_memory_shared.ts'; -import { buildBrokerVerificationStatus } from './package_review_test_builder_ops.ts'; - -export function latestOfficialForScope( - state: InMemoryRepositoryState, - scope: BrokerVerificationStatus['supportedPath'], -): OfficialBrokerCertificationStatus { - const latestMatch = [...state.brokerVerifications] - .filter((candidate) => candidate.supportedPath === scope) - .sort((left, right) => { - const leftCheckedAt = left.official.checkedAt ?? ''; - const rightCheckedAt = right.official.checkedAt ?? ''; - - return rightCheckedAt.localeCompare(leftCheckedAt); - })[0]; - - return ( - latestMatch?.official ?? { - state: 'notCertified', - checkedAt: null, - directoryUrl: null, - } - ); -} - -export function applyOfficialVerificationToDeployment( - deployment: ControlPlaneDeploymentInventoryRow, - scope: BrokerVerificationStatus['supportedPath'], - official: OfficialBrokerCertificationStatus, -): ControlPlaneDeploymentInventoryRow { - if (resolveSupportedPathForDeployment(deployment) !== scope) { - return deployment; - } - - return { - ...deployment, - brokerVerification: buildBrokerVerificationStatus({ - supportedPath: scope, - internal: deployment.brokerVerification?.internal ?? null, - official, - }), - }; -} - -export function applyOfficialVerificationToDetail( - detail: ControlPlaneDeploymentDetailSnapshot, - scope: BrokerVerificationStatus['supportedPath'], - official: OfficialBrokerCertificationStatus, -): ControlPlaneDeploymentDetailSnapshot { - if (resolveSupportedPathForDeployment(detail.inventory) !== scope) { - return detail; - } - - const brokerVerification = buildBrokerVerificationStatus({ - supportedPath: scope, - internal: detail.brokerVerification?.internal ?? - detail.inventory.brokerVerification?.internal ?? null, - official, - }); - - return { - ...detail, - inventory: { - ...detail.inventory, - brokerVerification, - }, - brokerVerification, - }; -} diff --git a/src/test_helpers/package_review_in_memory_placements.ts b/src/test_helpers/package_review_in_memory_placements.ts deleted file mode 100644 index 4f19e0f..0000000 --- a/src/test_helpers/package_review_in_memory_placements.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { PackageReviewRepository } from '../package_review/repository.ts'; -import type { InMemoryRepositoryState } from './package_review_in_memory_shared.ts'; -import { cloneRecord } from './package_review_in_memory_shared.ts'; - -type PlacementRepository = Pick< - PackageReviewRepository, - | 'createReviewedPlacement' - | 'getReviewedPlacementById' - | 'listReviewedPlacementsByPackageVersion' - | 'getPlacementAuditSnapshotById' - | 'requirePlacementAuditSnapshotById' - | 'bindReviewedPlacementResourceLink' ->; - -export function createInMemoryPlacementRepository( - state: InMemoryRepositoryState, -): PlacementRepository { - return { - createReviewedPlacement(record) { - const existing = state.reviewedPlacements.find( - (candidate) => candidate.placementId === record.placementId, - ); - - if (existing) { - throw new Error( - `Reviewed placement ${record.placementId} already exists and cannot be replaced.`, - ); - } - - state.reviewedPlacements.push(cloneRecord(record)); - return Promise.resolve(cloneRecord(record)); - }, - - getReviewedPlacementById(placementId) { - const record = state.reviewedPlacements.find( - (candidate) => candidate.placementId === placementId, - ); - return Promise.resolve(record ? cloneRecord(record) : null); - }, - - listReviewedPlacementsByPackageVersion(packageVersionId) { - return Promise.resolve( - state.reviewedPlacements - .filter((candidate) => candidate.packageVersionId === packageVersionId) - .sort((left, right) => { - const timeOrder = Date.parse(right.createdAt) - Date.parse(left.createdAt); - - return timeOrder === 0 ? left.placementId.localeCompare(right.placementId) : timeOrder; - }) - .map(cloneRecord), - ); - }, - - getPlacementAuditSnapshotById(placementId) { - const placement = state.reviewedPlacements.find( - (candidate) => candidate.placementId === placementId, - ); - - if (!placement) { - return Promise.resolve(null); - } - - const latestPreviewSession = state.previewSessions - .filter((candidate) => candidate.packageVersionId === placement.packageVersionId) - .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))[0] ?? - null; - const previewRows = latestPreviewSession === null ? [] : state.previewEvidence - .filter((candidate) => candidate.previewSessionId === latestPreviewSession.sessionId) - .sort((left, right) => left.sequence - right.sequence); - const deepLinkingRequestCount = state.auditEvents.filter( - (candidate) => - candidate.deploymentRecordId === placement.deploymentRecordId && - candidate.packageVersionId === placement.packageVersionId && - candidate.eventType.startsWith('deep_linking.request.'), - ).length; - const placementEventCount = state.auditEvents.filter( - (candidate) => - candidate.deploymentRecordId === placement.deploymentRecordId && - candidate.packageVersionId === placement.packageVersionId && - candidate.eventType.startsWith('deep_linking.placement.') && - candidate.detail.placementId === placement.placementId, - ).length; - const reviewerEventCount = state.auditEvents.filter( - (candidate) => - candidate.deploymentRecordId === placement.deploymentRecordId && - candidate.packageVersionId === placement.packageVersionId && - candidate.eventType.startsWith('reviewer.') && - candidate.detail.placementId === placement.placementId, - ).length; - const latestOccurredAt = state.auditEvents - .filter( - (candidate) => - candidate.deploymentRecordId === placement.deploymentRecordId && - candidate.packageVersionId === placement.packageVersionId && - (candidate.eventType.startsWith('deep_linking.request.') || - candidate.eventType.startsWith('deep_linking.placement.') || - candidate.eventType.startsWith('reviewer.')), - ) - .sort((left, right) => Date.parse(right.occurredAt) - Date.parse(left.occurredAt))[0] - ?.occurredAt ?? null; - const previewEvidenceCount = previewRows.length; - const status = reviewerEventCount > 0 - ? 'reviewed' - : placement.resourceLinkId === null - ? 'awaiting_canvas_binding' - : previewEvidenceCount > 0 - ? 'bound_with_preview' - : 'bound_no_preview'; - - return Promise.resolve({ - placement: cloneRecord(placement), - status, - latestPreviewSessionId: latestPreviewSession?.sessionId ?? null, - latestPreviewOccurredAt: previewRows[previewRows.length - 1]?.occurredAt ?? null, - previewEvidenceCount, - evidenceSummary: { - deepLinkingRequestCount, - placementEventCount, - reviewerEventCount, - latestOccurredAt, - }, - }); - }, - - requirePlacementAuditSnapshotById(placementId) { - return this.getPlacementAuditSnapshotById(placementId).then((snapshot) => { - if (snapshot === null) { - throw new Error(`Reviewed placement ${placementId} was not found.`); - } - - return snapshot; - }); - }, - - bindReviewedPlacementResourceLink(input) { - const index = state.reviewedPlacements.findIndex( - (candidate) => candidate.placementId === input.placementId, - ); - - if (index < 0) { - throw new Error(`Reviewed placement ${input.placementId} was not found.`); - } - - const existing = state.reviewedPlacements[index]; - - if (!existing) { - throw new Error(`Reviewed placement ${input.placementId} was not found.`); - } - - if (existing.resourceLinkId !== null && existing.resourceLinkId !== input.resourceLinkId) { - throw new Error( - `Reviewed placement ${input.placementId} is already bound to Canvas resource link ${existing.resourceLinkId}.`, - ); - } - - const conflictingPlacement = state.reviewedPlacements.find( - (candidate) => - candidate.placementId !== input.placementId && - candidate.deploymentRecordId === existing.deploymentRecordId && - candidate.resourceLinkId === input.resourceLinkId, - ); - - if (conflictingPlacement) { - throw new Error( - `Canvas resource link ${input.resourceLinkId} is already bound to another reviewed placement in deployment ${existing.deploymentSlug}.`, - ); - } - - if (existing.resourceLinkId === input.resourceLinkId) { - return Promise.resolve(cloneRecord(existing)); - } - - const nextRecord = cloneRecord({ - ...existing, - resourceLinkId: input.resourceLinkId, - boundAt: input.boundAt, - }); - state.reviewedPlacements.splice(index, 1, nextRecord); - return Promise.resolve(cloneRecord(nextRecord)); - }, - }; -} diff --git a/src/test_helpers/package_review_in_memory_preview_test.ts b/src/test_helpers/package_review_in_memory_preview_test.ts index 2d41ecf..5b42555 100644 --- a/src/test_helpers/package_review_in_memory_preview_test.ts +++ b/src/test_helpers/package_review_in_memory_preview_test.ts @@ -38,11 +38,7 @@ Deno.test('in-memory preview helpers seed admin and authoring sessions with type ); assertEquals( (await repository.getPreviewSessionById(authoringSession.sessionId))?.origin, - 'deepLinkingAuthoring', - ); - assertEquals( - (await repository.getPreviewSessionById(authoringSession.sessionId))?.deepLinkingSessionId, - 'deep-linking-session-123', + 'adminAuthoringDraft', ); }); @@ -87,7 +83,7 @@ Deno.test('in-memory preview lookup honors the same origin filter as the reposit ( await repository.getLatestPreviewSessionByPackageVersion( packageVersion.id, - 'deepLinkingAuthoring', + 'adminAuthoringDraft', ) )?.sessionId, authoringSession.sessionId, @@ -100,9 +96,6 @@ Deno.test('preview test builders expose explicit origin and selected content def assertEquals(adminSession.origin, 'adminTestLaunch'); assertEquals(adminSession.contentPath, '/content/activity.json'); - assertEquals(adminSession.deepLinkingSessionId, null); - - assertEquals(authoringSession.origin, 'deepLinkingAuthoring'); + assertEquals(authoringSession.origin, 'adminAuthoringDraft'); assertEquals(authoringSession.contentPath, '/content/bonus.json'); - assertEquals(authoringSession.deepLinkingSessionId, 'deep-linking-session-123'); }); diff --git a/src/test_helpers/package_review_in_memory_repository.ts b/src/test_helpers/package_review_in_memory_repository.ts index 0262a22..6bec6fb 100644 --- a/src/test_helpers/package_review_in_memory_repository.ts +++ b/src/test_helpers/package_review_in_memory_repository.ts @@ -1,9 +1,6 @@ import { createInMemoryAttemptRepository } from './package_review_in_memory_attempts.ts'; import { createInMemoryAuthoringRepository } from './package_review_in_memory_authoring.ts'; import { createInMemoryAppGenerationRepository } from './package_review_in_memory_app_generation.ts'; -import { createInMemoryDeploymentRepository } from './package_review_in_memory_deployments.ts'; -import { createInMemoryOpsRepositorySection } from './package_review_in_memory_ops.ts'; -import { createInMemoryPlacementRepository } from './package_review_in_memory_placements.ts'; import { createInMemoryPreviewRepository } from './package_review_in_memory_preview.ts'; import { createState, @@ -20,13 +17,10 @@ export function createInMemoryPackageReviewRepository( return { ...createInMemoryVersionRepository(state), - ...createInMemoryDeploymentRepository(state), ...createInMemorySessionRepository(state), - ...createInMemoryPlacementRepository(state), ...createInMemoryAuthoringRepository(state), ...createInMemoryAppGenerationRepository(state), ...createInMemoryPreviewRepository(state), ...createInMemoryAttemptRepository(state), - ...createInMemoryOpsRepositorySection(state), }; } diff --git a/src/test_helpers/package_review_in_memory_sessions.ts b/src/test_helpers/package_review_in_memory_sessions.ts index 8982369..0b90c41 100644 --- a/src/test_helpers/package_review_in_memory_sessions.ts +++ b/src/test_helpers/package_review_in_memory_sessions.ts @@ -1,195 +1,30 @@ import type { PackageReviewRepository } from '../package_review/repository.ts'; -import type { - InMemoryDeepLinkingRepository, - InMemoryOpsRepository, - InMemoryRepositoryState, -} from './package_review_in_memory_shared.ts'; +import type { InMemoryRepositoryState } from './package_review_in_memory_shared.ts'; import { cloneRecord } from './package_review_in_memory_shared.ts'; -type SessionRepository = - & Pick< - PackageReviewRepository, - | 'createDynamicRegistrationState' - | 'getDynamicRegistrationStateByState' - | 'consumeDynamicRegistrationState' - | 'createRuntimeSession' - | 'getRuntimeSessionById' - | 'getLatestRuntimeSessionByDeploymentId' - > - & InMemoryDeepLinkingRepository - & Pick; +type SessionRepository = Pick< + PackageReviewRepository, + | 'createRuntimeSession' + | 'getRuntimeSessionById' +>; export function createInMemorySessionRepository(state: InMemoryRepositoryState): SessionRepository { return { - createDynamicRegistrationState(record) { - const existing = state.dynamicRegistrationStates.find( - (candidate) => candidate.state === record.state, - ); - - if (existing) { - throw new Error( - `Dynamic registration state ${record.state} already exists and cannot be reused.`, - ); - } - - state.dynamicRegistrationStates.push(cloneRecord(record)); - return Promise.resolve(cloneRecord(record)); - }, - - getDynamicRegistrationStateByState(stateId) { - const record = state.dynamicRegistrationStates.find( - (candidate) => candidate.state === stateId, - ); - return Promise.resolve(record ? cloneRecord(record) : null); - }, - - consumeDynamicRegistrationState(input) { - const index = state.dynamicRegistrationStates.findIndex( - (candidate) => candidate.state === input.state, - ); - - if (index < 0) { - throw new Error(`Dynamic registration state ${input.state} was not found.`); - } - - const existing = state.dynamicRegistrationStates[index]; - - if (!existing) { - throw new Error(`Dynamic registration state ${input.state} was not found.`); - } - - if (existing.usedAt !== null) { - throw new Error(`Dynamic registration state ${input.state} has already been used.`); - } - - const nextRecord = cloneRecord({ ...existing, usedAt: input.usedAt }); - state.dynamicRegistrationStates.splice(index, 1, nextRecord); - return Promise.resolve(cloneRecord(nextRecord)); - }, - createRuntimeSession(record) { const existing = state.runtimeSessions.find( (candidate) => candidate.sessionId === record.sessionId || candidate.sessionToken === record.sessionToken, ); - - if (existing) { - throw new Error( - `Runtime session ${record.sessionId} already exists and cannot be replaced.`, - ); + if (existing !== undefined) { + throw new Error(`Runtime session ${record.sessionId} already exists.`); } - state.runtimeSessions.push(cloneRecord(record)); return Promise.resolve(cloneRecord(record)); }, - getRuntimeSessionById(sessionId) { const record = state.runtimeSessions.find((candidate) => candidate.sessionId === sessionId); - return Promise.resolve(record ? cloneRecord(record) : null); - }, - - getLatestRuntimeSessionByDeploymentId(deploymentRecordId) { - const record = [...state.runtimeSessions] - .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) - .find((candidate) => candidate.deploymentRecordId === deploymentRecordId); - - return Promise.resolve(record ? cloneRecord(record) : null); - }, - - getRuntimeSessionByAttemptId(attemptId) { - const record = [...state.runtimeSessions] - .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) - .find((candidate) => candidate.attemptId === attemptId); - - return Promise.resolve(record ? cloneRecord(record) : null); - }, - - createDeepLinkingSession(record) { - const existing = state.deepLinkingSessions.find( - (candidate) => - candidate.sessionId === record.sessionId || - candidate.sessionToken === record.sessionToken, - ); - - if (existing) { - throw new Error( - `Deep Linking session ${record.sessionId} already exists and cannot be replaced.`, - ); - } - - state.deepLinkingSessions.push(cloneRecord(record)); - return Promise.resolve(cloneRecord(record)); - }, - - getDeepLinkingSessionById(sessionId) { - const record = state.deepLinkingSessions.find( - (candidate) => candidate.sessionId === sessionId, - ); - return Promise.resolve(record ? cloneRecord(record) : null); - }, - - consumeDeepLinkingSession(input) { - const index = state.deepLinkingSessions.findIndex( - (candidate) => candidate.sessionId === input.sessionId, - ); - - if (index < 0) { - throw new Error(`Deep Linking session ${input.sessionId} was not found.`); - } - - const existing = state.deepLinkingSessions[index]; - - if (!existing) { - throw new Error(`Deep Linking session ${input.sessionId} was not found.`); - } - - if (existing.usedAt !== null) { - throw new Error(`Deep Linking session ${input.sessionId} has already been used.`); - } - - const nextRecord = cloneRecord({ ...existing, usedAt: input.usedAt }); - state.deepLinkingSessions.splice(index, 1, nextRecord); - return Promise.resolve(cloneRecord(nextRecord)); - }, - - updateDeepLinkingSessionSelection(input) { - const index = state.deepLinkingSessions.findIndex( - (candidate) => candidate.sessionId === input.sessionId, - ); - - if (index < 0) { - throw new Error(`Deep Linking session ${input.sessionId} was not found.`); - } - - const existing = state.deepLinkingSessions[index]; - - if (!existing) { - throw new Error(`Deep Linking session ${input.sessionId} was not found.`); - } - - if (existing.usedAt !== null) { - throw new Error(`Deep Linking session ${input.sessionId} has already been used.`); - } - - const nextRecord = cloneRecord({ - ...existing, - selection: input.selection === null ? null : { ...input.selection }, - }); - state.deepLinkingSessions.splice(index, 1, nextRecord); - return Promise.resolve(cloneRecord(nextRecord)); - }, - - listDeepLinkingResourceOptions(appId, placement) { - const installScope = placement === 'assignment_selection' ? 'assignment' : 'course'; - - return Promise.resolve( - state.deepLinkingResourceOptions - .filter( - (candidate) => candidate.appId === appId && candidate.installScope === installScope, - ) - .map(cloneRecord), - ); + return Promise.resolve(record === undefined ? null : cloneRecord(record)); }, }; } diff --git a/src/test_helpers/package_review_in_memory_shared.ts b/src/test_helpers/package_review_in_memory_shared.ts index 6f48608..da67229 100644 --- a/src/test_helpers/package_review_in_memory_shared.ts +++ b/src/test_helpers/package_review_in_memory_shared.ts @@ -1,24 +1,5 @@ import { compare, parse } from '@std/semver'; -import { CERTIFICATION_WORKFLOW_KEYS } from '../ops/types.ts'; -import type { - DeepLinkingSessionRecord, - DynamicRegistrationStateRecord, - LtiPlacement, - RuntimeSessionRecord, -} from '../lti/types.ts'; -import type { - BrokerVerificationRunStatus, - BrokerVerificationSource, - BrokerVerificationStatus, - CertificationWorkflowKey, - CertificationWorkflowStatus, - ControlPlaneDeploymentDetailSnapshot, - ControlPlaneDeploymentInventoryRow, - ControlPlaneDiagnosticItem, - LatestOfficialCertificationEvidence, - OfficialBrokerCertificationStatus, - RetryableGradePublicationLookup, -} from '../ops/types.ts'; +import type { RuntimeSessionRecord } from '../lti/types.ts'; import type { PackageReviewRepository } from '../package_review/repository.ts'; import type { ApprovalStatus, @@ -28,104 +9,28 @@ import type { AuditEventRecord, AuthoringDraftFileRecord, AuthoringDraftRecord, - DeepLinkingResourceOption, - DeploymentRecord, - GradePublicationRecord, - LanternLtiProfileSettingsRecord, - LineItemBindingRecord, PackageVersionRecord, PreviewEvidenceRecord, PreviewSessionRecord, - ReviewedPlacementRecord, } from '../package_review/types.ts'; import type { AppGenerationRunRecord, AppGenerationWorkspaceRecord } from '../app_writer/types.ts'; import { type AccessibilityReview, parseAccessibilityReview } from '../package_review/types.ts'; import { DEFAULT_REVIEWED_AT } from './package_review_test_defaults.ts'; - -export interface InMemoryOpsRepository { - listControlPlaneDeployments(): Promise; - getControlPlaneDeploymentDetail( - deploymentRecordId: number, - ): Promise; - listControlPlaneDiagnostics(deploymentRecordId: number): Promise; - listCertificationWorkflowStatuses(): Promise; - getLatestOfficialCertificationEvidence(): Promise; - getLatestBrokerVerificationStatus(): Promise; - recordBrokerVerificationRun(input: { - deploymentRecordId: number | null; - source: BrokerVerificationSource; - scope: BrokerVerificationStatus['supportedPath']; - workflowKey: CertificationWorkflowKey; - status: BrokerVerificationRunStatus | 'notCertified'; - certificationState: Exclude | null; - summary: string; - detailUrl: string | null; - checkedAt: string; - }): Promise; - getRetryableGradePublicationLookup( - attemptId: string, - ): Promise; - getRuntimeSessionByAttemptId(attemptId: string): Promise; -} - -export interface InMemoryDeepLinkingRepository { - createDynamicRegistrationState( - record: DynamicRegistrationStateRecord, - ): Promise; - getDynamicRegistrationStateByState(state: string): Promise; - consumeDynamicRegistrationState(input: { - state: string; - usedAt: string; - }): Promise; - createDeepLinkingSession(record: DeepLinkingSessionRecord): Promise; - getDeepLinkingSessionById(sessionId: string): Promise; - consumeDeepLinkingSession(input: { - sessionId: string; - usedAt: string; - }): Promise; - updateDeepLinkingSessionSelection(input: { - sessionId: string; - selection: DeepLinkingSessionRecord['selection']; - }): Promise; - listDeepLinkingResourceOptions( - appId: string, - placement: LtiPlacement, - ): Promise; -} - -export type InMemoryRepository = - & PackageReviewRepository - & InMemoryOpsRepository - & InMemoryDeepLinkingRepository; +export type InMemoryRepository = PackageReviewRepository; export type InMemoryPackageReviewRepositoryOptions = { packageVersions?: PackageVersionRecord[]; - deployments?: DeploymentRecord[]; attempts?: AttemptRecord[]; attemptEvidenceArtifacts?: AttemptEvidenceArtifactRecord[]; attemptEvents?: AttemptEventRecord[]; - lineItemBindings?: LineItemBindingRecord[]; - gradePublications?: GradePublicationRecord[]; auditEvents?: AuditEventRecord[]; - dynamicRegistrationStates?: DynamicRegistrationStateRecord[]; runtimeSessions?: RuntimeSessionRecord[]; - deepLinkingSessions?: DeepLinkingSessionRecord[]; - deepLinkingResourceOptions?: DeepLinkingResourceOption[]; - reviewedPlacements?: ReviewedPlacementRecord[]; authoringDrafts?: AuthoringDraftRecord[]; authoringDraftFiles?: AuthoringDraftFileRecord[]; appGenerationRuns?: AppGenerationRunRecord[]; appGenerationWorkspaces?: AppGenerationWorkspaceRecord[]; previewSessions?: PreviewSessionRecord[]; previewEvidence?: PreviewEvidenceRecord[]; - controlPlaneDeployments?: ControlPlaneDeploymentInventoryRow[]; - controlPlaneDeploymentDetails?: ControlPlaneDeploymentDetailSnapshot[]; - controlPlaneDiagnostics?: ControlPlaneDiagnosticItem[]; - brokerVerifications?: BrokerVerificationStatus[]; - certificationWorkflowStatuses?: CertificationWorkflowStatus[]; - latestOfficialCertificationEvidence?: LatestOfficialCertificationEvidence | null; - retryableGradePublications?: RetryableGradePublicationLookup[]; - lanternLtiProfileSettings?: LanternLtiProfileSettingsRecord; }; export type InMemoryRepositoryState = Required; @@ -139,41 +44,17 @@ export function createState( ): InMemoryRepositoryState { return { packageVersions: cloneRecord(options.packageVersions ?? []), - deployments: cloneRecord(options.deployments ?? []), attempts: cloneRecord(options.attempts ?? []), attemptEvidenceArtifacts: cloneRecord(options.attemptEvidenceArtifacts ?? []), attemptEvents: cloneRecord(options.attemptEvents ?? []), - lineItemBindings: cloneRecord(options.lineItemBindings ?? []), - gradePublications: cloneRecord(options.gradePublications ?? []), auditEvents: cloneRecord(options.auditEvents ?? []), - dynamicRegistrationStates: cloneRecord(options.dynamicRegistrationStates ?? []), runtimeSessions: cloneRecord(options.runtimeSessions ?? []), - deepLinkingSessions: cloneRecord(options.deepLinkingSessions ?? []), - deepLinkingResourceOptions: cloneRecord(options.deepLinkingResourceOptions ?? []), - reviewedPlacements: cloneRecord(options.reviewedPlacements ?? []), authoringDrafts: cloneRecord(options.authoringDrafts ?? []), authoringDraftFiles: cloneRecord(options.authoringDraftFiles ?? []), appGenerationRuns: cloneRecord(options.appGenerationRuns ?? []), appGenerationWorkspaces: cloneRecord(options.appGenerationWorkspaces ?? []), previewSessions: cloneRecord(options.previewSessions ?? []), previewEvidence: cloneRecord(options.previewEvidence ?? []), - controlPlaneDeployments: cloneRecord(options.controlPlaneDeployments ?? []), - controlPlaneDeploymentDetails: cloneRecord(options.controlPlaneDeploymentDetails ?? []), - controlPlaneDiagnostics: cloneRecord(options.controlPlaneDiagnostics ?? []), - brokerVerifications: cloneRecord(options.brokerVerifications ?? []), - certificationWorkflowStatuses: cloneRecord( - options.certificationWorkflowStatuses ?? createDefaultCertificationWorkflowStatuses(), - ), - latestOfficialCertificationEvidence: cloneRecord( - options.latestOfficialCertificationEvidence ?? null, - ), - retryableGradePublications: cloneRecord(options.retryableGradePublications ?? []), - lanternLtiProfileSettings: cloneRecord( - options.lanternLtiProfileSettings ?? { - defaultLtiProfile: 'governedCompatibility', - updatedAt: DEFAULT_REVIEWED_AT, - }, - ), }; } @@ -181,13 +62,6 @@ export function nextId(records: Array<{ id: number }>): number { return records.reduce((max, record) => Math.max(max, record.id), 0) + 1; } -function createDefaultCertificationWorkflowStatuses(): CertificationWorkflowStatus[] { - return CERTIFICATION_WORKFLOW_KEYS.map((workflowKey) => ({ - workflowKey, - latestInternal: null, - })); -} - export function sortPackageVersions( packageVersions: PackageVersionRecord[], ): PackageVersionRecord[] { @@ -249,16 +123,3 @@ export function reviewPackageVersion( return nextRecord; } - -export function getLatestBrokerVerificationRecord( - brokerVerifications: BrokerVerificationStatus[], -): BrokerVerificationStatus | null { - return ( - [...brokerVerifications].sort((left, right) => { - const leftCheckedAt = left.internal?.checkedAt ?? left.official.checkedAt ?? ''; - const rightCheckedAt = right.internal?.checkedAt ?? right.official.checkedAt ?? ''; - - return rightCheckedAt.localeCompare(leftCheckedAt); - })[0] ?? null - ); -} diff --git a/src/test_helpers/package_review_in_memory_versions.ts b/src/test_helpers/package_review_in_memory_versions.ts index a90445f..2cb591b 100644 --- a/src/test_helpers/package_review_in_memory_versions.ts +++ b/src/test_helpers/package_review_in_memory_versions.ts @@ -1,4 +1,5 @@ import type { PackageReviewRepository } from '../package_review/repository.ts'; +import type { ImportedPackageVersion } from '../package_review/intake.ts'; import type { InMemoryRepositoryState } from './package_review_in_memory_shared.ts'; import { cloneRecord, @@ -11,8 +12,10 @@ import { buildPackageVersionRecord } from './package_review_test_builder_base.ts type VersionRepository = Pick< PackageReviewRepository, | 'registerPackageVersion' + | 'registerPythonLabTrialPackageVersion' | 'listPackageVersions' | 'listPackageVersionsByApp' + | 'deletePythonLabPackageVersion' | 'getPackageVersionById' | 'getPackageVersionByAppVersion' | 'approvePackageVersion' @@ -22,48 +25,28 @@ type VersionRepository = Pick< export function createInMemoryVersionRepository(state: InMemoryRepositoryState): VersionRepository { return { registerPackageVersion(input) { - const existing = state.packageVersions.find( - (record) => - record.appId === input.reviewData.appId && record.version === input.reviewData.version, - ); - - if (existing) { - throw new Error( - `Package version ${input.reviewData.appId}@${input.reviewData.version} already exists and cannot be replaced.`, - ); - } + return registerInMemoryPackageVersion(state, input, 'catalog'); + }, - const nextRecord = buildPackageVersionRecord({ - id: nextId(state.packageVersions), - appId: input.reviewData.appId, - version: input.reviewData.version, - title: input.reviewData.title, - description: input.reviewData.description, - owner: input.reviewData.owner, - entrypoint: input.reviewData.entrypoint, - roles: input.reviewData.roles, - installScope: input.reviewData.installScope, - capabilities: input.reviewData.capabilities, - grading: input.reviewData.grading, - validationIssues: input.reviewData.validationIssues, - manifestJson: input.reviewData.manifestJson, - artifact: input.artifact, - runtimeContract: input.runtimeContract, - runtimeContractSignature: input.runtimeContractSignature, - }); - - state.packageVersions.push(nextRecord); - - return Promise.resolve(cloneRecord(nextRecord)); + registerPythonLabTrialPackageVersion(input) { + return registerInMemoryPackageVersion(state, input, 'python_lab_trial'); }, listPackageVersions() { - return Promise.resolve(sortPackageVersions(state.packageVersions).map(cloneRecord)); + return Promise.resolve( + sortPackageVersions( + state.packageVersions.filter((record) => record.catalogVisibility === 'catalog'), + ).map(cloneRecord), + ); }, listPackageVersionsByApp(appId) { return Promise.resolve( - sortPackageVersions(state.packageVersions.filter((record) => record.appId === appId)).map( + sortPackageVersions( + state.packageVersions.filter( + (record) => record.appId === appId && record.catalogVisibility === 'catalog', + ), + ).map( cloneRecord, ), ); @@ -81,6 +64,47 @@ export function createInMemoryVersionRepository(state: InMemoryRepositoryState): return Promise.resolve(record ? cloneRecord(record) : null); }, + deletePythonLabPackageVersion(id) { + const packageVersion = state.packageVersions.find((record) => record.id === id); + + if (packageVersion === undefined) { + return Promise.resolve(); + } + + if (packageVersion.catalogVisibility !== 'python_lab_trial') { + return Promise.reject( + new Error(`Package version ${id} is not a Python Lab trial package.`), + ); + } + + const attemptIds = new Set( + state.attempts + .filter((attempt) => attempt.packageVersionId === id) + .map((attempt) => attempt.attemptId), + ); + const previewSessionIds = new Set( + state.previewSessions + .filter((session) => session.packageVersionId === id) + .map((session) => session.sessionId), + ); + + removeWhere( + state.runtimeSessions, + (session) => session.packageVersionId === id, + ); + removeWhere(state.attempts, (attempt) => attemptIds.has(attempt.attemptId)); + removeWhere(state.attemptEvents, (event) => attemptIds.has(event.attemptId)); + removeWhere(state.attemptEvidenceArtifacts, (artifact) => attemptIds.has(artifact.attemptId)); + removeWhere( + state.previewEvidence, + (evidence) => previewSessionIds.has(evidence.previewSessionId), + ); + removeWhere(state.previewSessions, (session) => previewSessionIds.has(session.sessionId)); + removeWhere(state.packageVersions, (record) => record.id === id); + + return Promise.resolve(); + }, + approvePackageVersion(input) { return Promise.resolve( cloneRecord( @@ -110,3 +134,51 @@ export function createInMemoryVersionRepository(state: InMemoryRepositoryState): }, }; } + +function registerInMemoryPackageVersion( + state: InMemoryRepositoryState, + input: ImportedPackageVersion, + catalogVisibility: 'catalog' | 'python_lab_trial', +) { + const existing = state.packageVersions.find( + (record) => + record.appId === input.reviewData.appId && record.version === input.reviewData.version, + ); + + if (existing) { + throw new Error( + `Package version ${input.reviewData.appId}@${input.reviewData.version} already exists and cannot be replaced.`, + ); + } + + const nextRecord = buildPackageVersionRecord({ + id: nextId(state.packageVersions), + appId: input.reviewData.appId, + version: input.reviewData.version, + title: input.reviewData.title, + description: input.reviewData.description, + owner: input.reviewData.owner, + roles: input.reviewData.roles, + installScope: input.reviewData.installScope, + capabilities: input.reviewData.capabilities, + grading: input.reviewData.grading, + catalogVisibility, + validationIssues: input.reviewData.validationIssues, + manifestJson: input.reviewData.manifestJson, + artifact: input.artifact, + runtimeContract: input.runtimeContract, + runtimeContractSignature: input.runtimeContractSignature, + }); + + state.packageVersions.push(nextRecord); + + return Promise.resolve(cloneRecord(nextRecord)); +} + +function removeWhere(items: T[], predicate: (item: T) => boolean): void { + for (let index = items.length - 1; index >= 0; index -= 1) { + if (predicate(items[index]!)) { + items.splice(index, 1); + } + } +} diff --git a/src/test_helpers/package_review_test_builder_base.ts b/src/test_helpers/package_review_test_builder_base.ts index 219a4b7..5c6a342 100644 --- a/src/test_helpers/package_review_test_builder_base.ts +++ b/src/test_helpers/package_review_test_builder_base.ts @@ -6,17 +6,10 @@ import type { AttemptEvidenceArtifactRecord, AttemptRecord, AuditEventRecord, - DeploymentRecord, - GradePublicationRecord, - LineItemBindingRecord, PackageVersionRecord, } from '../package_review/types.ts'; import { normalizeAttemptEvent } from '../runtime/attempt_event_normalization.ts'; -import { - DEFAULT_IMPORTED_AT, - DEFAULT_PHASE3_AT, - DEFAULT_UPDATED_AT, -} from './package_review_test_defaults.ts'; +import { DEFAULT_IMPORTED_AT, DEFAULT_PHASE3_AT } from './package_review_test_defaults.ts'; export function buildAccessibilityReview( overrides: Partial = {}, @@ -45,7 +38,7 @@ export function buildPackageVersionRecord( title: 'Chapter 4 Asteroids', description: 'Shoot the correct vocabulary target.', owner: { type: 'user', id: 'instructor_123' }, - entrypoint: '/dist/index.html', + runtime: { kind: 'browser', entrypoint: '/dist/index.html' }, roles: ['learner', 'instructor'], installScope: 'course', capabilities: [ @@ -61,6 +54,7 @@ export function buildPackageVersionRecord( rubricFile: '/scoring/rubric.json', maxScore: 100, }, + catalogVisibility: 'catalog', approvalStatus: 'pending', reviewNotes: null, accessibilityReview: null, @@ -88,7 +82,7 @@ export function buildPackageVersionRecord( reviewData: { appId: record.appId, version: record.version, - entrypoint: record.entrypoint, + runtime: record.runtime, capabilities: record.capabilities, }, artifactDigest: record.artifact.digest, @@ -110,7 +104,7 @@ export function buildImportedPackageVersion( title: record.title, description: record.description, owner: record.owner, - entrypoint: record.entrypoint, + runtime: record.runtime, roles: record.roles, installScope: record.installScope, capabilities: record.capabilities, @@ -124,59 +118,11 @@ export function buildImportedPackageVersion( }; } -export function buildDeploymentRecord(overrides: Partial = {}): DeploymentRecord { - const appId = overrides.appId ?? 'chapter-4-asteroids'; - const binding = overrides.binding ?? null; - const lmsType = overrides.lmsType ?? binding?.lms ?? 'canvas'; - const defaultDeploymentIdentity = (() => { - switch (lmsType) { - case 'canvas': - return { - slug: `${appId}-pilot`, - label: 'Pilot Deployment', - }; - case 'moodle': - return { - slug: `${appId}-moodle`, - label: 'Moodle Deployment', - }; - case 'sakai': - return { - slug: `${appId}-sakai`, - label: 'Sakai Deployment', - }; - case 'preview': - return { - slug: `${appId}-preview`, - label: 'Preview Deployment', - }; - } - })(); - - return { - id: 1, - slug: defaultDeploymentIdentity.slug, - label: `${appId.replaceAll('-', ' ')} ${defaultDeploymentIdentity.label}` - .split(' ') - .map((segment) => (segment[0] ? `${segment[0].toUpperCase()}${segment.slice(1)}` : segment)) - .join(' '), - appId, - enabledPackageVersionId: 1, - enabledPackageVersion: '0.1.0', - lmsType, - binding, - ltiProfileOverride: null, - updatedAt: DEFAULT_UPDATED_AT, - ...overrides, - }; -} - export function buildAttemptRecord(overrides: Partial = {}): AttemptRecord { return { id: overrides.id ?? 1, attemptId: overrides.attemptId ?? 'attempt-123', - deploymentRecordId: overrides.deploymentRecordId ?? 1, - deploymentSlug: overrides.deploymentSlug ?? 'chapter-4-asteroids-pilot', + previewSessionId: overrides.previewSessionId ?? 'preview-session-123', appId: overrides.appId ?? 'chapter-4-asteroids', packageVersionId: overrides.packageVersionId ?? 1, packageVersion: overrides.packageVersion ?? '0.1.0', @@ -239,59 +185,14 @@ export function buildAttemptEvidenceArtifactRecord( }; } -export function buildLineItemBindingRecord( - overrides: Partial = {}, -): LineItemBindingRecord { - return { - id: overrides.id ?? 1, - deploymentRecordId: overrides.deploymentRecordId ?? 1, - packageVersionId: overrides.packageVersionId ?? 1, - contextId: overrides.contextId ?? 'course-42', - resourceLinkId: overrides.resourceLinkId ?? 'resource-link-123', - activityId: overrides.activityId ?? 'activity-123', - lineItemsUrl: overrides.lineItemsUrl ?? 'https://canvas.example/api/lti/courses/42/line_items', - lineItemUrl: overrides.lineItemUrl ?? 'https://canvas.example/api/lti/courses/42/line_items/9', - resourceId: overrides.resourceId ?? 'chapter-4-asteroids:0.1.0', - tag: overrides.tag ?? 'final-grade', - label: overrides.label ?? 'Chapter 4 Asteroids Final Grade', - scoreMaximum: overrides.scoreMaximum ?? 100, - createdAt: overrides.createdAt ?? DEFAULT_PHASE3_AT, - updatedAt: overrides.updatedAt ?? DEFAULT_PHASE3_AT, - }; -} - -export function buildGradePublicationRecord( - overrides: Partial = {}, -): GradePublicationRecord { - return { - id: overrides.id ?? 1, - attemptId: overrides.attemptId ?? 'attempt-123', - lineItemBindingId: overrides.lineItemBindingId ?? 1, - lineItemUrl: overrides.lineItemUrl ?? 'https://canvas.example/api/lti/courses/42/line_items/9', - platformUserId: overrides.platformUserId ?? 'canvas-user-123', - scoreGiven: overrides.scoreGiven ?? 85, - scoreMaximum: overrides.scoreMaximum ?? 100, - activityProgress: overrides.activityProgress ?? 'Completed', - gradingProgress: overrides.gradingProgress ?? 'FullyGraded', - status: overrides.status ?? 'published', - createdAt: overrides.createdAt ?? DEFAULT_PHASE3_AT, - updatedAt: overrides.updatedAt ?? DEFAULT_PHASE3_AT, - publishedAt: overrides.publishedAt ?? DEFAULT_PHASE3_AT, - errorCode: overrides.errorCode ?? null, - errorDetail: overrides.errorDetail ?? null, - }; -} - export function buildAuditEventRecord(overrides: Partial = {}): AuditEventRecord { return { id: overrides.id ?? 1, eventType: overrides.eventType ?? 'attempt.submitted', actorType: overrides.actorType ?? 'system', actorId: overrides.actorId ?? null, - deploymentRecordId: overrides.deploymentRecordId ?? 1, packageVersionId: overrides.packageVersionId ?? 1, attemptId: overrides.attemptId === undefined ? 'attempt-123' : overrides.attemptId, - lineItemBindingId: overrides.lineItemBindingId ?? null, status: overrides.status ?? 'accepted', summary: overrides.summary ?? 'Accepted attempt submission.', detail: overrides.detail ?? { diff --git a/src/test_helpers/package_review_test_builder_ops.ts b/src/test_helpers/package_review_test_builder_ops.ts deleted file mode 100644 index 627d756..0000000 --- a/src/test_helpers/package_review_test_builder_ops.ts +++ /dev/null @@ -1,367 +0,0 @@ -import type { RetryRuntimeSessionLookup } from '../ops/types.ts'; -import type { - BrokerVerificationStatus, - CertificationWorkflowStatus, - ControlPlaneAnonymousEvidenceArtifact, - ControlPlaneDeploymentDetailSnapshot, - ControlPlaneDeploymentHealth, - ControlPlaneDeploymentInventoryRow, - ControlPlaneDiagnosticItem, - ControlPlaneHealthDimension, - ControlPlaneRuntimeEvidenceSnapshot, - DeploymentActivitySnapshot, - DeploymentGradePublicationSnapshot, - InternalBrokerVerificationStatus, - LatestOfficialCertificationEvidence, - OfficialBrokerCertificationStatus, - PilotUsageMetrics, - RetryableGradePublicationLookup, -} from '../ops/types.ts'; -import { buildDeploymentRecentLaunch } from './package_review_test_builder_ops_launches.ts'; -import { - DEFAULT_PHASE3_AT, - DEFAULT_PHASE4_AT, - DEFAULT_UPDATED_AT, -} from './package_review_test_defaults.ts'; - -export { buildDeploymentRecentLaunch } from './package_review_test_builder_ops_launches.ts'; - -export function buildPilotUsageMetrics( - overrides: Partial = {}, -): PilotUsageMetrics { - return { - deploymentRecordId: overrides.deploymentRecordId ?? 1, - totalLaunches: overrides.totalLaunches ?? 4, - attemptsStarted: overrides.attemptsStarted ?? 3, - attemptsCompleted: overrides.attemptsCompleted ?? 2, - gradePublishesSucceeded: overrides.gradePublishesSucceeded ?? 2, - gradePublishesFailed: overrides.gradePublishesFailed ?? 1, - recentActiveUsers: overrides.recentActiveUsers ?? 2, - lastLaunchAt: overrides.lastLaunchAt ?? DEFAULT_PHASE4_AT, - measuredAt: overrides.measuredAt ?? DEFAULT_PHASE4_AT, - }; -} - -export function buildControlPlaneHealthDimension( - overrides: Partial = {}, -): ControlPlaneHealthDimension { - return { - name: overrides.name ?? 'review', - status: overrides.status ?? 'healthy', - summary: overrides.summary ?? 'Approved version is pinned for the pilot.', - checkedAt: overrides.checkedAt ?? DEFAULT_PHASE4_AT, - }; -} - -export function buildControlPlaneDeploymentHealth( - overrides: Partial = {}, -): ControlPlaneDeploymentHealth { - return { - overallStatus: overrides.overallStatus ?? 'attention', - summary: overrides.summary ?? - 'Deployment is readable in the control plane and needs one operator follow-up.', - dimensions: overrides.dimensions ?? { - review: buildControlPlaneHealthDimension({ - name: 'review', - status: 'healthy', - summary: 'Reviewed version is approved.', - }), - enablement: buildControlPlaneHealthDimension({ - name: 'enablement', - status: 'healthy', - summary: 'Deployment pin and Canvas binding are present.', - }), - launch: buildControlPlaneHealthDimension({ - name: 'launch', - status: 'attention', - summary: 'Latest launch needs confirmation from fresh operator evidence.', - }), - gradePublication: buildControlPlaneHealthDimension({ - name: 'gradePublication', - status: 'attention', - summary: 'Latest grade publish requires review.', - }), - nrps: buildControlPlaneHealthDimension({ - name: 'nrps', - status: 'healthy', - summary: 'Roster verification succeeded on the saved deployment path.', - }), - brokerVerification: buildControlPlaneHealthDimension({ - name: 'brokerVerification', - status: 'healthy', - summary: 'Latest broker verification evidence passed.', - }), - }, - }; -} - -export function buildDeploymentActivitySnapshot( - overrides: Partial = {}, -): DeploymentActivitySnapshot { - return { - status: overrides.status ?? 'succeeded', - occurredAt: overrides.occurredAt ?? DEFAULT_PHASE4_AT, - summary: overrides.summary ?? 'Latest operator-visible activity succeeded.', - attemptId: overrides.attemptId ?? 'attempt-123', - contextId: overrides.contextId ?? 'course-42', - detail: overrides.detail ?? { code: 'ok' }, - }; -} - -export function buildDeploymentGradePublicationSnapshot( - overrides: Partial = {}, -): DeploymentGradePublicationSnapshot { - return { - attemptId: overrides.attemptId ?? 'attempt-123', - status: overrides.status ?? 'failed', - lineItemUrl: overrides.lineItemUrl ?? 'https://canvas.example/api/lti/courses/42/line_items/9', - platformUserId: overrides.platformUserId ?? 'canvas-user-123', - scoreGiven: overrides.scoreGiven ?? 85, - scoreMaximum: overrides.scoreMaximum ?? 100, - activityProgress: overrides.activityProgress ?? 'Completed', - gradingProgress: overrides.gradingProgress ?? 'Failed', - publishedAt: overrides.publishedAt ?? null, - updatedAt: overrides.updatedAt ?? DEFAULT_PHASE4_AT, - errorCode: overrides.errorCode ?? 'canvas_score_rejected', - errorDetail: overrides.errorDetail ?? { status: 422 }, - }; -} - -export function buildControlPlaneDiagnosticItem( - overrides: Partial = {}, -): ControlPlaneDiagnosticItem { - return { - id: overrides.id ?? 1, - kind: overrides.kind ?? 'gradePublication', - eventType: overrides.eventType ?? 'grade_publish.failed', - actorType: overrides.actorType ?? 'platform', - status: overrides.status ?? 'failed', - deploymentRecordId: overrides.deploymentRecordId ?? 1, - attemptId: overrides.attemptId ?? 'attempt-123', - code: overrides.code ?? 'canvas_score_rejected', - boundaryDenialCategory: overrides.boundaryDenialCategory ?? null, - summary: overrides.summary ?? 'Canvas rejected the score publish.', - operatorSummary: overrides.operatorSummary ?? - 'Grade publish failed and can be retried from the control plane.', - retryable: overrides.retryable ?? false, - detail: overrides.detail ?? { httpStatus: 422 }, - occurredAt: overrides.occurredAt ?? DEFAULT_PHASE4_AT, - }; -} - -export function buildRetryRuntimeSessionLookup( - overrides: Partial = {}, -): RetryRuntimeSessionLookup { - return { - sessionId: overrides.sessionId ?? 'runtime-session-123', - ltiToolSessionId: overrides.ltiToolSessionId ?? 'lti-tool-session-123', - attemptId: overrides.attemptId ?? 'attempt-123', - deploymentRecordId: overrides.deploymentRecordId ?? 1, - deploymentSlug: overrides.deploymentSlug ?? 'chapter-4-asteroids-pilot', - appId: overrides.appId ?? 'chapter-4-asteroids', - packageVersionId: overrides.packageVersionId ?? 1, - packageVersion: overrides.packageVersion ?? '0.1.0', - services: overrides.services ?? { - ags: { - scope: [ - 'https://purl.imsglobal.org/spec/lti-ags/scope/score', - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', - ], - lineitemsUrl: 'https://canvas.example/api/lti/courses/42/line_items', - lineitemUrl: 'https://canvas.example/api/lti/courses/42/line_items/9', - }, - nrps: { - contextMembershipsUrl: 'https://canvas.example/api/lti/courses/42/names_and_roles', - serviceVersions: ['2.0'], - }, - }, - createdAt: overrides.createdAt ?? DEFAULT_PHASE3_AT, - expiresAt: overrides.expiresAt ?? '2026-03-26T02:45:00Z', - }; -} - -export function buildRetryableGradePublicationLookup( - overrides: Partial = {}, -): RetryableGradePublicationLookup { - return { - attemptId: overrides.attemptId ?? 'attempt-123', - deploymentRecordId: overrides.deploymentRecordId ?? 1, - deploymentSlug: overrides.deploymentSlug ?? 'chapter-4-asteroids-pilot', - publication: overrides.publication ?? buildDeploymentGradePublicationSnapshot(), - binding: overrides.binding ?? { - lms: 'canvas', - canvasEnvironment: 'production', - issuer: 'https://canvas.instructure.com', - clientId: '10000000000001', - deploymentId: 'deployment-123', - }, - runtimeSession: overrides.runtimeSession ?? buildRetryRuntimeSessionLookup(), - }; -} - -export function buildControlPlaneRuntimeEvidenceSnapshot( - overrides: Partial = {}, -): ControlPlaneRuntimeEvidenceSnapshot { - return { - eventType: overrides.eventType ?? 'runtime.session.started', - status: overrides.status ?? 'succeeded', - occurredAt: overrides.occurredAt ?? DEFAULT_PHASE4_AT, - summary: overrides.summary ?? - "Started the reviewed runtime session inside Lantern's contained browser boundary.", - attemptId: overrides.attemptId ?? 'attempt-123', - sessionId: overrides.sessionId ?? 'runtime-session-123', - packageVersionId: overrides.packageVersionId ?? 1, - packageVersion: overrides.packageVersion ?? '0.1.0', - artifactDigest: overrides.artifactDigest ?? 'sha256:chapter-4-asteroids-0.1.0', - runtimeContractSignature: overrides.runtimeContractSignature ?? - 'test-reviewed-runtime-contract-signature', - sandboxModel: overrides.sandboxModel ?? 'contained_browser_runtime', - boundary: overrides.boundary ?? 'app_runtime_origin', - deliverySubstrate: overrides.deliverySubstrate ?? 'dynamic_worker', - deliveryWorkerId: overrides.deliveryWorkerId ?? - 'reviewed-runtime:v1:test-reviewed-runtime-contract-signature', - deliveryState: overrides.deliveryState ?? 'started', - route: overrides.route ?? 'session', - capability: overrides.capability ?? null, - code: overrides.code ?? null, - boundaryDenialCategory: overrides.boundaryDenialCategory ?? null, - detail: overrides.detail ?? {}, - }; -} - -export function buildControlPlaneAnonymousEvidenceArtifact( - overrides: Partial = {}, -): ControlPlaneAnonymousEvidenceArtifact { - return { - artifactId: overrides.artifactId ?? 'artifact-001', - kind: overrides.kind ?? 'structured_json', - fileName: overrides.fileName ?? 'submission.json', - contentType: overrides.contentType ?? 'application/json', - byteSize: overrides.byteSize ?? 128, - sha256: overrides.sha256 ?? 'sha256:artifact-001', - createdAt: overrides.createdAt ?? DEFAULT_PHASE4_AT, - artifactUrl: overrides.artifactUrl ?? - '/admin/packages/chapter-4-asteroids/deployment/evidence/artifact-001', - }; -} - -export function buildInternalBrokerVerificationStatus( - overrides: Partial = {}, -): InternalBrokerVerificationStatus { - return { - source: overrides.source ?? 'manual', - status: overrides.status ?? 'passed', - checkedAt: overrides.checkedAt ?? DEFAULT_PHASE4_AT, - summary: overrides.summary ?? - 'Canvas launch, AGS publish, and NRPS verification all passed for the supported broker path.', - evidenceUrl: overrides.evidenceUrl ?? 'https://example.test/verification/internal-run', - }; -} - -export function buildOfficialBrokerCertificationStatus( - overrides: Partial = {}, -): OfficialBrokerCertificationStatus { - return { - state: overrides.state ?? 'notCertified', - checkedAt: overrides.checkedAt ?? DEFAULT_PHASE4_AT, - directoryUrl: overrides.directoryUrl ?? null, - }; -} - -export function buildBrokerVerificationStatus( - overrides: Partial = {}, -): BrokerVerificationStatus { - return { - supportedPath: overrides.supportedPath ?? 'lti13LaunchAgsNrps', - internal: overrides.internal ?? buildInternalBrokerVerificationStatus(), - official: overrides.official ?? buildOfficialBrokerCertificationStatus(), - }; -} - -export function buildCertificationWorkflowStatus( - overrides: Partial = {}, -): CertificationWorkflowStatus { - return { - workflowKey: overrides.workflowKey ?? 'core', - latestInternal: overrides.latestInternal ?? null, - }; -} - -export function buildLatestOfficialCertificationEvidence( - overrides: Partial = {}, -): LatestOfficialCertificationEvidence { - return { - workflowKey: overrides.workflowKey ?? 'core', - state: overrides.state ?? 'ltiAdvantageCertified', - checkedAt: overrides.checkedAt ?? DEFAULT_PHASE4_AT, - summary: overrides.summary ?? '1EdTech lists Lantern as LTI Advantage Certified.', - directoryUrl: overrides.directoryUrl ?? 'https://example.test/verification/1edtech-directory', - }; -} - -export function buildControlPlaneDeploymentInventoryRow( - overrides: Partial = {}, -): ControlPlaneDeploymentInventoryRow { - return { - deploymentId: overrides.deploymentId ?? 1, - deploymentSlug: overrides.deploymentSlug ?? 'chapter-4-asteroids-pilot', - deploymentLabel: overrides.deploymentLabel ?? 'Chapter 4 Asteroids Pilot Deployment', - appId: overrides.appId ?? 'chapter-4-asteroids', - appTitle: overrides.appTitle ?? 'Chapter 4 Asteroids', - ownerId: overrides.ownerId ?? 'instructor_123', - enabledPackageVersionId: overrides.enabledPackageVersionId ?? 1, - enabledPackageVersion: overrides.enabledPackageVersion ?? '0.1.0', - approvalStatus: overrides.approvalStatus ?? 'approved', - binding: overrides.binding ?? { - lms: 'canvas', - canvasEnvironment: 'production', - issuer: 'https://canvas.instructure.com', - clientId: '10000000000001', - deploymentId: 'deployment-123', - }, - installEvidence: overrides.installEvidence ?? null, - updatedAt: overrides.updatedAt ?? DEFAULT_UPDATED_AT, - lastLaunchAt: overrides.lastLaunchAt ?? DEFAULT_PHASE4_AT, - lastLaunchStatus: overrides.lastLaunchStatus ?? 'succeeded', - lastGradePublishAt: overrides.lastGradePublishAt ?? DEFAULT_PHASE4_AT, - lastGradePublishStatus: overrides.lastGradePublishStatus ?? 'failed', - lastNrpsReadAt: overrides.lastNrpsReadAt ?? DEFAULT_PHASE4_AT, - lastNrpsReadStatus: overrides.lastNrpsReadStatus ?? 'succeeded', - pilotUsage: overrides.pilotUsage ?? buildPilotUsageMetrics(), - health: overrides.health ?? buildControlPlaneDeploymentHealth(), - brokerVerification: overrides.brokerVerification === undefined - ? buildBrokerVerificationStatus() - : overrides.brokerVerification, - }; -} - -export function buildControlPlaneDeploymentDetailSnapshot( - overrides: Partial = {}, -): ControlPlaneDeploymentDetailSnapshot { - return { - inventory: overrides.inventory ?? buildControlPlaneDeploymentInventoryRow(), - latestInstallEvidence: overrides.latestInstallEvidence ?? null, - latestLaunch: overrides.latestLaunch ?? - buildDeploymentActivitySnapshot({ - summary: 'Latest launch completed and reached the runtime handoff.', - }), - latestRuntimeSession: overrides.latestRuntimeSession ?? null, - latestRuntimeOutcome: overrides.latestRuntimeOutcome ?? null, - latestAnonymousEvidence: overrides.latestAnonymousEvidence ?? [], - recentLaunches: overrides.recentLaunches ?? [buildDeploymentRecentLaunch()], - latestCompatibilityPath: overrides.latestCompatibilityPath ?? null, - latestAgsSmoke: overrides.latestAgsSmoke ?? null, - latestNrpsRead: overrides.latestNrpsRead ?? - buildDeploymentActivitySnapshot({ - summary: 'Latest roster verification succeeded.', - }), - latestGradePublish: overrides.latestGradePublish ?? buildDeploymentGradePublicationSnapshot(), - pilotUsage: overrides.pilotUsage ?? buildPilotUsageMetrics(), - diagnostics: overrides.diagnostics ?? [buildControlPlaneDiagnosticItem()], - retryableGradePublication: overrides.retryableGradePublication ?? - buildRetryableGradePublicationLookup(), - brokerVerification: overrides.brokerVerification === undefined - ? buildBrokerVerificationStatus() - : overrides.brokerVerification, - }; -} diff --git a/src/test_helpers/package_review_test_builder_ops_launches.ts b/src/test_helpers/package_review_test_builder_ops_launches.ts deleted file mode 100644 index 7b7efe6..0000000 --- a/src/test_helpers/package_review_test_builder_ops_launches.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { DeploymentRecentLaunch } from '../ops/types.ts'; - -export function buildDeploymentRecentLaunch( - overrides: Partial = {}, -): DeploymentRecentLaunch { - return { - occurredAt: overrides.occurredAt ?? '2026-03-24T12:30:00Z', - summary: overrides.summary ?? 'Accepted the governed LMS launch.', - attemptId: overrides.attemptId ?? 'attempt-123', - userId: overrides.userId ?? 'instructor_123', - userDisplayName: overrides.userDisplayName ?? null, - userEmail: overrides.userEmail ?? null, - userLogin: overrides.userLogin ?? null, - contextId: overrides.contextId ?? 'course-42', - resourceLinkId: overrides.resourceLinkId ?? 'resource-link-123', - ltiProfileId: overrides.ltiProfileId ?? null, - ltiProfileSource: overrides.ltiProfileSource ?? null, - }; -} diff --git a/src/test_helpers/package_review_test_builder_preview.ts b/src/test_helpers/package_review_test_builder_preview.ts index b0eab52..17fc120 100644 --- a/src/test_helpers/package_review_test_builder_preview.ts +++ b/src/test_helpers/package_review_test_builder_preview.ts @@ -1,65 +1,5 @@ -import type { - DeepLinkingResourceOption, - DeepLinkingResourceSelection, - PreviewEvidenceRecord, - PreviewSessionRecord, - ReviewedPlacementRecord, -} from '../package_review/types.ts'; -import { DEFAULT_PHASE4_AT, DEFAULT_REVIEWED_AT } from './package_review_test_defaults.ts'; - -export function buildDeepLinkingResourceOption( - overrides: Partial = {}, -): DeepLinkingResourceOption { - return { - packageVersionId: overrides.packageVersionId ?? 1, - appId: overrides.appId ?? 'chapter-4-asteroids', - packageVersion: overrides.packageVersion ?? '0.1.0', - packageTitle: overrides.packageTitle ?? 'Chapter 4 Asteroids', - ownerId: overrides.ownerId ?? 'instructor_123', - installScope: overrides.installScope ?? 'assignment', - approvalStatus: 'approved', - reviewedAt: overrides.reviewedAt ?? DEFAULT_REVIEWED_AT, - activityId: overrides.activityId ?? '/content/activity.json', - contentPath: overrides.contentPath ?? '/content/activity.json', - contentTitle: overrides.contentTitle ?? 'Activity', - }; -} - -export function buildDeepLinkingResourceSelection( - overrides: Partial = {}, -): DeepLinkingResourceSelection { - return { - packageVersionId: overrides.packageVersionId ?? 1, - packageVersion: overrides.packageVersion ?? '0.1.0', - packageTitle: overrides.packageTitle ?? 'Chapter 4 Asteroids', - activityId: overrides.activityId ?? '/content/activity.json', - contentPath: overrides.contentPath ?? '/content/activity.json', - contentTitle: overrides.contentTitle ?? 'Activity', - }; -} - -export function buildReviewedPlacementRecord( - overrides: Partial = {}, -): ReviewedPlacementRecord { - return { - placementId: overrides.placementId ?? 'placement-123', - deploymentRecordId: overrides.deploymentRecordId ?? 1, - deploymentSlug: overrides.deploymentSlug ?? 'chapter-4-asteroids-pilot', - appId: overrides.appId ?? 'chapter-4-asteroids', - contextId: overrides.contextId ?? 'course-42', - contextTitle: overrides.contextTitle ?? 'Physics 101', - packageVersionId: overrides.packageVersionId ?? 1, - packageVersion: overrides.packageVersion ?? '0.1.0', - packageTitle: overrides.packageTitle ?? 'Chapter 4 Asteroids', - activityId: overrides.activityId ?? '/content/activity.json', - contentPath: overrides.contentPath ?? '/content/activity.json', - contentTitle: overrides.contentTitle ?? 'Activity', - createdByUserId: overrides.createdByUserId ?? 'canvas-user-123', - resourceLinkId: overrides.resourceLinkId ?? null, - createdAt: overrides.createdAt ?? DEFAULT_PHASE4_AT, - boundAt: overrides.boundAt ?? null, - }; -} +import type { PreviewEvidenceRecord, PreviewSessionRecord } from '../package_review/types.ts'; +import { DEFAULT_PHASE4_AT } from './package_review_test_defaults.ts'; export function buildPreviewSessionRecord( overrides: Partial = {}, @@ -72,7 +12,6 @@ export function buildPreviewSessionRecord( packageTitle: overrides.packageTitle ?? 'Chapter 4 Asteroids', origin: overrides.origin ?? 'adminTestLaunch', contentPath: overrides.contentPath ?? '/content/activity.json', - deepLinkingSessionId: overrides.deepLinkingSessionId ?? null, capabilities: overrides.capabilities ?? [ 'read_launch_context', 'read_activity_content', @@ -111,7 +50,6 @@ export function buildAdminPreviewSessionRecord( return buildPreviewSessionRecord({ origin: 'adminTestLaunch', contentPath: '/content/activity.json', - deepLinkingSessionId: null, ...overrides, }); } @@ -120,9 +58,8 @@ export function buildAuthoringPreviewSessionRecord( overrides: Partial = {}, ): PreviewSessionRecord { return buildPreviewSessionRecord({ - origin: 'deepLinkingAuthoring', + origin: 'adminAuthoringDraft', contentPath: '/content/bonus.json', - deepLinkingSessionId: 'deep-linking-session-123', ...overrides, }); } diff --git a/src/worker.ts b/src/worker.ts index dc35b55..2a50ff4 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,6 +1,9 @@ -import type { ExecutionContext } from '@hono/hono'; -import { buildApp } from './app_core.ts'; export { AppWriterAgent } from './app_writer/agent.ts'; +export { + createCloudflarePythonGraderSandboxFactory, + PythonGraderSandbox, +} from './python_lab/cloudflare_sandbox.ts'; +import { createCloudflarePythonGraderSandboxFactory } from './python_lab/cloudflare_sandbox.ts'; import { type AppGenerationWorkflowParams, runAppGenerationFileWorkflowStep, @@ -9,17 +12,18 @@ import { runAppGenerationPlanningWorkflowStep, } from './app_writer/workflow_runner.ts'; import { resolveWorkerServices, type WorkerBindings } from './app_worker_services.ts'; -import { statusForError } from './app_status_support.ts'; -import { buildCanvasConfigDocument } from './lti/config.ts'; import { createObjectEnvReader } from './platform/env.ts'; -import { readEnv } from './platform/env.ts'; -import { resolveConfiguredPublicOrigin } from './public_origin.ts'; +import { runPythonLabGenerationWorkflow } from './python_lab/workflow_runner.ts'; +import type { PythonLabGenerationWorkflowParams } from './python_lab/workflow.ts'; +import type { PrimitiveResult, RosterPage } from '../sdk/worker-app-sdk.ts'; +import { createWorkerHandler } from './worker_handler.ts'; interface WorkflowEntrypointRuntimeClass { new (): object; } declare const WorkflowEntrypoint: WorkflowEntrypointRuntimeClass | undefined; +declare const WorkerEntrypoint: WorkflowEntrypointRuntimeClass | undefined; interface WorkflowEvent { readonly payload: Readonly; @@ -45,77 +49,18 @@ interface WorkflowStepConfig { timeout?: string | number; } -interface WorkerExecutionContext { - waitUntil?(promise: Promise): void; - passThroughOnException?(): void; -} - const WorkflowEntrypointBase: WorkflowEntrypointRuntimeClass = typeof WorkflowEntrypoint === 'function' ? WorkflowEntrypoint : Object; +const WorkerEntrypointBase: WorkflowEntrypointRuntimeClass = typeof WorkerEntrypoint === 'function' + ? WorkerEntrypoint + : Object; -export default { - fetch( - request: Request, - env: WorkerBindings = {}, - executionContext?: WorkerExecutionContext, - ): Response | Promise { - const publicResponse = handlePublicWorkerRequest(request, env); - - if (publicResponse !== null) { - return publicResponse; - } - - const app = buildApp(resolveWorkerServices(env, createObjectEnvReader(env))); - - return app.fetch(request, env, toHonoExecutionContext(executionContext)); - }, -}; - -function handlePublicWorkerRequest( - request: Request, - env: WorkerBindings, -): Response | Promise | null { - const url = new URL(request.url); - - if (url.pathname === '/health') { - return Response.json({ ok: true, appWriterHarness: 'workspace-runner-v1' }); - } - - if (url.pathname !== '/lti/canvas/config.json') { - return null; - } - - return buildWorkerCanvasConfigResponse(request, env); +interface LanternLmsBindingProps { + readonly appId: string; + readonly packageVersionId: number; } -async function buildWorkerCanvasConfigResponse( - request: Request, - env: WorkerBindings, -): Promise { - const envReader = createObjectEnvReader(env); - - try { - return Response.json( - await buildCanvasConfigDocument( - resolveConfiguredPublicOrigin({ - requestUrl: request.url, - forwardedHeader: request.headers.get('forwarded'), - xForwardedHost: request.headers.get('x-forwarded-host'), - xForwardedProto: request.headers.get('x-forwarded-proto'), - configuredOrigin: readEnv('APP_ORIGIN', envReader), - }), - envReader, - ), - ); - } catch (error) { - return Response.json( - { - error: error instanceof Error ? error.message : 'Config unavailable.', - }, - { status: statusForError(error) }, - ); - } -} +export default createWorkerHandler(createCloudflarePythonGraderSandboxFactory); export class AppGenerationWorkflow extends WorkflowEntrypointBase { declare readonly env: WorkerBindings; @@ -206,20 +151,64 @@ export class AppGenerationWorkflow extends WorkflowEntrypointBase { } } -function toHonoExecutionContext( - executionContext?: WorkerExecutionContext, -): ExecutionContext | undefined { - if (executionContext === undefined) { - return undefined; +export class PythonLabGenerationWorkflow extends WorkflowEntrypointBase { + declare readonly env: WorkerBindings; + + async run( + event: WorkflowEvent, + step: WorkflowStep, + ): Promise { + return await step.do( + 'generate Python autograder Lab', + { timeout: '10 minutes' }, + () => + runPythonLabGenerationWorkflow({ + bindings: this.env, + params: event.payload, + createPythonSandboxFactory: createCloudflarePythonGraderSandboxFactory, + }), + ); } +} - return { - props: {}, - waitUntil(promise) { - executionContext.waitUntil?.(promise); - }, - passThroughOnException() { - executionContext.passThroughOnException?.(); - }, - }; +export class LanternLmsCapabilityBinding extends WorkerEntrypointBase { + declare readonly env: WorkerBindings; + declare readonly props: LanternLmsBindingProps; + + async listRoster(value: unknown): Promise> { + const traceId = crypto.randomUUID(); + if (!isLmsBindingRosterEnvelope(value)) { + return { + ok: false, + error: { code: 'invalid_request', traceId }, + }; + } + const services = resolveWorkerServices(this.env, createObjectEnvReader(this.env)); + return await services.governedRosterService.listRoster({ + runtimeSessionId: value.runtimeSessionId, + runtimeSessionToken: value.runtimeSessionToken, + ...(value.traceId === undefined ? {} : { traceId: value.traceId }), + request: value.request, + bindingIdentity: this.props, + }); + } +} + +function isLmsBindingRosterEnvelope(value: unknown): value is { + readonly runtimeSessionId: string; + readonly runtimeSessionToken: string; + readonly traceId?: string; + readonly request: unknown; +} { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const record = value as Record; + return typeof record.runtimeSessionId === 'string' && + record.runtimeSessionId.length > 0 && + typeof record.runtimeSessionToken === 'string' && + record.runtimeSessionToken.length > 0 && + (record.traceId === undefined || + (typeof record.traceId === 'string' && record.traceId.length > 0)) && + 'request' in record; } diff --git a/src/worker_handler.ts b/src/worker_handler.ts new file mode 100644 index 0000000..76d5d61 --- /dev/null +++ b/src/worker_handler.ts @@ -0,0 +1,86 @@ +import type { ExecutionContext } from '@hono/hono'; +import { buildApp } from './app_core.ts'; +import { + type PythonGraderSandboxFactoryProvider, + resolveWorkerServices, + type WorkerBindings, +} from './app_worker_services.ts'; +import { createObjectEnvReader } from './platform/env.ts'; +import { purgeExpiredPythonLabs } from './python_lab/retention.ts'; + +interface WorkerExecutionContext { + waitUntil?(promise: Promise): void; + passThroughOnException?(): void; + readonly exports?: { + LanternLmsCapabilityBinding(input: { + readonly props: { readonly appId: string; readonly packageVersionId: number }; + }): unknown; + }; +} + +interface ScheduledController { + readonly cron: string; + readonly scheduledTime: number; +} + +export function createWorkerHandler( + createPythonSandboxFactory?: PythonGraderSandboxFactoryProvider, +) { + return { + fetch( + request: Request, + env: WorkerBindings = {}, + executionContext?: WorkerExecutionContext, + ): Response | Promise { + const app = buildApp( + resolveWorkerServices( + env, + createObjectEnvReader(env), + executionContext?.exports === undefined + ? undefined + : (identity) => + executionContext.exports!.LanternLmsCapabilityBinding({ props: identity }), + createPythonSandboxFactory, + ), + { kernelAdminContext: true }, + ); + + return app.fetch(request, env, toHonoExecutionContext(executionContext)); + }, + + scheduled( + _controller: ScheduledController, + env: WorkerBindings = {}, + executionContext?: WorkerExecutionContext, + ): void | Promise { + const services = resolveWorkerServices(env, createObjectEnvReader(env)); + const cleanup = purgeExpiredPythonLabs({ + repository: services.getRepository(), + pythonLabRepository: services.pythonLabRepository, + pythonLabArtifactStore: services.pythonLabArtifactStore, + }); + + if (executionContext?.waitUntil !== undefined) { + executionContext.waitUntil(cleanup); + return; + } + + return cleanup.then(() => undefined); + }, + }; +} + +function toHonoExecutionContext( + executionContext?: WorkerExecutionContext, +): ExecutionContext | undefined { + if (executionContext === undefined) return undefined; + return { + props: {}, + waitUntil(promise) { + executionContext.waitUntil?.(promise); + }, + passThroughOnException() { + executionContext.passThroughOnException?.(); + }, + }; +} diff --git a/src/worker_test.ts b/src/worker_test.ts deleted file mode 100644 index e80acd0..0000000 --- a/src/worker_test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { assertEquals } from '@std/assert'; -import worker from './worker.ts'; -import { getTestToolPrivateJwkEnvValue } from './test_helpers/lti.ts'; - -Deno.test('Cloudflare worker entrypoint serves health without Deno-only bindings', async () => { - const response = await worker.fetch(new Request('https://lantern.example/health'), {}, {}); - - assertEquals(response.status, 200); - assertEquals(await response.json(), { - ok: true, - appWriterHarness: 'workspace-runner-v1', - }); -}); - -Deno.test('Cloudflare worker entrypoint serves Canvas config from worker env bindings', async () => { - const response = await worker.fetch( - new Request('https://worker.internal/lti/canvas/config.json'), - { - APP_ORIGIN: 'https://lantern.example', - LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), - }, - {}, - ); - const body = (await response.json()) as { - oidc_initiation_url: string; - target_link_uri: string; - extensions: Array<{ - settings: { - placements: Array<{ - target_link_uri: string; - }>; - }; - }>; - }; - - assertEquals(response.status, 200); - assertEquals(body.oidc_initiation_url, 'https://lantern.example/lti/login'); - assertEquals(body.target_link_uri, 'https://lantern.example/lti/launch'); - assertEquals( - body.extensions.flatMap((extension) => extension.settings.placements).some((placement) => - placement.target_link_uri === 'https://lantern.example/lti/launch' - ), - true, - ); -}); diff --git a/tools/app_validate.ts b/tools/app_validate.ts index efe9ec6..d2fb932 100644 --- a/tools/app_validate.ts +++ b/tools/app_validate.ts @@ -66,7 +66,7 @@ export function renderValidationSuccess( 'Lantern app validation passed.', `- App ID: ${result.appPackage.reviewData.appId}`, `- Version: ${result.appPackage.reviewData.version}`, - `- Entrypoint: ${result.appPackage.manifest.entrypoint}`, + `- Entrypoint: ${result.appPackage.manifest.runtime.entrypoint}`, `- Capabilities: ${result.appPackage.reviewData.capabilities.join(', ')}`, `- Preview tests: ${String(result.appPackage.previewTests.length)}`, ]; @@ -122,7 +122,7 @@ export function serializeValidationResult(result: LocalAppValidationResult): str appId: result.appPackage.reviewData.appId, version: result.appPackage.reviewData.version, title: result.appPackage.reviewData.title, - entrypoint: result.appPackage.manifest.entrypoint, + entrypoint: result.appPackage.manifest.runtime.entrypoint, capabilities: result.appPackage.reviewData.capabilities, previewTestCount: result.appPackage.previewTests.length, }, diff --git a/tools/build_reviewed_worker_app.ts b/tools/build_reviewed_worker_app.ts new file mode 100644 index 0000000..b4a6453 --- /dev/null +++ b/tools/build_reviewed_worker_app.ts @@ -0,0 +1,37 @@ +const appId = Deno.args[0]?.trim(); + +if (!appId || !/^[a-z0-9][a-z0-9-]*$/.test(appId)) { + throw new Error('Usage: deno task app:build-worker '); +} + +const root = new URL('../', import.meta.url); +const appRoot = new URL(`examples/apps/${appId}/`, root); +const source = new URL('source/worker.ts', appRoot); +const output = new URL('dist/worker.js', appRoot); + +try { + const sourceInfo = await Deno.stat(source); + if (!sourceInfo.isFile) { + throw new Error(); + } +} catch { + throw new Error(`Reviewed Worker source was not found for ${appId}.`); +} + +await Deno.mkdir(new URL('dist/', appRoot), { recursive: true }); +const command = new Deno.Command(Deno.execPath(), { + args: [ + 'bundle', + '--platform=browser', + '--minify', + source.pathname, + '-o', + output.pathname, + ], +}); +const result = await command.output(); +if (!result.success) { + throw new Error(new TextDecoder().decode(result.stderr).trim()); +} + +console.log(`Built reviewed Worker app ${appId} at ${output.pathname}.`); diff --git a/tools/build_worker_bundle.ts b/tools/build_worker_bundle.ts index 4ed1494..0c34e86 100644 --- a/tools/build_worker_bundle.ts +++ b/tools/build_worker_bundle.ts @@ -25,7 +25,8 @@ if (!result.success) { } const rawBundle = await Deno.readTextFile(rawBundlePath); -const workflowImport = 'import { WorkflowEntrypoint } from "cloudflare:workers";\n'; +const workflowImport = + 'import { WorkflowEntrypoint, WorkerEntrypoint } from "cloudflare:workers";\n'; const workerSafeBundle = neutralizeBundledDenoRuntimeDetection(rawBundle); await Deno.writeTextFile(bundlePath, `${workflowImport}${workerSafeBundle}`); diff --git a/wrangler.jsonc b/wrangler.jsonc index 44044b8..a28dbb8 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -3,7 +3,7 @@ // Replace the placeholder binding values with your account values before deploy. "name": "lantern", "main": "output/worker.bundle.mjs", - "compatibility_date": "2026-04-06", + "compatibility_date": "2026-07-13", "compatibility_flags": ["nodejs_compat"], "build": { // Wrangler cannot consume Deno import-map specifiers directly, so build one @@ -37,6 +37,17 @@ "name": "lantern-app-generation", "binding": "APP_GENERATION_WORKFLOW", "class_name": "AppGenerationWorkflow" + }, + { + "name": "lantern-python-lab-generation", + "binding": "PYTHON_LAB_GENERATION_WORKFLOW", + "class_name": "PythonLabGenerationWorkflow" + } + ], + "containers": [ + { + "class_name": "PythonGraderSandbox", + "image": "./Dockerfile.python-grader" } ], "durable_objects": { @@ -44,6 +55,10 @@ { "name": "APP_WRITER_AGENT", "class_name": "AppWriterAgent" + }, + { + "name": "PYTHON_GRADER_SANDBOX", + "class_name": "PythonGraderSandbox" } ] }, @@ -51,6 +66,10 @@ { "tag": "v1_app_writer_agent", "new_sqlite_classes": ["AppWriterAgent"] + }, + { + "tag": "v2_python_grader_sandbox", + "new_sqlite_classes": ["PythonGraderSandbox"] } ], "services": [ @@ -85,5 +104,8 @@ // deno task reference:sync --bucket= "bucket_name": "lantern-prod-artifacts" } - ] + ], + "triggers": { + "crons": ["17 3 * * *"] + } } From 31ee9dfdae5bc4336f65bb810753455d5e2ab11a Mon Sep 17 00:00:00 2001 From: Sam Ottenhoff Date: Wed, 22 Jul 2026 12:28:33 -0400 Subject: [PATCH 07/12] chore: bind clean production database --- wrangler.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wrangler.jsonc b/wrangler.jsonc index a28dbb8..95eeaba 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -91,7 +91,7 @@ { "binding": "DB", "database_name": "lantern", - "database_id": "3ed3e887-c7c3-458e-8549-51974a9e9ac6", + "database_id": "0f0a956c-c1f8-43dd-bcdb-80577be94856", "migrations_dir": "src/db/d1_migrations" } ], From cc044c4a675b15871174beedbea4176ad8c942af Mon Sep 17 00:00:00 2001 From: Sam Ottenhoff Date: Thu, 1 Oct 2026 11:58:17 -0400 Subject: [PATCH 08/12] Fix governed LMS runtime security and Photo Roster pagination --- examples/apps/photo-roster/dist/worker.js | 22 +- examples/apps/photo-roster/manifest.json | 2 +- examples/apps/photo-roster/source/worker.ts | 21 +- sdk/worker-app-sdk.ts | 9 + src/admin/governance_page.ts | 3 + src/app_admin_governance_routes.ts | 1 + src/app_governed_runtime_routes.ts | 1 + src/app_governed_runtime_routes_test.ts | 99 +++++- .../0004_governed_lms_primitive_kernel.sql | 1 + src/lms_kernel/admin_connection.ts | 1 + src/lms_kernel/admin_service.ts | 1 + src/lms_kernel/admin_support.ts | 12 + src/lms_kernel/connector.ts | 12 +- src/lms_kernel/connector_test.ts | 1 + src/lms_kernel/context_roles.ts | 59 ++++ src/lms_kernel/context_roles_test.ts | 52 ++++ src/lms_kernel/d1_test_support.ts | 28 ++ src/lms_kernel/launch_service.ts | 11 +- src/lms_kernel/lms_origin.ts | 16 + src/lms_kernel/lms_origin_test.ts | 45 +++ src/lms_kernel/media.ts | 93 +++++- src/lms_kernel/media_test.ts | 103 +++++- src/lms_kernel/policy_test.ts | 1 + src/lms_kernel/repository.ts | 1 + src/lms_kernel/repository_d1_connections.ts | 5 +- src/lms_kernel/repository_d1_installations.ts | 44 +++ .../repository_d1_integration_test.ts | 28 +- src/lms_kernel/repository_d1_row.ts | 9 + src/lms_kernel/repository_d1_runtime.ts | 5 + src/lms_kernel/repository_d1_runtime_test.ts | 294 ++++++++++++++++++ .../repository_memory_test_support.ts | 2 + src/lms_kernel/types.ts | 2 + src/runtime/reviewed_worker_http.ts | 14 +- 33 files changed, 925 insertions(+), 73 deletions(-) create mode 100644 src/lms_kernel/context_roles.ts create mode 100644 src/lms_kernel/context_roles_test.ts create mode 100644 src/lms_kernel/d1_test_support.ts create mode 100644 src/lms_kernel/lms_origin.ts create mode 100644 src/lms_kernel/lms_origin_test.ts create mode 100644 src/lms_kernel/repository_d1_runtime_test.ts diff --git a/examples/apps/photo-roster/dist/worker.js b/examples/apps/photo-roster/dist/worker.js index 2e94f86..eb37967 100644 --- a/examples/apps/photo-roster/dist/worker.js +++ b/examples/apps/photo-roster/dist/worker.js @@ -1,25 +1,25 @@ -function s(e){let t=e.request.headers.get("x-lantern-runtime-session-id"),a=e.request.headers.get("x-lantern-runtime-session-token"),n=e.request.headers.get("x-lantern-trace-id");return{listRoster(r){return!t||!a?Promise.resolve({ok:!1,error:{code:"session_expired",traceId:crypto.randomUUID()}}):e.binding.listRoster({runtimeSessionId:t,runtimeSessionToken:a,...n===null?{}:{traceId:n},request:r})}}}var b={async fetch(e,t){let n=new URL(e.url).searchParams.get("cursor")??void 0,o=await s({request:e,binding:t.LANTERN_LMS}).listRoster({fields:["email","photo"],...n===void 0?{}:{cursor:n}});return o.ok?l(u(o.value.members,o.value.nextCursor),200):l(h(o.error.code,o.error.traceId),403)}};function u(e,t){let a=e.map(r=>{let o=r.photo===void 0?``:``,c=r.email===void 0?'Email not shared':`${i(r.email)}`;return`
  • - ${o} +function p(e,r){let a=e.headers.get("x-lantern-runtime-page-url");if(a===null)return null;let t=new URL(a,"https://reviewed-app.internal");return t.searchParams.set("cursor",r),`${t.pathname}${t.search}`}function c(e){let r=e.request.headers.get("x-lantern-runtime-session-id"),a=e.request.headers.get("x-lantern-runtime-session-token"),t=e.request.headers.get("x-lantern-trace-id");return{listRoster(n){return!r||!a?Promise.resolve({ok:!1,error:{code:"session_expired",traceId:crypto.randomUUID()}}):e.binding.listRoster({runtimeSessionId:r,runtimeSessionToken:a,...t===null?{}:{traceId:t},request:n})}}}var w={async fetch(e,r){let t=new URL(e.url).searchParams.get("cursor")??void 0,i=await c({request:e,binding:r.LANTERN_LMS}).listRoster({fields:["email","photo"],...t===void 0?{}:{cursor:t}});if(!i.ok)return l(u(i.error.code,i.error.traceId),403);let s=i.value.nextCursor===void 0?void 0:p(e,i.value.nextCursor);return s===null?l(u("session_expired",e.headers.get("x-lantern-trace-id")??crypto.randomUUID()),403):l(m(i.value.members,s),200)}};function m(e,r){let a=e.map(n=>{let i=n.photo===void 0?``:``,s=n.email===void 0?'Email not shared':`${o(n.email)}`;return`
  • + ${i}
    -

    ${i(r.displayName)}

    -

    ${i(r.roles.map(g).join(", "))}

    -

    ${c}

    +

    ${o(n.displayName)}

    +

    ${o(n.roles.map(x).join(", "))}

    +

    ${s}

    -
  • `}).join(""),n=t===void 0?"":``;return p(` + `}).join(""),t=r===void 0?"":``;return h(`

    Current LMS course

    Photo Roster

    ${e.length} people on this page

      ${a}
    - - `)}function h(e,t){return p(` + + `)}function u(e,r){return h(` - `)}function p(e){return` + `)}function h(e){return` @@ -48,4 +48,4 @@ function s(e){let t=e.request.headers.get("x-lantern-runtime-session-id"),a=e.re
    ${e}
    -`}function l(e,t){return new Response(e,{status:t,headers:{"content-type":"text/html; charset=UTF-8","cache-control":"private, no-store"}})}function m(e){return e.split(/\s+/).slice(0,2).map(t=>t.slice(0,1).toUpperCase()).join("")}function g(e){switch(e){case"administrator":return"Administrator";case"instructor":return"Instructor";case"learner":return"Learner";case"other":return"Other"}}function i(e){return e.replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}function d(e){return i(e)}export{b as default}; +`}function l(e,r){return new Response(e,{status:r,headers:{"content-type":"text/html; charset=UTF-8","cache-control":"private, no-store"}})}function g(e){return e.split(/\s+/).slice(0,2).map(r=>r.slice(0,1).toUpperCase()).join("")}function x(e){switch(e){case"administrator":return"Administrator";case"instructor":return"Instructor";case"learner":return"Learner";case"other":return"Other"}}function o(e){return e.replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}function d(e){return o(e)}export{w as default}; diff --git a/examples/apps/photo-roster/manifest.json b/examples/apps/photo-roster/manifest.json index b687d5d..ab8a0a3 100644 --- a/examples/apps/photo-roster/manifest.json +++ b/examples/apps/photo-roster/manifest.json @@ -1,7 +1,7 @@ { "schema_version": "2", "app_id": "photo-roster", - "version": "0.1.0", + "version": "0.1.1", "title": "Photo Roster", "description": "A governed course roster with names, roles, contact details, and protected profile photos.", "owner": { diff --git a/examples/apps/photo-roster/source/worker.ts b/examples/apps/photo-roster/source/worker.ts index e7682f3..9213550 100644 --- a/examples/apps/photo-roster/source/worker.ts +++ b/examples/apps/photo-roster/source/worker.ts @@ -1,5 +1,6 @@ import { createLanternLmsClient, + createLanternRuntimePageUrl, type LanternLmsCapabilityBinding, type RosterMember, } from '../../../../sdk/worker-app-sdk.ts'; @@ -25,11 +26,23 @@ export default { return htmlResponse(renderFailure(result.error.code, result.error.traceId), 403); } - return htmlResponse(renderRoster(result.value.members, result.value.nextCursor), 200); + const nextUrl = result.value.nextCursor === undefined + ? undefined + : createLanternRuntimePageUrl(request, result.value.nextCursor); + if (nextUrl === null) { + return htmlResponse( + renderFailure( + 'session_expired', + request.headers.get('x-lantern-trace-id') ?? crypto.randomUUID(), + ), + 403, + ); + } + return htmlResponse(renderRoster(result.value.members, nextUrl), 200); }, }; -function renderRoster(members: readonly RosterMember[], nextCursor: string | undefined): string { +function renderRoster(members: readonly RosterMember[], nextUrl: string | undefined): string { const cards = members.map((member) => { const photo = member.photo === undefined ? `` @@ -48,9 +61,9 @@ function renderRoster(members: readonly RosterMember[], nextCursor: string | und
    `; }).join(''); - const next = nextCursor === undefined + const next = nextUrl === undefined ? '' - : ``; + : ``; return page(`
    diff --git a/sdk/worker-app-sdk.ts b/sdk/worker-app-sdk.ts index 01de3b2..698aa94 100644 --- a/sdk/worker-app-sdk.ts +++ b/sdk/worker-app-sdk.ts @@ -58,6 +58,15 @@ export interface LanternLmsCapabilityBinding { }): Promise>; } +/** Builds a same-session page URL using the gateway-owned navigation base. */ +export function createLanternRuntimePageUrl(request: Request, cursor: string): string | null { + const base = request.headers.get('x-lantern-runtime-page-url'); + if (base === null) return null; + const url = new URL(base, 'https://reviewed-app.internal'); + url.searchParams.set('cursor', cursor); + return `${url.pathname}${url.search}`; +} + export function createLanternLmsClient(input: { readonly request: Request; readonly binding: LanternLmsCapabilityBinding; diff --git a/src/admin/governance_page.ts b/src/admin/governance_page.ts index 5ba6118..c839e62 100644 --- a/src/admin/governance_page.ts +++ b/src/admin/governance_page.ts @@ -139,6 +139,7 @@ function renderConnections(
    Client ${escapeHtml(connection.clientId)} Deployment ${escapeHtml(connection.deploymentId)} + Approved embedding origin ${escapeHtml(connection.lmsOrigin)} Validated ${ escapeHtml(formatDateTime(connection.validatedAt)) } @@ -194,6 +195,8 @@ function renderConnectionForm(): string {

    For a real LMS, enter the exact values issued by its LTI 1.3 registration.

    + +

    The HTTPS origin allowed to embed apps, for example https://courses.example.edu. Do not include a path. This can differ from the issuer or authorization host. Synthetic does not need this field.

    diff --git a/src/app_admin_governance_routes.ts b/src/app_admin_governance_routes.ts index f75c774..feee5a2 100644 --- a/src/app_admin_governance_routes.ts +++ b/src/app_admin_governance_routes.ts @@ -67,6 +67,7 @@ export function registerAdminGovernanceRoutes(app: Hono, services: GovernanceRou membership: getKernelAdminContext(context).membership, label: formString(form, 'label'), platformType, + lmsOrigin: formString(form, 'lmsOrigin'), issuer: formString(form, 'issuer'), clientId: formString(form, 'clientId'), deploymentId: formString(form, 'deploymentId'), diff --git a/src/app_governed_runtime_routes.ts b/src/app_governed_runtime_routes.ts index 185197d..66a0ee5 100644 --- a/src/app_governed_runtime_routes.ts +++ b/src/app_governed_runtime_routes.ts @@ -65,6 +65,7 @@ export function registerGovernedRuntimeRoutes( traceId, ), }), + session.lmsOrigin, ); response.headers.set('x-lantern-trace-id', traceId); await recordLaunchEvent(repository, session, traceId, 'completed', null); diff --git a/src/app_governed_runtime_routes_test.ts b/src/app_governed_runtime_routes_test.ts index 12e7bca..904610e 100644 --- a/src/app_governed_runtime_routes_test.ts +++ b/src/app_governed_runtime_routes_test.ts @@ -1,7 +1,13 @@ -import { assertEquals, assertStringIncludes } from '@std/assert'; +import { assert, assertEquals, assertStringIncludes } from '@std/assert'; +import photoRoster from '../examples/apps/photo-roster/source/worker.ts'; +import { createSyntheticRosterConnector } from './lms_kernel/connector.ts'; +import { createGovernedRosterService } from './lms_kernel/service.ts'; +import { createObjectEnvReader } from './platform/env.ts'; +import { getTestToolPrivateJwkEnvValue } from './test_helpers/lti.ts'; import { createTestApp as createApp } from './test_helpers/app.ts'; import { buildGovernedSyntheticSession, + buildKernelGrant, MemoryLmsKernelRepository, } from './lms_kernel/repository_memory_test_support.ts'; import type { GovernedRuntimeSession } from './lms_kernel/types.ts'; @@ -13,6 +19,7 @@ Deno.test('governed Worker route uses the standalone session and strips app-cont const session: GovernedRuntimeSession = { ...buildGovernedSyntheticSession(), runtimeExpiresAt: '2099-07-19T17:00:00.000Z', + lmsOrigin: 'https://courses.example.edu', }; const kernelRepository = new MemoryLmsKernelRepository({ session, @@ -81,6 +88,10 @@ Deno.test('governed Worker route uses the standalone session and strips app-cont assertEquals(response.headers.get('location'), null); assertEquals(response.headers.get('cache-control'), 'private, no-store'); assertStringIncludes(response.headers.get('content-security-policy') ?? '', "default-src 'none'"); + assertStringIncludes( + response.headers.get('content-security-policy') ?? '', + "frame-ancestors 'self' https://courses.example.edu", + ); assertEquals(response.headers.get('x-lantern-trace-id'), session.launchTraceId); assertEquals( workerRequest.headers.get('x-lantern-runtime-session-id'), @@ -90,3 +101,89 @@ Deno.test('governed Worker route uses the standalone session and strips app-cont assertEquals(kernelRepository.events.at(-1)?.traceId, session.launchTraceId); assertEquals(kernelRepository.events.at(-1)?.eventType, 'runtime.response'); }); + +Deno.test('Photo Roster follows gateway-owned pagination URLs through authorization and signed cursors', async () => { + const session = { + ...buildGovernedSyntheticSession(), + runtimeExpiresAt: '2099-07-19T17:00:00.000Z', + }; + const repository = new MemoryLmsKernelRepository({ + session, + grants: [ + buildKernelGrant('members-grant', 'lms.roster.members.read'), + buildKernelGrant('email-grant', 'lms.roster.contact.read'), + buildKernelGrant('photo-grant', 'lms.roster.profile_photo.read'), + ], + }); + const connector = createSyntheticRosterConnector(); + const roster = createGovernedRosterService({ + repository, + nrpsConnector: connector, + syntheticConnector: connector, + env: createObjectEnvReader({ LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue() }), + publicOrigin: 'https://runtime.lantern.example', + }); + const packageVersion = buildPackageVersionRecord({ + id: session.packageVersionId, + appId: session.appId, + version: session.packageVersion, + runtime: { kind: 'worker', entrypoint: '/dist/worker.js' }, + capabilities: [ + 'lms.roster.members.read', + 'lms.roster.contact.read', + 'lms.roster.profile_photo.read', + ], + approvalStatus: 'approved', + }); + const runtimeDelivery: RuntimeDelivery = { + substrate: 'dynamic_worker', + describeDelivery() { + return { substrate: 'dynamic_worker', workerId: 'worker-1' }; + }, + loadReviewedAsset() { + return Promise.reject(new Error('not used')); + }, + loadBrowserGraderAsset() { + return Promise.resolve(null); + }, + invokeReviewedWorker(input) { + return photoRoster.fetch(input.request, { + LANTERN_LMS: { + listRoster: (command) => + roster.listRoster({ + ...command, + bindingIdentity: { appId: session.appId, packageVersionId: session.packageVersionId }, + }), + }, + }); + }, + }; + const app = createApp({ + runtimeDelivery, + getLmsKernelRepository: () => repository, + getRepository: () => + createInMemoryPackageReviewRepository({ packageVersions: [packageVersion] }), + }); + const launchUrl = + `https://runtime.lantern.example/governed/sessions/${session.runtimeSessionId}?token=${session.runtimeSessionToken}&ignored=untrusted`; + let currentUrl = launchUrl; + for (const expectedCount of [24, 24, 9]) { + const response = await app.request(currentUrl); + assertEquals(response.status, 200); + const html = await response.text(); + assertEquals((html.match(/
  • /g) ?? []).length, expectedCount); + const href = html.match(/href="([^"]+)">Next page/)?.[1]; + if (expectedCount === 9) { + assertEquals(href, undefined); + break; + } + assert(href !== undefined); + const nextUrl = new URL(href.replaceAll('&', '&'), currentUrl); + assertEquals(nextUrl.searchParams.get('token'), session.runtimeSessionToken); + assertEquals(nextUrl.searchParams.has('ignored'), false); + currentUrl = nextUrl.toString(); + } + const noToken = new URL(currentUrl); + noToken.searchParams.delete('token'); + assertEquals((await app.request(noToken.toString())).status, 401); +}); diff --git a/src/db/d1_migrations/0004_governed_lms_primitive_kernel.sql b/src/db/d1_migrations/0004_governed_lms_primitive_kernel.sql index 027e019..51d9d2f 100644 --- a/src/db/d1_migrations/0004_governed_lms_primitive_kernel.sql +++ b/src/db/d1_migrations/0004_governed_lms_primitive_kernel.sql @@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS lms_connections ( platform_type IN ('canvas', 'moodle', 'brightspace', 'sakai', 'synthetic') ), issuer TEXT NOT NULL, + lms_origin TEXT NOT NULL, client_id TEXT NOT NULL, deployment_id TEXT NOT NULL, authorization_endpoint TEXT NOT NULL, diff --git a/src/lms_kernel/admin_connection.ts b/src/lms_kernel/admin_connection.ts index 925f858..e417c13 100644 --- a/src/lms_kernel/admin_connection.ts +++ b/src/lms_kernel/admin_connection.ts @@ -9,6 +9,7 @@ export function createConnection( readonly membership: InstitutionMembershipRecord; readonly label: string; readonly platformType: LmsPlatformType; + readonly lmsOrigin?: string; readonly issuer?: string; readonly clientId?: string; readonly deploymentId?: string; diff --git a/src/lms_kernel/admin_service.ts b/src/lms_kernel/admin_service.ts index 615dabc..2f89786 100644 --- a/src/lms_kernel/admin_service.ts +++ b/src/lms_kernel/admin_service.ts @@ -41,6 +41,7 @@ export interface LmsKernelAdminService { readonly membership: InstitutionMembershipRecord; readonly label: string; readonly platformType: LmsPlatformType; + readonly lmsOrigin?: string; readonly issuer?: string; readonly clientId?: string; readonly deploymentId?: string; diff --git a/src/lms_kernel/admin_support.ts b/src/lms_kernel/admin_support.ts index 8da9ba0..1268e91 100644 --- a/src/lms_kernel/admin_support.ts +++ b/src/lms_kernel/admin_support.ts @@ -2,6 +2,7 @@ import type { PackageReviewRepository } from '../package_review/repository.ts'; import type { LmsKernelRepository } from './repository.ts'; import type { InstitutionMembershipRecord, LmsConnectionRecord, LmsPlatformType } from './types.ts'; import { failure, type KernelAdminResult } from './admin_result.ts'; +import { parseLmsOrigin } from './lms_origin.ts'; export interface KernelAdminDeps { readonly repository: LmsKernelRepository; @@ -37,6 +38,7 @@ export async function sha256(value: string): Promise { export function parseConnectionFields(input: { readonly platformType: LmsPlatformType; + readonly lmsOrigin?: string; readonly issuer?: string; readonly clientId?: string; readonly deploymentId?: string; @@ -44,6 +46,7 @@ export function parseConnectionFields(input: { readonly accessTokenUrl?: string; readonly jwksUrl?: string; }): KernelAdminResult<{ + readonly lmsOrigin: string; readonly issuer: string; readonly clientId: string; readonly deploymentId: string; @@ -55,6 +58,7 @@ export function parseConnectionFields(input: { return { ok: true, value: { + lmsOrigin: 'https://synthetic.lantern.invalid', issuer: 'https://synthetic.lantern.invalid', clientId: `synthetic-${crypto.randomUUID()}`, deploymentId: `synthetic-${crypto.randomUUID()}`, @@ -64,7 +68,15 @@ export function parseConnectionFields(input: { }, }; } + const lmsOrigin = parseLmsOrigin(input.lmsOrigin ?? ''); + if (lmsOrigin === null) { + return failure( + 'invalid_request', + 'Enter the HTTPS origin of the LMS course pages, without a path, query, or fragment.', + ); + } const values = { + lmsOrigin, issuer: input.issuer?.trim() ?? '', clientId: input.clientId?.trim() ?? '', deploymentId: input.deploymentId?.trim() ?? '', diff --git a/src/lms_kernel/connector.ts b/src/lms_kernel/connector.ts index 927e2fe..aad3e6c 100644 --- a/src/lms_kernel/connector.ts +++ b/src/lms_kernel/connector.ts @@ -1,6 +1,7 @@ import type { PrimitiveErrorCode, RosterRole } from '../../sdk/worker-app-sdk.ts'; import type { LanternLtiToolContext } from '../lti/tool_provider.ts'; import { requireVerifiedLtiSessionWithContext } from '../lti/session_boundary.ts'; +import { parseLtiContextRole } from './context_roles.ts'; import type { GovernedRuntimeSession, RosterConnectorMember, @@ -111,16 +112,7 @@ export function createSyntheticRosterConnector(): RosterConnector { function normalizeRoles(roles: readonly string[]): RosterRole[] { const normalized = new Set(); for (const role of roles) { - const value = role.toLowerCase(); - if (value.includes('administrator')) { - normalized.add('administrator'); - } else if (value.includes('instructor') || value.includes('teachingassistant')) { - normalized.add('instructor'); - } else if (value.includes('learner') || value.includes('student')) { - normalized.add('learner'); - } else { - normalized.add('other'); - } + normalized.add(parseLtiContextRole(role)); } return normalized.size === 0 ? ['other'] : [...normalized]; } diff --git a/src/lms_kernel/connector_test.ts b/src/lms_kernel/connector_test.ts index 636edc7..6a8afae 100644 --- a/src/lms_kernel/connector_test.ts +++ b/src/lms_kernel/connector_test.ts @@ -29,6 +29,7 @@ Deno.test('synthetic roster connector follows the same opaque page seam as NRPS' function buildSyntheticSession(): GovernedRuntimeSession { return { + lmsOrigin: 'https://synthetic.lantern.invalid', runtimeSessionId: 'runtime-1', runtimeSessionToken: 'token', launchTraceId: 'trace-1', diff --git a/src/lms_kernel/context_roles.ts b/src/lms_kernel/context_roles.ts new file mode 100644 index 0000000..bc1db19 --- /dev/null +++ b/src/lms_kernel/context_roles.ts @@ -0,0 +1,59 @@ +import type { RosterRole } from '../../sdk/worker-app-sdk.ts'; + +const MEMBERSHIP = 'http://purl.imsglobal.org/vocab/lis/v2/membership'; +const SUBROLES = new Map([ + ['Administrator', [ + 'Administrator', + 'Developer', + 'ExternalDeveloper', + 'ExternalSupport', + 'ExternalSystemAdministrator', + 'Support', + 'SystemAdministrator', + ]], + ['Instructor', [ + 'ExternalInstructor', + 'Grader', + 'GuestInstructor', + 'Lecturer', + 'PrimaryInstructor', + 'SecondaryInstructor', + 'TeachingAssistant', + 'TeachingAssistantGroup', + 'TeachingAssistantOffering', + 'TeachingAssistantSection', + 'TeachingAssistantSectionAssociation', + 'TeachingAssistantTemplate', + ]], + ['Learner', ['ExternalLearner', 'GuestLearner', 'Instructor', 'Learner', 'NonCreditLearner']], +]); + +/** Maps only standard context roles; institution/system roles grant no course authority. */ +export function parseLtiContextRole(value: string): RosterRole { + let principal: string; + if (value.startsWith(`${MEMBERSHIP}#`)) { + principal = value.slice(MEMBERSHIP.length + 1); + } else if (value.startsWith(`${MEMBERSHIP}/`)) { + const parts = value.slice(MEMBERSHIP.length + 1).split('#'); + const [role, subrole] = parts; + if ( + parts.length !== 2 || role === undefined || subrole === undefined || + !SUBROLES.get(role)?.includes(subrole) + ) { + return 'other'; + } + principal = role; + } else { + return 'other'; + } + switch (principal) { + case 'Administrator': + return 'administrator'; + case 'Instructor': + return 'instructor'; + case 'Learner': + return 'learner'; + default: + return 'other'; + } +} diff --git a/src/lms_kernel/context_roles_test.ts b/src/lms_kernel/context_roles_test.ts new file mode 100644 index 0000000..1ad08d7 --- /dev/null +++ b/src/lms_kernel/context_roles_test.ts @@ -0,0 +1,52 @@ +import { assertEquals } from '@std/assert'; +import { parseLtiContextRole } from './context_roles.ts'; +import { resolveGovernedLaunchActorType } from './launch_service.ts'; + +const MEMBERSHIP = 'http://purl.imsglobal.org/vocab/lis/v2/membership'; + +Deno.test('governed launches require exact standard instructor or administrator context roles', () => { + assertEquals(resolveGovernedLaunchActorType([`${MEMBERSHIP}#Instructor`]), 'instructor'); + assertEquals(resolveGovernedLaunchActorType([`${MEMBERSHIP}#Administrator`]), 'administrator'); + assertEquals( + resolveGovernedLaunchActorType([`${MEMBERSHIP}/Instructor#TeachingAssistant`]), + 'instructor', + ); + assertEquals( + resolveGovernedLaunchActorType([`${MEMBERSHIP}/Administrator#Support`]), + 'administrator', + ); + assertEquals( + resolveGovernedLaunchActorType([ + `${MEMBERSHIP}#Learner`, + `${MEMBERSHIP}/Learner#Instructor`, + 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor', + 'http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator', + ]), + null, + ); + for ( + const role of [ + 'Instructor', + 'administrator', + `${MEMBERSHIP}#NotAnInstructor`, + `${MEMBERSHIP}/Instructor#Unknown`, + `${MEMBERSHIP}/toString#Instructor`, + `${MEMBERSHIP}/__proto__#Administrator`, + `${MEMBERSHIP}/Administrator#Unknown`, + `${MEMBERSHIP}/Instructor#TeachingAssistant#Learner`, + `${MEMBERSHIP}#Instructor/anything`, + ] + ) { + assertEquals(resolveGovernedLaunchActorType([role]), null, role); + } +}); + +Deno.test('roster role normalization respects the principal context role', () => { + assertEquals(parseLtiContextRole(`${MEMBERSHIP}/Learner#Instructor`), 'learner'); + assertEquals(parseLtiContextRole(`${MEMBERSHIP}/Instructor#Grader`), 'instructor'); + assertEquals(parseLtiContextRole(`${MEMBERSHIP}#Mentor`), 'other'); + assertEquals( + parseLtiContextRole('http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor'), + 'other', + ); +}); diff --git a/src/lms_kernel/d1_test_support.ts b/src/lms_kernel/d1_test_support.ts new file mode 100644 index 0000000..daf0508 --- /dev/null +++ b/src/lms_kernel/d1_test_support.ts @@ -0,0 +1,28 @@ +import { Miniflare } from 'miniflare'; +import type { D1Database } from '../db/d1.ts'; +import { runD1Statements } from '../db/d1.ts'; + +/** Runs a kernel persistence test against isolated D1 with the actual schema. */ +export async function withKernelD1(run: (db: D1Database) => Promise): Promise { + const miniflare = new Miniflare({ + modules: true, + script: 'export default { fetch() { return new Response("ok"); } };', + d1Databases: { DB: crypto.randomUUID() }, + }); + try { + const db: D1Database = await miniflare.getD1Database('DB'); + for ( + const migration of [ + '0001_initial_schema.sql', + '0002_auth.sql', + '0003_python_autograder_lab.sql', + '0004_governed_lms_primitive_kernel.sql', + ] + ) { + await runD1Statements(db, await Deno.readTextFile(`src/db/d1_migrations/${migration}`)); + } + await run(db); + } finally { + await miniflare.dispose(); + } +} diff --git a/src/lms_kernel/launch_service.ts b/src/lms_kernel/launch_service.ts index 4cad636..b8ee675 100644 --- a/src/lms_kernel/launch_service.ts +++ b/src/lms_kernel/launch_service.ts @@ -1,6 +1,7 @@ import type { LmsKernelRepository } from './repository.ts'; import type { AppInstallationRecord, ConnectorKind, LmsConnectionRecord } from './types.ts'; import { connectionValidatedEvent, launchAcceptedEvent } from './governance_events.ts'; +import { parseLtiContextRole } from './context_roles.ts'; /** * Governed launch service. @@ -48,15 +49,11 @@ const RUNTIME_SESSION_TTL_MS = 10 * 60 * 1000; export function resolveGovernedLaunchActorType( roles: readonly string[], ): 'instructor' | 'administrator' | null { - if (roles.some((role) => role.toLowerCase().includes('administrator'))) { + const contextRoles = roles.map(parseLtiContextRole); + if (contextRoles.includes('administrator')) { return 'administrator'; } - if ( - roles.some((role) => { - const normalized = role.toLowerCase(); - return normalized.includes('instructor') || normalized.includes('teachingassistant'); - }) - ) { + if (contextRoles.includes('instructor')) { return 'instructor'; } return null; diff --git a/src/lms_kernel/lms_origin.ts b/src/lms_kernel/lms_origin.ts new file mode 100644 index 0000000..36240f8 --- /dev/null +++ b/src/lms_kernel/lms_origin.ts @@ -0,0 +1,16 @@ +/** Parses one explicitly approved HTTPS origin for LMS framing. */ +export function parseLmsOrigin(value: string): string | null { + try { + const url = new URL(value.trim()); + if ( + url.protocol !== 'https:' || url.username !== '' || url.password !== '' || + url.hostname.includes('*') || + url.pathname !== '/' || url.search !== '' || url.hash !== '' + ) { + return null; + } + return url.origin; + } catch { + return null; + } +} diff --git a/src/lms_kernel/lms_origin_test.ts b/src/lms_kernel/lms_origin_test.ts new file mode 100644 index 0000000..b66866b --- /dev/null +++ b/src/lms_kernel/lms_origin_test.ts @@ -0,0 +1,45 @@ +import { assert, assertEquals } from '@std/assert'; +import { parseLmsOrigin } from './lms_origin.ts'; +import { parseConnectionFields } from './admin_support.ts'; + +Deno.test('LMS embedding origins accept only exact HTTPS origins', () => { + assertEquals(parseLmsOrigin(' https://Courses.Example.edu:443/ '), 'https://courses.example.edu'); + assertEquals( + parseLmsOrigin('https://courses.example.edu:8443'), + 'https://courses.example.edu:8443', + ); + for ( + const value of [ + '', + '*', + 'https://*.example.edu', + 'http://courses.example.edu', + 'https://courses.example.edu/course', + 'https://courses.example.edu?token=x', + 'https://courses.example.edu/#fragment', + 'https://user:password@courses.example.edu', + 'https://courses.example.edu; frame-ancestors *', + ] + ) { + assertEquals(parseLmsOrigin(value), null, value); + } +}); + +Deno.test('connection registration requires an explicit course-page origin independent of OIDC hosts', () => { + const fields = { + platformType: 'canvas' as const, + issuer: 'https://issuer.example', + clientId: 'client', + deploymentId: 'deployment', + authorizationEndpoint: 'https://sso.example/authorize', + accessTokenUrl: 'https://sso.example/token', + jwksUrl: 'https://sso.example/jwks', + }; + assertEquals(parseConnectionFields(fields).ok, false); + const accepted = parseConnectionFields({ ...fields, lmsOrigin: 'https://courses.example.edu/' }); + assert(accepted.ok); + assertEquals(accepted.value.lmsOrigin, 'https://courses.example.edu'); + const synthetic = parseConnectionFields({ platformType: 'synthetic' }); + assert(synthetic.ok); + assertEquals(synthetic.value.lmsOrigin, 'https://synthetic.lantern.invalid'); +}); diff --git a/src/lms_kernel/media.ts b/src/lms_kernel/media.ts index c004597..e1574cb 100644 --- a/src/lms_kernel/media.ts +++ b/src/lms_kernel/media.ts @@ -38,14 +38,18 @@ export function createProtectedMediaService(input: { redirect: 'follow', headers: { accept: 'image/*' }, }); - if (!upstream.ok || !isImageContentType(upstream.headers.get('content-type'))) { + const contentType = + upstream.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() ?? ''; + const body = upstream.ok ? await readRasterImage(upstream, contentType) : null; + if (body === null) { + await upstream.body?.cancel(); await recordMediaEvent(input.repository, reference, 'failed', 'upstream_unavailable'); return mediaResponse('Profile photo is unavailable.', 502); } await recordMediaEvent(input.repository, reference, 'completed', null); - return new Response(upstream.body, { + return new Response(body, { status: 200, - headers: mediaHeaders(upstream.headers.get('content-type')!), + headers: mediaHeaders(contentType), }); } catch { await recordMediaEvent(input.repository, reference, 'failed', 'upstream_unavailable'); @@ -101,9 +105,88 @@ function mediaHeaders(contentType: string): Record { 'content-type': contentType, 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer', + 'x-content-type-options': 'nosniff', + 'content-security-policy': + "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + 'cross-origin-resource-policy': 'same-origin', }; } -function isImageContentType(value: string | null): value is string { - return value !== null && value.toLowerCase().startsWith('image/'); +async function readRasterImage( + response: Response, + contentType: string, +): Promise | null> { + if ( + !['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(contentType) || + response.body === null + ) { + return null; + } + const reader = response.body.getReader(); + const prefix = new Uint8Array(12); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (length < prefix.length) { + const part = await reader.read(); + if (part.done) break; + chunks.push(part.value); + const copied = part.value.subarray(0, prefix.length - length); + prefix.set(copied, length); + length += copied.length; + } + if (!matchesRasterSignature(prefix.subarray(0, length), contentType)) { + await reader.cancel(); + reader.releaseLock(); + return null; + } + } catch (cause: unknown) { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + throw cause; + } + return new ReadableStream({ + async pull(controller) { + const chunk = chunks.shift(); + if (chunk !== undefined) { + controller.enqueue(chunk); + return; + } + try { + const part = await reader.read(); + if (part.done) { + reader.releaseLock(); + controller.close(); + } else { + controller.enqueue(part.value); + } + } catch (cause: unknown) { + reader.releaseLock(); + controller.error(cause); + } + }, + async cancel(reason) { + await reader.cancel(reason); + reader.releaseLock(); + }, + }); +} + +function matchesRasterSignature(bytes: Uint8Array, contentType: string): boolean { + const startsWith = (signature: readonly number[]): boolean => + signature.every((byte, index) => bytes[index] === byte); + switch (contentType) { + case 'image/png': + return startsWith([137, 80, 78, 71, 13, 10, 26, 10]); + case 'image/jpeg': + return startsWith([255, 216, 255]); + case 'image/gif': + return startsWith([71, 73, 70, 56]) && + (bytes[4] === 55 || bytes[4] === 57) && bytes[5] === 97; + case 'image/webp': + return startsWith([82, 73, 70, 70]) && + bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80; + default: + return false; + } } diff --git a/src/lms_kernel/media_test.ts b/src/lms_kernel/media_test.ts index b97bfe2..9cb65f7 100644 --- a/src/lms_kernel/media_test.ts +++ b/src/lms_kernel/media_test.ts @@ -1,4 +1,4 @@ -import { assertEquals } from '@std/assert'; +import { assertEquals, assertStringIncludes } from '@std/assert'; import { createProtectedMediaService } from './media.ts'; import { buildGovernedSyntheticSession, @@ -7,6 +7,12 @@ import { } from './repository_memory_test_support.ts'; const NOW = new Date('2026-07-19T16:00:00.000Z'); +const PNG = Uint8Array.from( + atob( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jWZkAAAAASUVORK5CYII=', + ), + (char) => char.charCodeAt(0), +); Deno.test('protected media revalidates the photo grant and records the shared trace', async () => { const session = buildGovernedSyntheticSession(); @@ -34,7 +40,7 @@ Deno.test('protected media revalidates the photo grant and records the shared tr repository, fetchUpstream() { return Promise.resolve( - new Response(new Uint8Array([1, 2, 3]), { + new Response(PNG, { headers: { 'content-type': 'image/png' }, }), ); @@ -46,11 +52,104 @@ Deno.test('protected media revalidates the photo grant and records the shared tr assertEquals(response.status, 200); assertEquals(response.headers.get('cache-control'), 'private, no-store'); assertEquals(response.headers.get('referrer-policy'), 'no-referrer'); + assertEquals(response.headers.get('x-content-type-options'), 'nosniff'); + assertStringIncludes(response.headers.get('content-security-policy') ?? '', 'sandbox'); + assertEquals(new Uint8Array(await response.arrayBuffer()), PNG); assertEquals(repository.events.length, 1); assertEquals(repository.events[0]?.traceId, session.launchTraceId); assertEquals(repository.events[0]?.capability, 'lms.roster.profile_photo.read'); }); +Deno.test('protected media rejects SVG and non-raster types, including MIME-spoofed SVG', async () => { + for ( + const contentType of [ + 'image/svg+xml', + 'image/svg+xml; charset=UTF-8', + 'text/html', + 'image/bmp', + 'image/png', + 'image/jpeg', + ] + ) { + const repository = await mediaRepository(); + const service = createProtectedMediaService({ + repository, + fetchUpstream: () => + Promise.resolve( + new Response( + '', + { headers: { 'content-type': contentType } }, + ), + ), + }); + const response = await service.fetch({ mediaId: 'media-1', now: NOW }); + assertEquals(response.status, 502, contentType); + assertEquals((await response.text()).includes('\n', - } - : file + ...file, + contents: + '
    Phonics Match
    \n', + } + : file, ); const findings = await validateGeneratedAppPackage({ selectedStarterId: 'simple-activity', @@ -225,16 +225,16 @@ Deno.test('generated app validation rejects missing Lantern design shell', async ); }); -Deno.test('generated app validation rejects manifest drift from the app plan', () => { +test('generated app validation rejects manifest drift from the app plan', () => { const files = buildValidSimpleActivityFiles().map((file) => file.path === 'manifest.json' ? { - ...file, - contents: file.contents - .replace('"app_id": "phonics-match"', '"app_id": "starter-simple-activity"') - .replace('"finalize_attempt"', '"write_local_state"'), - } - : file + ...file, + contents: file.contents + .replace('"app_id": "phonics-match"', '"app_id": "starter-simple-activity"') + .replace('"finalize_attempt"', '"write_local_state"'), + } + : file, ); const findings = validateGeneratedAppPackagePlanAlignment({ appPlan: buildPlan(), @@ -255,7 +255,7 @@ Deno.test('generated app validation rejects manifest drift from the app plan', ( ); }); -Deno.test('generated app validation accepts a browser autograder package shape', async () => { +test('generated app validation accepts a browser autograder package shape', async () => { const findings = await validateGeneratedAppPackage({ selectedStarterId: 'browser-autograder', files: buildValidBrowserAutograderFiles(), diff --git a/src/app_writer/workflow_runner.ts b/src/app_writer/workflow_runner.ts index 1b9c578..79efca9 100644 --- a/src/app_writer/workflow_runner.ts +++ b/src/app_writer/workflow_runner.ts @@ -23,13 +23,13 @@ export interface AppGenerationWorkflowResult { export type AppGenerationWorkflowStageResult = | { - ok: true; - value: T; - } + ok: true; + value: T; + } | { - ok: false; - result: AppGenerationWorkflowResult; - }; + ok: false; + result: AppGenerationWorkflowResult; + }; export async function runAppGenerationWorkflow(input: { bindings: WorkerBindings; diff --git a/src/app_writer/workflow_scheduler.ts b/src/app_writer/workflow_scheduler.ts index ad8dec6..6f17d68 100644 --- a/src/app_writer/workflow_scheduler.ts +++ b/src/app_writer/workflow_scheduler.ts @@ -17,12 +17,12 @@ export interface AppGenerationWorkflowInstance { export type AppGenerationScheduleResult = | { - mode: 'workflow'; - workflowInstanceId: string | null; - } + mode: 'workflow'; + workflowInstanceId: string | null; + } | { - mode: 'not_configured'; - }; + mode: 'not_configured'; + }; export interface AppGenerationRunScheduler { schedule(input: AppGenerationWorkflowParams): Promise; diff --git a/src/app_writer/workspace_harness_result.ts b/src/app_writer/workspace_harness_result.ts index 510e817..e0f881d 100644 --- a/src/app_writer/workspace_harness_result.ts +++ b/src/app_writer/workspace_harness_result.ts @@ -37,10 +37,10 @@ export function parseStructuredWorkspaceHarnessResult( files, progressUpdates: parseProgressUpdates(record.progressUpdates, `${fieldName}.progressUpdates`), notes: expectStringArray(record.notes, `${fieldName}.notes`), - validationFindings: record.validationFindings === undefined ? [] : parseValidationFindings( - record.validationFindings, - `${fieldName}.validationFindings`, - ), + validationFindings: + record.validationFindings === undefined + ? [] + : parseValidationFindings(record.validationFindings, `${fieldName}.validationFindings`), }; } @@ -51,10 +51,9 @@ export function parseWorkspaceHarnessResponse( const record = expectRecord(value, fieldName); const parsed = parseStructuredWorkspaceHarnessResult(record, fieldName); const modelRequestMetadata: AppGenerationModelRequestMetadata[] = - record.modelRequestMetadata === undefined ? [] : parseModelRequestMetadata( - record.modelRequestMetadata, - `${fieldName}.modelRequestMetadata`, - ); + record.modelRequestMetadata === undefined + ? [] + : parseModelRequestMetadata(record.modelRequestMetadata, `${fieldName}.modelRequestMetadata`); return { ...parsed, diff --git a/src/app_writer/workspace_harness_result_test.ts b/src/app_writer/workspace_harness_result_test.ts index 6ee068b..53c68a4 100644 --- a/src/app_writer/workspace_harness_result_test.ts +++ b/src/app_writer/workspace_harness_result_test.ts @@ -1,10 +1,11 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertThrows } from '@std/assert'; import { formatStructuredWorkspaceHarnessResultContract, parseStructuredWorkspaceHarnessResult, } from './workspace_harness_result.ts'; -Deno.test('structured workspace harness prompt contract parses as the same result shape', () => { +test('structured workspace harness prompt contract parses as the same result shape', () => { const parsed = parseStructuredWorkspaceHarnessResult( JSON.parse( formatStructuredWorkspaceHarnessResultContract({ @@ -19,7 +20,7 @@ Deno.test('structured workspace harness prompt contract parses as the same resul assertEquals(parsed.validationFindings[0]?.severity, 'error'); }); -Deno.test('structured workspace harness result rejects duplicate file paths', () => { +test('structured workspace harness result rejects duplicate file paths', () => { assertThrows( () => parseStructuredWorkspaceHarnessResult( diff --git a/src/app_writer/workspace_initialization.ts b/src/app_writer/workspace_initialization.ts index b79c746..45c5adc 100644 --- a/src/app_writer/workspace_initialization.ts +++ b/src/app_writer/workspace_initialization.ts @@ -15,21 +15,21 @@ export function buildInitializedAppWriterWorkspace(input: { revisionSourceFiles?: readonly AppWriterWorkspaceFile[]; }): AppGenerationWorkspaceRecord { const revision = readAppWriterRevisionContext(input.contextSelection.selectedContext); - const starter = revision === null - ? buildAppWriterStarterWorkspace( - input.contextSelection.starterId, - input.contextSelection.selectedContext.authoringMode, - ) - : null; + const starter = + revision === null + ? buildAppWriterStarterWorkspace( + input.contextSelection.starterId, + input.contextSelection.selectedContext.authoringMode, + ) + : null; const sourcePackageFiles = input.revisionSourceFiles ?? starter?.files ?? []; - const packageFiles = sourcePackageFiles.map( - (file): AppWriterWorkspaceFile => ({ - ...file, - role: isTypeScriptAuthoringSourcePath(file.path) ? 'evidence' : 'package', - }), - ); + const packageFiles = sourcePackageFiles.map((file): AppWriterWorkspaceFile => ({ + ...file, + role: isTypeScriptAuthoringSourcePath(file.path) ? 'evidence' : 'package', + })); const instructionFiles = buildInstructionFiles({ - instructions: starter?.instructions ?? + instructions: + starter?.instructions ?? buildRevisionInstructions({ contextSelection: input.contextSelection, }), @@ -50,12 +50,14 @@ export function buildInitializedAppWriterWorkspace(input: { authoringMode: input.contextSelection.selectedContext.authoringMode, starterId: input.contextSelection.starterId, initializationMode: revision === null ? 'starter' : 'revision_snapshot', - ...(revision === null ? {} : { - sourcePackageVersionId: revision.sourcePackageVersionId, - sourceAppId: revision.sourceAppId, - sourceVersion: revision.sourceVersion, - targetVersion: revision.targetVersion, - }), + ...(revision === null + ? {} + : { + sourcePackageVersionId: revision.sourcePackageVersionId, + sourceAppId: revision.sourceAppId, + sourceVersion: revision.sourceVersion, + targetVersion: revision.targetVersion, + }), fileCount: files.length, }, }), @@ -133,28 +135,28 @@ function buildInstructionFiles(input: { { path: '.lantern/contracts/prompt-context.json', role: 'contract', - contents: `${ - JSON.stringify( + contents: `${JSON.stringify( + { + referenceAppIds: input.contextSelection.selectedContext.referenceAppIds, + publicContractSources: input.contextSelection.selectedContext.publicContractSources, + promptContextVersion: input.contextSelection.selectedContext.promptContextVersion, + promptContextExcerpts: input.contextSelection.selectedContext.promptContextExcerpts, + selectionReason: input.contextSelection.selectedContext.selectionReason, + revision, + }, + null, + 2, + )}\n`, + }, + ...(revision === null + ? [] + : [ { - referenceAppIds: input.contextSelection.selectedContext.referenceAppIds, - publicContractSources: input.contextSelection.selectedContext.publicContractSources, - promptContextVersion: input.contextSelection.selectedContext.promptContextVersion, - promptContextExcerpts: input.contextSelection.selectedContext.promptContextExcerpts, - selectionReason: input.contextSelection.selectedContext.selectionReason, - revision, + path: '.lantern/contracts/source-package.json', + role: 'contract' as const, + contents: `${JSON.stringify(revision, null, 2)}\n`, }, - null, - 2, - ) - }\n`, - }, - ...(revision === null ? [] : [ - { - path: '.lantern/contracts/source-package.json', - role: 'contract' as const, - contents: `${JSON.stringify(revision, null, 2)}\n`, - }, - ]), + ]), { path: '.lantern/contracts/generated-app-contract.md', role: 'contract', diff --git a/src/app_writer/workspace_initialization_test.ts b/src/app_writer/workspace_initialization_test.ts index e70321c..82e4979 100644 --- a/src/app_writer/workspace_initialization_test.ts +++ b/src/app_writer/workspace_initialization_test.ts @@ -1,8 +1,9 @@ +import { test } from 'vite-plus/test'; import { assertEquals } from '@std/assert'; import { selectAppWriterContext } from './context.ts'; import { buildInitializedAppWriterWorkspace } from './workspace_initialization.ts'; -Deno.test('app writer workspace initialization includes instructions, SDK, DoD, generated-app, design, and validation contracts', () => { +test('app writer workspace initialization includes instructions, SDK, DoD, generated-app, design, and validation contracts', () => { const contextSelection = selectAppWriterContext({ promptText: 'Create a phonics flashcard app with student progress.', requestedAppId: 'phonics-flashcards', diff --git a/src/app_writer/workspace_runner_test.ts b/src/app_writer/workspace_runner_test.ts index e3c5749..7638f96 100644 --- a/src/app_writer/workspace_runner_test.ts +++ b/src/app_writer/workspace_runner_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals } from '@std/assert'; import { emptyAppWriterSelectedContext } from './context.ts'; import { @@ -11,7 +12,7 @@ import type { } from './types.ts'; import { buildAppGenerationWorkspaceRecord } from '../test_helpers/package_review_in_memory_app_generation.ts'; -Deno.test('harness workspace runner gives the harness the initialized workspace for planning and authoring', async () => { +test('harness workspace runner gives the harness the initialized workspace for planning and authoring', async () => { const calls: string[] = []; const initializedWorkspace = buildWorkspace(); const harness: AppWriterWorkspaceHarness = { @@ -22,7 +23,7 @@ Deno.test('harness workspace runner gives the harness the initialized workspace files: input.workspace.files.map((file) => file.path === 'manifest.json' ? { ...file, contents: '{"app_id":"phonics-match"}\n' } - : file + : file, ), progressUpdates: [ { @@ -53,7 +54,7 @@ Deno.test('harness workspace runner gives the harness the initialized workspace assertEquals(authored.validationFindings, []); }); -Deno.test('harness workspace runner repairs from the persisted workspace snapshot', async () => { +test('harness workspace runner repairs from the persisted workspace snapshot', async () => { const workspace = buildWorkspace(); const harness: AppWriterWorkspaceHarness = { author(_input) { diff --git a/src/authoring/draft_diff.ts b/src/authoring/draft_diff.ts index 4811a47..26cb3b9 100644 --- a/src/authoring/draft_diff.ts +++ b/src/authoring/draft_diff.ts @@ -30,7 +30,7 @@ export function buildDraftDiff(input: { after: generatedByPath.get(path) ?? '', hasBefore: currentByPath.has(path), hasAfter: generatedByPath.has(path), - }) + }), ); } diff --git a/src/authoring/draft_diff_test.ts b/src/authoring/draft_diff_test.ts index 9ebd462..0654a14 100644 --- a/src/authoring/draft_diff_test.ts +++ b/src/authoring/draft_diff_test.ts @@ -1,7 +1,8 @@ +import { test } from 'vite-plus/test'; import { assertEquals } from '@std/assert'; import { buildDraftDiff } from './draft_diff.ts'; -Deno.test('buildDraftDiff renders explicit additions and removals for authoring files', () => { +test('buildDraftDiff renders explicit additions and removals for authoring files', () => { const [diff] = buildDraftDiff({ currentFiles: [ { diff --git a/src/authoring/draft_snapshot.ts b/src/authoring/draft_snapshot.ts index 2e55684..3a0980e 100644 --- a/src/authoring/draft_snapshot.ts +++ b/src/authoring/draft_snapshot.ts @@ -1,3 +1,4 @@ +import { copyFile, mkdir, readdir, writeFile } from 'node:fs/promises'; import type { AuthoringDraftRecord, PackageVersionRecord } from '../package_review/types.ts'; import { joinSnapshotPath, trimLeadingSlash } from '../package_review/snapshot_path.ts'; @@ -20,8 +21,8 @@ export async function materializeDraftPreviewPackageVersion(input: { DRAFT_SNAPSHOT_OUTSIDE_MESSAGE, ); - await Deno.mkdir(parentDirectory(absolutePath), { recursive: true }); - await Deno.writeTextFile(absolutePath, file.contents); + await mkdir(parentDirectory(absolutePath), { recursive: true }); + await writeFile(absolutePath, file.contents); } return { @@ -34,11 +35,9 @@ export async function materializeDraftPreviewPackageVersion(input: { trimLeadingSlash(input.packageVersion.runtime.entrypoint), DRAFT_SNAPSHOT_OUTSIDE_MESSAGE, ), - digest: `sha256:authoring-draft-${input.draft.draftId}-${ - formatSnapshotTimestamp( - input.createdAt, - ) - }`, + digest: `sha256:authoring-draft-${input.draft.draftId}-${formatSnapshotTimestamp( + input.createdAt, + )}`, }, }; } @@ -58,20 +57,20 @@ function formatSnapshotTimestamp(createdAt: string): string { } async function copyDirectory(sourceRoot: string, targetRoot: string): Promise { - await Deno.mkdir(targetRoot, { recursive: true }); + await mkdir(targetRoot, { recursive: true }); - for await (const entry of Deno.readDir(sourceRoot)) { + for (const entry of await readdir(sourceRoot, { withFileTypes: true })) { const sourcePath = `${sourceRoot}/${entry.name}`; const targetPath = `${targetRoot}/${entry.name}`; - if (entry.isDirectory) { + if (entry.isDirectory()) { await copyDirectory(sourcePath, targetPath); continue; } - if (entry.isFile) { - await Deno.mkdir(parentDirectory(targetPath), { recursive: true }); - await Deno.copyFile(sourcePath, targetPath); + if (entry.isFile()) { + await mkdir(parentDirectory(targetPath), { recursive: true }); + await copyFile(sourcePath, targetPath); } } } diff --git a/src/authoring/draft_snapshot_test.ts b/src/authoring/draft_snapshot_test.ts index c9d8fa1..f2514cd 100644 --- a/src/authoring/draft_snapshot_test.ts +++ b/src/authoring/draft_snapshot_test.ts @@ -1,3 +1,7 @@ +import { join } from 'node:path'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { test } from 'vite-plus/test'; +import { tmpdir } from 'node:os'; import { assertEquals, assertStringIncludes } from '@std/assert'; import { buildPackageVersionRecord } from '../test_helpers/package_review.ts'; import type { AuthoringDraftRecord } from '../package_review/types.ts'; @@ -6,10 +10,8 @@ import { materializeDraftPreviewPackageVersion, } from './draft_snapshot.ts'; -Deno.test('materializeDraftPreviewPackageVersion overlays saved draft files into var/authoring-drafts snapshots', async () => { - const sourceRoot = await Deno.makeTempDir({ - prefix: 'lantern-authoring-source-', - }); +test('materializeDraftPreviewPackageVersion overlays saved draft files into var/authoring-drafts snapshots', async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), 'lantern-authoring-source-')); const snapshotRoot = buildAuthoringDraftSnapshotRoot( 'draft-template-app', '2026-04-08T17:00:00Z', @@ -76,11 +78,13 @@ Deno.test('materializeDraftPreviewPackageVersion overlays saved draft files into packageVersion, createdAt: '2026-04-08T17:00:00Z', }); - const materializedSpec = await Deno.readTextFile( + const materializedSpec = await readFile( `${previewPackageVersion.artifact.snapshotRoot}/grading/specs/checks.spec.js`, + 'utf8', ); - const copiedManifest = await Deno.readTextFile( + const copiedManifest = await readFile( `${previewPackageVersion.artifact.snapshotRoot}/manifest.json`, + 'utf8', ); assertEquals(previewPackageVersion.artifact.snapshotRoot, snapshotRoot); @@ -89,26 +93,26 @@ Deno.test('materializeDraftPreviewPackageVersion overlays saved draft files into assertStringIncludes(copiedManifest, '"template-app"'); assertEquals(packageVersion.artifact.snapshotRoot, sourceRoot); } finally { - await Deno.remove(sourceRoot, { recursive: true }); - await Deno.remove('var/authoring-drafts', { recursive: true }).catch(() => {}); + await rm(sourceRoot, { recursive: true }); + await rm('var/authoring-drafts', { recursive: true }).catch(() => {}); } }); async function seedTemplateSnapshot(root: string): Promise { - await Deno.mkdir(`${root}/dist`, { recursive: true }); - await Deno.mkdir(`${root}/preview`, { recursive: true }); - await Deno.mkdir(`${root}/grading/specs`, { recursive: true }); - await Deno.mkdir(`${root}/evidence`, { recursive: true }); - await Deno.writeTextFile(`${root}/manifest.json`, '{"app_id":"template-app"}\n'); - await Deno.writeTextFile(`${root}/dist/index.html`, 'Template\n'); - await Deno.writeTextFile( + await mkdir(`${root}/dist`, { recursive: true }); + await mkdir(`${root}/preview`, { recursive: true }); + await mkdir(`${root}/grading/specs`, { recursive: true }); + await mkdir(`${root}/evidence`, { recursive: true }); + await writeFile(`${root}/manifest.json`, '{"app_id":"template-app"}\n'); + await writeFile(`${root}/dist/index.html`, 'Template\n'); + await writeFile( `${root}/preview/fixtures.json`, '{"launch":{"user_role":"instructor","course_id":"course_demo","assignment_id":null,"activity_id":"template-app"},"attempt_id":"attempt_demo","local_state":null}\n', ); - await Deno.writeTextFile(`${root}/preview/tests.json`, '[]\n'); - await Deno.writeTextFile( + await writeFile(`${root}/preview/tests.json`, '[]\n'); + await writeFile( `${root}/grading/specs/checks.spec.js`, 'describe("template authoring checks", () => {\n it("renders the starter title", () => {});\n});\n', ); - await Deno.writeTextFile(`${root}/evidence/example-output.json`, '{"status":"ok"}\n'); + await writeFile(`${root}/evidence/example-output.json`, '{"status":"ok"}\n'); } diff --git a/src/authoring/example_context.ts b/src/authoring/example_context.ts index b2c0f07..4936ecf 100644 --- a/src/authoring/example_context.ts +++ b/src/authoring/example_context.ts @@ -15,7 +15,7 @@ export async function loadBrowserAutograderDraftReferenceExamples(): Promise< > { return await Promise.all( BROWSER_AUTOGRADER_DRAFT_REFERENCE_APP_IDS.map((appId) => - loadBrowserAutograderDraftReferenceExample(appId) + loadBrowserAutograderDraftReferenceExample(appId), ), ); } diff --git a/src/authoring/local_app.ts b/src/authoring/local_app.ts index c3aca39..5b5aa2d 100644 --- a/src/authoring/local_app.ts +++ b/src/authoring/local_app.ts @@ -1,3 +1,4 @@ +import { realpath } from 'node:fs/promises'; import type { Capability } from '../../sdk/app-sdk.ts'; import { type ManifestReviewData, validateManifest } from '../package_review/manifest.ts'; import type { AppManifest } from '../package_review/manifest_contract.ts'; @@ -52,29 +53,29 @@ export interface LocalAppPackage extends ValidatedLocalAppSourcePackage { export type LocalAppPreflightResult = | { - ok: true; - diagnostics: []; - issues: []; - warnings: string[]; - validatedPackage: ValidatedLocalAppSourcePackage; - } + ok: true; + diagnostics: []; + issues: []; + warnings: string[]; + validatedPackage: ValidatedLocalAppSourcePackage; + } | { - ok: false; - diagnostics: LocalAppValidationDiagnostic[]; - issues: string[]; - warnings: string[]; - validatedPackage?: undefined; - }; + ok: false; + diagnostics: LocalAppValidationDiagnostic[]; + issues: string[]; + warnings: string[]; + validatedPackage?: undefined; + }; export type LocalAppValidationResult = | (LocalAppPreflightResult & { - ok: true; - appPackage: LocalAppPackage; - }) + ok: true; + appPackage: LocalAppPackage; + }) | (LocalAppPreflightResult & { - ok: false; - appPackage?: undefined; - }); + ok: false; + appPackage?: undefined; + }); class LocalAppDiagnosticError extends Error { readonly diagnostic: LocalAppValidationDiagnostic; @@ -116,8 +117,7 @@ export async function preflightLocalAppPackageSource( severity: 'error', field: '/preview', message: 'Lantern authoring requires preview.fixtures_file and preview.tests_file.', - fix: - 'Add preview.fixtures_file and preview.tests_file to manifest.json and point them at reviewed files under /preview.', + fix: 'Add preview.fixtures_file and preview.tests_file to manifest.json and point them at reviewed files under /preview.', }); } @@ -130,29 +130,28 @@ export async function preflightLocalAppPackageSource( severity: 'error', field: '/entrypoint', file: manifest.runtime.entrypoint, - message: - `Runtime entrypoint ${manifest.runtime.entrypoint} could not be read from the package.`, - fix: - `Add ${manifest.runtime.entrypoint} to the reviewed package and keep /runtime/entrypoint pointed at that file.`, + message: `Runtime entrypoint ${manifest.runtime.entrypoint} could not be read from the package.`, + fix: `Add ${manifest.runtime.entrypoint} to the reviewed package and keep /runtime/entrypoint pointed at that file.`, }), diagnostics, ); const fixtureData = previewConfig ? await captureDiagnostic( - () => loadPreviewFixtureDataFromSource(source, previewConfig.fixtures_file), - diagnostics, - ) + () => loadPreviewFixtureDataFromSource(source, previewConfig.fixtures_file), + diagnostics, + ) : null; const previewTests = previewConfig ? await captureDiagnostic( - () => loadPreviewTestsFromSource(source, previewConfig.tests_file), - diagnostics, - ) + () => loadPreviewTestsFromSource(source, previewConfig.tests_file), + diagnostics, + ) : null; const contentPath = resolveContentPath(manifest, manifestValidation.reviewData.capabilities); - const content = contentPath === null - ? null - : await captureDiagnostic(() => loadContentJsonFromSource(source, contentPath), diagnostics); + const content = + contentPath === null + ? null + : await captureDiagnostic(() => loadContentJsonFromSource(source, contentPath), diagnostics); if ( diagnostics.length > 0 || @@ -184,7 +183,7 @@ export async function preflightLocalAppPackageSource( export async function validateLocalAppPackage(rootPath: string): Promise { try { - const resolvedRoot = await Deno.realPath(rootPath); + const resolvedRoot = await realpath(rootPath); const source = createFileSystemPackageSource(resolvedRoot); return await validateLocalAppPackageSource(source, resolvedRoot); } catch (error) { @@ -220,11 +219,9 @@ export async function validateLocalAppPackageSource( } export function formatLocalAppDiagnostic(diagnostic: LocalAppValidationDiagnostic): string { - return `${ - describeLocalAppDiagnosticLabel( - diagnostic, - ) - }: ${diagnostic.message} Fix: ${diagnostic.fix}`; + return `${describeLocalAppDiagnosticLabel( + diagnostic, + )}: ${diagnostic.message} Fix: ${diagnostic.fix}`; } export function describeLocalAppDiagnosticLabel( @@ -250,8 +247,8 @@ export function groupLocalAppDiagnostics( const key = diagnostic.file ? `file:${diagnostic.file}` : diagnostic.field - ? `field:${diagnostic.field}` - : 'package'; + ? `field:${diagnostic.field}` + : 'package'; const label = describeLocalAppDiagnosticLabel(diagnostic); const existing = groups.get(key); @@ -299,8 +296,7 @@ async function loadContentJsonFromSource( severity: 'error', file: contentPath, message: `App content file ${contentPath} could not be read from the package.`, - fix: - `Add ${contentPath} to the reviewed package or update content_files to point at an existing JSON file.`, + fix: `Add ${contentPath} to the reviewed package or update content_files to point at an existing JSON file.`, }); return parseJsonText(text, { @@ -322,8 +318,7 @@ async function loadPreviewFixtureDataFromSource( field: '/preview/fixtures_file', file: fixturesFile, message: `Preview fixtures file ${fixturesFile} could not be read from the package.`, - fix: - `Add ${fixturesFile} to the reviewed package or update preview.fixtures_file to point at an existing JSON file.`, + fix: `Add ${fixturesFile} to the reviewed package or update preview.fixtures_file to point at an existing JSON file.`, }); const parsed = parseJsonText(text, { code: 'preview_fixtures_invalid_json', @@ -342,11 +337,11 @@ async function loadPreviewFixtureDataFromSource( severity: 'error', field: '/preview/fixtures_file', file: fixturesFile, - message: error instanceof Error - ? error.message - : `Preview fixtures file ${fixturesFile} is not valid for Lantern authoring.`, - fix: - `Add launch.user_role, launch.course_id, launch.activity_id, attempt_id, and local_state to ${fixturesFile}.`, + message: + error instanceof Error + ? error.message + : `Preview fixtures file ${fixturesFile} is not valid for Lantern authoring.`, + fix: `Add launch.user_role, launch.course_id, launch.activity_id, attempt_id, and local_state to ${fixturesFile}.`, }); } } @@ -361,8 +356,7 @@ async function loadPreviewTestsFromSource( field: '/preview/tests_file', file: testsFile, message: `Preview tests file ${testsFile} could not be read from the package.`, - fix: - `Add ${testsFile} to the reviewed package or update preview.tests_file to point at an existing JSON file.`, + fix: `Add ${testsFile} to the reviewed package or update preview.tests_file to point at an existing JSON file.`, }); const parsed = parseJsonText(text, { code: 'preview_tests_invalid_json', @@ -574,9 +568,10 @@ function buildValidationFailure( } function mapValidationIssueToDiagnostic(issue: ValidationIssue): LocalAppValidationDiagnostic { - const missingPath = issue.keyword === 'missing_file' - ? issue.message.match(/Referenced file ([^ ]+) is missing/)?.[1] - : undefined; + const missingPath = + issue.keyword === 'missing_file' + ? issue.message.match(/Referenced file ([^ ]+) is missing/)?.[1] + : undefined; return { code: issue.keyword, diff --git a/src/authoring/local_app_test.ts b/src/authoring/local_app_test.ts index 93e9e8d..13ee07a 100644 --- a/src/authoring/local_app_test.ts +++ b/src/authoring/local_app_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assert, assertEquals, assertStringIncludes } from '@std/assert'; import { type LocalAppValidationDiagnostic, preflightLocalAppPackageSource } from './local_app.ts'; import { renderValidationFailure, serializeValidationResult } from '../../tools/app_validate.ts'; @@ -6,7 +7,7 @@ import { type MemoryPackageSourceFile, } from '../package_review/package_source.ts'; -Deno.test('preflightLocalAppPackageSource rejects missing preview config with manifest-scoped fix guidance', async () => { +test('preflightLocalAppPackageSource rejects missing preview config with manifest-scoped fix guidance', async () => { const manifest = buildManifest(); delete manifest.preview; @@ -30,7 +31,7 @@ Deno.test('preflightLocalAppPackageSource rejects missing preview config with ma ); }); -Deno.test('preflightLocalAppPackageSource rejects empty preview tests with file-scoped fix guidance', async () => { +test('preflightLocalAppPackageSource rejects empty preview tests with file-scoped fix guidance', async () => { const result = await preflightLocalAppPackageSource(buildPackageSource({ previewTests: [] })); assertEquals(result.ok, false); @@ -44,7 +45,7 @@ Deno.test('preflightLocalAppPackageSource rejects empty preview tests with file- assertStringIncludes(result.diagnostics[0]?.fix ?? '', 'Add at least one named preview test'); }); -Deno.test('preflightLocalAppPackageSource rejects duplicate preview test names', async () => { +test('preflightLocalAppPackageSource rejects duplicate preview test names', async () => { const result = await preflightLocalAppPackageSource( buildPackageSource({ previewTests: [ @@ -73,7 +74,7 @@ Deno.test('preflightLocalAppPackageSource rejects duplicate preview test names', ); }); -Deno.test('renderValidationFailure groups diagnostics by manifest field and file', () => { +test('renderValidationFailure groups diagnostics by manifest field and file', () => { const output = renderValidationFailure({ ok: false, diagnostics: [ @@ -104,7 +105,7 @@ Deno.test('renderValidationFailure groups diagnostics by manifest field and file assertStringIncludes(output, 'Fix: Rename duplicate preview tests.'); }); -Deno.test('serializeValidationResult emits stable JSON for success and failure', async () => { +test('serializeValidationResult emits stable JSON for success and failure', async () => { const source = buildPackageSource(); const success = await preflightLocalAppPackageSource(source); @@ -176,7 +177,8 @@ function buildPackageFiles( }, { relativePath: 'dist/index.html', - bytes: input.entrypointHtml ?? + bytes: + input.entrypointHtml ?? '
    Uploaded Quiz
    ', }, { @@ -254,9 +256,8 @@ function buildManifest(): Record { } function buildDiagnostic( - input: - & Partial - & Pick, + input: Partial & + Pick, ): LocalAppValidationDiagnostic { return { code: input.code ?? 'manual', diff --git a/src/authoring/local_browser.ts b/src/authoring/local_browser.ts new file mode 100644 index 0000000..2064227 --- /dev/null +++ b/src/authoring/local_browser.ts @@ -0,0 +1,29 @@ +import { Browser, computeExecutablePath, install } from '@puppeteer/browsers'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import puppeteer, { type Browser as PreviewBrowser } from 'puppeteer-core'; +import { PUPPETEER_REVISIONS } from 'puppeteer-core/internal/revisions.js'; + +// Use explicit pinned configuration. The convenience Puppeteer package searches +// for executable config files relative to cwd, which may be an untrusted app. +const cacheDir = process.env.PUPPETEER_CACHE_DIR ?? join(homedir(), '.cache', 'puppeteer'); +const browserOptions = { + browser: Browser.CHROME, + buildId: PUPPETEER_REVISIONS.chrome, + cacheDir, +}; + +/** Install the exact Chrome revision used by the local preview runner. */ +export async function installLocalPreviewBrowser(): Promise { + await install(browserOptions); +} + +/** Launch a fresh sandboxed Chrome instance without host environment or app config. */ +export async function launchLocalPreviewBrowser(): Promise { + return await puppeteer.launch({ + executablePath: computeExecutablePath(browserOptions), + env: {}, + pipe: true, + ignoreDefaultArgs: ['--no-sandbox', '--disable-setuid-sandbox'], + }); +} diff --git a/src/authoring/local_dev.ts b/src/authoring/local_dev.ts index ac47d0d..51472dd 100644 --- a/src/authoring/local_dev.ts +++ b/src/authoring/local_dev.ts @@ -18,33 +18,33 @@ import { renderPreviewAssertionResult } from './local_preview_assertion_report.t export type LocalDevPreviewCheck = | { - kind: 'passed'; - result: Omit; - } + kind: 'passed'; + result: Omit; + } | { - kind: 'failed'; - result: Omit; - } + kind: 'failed'; + result: Omit; + } | { - kind: 'runtime_failed'; - result: Omit; - }; + kind: 'runtime_failed'; + result: Omit; + }; export type LocalDevState = | { - kind: 'valid'; - warnings: string[]; - appPackage: LocalAppPackage; - harness: LocalPreviewHarness; - previewCheck: LocalDevPreviewCheck; - } + kind: 'valid'; + warnings: string[]; + appPackage: LocalAppPackage; + harness: LocalPreviewHarness; + previewCheck: LocalDevPreviewCheck; + } | { - kind: 'invalid'; - packageRoot: string; - diagnostics: LocalAppValidationDiagnostic[]; - issues: string[]; - warnings: string[]; - }; + kind: 'invalid'; + packageRoot: string; + diagnostics: LocalAppValidationDiagnostic[]; + issues: string[]; + warnings: string[]; + }; export interface LocalDevStatus { ok: boolean; @@ -92,17 +92,17 @@ export async function loadLocalDevState( const previewCheck = previewAssertionRun.ok ? previewAssertionRun.failedCount === 0 ? { - kind: 'passed' as const, - result: previewAssertionRun, - } + kind: 'passed' as const, + result: previewAssertionRun, + } : { - kind: 'failed' as const, - result: previewAssertionRun, - } + kind: 'failed' as const, + result: previewAssertionRun, + } : { - kind: 'runtime_failed' as const, - result: previewAssertionRun, - }; + kind: 'runtime_failed' as const, + result: previewAssertionRun, + }; return { kind: 'valid', @@ -228,8 +228,8 @@ export function renderLocalDevStateSummary(input: { input.state.previewCheck.kind === 'passed' ? 'passing' : input.state.previewCheck.kind === 'failed' - ? 'failing' - : 'runtime error' + ? 'failing' + : 'runtime error' }`, renderedPreviewChecks.output, ]; @@ -389,37 +389,35 @@ function renderInvalidLocalDevPage(state: ExtractLantern authoring

    Preview is blocked until the reviewed package validates.

    Fix the issues below and save again. Lantern will reload this preview automatically after the next valid change.

    - ${ - groupLocalAppDiagnostics(state.diagnostics) - .map( - (group) => - `
    + ${groupLocalAppDiagnostics(state.diagnostics) + .map( + (group) => + `

    ${escapeHtml(group.label)}

      - ${ - group.diagnostics - .map( - (diagnostic) => - `
    • + ${group.diagnostics + .map( + (diagnostic) => + `
    • ${escapeHtml(diagnostic.message)}
      Fix: ${escapeHtml(diagnostic.fix)}
    • `, - ) - .join('') - } + ) + .join('')}
    `, - ) - .join('') - } + ) + .join('')} ${ - state.warnings.length === 0 ? '' : `
    + state.warnings.length === 0 + ? '' + : `

    Warnings

      ${state.warnings.map((warning) => `
    • ${escapeHtml(warning)}
    • `).join('')}
    ` - } + }

    Package root

    ${escapeHtml(state.packageRoot)}

    @@ -447,9 +445,10 @@ function summarizePreviewCheck(check: LocalDevPreviewCheck): LocalDevStatus['pre assertionCount, passedAssertionCount: check.result.passedCount, failedAssertionCount: check.result.failedCount, - summary: check.kind === 'passed' - ? `Lantern preview assertions passed. ${check.result.passedCount}/${assertionCount} checks passed.` - : `Lantern preview assertions failed. ${check.result.passedCount} passed, ${check.result.failedCount} failed.`, + summary: + check.kind === 'passed' + ? `Lantern preview assertions passed. ${check.result.passedCount}/${assertionCount} checks passed.` + : `Lantern preview assertions failed. ${check.result.passedCount} passed, ${check.result.failedCount} failed.`, }; } diff --git a/src/authoring/local_dev_test.ts b/src/authoring/local_dev_test.ts index b1a038a..a690da9 100644 --- a/src/authoring/local_dev_test.ts +++ b/src/authoring/local_dev_test.ts @@ -1,3 +1,8 @@ +import { expectRecord } from '../app_writer/binding_result.ts'; +import { copyFile, mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { test } from 'vite-plus/test'; +import { tmpdir } from 'node:os'; import { assertEquals, assertMatch, assertStringIncludes } from '@std/assert'; import { createLocalDevRequestHandler, @@ -7,7 +12,7 @@ import { const TEMPLATE_APP_ROOT = 'examples/apps/template'; -Deno.test('loadLocalDevState returns a valid preview state for the template app', async () => { +test('loadLocalDevState returns a valid preview state for the template app', async () => { const state = await loadLocalDevState(TEMPLATE_APP_ROOT); assertEquals(state.kind, 'valid'); @@ -20,11 +25,11 @@ Deno.test('loadLocalDevState returns a valid preview state for the template app' assertEquals(state.previewCheck.kind, 'passed'); }); -Deno.test('renderLocalDevStateSummary reports preview check failures without blocking preview', async () => { +test('renderLocalDevStateSummary reports preview check failures without blocking preview', async () => { const tempRoot = await cloneTemplateApp(); try { - await Deno.writeTextFile( + await writeFile( `${tempRoot}/preview/tests.json`, JSON.stringify( [ @@ -52,15 +57,15 @@ Deno.test('renderLocalDevStateSummary reports preview check failures without blo assertStringIncludes(summary, '- Preview checks: failing'); assertStringIncludes(summary, '- missing title'); } finally { - await Deno.remove(tempRoot, { recursive: true }); + await rm(tempRoot, { recursive: true }); } }); -Deno.test('createLocalDevRequestHandler serves a validation page while package is invalid', async () => { +test('createLocalDevRequestHandler serves a validation page while package is invalid', async () => { const tempRoot = await cloneTemplateApp(); try { - await Deno.remove(`${tempRoot}/preview/tests.json`); + await rm(`${tempRoot}/preview/tests.json`); const state = await loadLocalDevState(tempRoot); assertEquals(state.kind, 'invalid'); @@ -79,11 +84,11 @@ Deno.test('createLocalDevRequestHandler serves a validation page while package i assertStringIncludes(body, '/__lantern/dev/status'); assertStringIncludes(body, '/preview/tests.json'); } finally { - await Deno.remove(tempRoot, { recursive: true }); + await rm(tempRoot, { recursive: true }); } }); -Deno.test('createLocalDevRequestHandler delegates to the current preview harness when valid', async () => { +test('createLocalDevRequestHandler delegates to the current preview harness when valid', async () => { const state = await loadLocalDevState(TEMPLATE_APP_ROOT); assertEquals(state.kind, 'valid'); @@ -99,11 +104,11 @@ Deno.test('createLocalDevRequestHandler delegates to the current preview harness assertMatch(response.headers.get('location') ?? '', /\/dist\/index\.html$/); }); -Deno.test('createLocalDevRequestHandler reports invalid status as JSON', async () => { +test('createLocalDevRequestHandler reports invalid status as JSON', async () => { const tempRoot = await cloneTemplateApp(); try { - await Deno.remove(`${tempRoot}/preview/tests.json`); + await rm(`${tempRoot}/preview/tests.json`); const state = await loadLocalDevState(tempRoot); assertEquals(state.kind, 'invalid'); @@ -120,22 +125,24 @@ Deno.test('createLocalDevRequestHandler reports invalid status as JSON', async ( }, }); const response = await handler(new Request('http://localhost/__lantern/dev/status')); - const status = await response.json(); + const status = expectRecord(await response.json(), 'preview status'); assertEquals(response.status, 200); assertEquals(status.ok, false); assertEquals(status.kind, 'invalid'); - assertEquals(status.previewCheck.kind, 'not_available'); + assertEquals(expectRecord(status.previewCheck, 'preview check').kind, 'not_available'); assertEquals( - status.diagnostics.some((item: { file?: string }) => item.file === '/preview/tests.json'), + (Array.isArray(status.diagnostics) ? status.diagnostics : []).some( + (item: { file?: string }) => item.file === '/preview/tests.json', + ), true, ); } finally { - await Deno.remove(tempRoot, { recursive: true }); + await rm(tempRoot, { recursive: true }); } }); -Deno.test('createLocalDevRequestHandler reports valid preview status as JSON', async () => { +test('createLocalDevRequestHandler reports valid preview status as JSON', async () => { const state = await loadLocalDevState(TEMPLATE_APP_ROOT); assertEquals(state.kind, 'valid'); @@ -152,21 +159,19 @@ Deno.test('createLocalDevRequestHandler reports valid preview status as JSON', a }, }); const response = await handler(new Request('http://localhost/__lantern/dev/status')); - const status = await response.json(); + const status = expectRecord(await response.json(), 'preview status'); assertEquals(response.status, 200); assertEquals(status.ok, true); assertEquals(status.kind, 'valid'); assertEquals(status.previewUrl, 'http://127.0.0.1:8420/'); - assertEquals(status.app.appId, 'template-app'); - assertEquals(status.previewCheck.kind, 'passed'); - assertEquals(status.previewCheck.assertionCount, 4); + assertEquals(expectRecord(status.app, 'preview app').appId, 'template-app'); + assertEquals(expectRecord(status.previewCheck, 'preview check').kind, 'passed'); + assertEquals(expectRecord(status.previewCheck, 'preview check').assertionCount, 4); }); async function cloneTemplateApp(): Promise { - const tempRoot = await Deno.makeTempDir({ - prefix: 'lantern-local-dev-', - }); + const tempRoot = await mkdtemp(join(tmpdir(), 'lantern-local-dev-')); const outputRoot = `${tempRoot}/template-app`; await copyDirectory(TEMPLATE_APP_ROOT, outputRoot); @@ -175,19 +180,19 @@ async function cloneTemplateApp(): Promise { } async function copyDirectory(sourceRoot: string, destinationRoot: string): Promise { - await Deno.mkdir(destinationRoot, { recursive: true }); + await mkdir(destinationRoot, { recursive: true }); - for await (const entry of Deno.readDir(sourceRoot)) { + for (const entry of await readdir(sourceRoot, { withFileTypes: true })) { const sourcePath = `${sourceRoot}/${entry.name}`; const destinationPath = `${destinationRoot}/${entry.name}`; - if (entry.isDirectory) { + if (entry.isDirectory()) { await copyDirectory(sourcePath, destinationPath); continue; } - if (entry.isFile) { - await Deno.copyFile(sourcePath, destinationPath); + if (entry.isFile()) { + await copyFile(sourcePath, destinationPath); } } } diff --git a/src/authoring/local_preview.ts b/src/authoring/local_preview.ts index ce8dae4..47ed605 100644 --- a/src/authoring/local_preview.ts +++ b/src/authoring/local_preview.ts @@ -1,4 +1,4 @@ -import { +import type { BootstrapPayload, Capability, GatewayEvidenceArtifactAcceptedResult, @@ -235,7 +235,8 @@ export function createLocalPreviewHarness(input: { authorizeRequest(request, sessionToken); requireCapability(input.appPackage, 'finalize_attempt'); const finalizeInput = parseFinalizeAttemptInput(await request.json()); - const scoreMaximum = latestScoreProposal?.scoreMaximum ?? + const scoreMaximum = + latestScoreProposal?.scoreMaximum ?? input.appPackage.reviewData.grading.maxScore ?? 100; const scoreGiven = latestScoreProposal?.scoreGiven ?? 0; @@ -399,9 +400,10 @@ async function serveStaticFile(input: { return new Response('Preview file not found.', { status: 404 }); } - const body = bytes.buffer instanceof ArrayBuffer - ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) - : new Uint8Array(bytes).buffer; + const body = + bytes.buffer instanceof ArrayBuffer + ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) + : new Uint8Array(bytes).buffer; return new Response(body, { status: 200, diff --git a/src/authoring/local_preview_assertion_report.ts b/src/authoring/local_preview_assertion_report.ts index bbbf6dc..c985c17 100644 --- a/src/authoring/local_preview_assertion_report.ts +++ b/src/authoring/local_preview_assertion_report.ts @@ -43,8 +43,7 @@ export function renderPreviewAssertionResult( if (result.failedCount === 0) { return { exitCode: 0, - output: - `Lantern preview assertions passed. ${result.passedCount}/${result.results.length} checks passed.`, + output: `Lantern preview assertions passed. ${result.passedCount}/${result.results.length} checks passed.`, }; } diff --git a/src/authoring/local_preview_assertions.ts b/src/authoring/local_preview_assertions.ts index 0f6fd2c..7bcb852 100644 --- a/src/authoring/local_preview_assertions.ts +++ b/src/authoring/local_preview_assertions.ts @@ -1,4 +1,5 @@ -import { Browser, type BrowserWindow } from '@happy-dom'; +import type { HTTPRequest } from 'puppeteer-core'; +import { launchLocalPreviewBrowser } from './local_browser.ts'; import { type LocalAppPackage, type LocalAppValidationDiagnostic, @@ -11,8 +12,7 @@ import { createLocalPreviewHarness, type LocalPreviewLogEntry } from './local_pr import type { PackageSource } from '../package_review/package_source.ts'; const DEFAULT_SETTLE_TIMEOUT_MS = 1_000; -const DEFAULT_IDLE_POLL_MS = 20; -const DEFAULT_IDLE_STREAK = 3; +const PREVIEW_ORIGIN = 'https://lantern-preview.local'; export type LocalPreviewAssertionFailureCode = | 'selector_not_found' @@ -66,13 +66,6 @@ export type LocalPreviewAssertionRunResult = | FailedLocalPreviewAssertionValidationRun | FailedLocalPreviewAssertionRuntimeRun; -interface PreviewRuntimeHandle { - window: BrowserWindow; - close(): Promise; -} - -type PreviewFetch = (url: string, init?: RequestInit) => Promise; - export async function runLocalPreviewAssertions( packageRoot: string, input: { @@ -125,35 +118,128 @@ export async function runLocalPreviewAssertionsForPackage( | Omit > { const runtimeLog: LocalPreviewLogEntry[] = []; - const runtime = await loadPreviewRuntime(appPackage, { - ...input, - runtimeLog, - }); - + let failureMessage = + 'Chrome could not start. Run npm run browser:install and check that Chrome sandboxing is available.'; try { - const results = evaluatePreviewAssertions(runtime.window.document, appPackage.previewTests); - const failedCount = results.filter((result) => !result.passed).length; - - return { - ok: true, - results, - passedCount: results.length - failedCount, - failedCount, - runtimeLog, - }; - } catch (error) { + // Generated code runs only in Chrome's sandboxed renderer. No Node objects or + // host functions are exposed to the page; the gateway stays in this process. + const browser = await launchLocalPreviewBrowser(); + try { + failureMessage = 'The local preview browser could not create a page.'; + const page = await browser.newPage(); + const runtimeErrors: string[] = []; + const harness = createLocalPreviewHarness({ + appPackage, + logger(entry) { + runtimeLog.push(entry); + }, + }); + await page.setBypassServiceWorker(true); + await page.setRequestInterception(true); + page.on('request', (request) => { + void respondToPreviewRequest(request, harness).catch(async () => { + runtimeErrors.push('Lantern could not serve a preview request.'); + await request.abort().catch(() => {}); + }); + }); + page.on('pageerror', () => { + runtimeErrors.push('The preview reported a JavaScript runtime error.'); + }); + page.on('error', () => { + runtimeErrors.push('The preview browser page crashed.'); + }); + page.on('console', (message) => { + if (message.type() === 'error') { + runtimeErrors.push('The preview reported a browser console error.'); + } + }); + failureMessage = 'The preview page failed to load within 10 seconds.'; + await page.goto(`${PREVIEW_ORIGIN}${harness.entrypointPath}`, { + waitUntil: 'domcontentloaded', + timeout: 10_000, + }); + failureMessage = `The preview did not settle within ${input.settleTimeoutMs ?? DEFAULT_SETTLE_TIMEOUT_MS}ms.`; + await page.waitForNetworkIdle({ + idleTime: 60, + timeout: input.settleTimeoutMs ?? DEFAULT_SETTLE_TIMEOUT_MS, + }); + if (runtimeErrors.length > 0) { + return { + ok: false, + kind: 'runtime_failed', + message: runtimeErrors[0] ?? 'The preview runtime failed.', + details: runtimeErrors, + runtimeLog, + }; + } + failureMessage = 'The browser could not evaluate the preview assertions.'; + const results: LocalPreviewAssertionResult[] = []; + for (const test of appPackage.previewTests) { + const element = await page.$(test.assert.selector); + const actualText = element + ? await element.evaluate((node) => node.textContent ?? '') + : null; + await element?.dispose(); + results.push(evaluatePreviewAssertion(actualText, test)); + } + const failedCount = results.filter((result) => !result.passed).length; + return { + ok: true, + results, + passedCount: results.length - failedCount, + failedCount, + runtimeLog, + }; + } finally { + await browser.close(); + } + } catch { + // Browser errors can contain app-controlled text. Keep host diagnostics and + // package output out of this structured failure, including missing-browser errors. return { ok: false, kind: 'runtime_failed', - message: error instanceof Error ? error.message : 'Lantern preview assertion runtime failed.', + message: failureMessage, details: [], runtimeLog, }; - } finally { - await runtime.close(); } } +async function respondToPreviewRequest( + request: HTTPRequest, + harness: ReturnType, +): Promise { + const url = new URL(request.url()); + if (url.origin !== PREVIEW_ORIGIN) { + await request.abort('blockedbyclient'); + return; + } + if (url.pathname === '/favicon.ico') { + await request.respond({ status: 204 }); + return; + } + const method = request.method(); + const body = request.postData(); + const response = await harness.handle( + new Request(url, { + method, + headers: request.headers(), + ...(body === undefined || method === 'GET' || method === 'HEAD' ? {} : { body }), + }), + ); + await request.respond({ + status: response.status, + headers: { + ...Object.fromEntries(response.headers), + 'connection-allowlist': '(response-origin)', + 'content-security-policy': + "default-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self'; worker-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'", + }, + body: new Uint8Array(await response.arrayBuffer()), + }); +} + function buildValidationFailure( packageRoot: string, validation: LocalAppValidationResult, @@ -168,365 +254,14 @@ function buildValidationFailure( }; } -async function loadPreviewRuntime( - appPackage: LocalAppPackage, - input: { - settleTimeoutMs?: number; - runtimeLog: LocalPreviewLogEntry[]; - }, -): Promise { - const harness = createLocalPreviewHarness({ - appPackage, - logger(entry) { - input.runtimeLog.push(entry); - }, - }); - const origin = 'https://lantern-preview.local'; - const entrypointUrl = `${origin}${harness.entrypointPath}`; - const browser = new Browser({ - settings: { - disableJavaScriptFileLoading: true, - handleDisabledFileLoadingAsSuccess: true, - }, - }); - const page = browser.newPage(); - const fetchPreview: PreviewFetch = async (url, init = {}) => { - const requestUrl = new URL(url, entrypointUrl); - - if (requestUrl.origin !== origin) { - return await fetch(requestUrl, init); - } - - return await harness.handle(new Request(requestUrl, init)); - }; - - try { - const entrypointResponse = await fetchPreview(entrypointUrl); - - if (!entrypointResponse.ok) { - throw new Error( - `Preview entrypoint request failed for ${entrypointUrl} with status ${entrypointResponse.status}.`, - ); - } - - page.content = injectPreviewBaseHref(await entrypointResponse.text(), entrypointUrl); - await page.waitUntilComplete(); - - const frame = page.frames[0]; - - if (!frame) { - throw new Error('Lantern preview assertion runner could not load the preview frame.'); - } - - const runtimeErrors: string[] = []; - attachRuntimeErrorCapture(frame.window, runtimeErrors); - installControlledScriptLoader(frame.window, entrypointUrl, fetchPreview); - const pendingFetches = trackWindowFetch(frame.window, fetchPreview); - - await executePreviewScripts(frame.window, entrypointUrl, fetchPreview); - await waitForPreviewIdle({ - runtimeErrors, - pendingFetches, - settleTimeoutMs: input.settleTimeoutMs ?? DEFAULT_SETTLE_TIMEOUT_MS, - }); - - if (runtimeErrors.length > 0) { - throw new Error(runtimeErrors.join(' | ')); - } - - return { - window: frame.window, - async close() { - await browser.close(); - }, - }; - } catch (error) { - await browser.close(); - throw error; - } -} - -function installControlledScriptLoader( - window: BrowserWindow, - entrypointUrl: string, - fetchPreview: PreviewFetch, -): void { - const allowedOrigin = new URL(entrypointUrl).origin; - const originalAppendChild = window.Node.prototype.appendChild; - - window.Node.prototype.appendChild = function ( - this: InstanceType, - node: InstanceType, - ): InstanceType { - if (isWindowScriptElement(window, node) && node.src.trim() !== '') { - void loadControlledScript(window, node, allowedOrigin, fetchPreview); - - return node; - } - - return originalAppendChild.call(this, node) as InstanceType; - }; -} - -async function loadControlledScript( - window: BrowserWindow, - element: InstanceType, - allowedOrigin: string, - fetchPreview: PreviewFetch, -): Promise { - try { - const scriptUrl = new URL(element.src, window.location.href); - - if (scriptUrl.origin !== allowedOrigin) { - throw new Error( - `Preview dynamic script ${scriptUrl.toString()} is outside the preview origin.`, - ); - } - - const response = await fetchPreview(scriptUrl.toString()); - - if (!response.ok) { - throw new Error( - `Preview dynamic script request failed for ${scriptUrl.toString()} with status ${response.status}.`, - ); - } - - const source = await response.text(); - - evaluateDynamicScript(window, element, source, scriptUrl.toString()); - element.dispatchEvent(new window.Event('load')); - } catch { - element.dispatchEvent(new window.Event('error')); - } -} - -function evaluateDynamicScript( - window: BrowserWindow, - element: InstanceType, - source: string, - sourceUrl: string, -): void { - const originalCurrentScript = Object.getOwnPropertyDescriptor(window.document, 'currentScript'); - const windowRecord = window as unknown as Record; - const originalLanternCurrentScript = windowRecord.__LanternCurrentScript; - - Object.defineProperty(window.document, 'currentScript', { - configurable: true, - get() { - return element; - }, - }); - windowRecord.__LanternCurrentScript = element; - - try { - window.eval(`${source}\n//# sourceURL=${sourceUrl}`); - } finally { - if (originalLanternCurrentScript === undefined) { - delete windowRecord.__LanternCurrentScript; - } else { - windowRecord.__LanternCurrentScript = originalLanternCurrentScript; - } - - if (originalCurrentScript === undefined) { - delete (window.document as unknown as Record).currentScript; - } else { - Object.defineProperty(window.document, 'currentScript', originalCurrentScript); - } - } -} - -function isWindowScriptElement( - window: BrowserWindow, - value: unknown, -): value is InstanceType { - return value instanceof window.HTMLScriptElement; -} - -function attachRuntimeErrorCapture(window: BrowserWindow, runtimeErrors: string[]): void { - window.addEventListener('error', (event) => { - const errorEvent = event as unknown as { - error?: unknown; - message?: string; - }; - - runtimeErrors.push( - formatUnknownError(errorEvent.error ?? errorEvent.message ?? 'Unknown runtime error.'), - ); - }); - window.addEventListener('unhandledrejection', (event) => { - const rejectionEvent = event as unknown as { - reason?: unknown; - preventDefault(): void; - }; - - runtimeErrors.push(formatUnknownError(rejectionEvent.reason ?? 'Unknown rejected promise.')); - rejectionEvent.preventDefault(); - }); -} - -function trackWindowFetch(window: BrowserWindow, fetchPreview: PreviewFetch): () => number { - type WindowFetch = BrowserWindow['fetch']; - - let pendingFetches = 0; - - const wrappedFetch: WindowFetch = async (...args) => { - pendingFetches += 1; - - try { - const response = await fetchPreview( - readWindowFetchUrl(window, args[0]), - args[1] as RequestInit, - ); - const body = await response.arrayBuffer(); - - return new window.Response(body, { - status: response.status, - statusText: response.statusText, - headers: [...response.headers.entries()], - }); - } finally { - pendingFetches -= 1; - } - }; - window.fetch = wrappedFetch; - - return () => pendingFetches; -} - -async function executePreviewScripts( - window: BrowserWindow, - entrypointUrl: string, - fetchPreview: PreviewFetch, -): Promise { - const scripts = [...window.document.querySelectorAll('script')]; - - for (const [index, script] of scripts.entries()) { - const type = script.getAttribute('type')?.trim() ?? ''; - - if (!isSupportedPreviewScriptType(type)) { - throw new Error( - `Preview assertion runner only supports classic script tags. Unsupported script type "${type}" found in ${entrypointUrl}.`, - ); - } - - const scriptSource = script.getAttribute('src'); - - if (!scriptSource) { - const source = script.textContent ?? ''; - - if (source.trim() === '') { - continue; - } - - window.eval(`${source}\n//# sourceURL=${entrypointUrl}#inline-${index}`); - continue; - } - - const scriptUrl = new URL(scriptSource, entrypointUrl).toString(); - const response = await fetchPreview(scriptUrl); - - if (!response.ok) { - throw new Error( - `Preview script request failed for ${scriptUrl} with status ${response.status}.`, - ); - } - - const source = await response.text(); - - window.eval(`${source}\n//# sourceURL=${scriptUrl}`); - } -} - -function injectPreviewBaseHref(html: string, entrypointUrl: string): string { - const tag = ``; - - if (/]*>/i.test(html)) { - return html.replace(/]*>/i, (match) => `${match}${tag}`); - } - - return `${tag}${html}`; -} - -function readWindowFetchUrl( - window: BrowserWindow, - input: Parameters[0], -): string { - if (typeof input === 'string') { - return new URL(input, window.location.href).toString(); - } - - if (input instanceof URL) { - return input.toString(); - } - - if (input instanceof window.Request) { - return input.url; - } - - return String(input); -} - -function isSupportedPreviewScriptType(type: string): boolean { - return type === '' || type === 'text/javascript' || type === 'application/javascript'; -} - -async function waitForPreviewIdle(input: { - runtimeErrors: string[]; - pendingFetches: () => number; - settleTimeoutMs: number; -}): Promise { - const deadline = Date.now() + input.settleTimeoutMs; - let idleStreak = 0; - - while (Date.now() <= deadline) { - if (input.pendingFetches() === 0) { - idleStreak += 1; - } else { - idleStreak = 0; - } - - if (idleStreak >= DEFAULT_IDLE_STREAK) { - await sleep(DEFAULT_IDLE_POLL_MS); - - if (input.runtimeErrors.length > 0) { - throw new Error(input.runtimeErrors.join(' | ')); - } - - return; - } - - if (input.runtimeErrors.length > 0 && input.pendingFetches() === 0) { - throw new Error(input.runtimeErrors.join(' | ')); - } - - await sleep(DEFAULT_IDLE_POLL_MS); - } - - if (input.runtimeErrors.length > 0) { - throw new Error(input.runtimeErrors.join(' | ')); - } - - throw new Error( - `Lantern preview assertion runner timed out after ${input.settleTimeoutMs}ms waiting for the preview to settle.`, - ); -} - -function evaluatePreviewAssertions( - document: BrowserWindow['document'], - tests: readonly LocalPreviewTest[], -): LocalPreviewAssertionResult[] { - return tests.map((test) => evaluatePreviewAssertion(document, test)); -} - function evaluatePreviewAssertion( - document: BrowserWindow['document'], + textContent: string | null, test: LocalPreviewTest, ): LocalPreviewAssertionResult { const selector = test.assert.selector; - const element = document.querySelector(selector); const expectation = describeExpectation(test); - if (!element) { + if (textContent === null) { return { name: test.name, selector, @@ -537,7 +272,7 @@ function evaluatePreviewAssertion( }; } - const actualText = normalizeAssertionText(element.textContent ?? ''); + const actualText = normalizeAssertionText(textContent); if (test.assert.text !== undefined) { const expectedText = normalizeAssertionText(test.assert.text); @@ -605,21 +340,3 @@ function describeExpectation(test: LocalPreviewTest): LocalPreviewAssertionExpec function normalizeAssertionText(value: string): string { return value.replaceAll(/\s+/g, ' ').trim(); } - -function formatUnknownError(value: unknown): string { - if (value instanceof Error) { - return value.message; - } - - if (typeof value === 'string') { - return value; - } - - return 'Unknown runtime error.'; -} - -function sleep(durationMs: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, durationMs); - }); -} diff --git a/src/authoring/local_preview_assertions_test.ts b/src/authoring/local_preview_assertions_test.ts index 52cee95..544e9e2 100644 --- a/src/authoring/local_preview_assertions_test.ts +++ b/src/authoring/local_preview_assertions_test.ts @@ -1,3 +1,11 @@ +import { execFile } from 'node:child_process'; +import { createServer } from 'node:http'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { copyFile, readFile, mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { test } from 'vite-plus/test'; +import { tmpdir } from 'node:os'; import { assert, assertEquals, assertStringIncludes } from '@std/assert'; import { renderPreviewAssertionResult } from './local_preview_assertion_report.ts'; import { @@ -8,7 +16,7 @@ import { collectWatchBatch, runWatchMode } from './watch.ts'; const TEMPLATE_APP_ROOT = 'examples/apps/template'; -Deno.test('runLocalPreviewAssertions passes the template app preview checks', async () => { +test('runLocalPreviewAssertions passes the template app preview checks', async () => { const result = await runLocalPreviewAssertions(TEMPLATE_APP_ROOT); assertSuccessfulRun(result); @@ -17,7 +25,7 @@ Deno.test('runLocalPreviewAssertions passes the template app preview checks', as assertEquals(result.results.length, 4); }); -Deno.test('runLocalPreviewAssertions reports missing selectors with test name and selector', async () => { +test('runLocalPreviewAssertions reports missing selectors with test name and selector', async () => { const tempRoot = await cloneTemplateApp(); try { @@ -38,11 +46,11 @@ Deno.test('runLocalPreviewAssertions reports missing selectors with test name an assertEquals(result.results[0]?.name, 'missing title'); assertEquals(result.results[0]?.selector, "[data-test='missing-title']"); } finally { - await Deno.remove(tempRoot, { recursive: true }); + await rm(tempRoot, { recursive: true }); } }); -Deno.test('runLocalPreviewAssertions reports exact text mismatches', async () => { +test('runLocalPreviewAssertions reports exact text mismatches', async () => { const tempRoot = await cloneTemplateApp(); try { @@ -63,11 +71,11 @@ Deno.test('runLocalPreviewAssertions reports exact text mismatches', async () => assertEquals(result.results[0]?.code, 'text_mismatch'); assertEquals(result.results[0]?.actualText, 'Template App'); } finally { - await Deno.remove(tempRoot, { recursive: true }); + await rm(tempRoot, { recursive: true }); } }); -Deno.test('runLocalPreviewAssertions reports contains mismatches', async () => { +test('runLocalPreviewAssertions reports contains mismatches', async () => { const tempRoot = await cloneTemplateApp(); try { @@ -88,11 +96,96 @@ Deno.test('runLocalPreviewAssertions reports contains mismatches', async () => { assertEquals(result.results[0]?.code, 'contains_mismatch'); assertStringIncludes(result.results[0]?.message ?? '', 'to contain'); } finally { - await Deno.remove(tempRoot, { recursive: true }); + await rm(tempRoot, { recursive: true }); } }); -Deno.test('renderPreviewAssertionResult surfaces grouped validation diagnostics', () => { +test('preview CLI blocks constructor escapes to host environment, files, and subprocesses', async () => { + const packageRoot = await cloneTemplateApp(); + const canaryPath = join(packageRoot, 'host-canary.txt'); + const childMarker = join(packageRoot, 'child-marker.txt'); + try { + await writeFile(canaryPath, 'host-file-canary'); + await writeFile( + join(packageRoot, 'dist/app.js'), + ` + const getProcess = () => window.constructor.constructor('return process')(); + const probes = [ + () => getProcess().env.LANTERN_PREVIEW_HOST_CANARY, + () => getProcess().getBuiltinModule('node:fs').readFileSync(${JSON.stringify(canaryPath)}, 'utf8'), + () => getProcess().getBuiltinModule('node:fs').writeFileSync(${JSON.stringify(canaryPath)}, 'overwritten'), + () => getProcess().getBuiltinModule('node:child_process').execFileSync(getProcess().execPath, [ + '-e', ${JSON.stringify(`require('node:fs').writeFileSync(${JSON.stringify(childMarker)}, 'executed')`)} + ]), + ]; + document.querySelector('[data-test="app-title"]').textContent = probes.map(probe => { + try { probe(); return 'escaped'; } catch { return 'blocked'; } + }).join('|'); + `, + ); + await writePreviewTests(packageRoot, [ + { + name: 'host authority is unavailable', + assert: { selector: '[data-test="app-title"]', text: 'blocked|blocked|blocked|blocked' }, + }, + ]); + await writeFile( + join(packageRoot, '.puppeteerrc.cjs'), + `require('node:fs').writeFileSync(${JSON.stringify(childMarker)}, 'config-executed'); module.exports = {};`, + ); + const { stdout } = await promisify(execFile)( + process.execPath, + [fileURLToPath(new URL('../../tools/app_test_preview.ts', import.meta.url)), packageRoot], + { + cwd: packageRoot, + env: { ...process.env, LANTERN_PREVIEW_HOST_CANARY: 'synthetic-host-secret' }, + timeout: 20_000, + }, + ); + assertStringIncludes(stdout, '1/1 checks passed'); + assertEquals(await readFile(canaryPath, 'utf8'), 'host-file-canary'); + assertEquals(await readFile(childMarker, 'utf8').catch(() => null), null); + } finally { + await rm(packageRoot, { recursive: true }); + } +}); + +test('preview blocks access to host files and external HTTP requests', async () => { + const packageRoot = await cloneTemplateApp(); + let hostRequests = 0; + const server = createServer((_request, response) => { + hostRequests += 1; + response.end('private-host-response'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address(); + assert(address && typeof address !== 'string'); + await writeFile( + join(packageRoot, 'dist/app.js'), + ` + Promise.all([ + fetch('file:///etc/passwd').then(() => 'escaped', () => 'blocked'), + fetch('http://127.0.0.1:${address.port}/private').then(() => 'escaped', () => 'blocked'), + fetch('https://network-probe.invalid/private').then(() => 'escaped', () => 'blocked'), + ]).then(results => { + document.querySelector('[data-test="app-title"]').textContent = results.join('|'); + }); + `, + ); + const result = await runLocalPreviewAssertions(packageRoot); + assert(!result.ok); + assertEquals(result.kind, 'runtime_failed'); + assertEquals(hostRequests, 0); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await rm(packageRoot, { recursive: true }); + } +}); + +test('renderPreviewAssertionResult surfaces grouped validation diagnostics', () => { const rendered = renderPreviewAssertionResult({ ok: false, kind: 'validation_failed', @@ -115,7 +208,7 @@ Deno.test('renderPreviewAssertionResult surfaces grouped validation diagnostics' assertStringIncludes(rendered.output, 'Fix: Add at least one named preview test'); }); -Deno.test('collectWatchBatch merges rapid file events into one rerun batch', async () => { +test('collectWatchBatch merges rapid file events into one rerun batch', async () => { const iterator = createAsyncIterator([ { paths: ['preview/tests.json'], @@ -130,7 +223,7 @@ Deno.test('collectWatchBatch merges rapid file events into one rerun batch', asy assertEquals(batch, ['dist/app.js', 'preview/tests.json']); }); -Deno.test('runWatchMode performs the initial run and one debounced rerun', async () => { +test('runWatchMode performs the initial run and one debounced rerun', async () => { const iterator = createAsyncIterator([ { paths: ['preview/tests.json'], @@ -156,9 +249,7 @@ Deno.test('runWatchMode performs the initial run and one debounced rerun', async }); async function cloneTemplateApp(): Promise { - const tempRoot = await Deno.makeTempDir({ - prefix: 'lantern-preview-assertions-', - }); + const tempRoot = await mkdtemp(join(tmpdir(), 'lantern-preview-assertions-')); const outputRoot = `${tempRoot}/template-app`; await copyDirectory(TEMPLATE_APP_ROOT, outputRoot); @@ -167,25 +258,25 @@ async function cloneTemplateApp(): Promise { } async function copyDirectory(sourceRoot: string, destinationRoot: string): Promise { - await Deno.mkdir(destinationRoot, { recursive: true }); + await mkdir(destinationRoot, { recursive: true }); - for await (const entry of Deno.readDir(sourceRoot)) { + for (const entry of await readdir(sourceRoot, { withFileTypes: true })) { const sourcePath = `${sourceRoot}/${entry.name}`; const destinationPath = `${destinationRoot}/${entry.name}`; - if (entry.isDirectory) { + if (entry.isDirectory()) { await copyDirectory(sourcePath, destinationPath); continue; } - if (entry.isFile) { - await Deno.copyFile(sourcePath, destinationPath); + if (entry.isFile()) { + await copyFile(sourcePath, destinationPath); } } } async function writePreviewTests(packageRoot: string, tests: unknown): Promise { - await Deno.writeTextFile(`${packageRoot}/preview/tests.json`, JSON.stringify(tests, null, 2)); + await writeFile(`${packageRoot}/preview/tests.json`, JSON.stringify(tests, null, 2)); } function assertSuccessfulRun( diff --git a/src/authoring/local_preview_test.ts b/src/authoring/local_preview_test.ts index a174642..0f4622c 100644 --- a/src/authoring/local_preview_test.ts +++ b/src/authoring/local_preview_test.ts @@ -1,10 +1,11 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertStringIncludes } from '@std/assert'; import { validateLocalAppPackage } from './local_app.ts'; import { createLocalPreviewHarness } from './local_preview.ts'; const TEMPLATE_APP_ROOT = 'examples/apps/template'; -Deno.test('validateLocalAppPackage accepts the template app', async () => { +test('validateLocalAppPackage accepts the template app', async () => { const result = await validateLocalAppPackage(TEMPLATE_APP_ROOT); assertEquals(result.ok, true); @@ -27,7 +28,7 @@ Deno.test('validateLocalAppPackage accepts the template app', async () => { ); }); -Deno.test('local preview harness injects GatewayApp and serves preview state', async () => { +test('local preview harness injects GatewayApp and serves preview state', async () => { const validation = await validateLocalAppPackage(TEMPLATE_APP_ROOT); assertEquals(validation.ok, true); @@ -86,8 +87,7 @@ Deno.test('local preview harness injects GatewayApp and serves preview state', a instructions: 'Edit this file first. Keep the app logic small and move lesson-specific data here.', prompt: 'What is the one question or interaction this app should teach?', - hint: - 'Use Lantern content files for reviewed lesson data, not hard-coded course text in app.js.', + hint: 'Use Lantern content files for reviewed lesson data, not hard-coded course text in app.js.', }, ); diff --git a/src/authoring/scaffold.ts b/src/authoring/scaffold.ts index 72d1ba9..9fb8049 100644 --- a/src/authoring/scaffold.ts +++ b/src/authoring/scaffold.ts @@ -1,3 +1,4 @@ +import { copyFile, mkdir, readFile, readdir, stat as fileStat, writeFile } from 'node:fs/promises'; import type { AppManifest } from '../package_review/manifest_contract.ts'; import { joinSnapshotPath } from '../package_review/snapshot_path.ts'; @@ -126,7 +127,7 @@ function resolveOutputRoot(value: string): string { const trimmed = requireNonEmptyString(value, 'Output path is required.'); const absolute = isAbsolutePath(trimmed) ? trimmed - : `${Deno.cwd().replace(/\/+$/, '')}/${trimmed}`; + : `${process.cwd().replace(/\/+$/, '')}/${trimmed}`; return absolute.replace(/\/+$/, ''); } @@ -137,18 +138,18 @@ function isAbsolutePath(value: string): boolean { async function prepareOutputRoot(outputRoot: string): Promise { try { - const stat = await Deno.stat(outputRoot); + const stat = await fileStat(outputRoot); - if (!stat.isDirectory) { + if (!stat.isDirectory()) { throw new Error(`Scaffold output path ${outputRoot} must be a directory.`); } - for await (const _entry of Deno.readDir(outputRoot)) { + for (const _entry of await readdir(outputRoot, { withFileTypes: true })) { throw new Error(`Scaffold output path ${outputRoot} must not already contain files.`); } } catch (error) { - if (error instanceof Deno.errors.NotFound) { - await Deno.mkdir(outputRoot, { recursive: true }); + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + await mkdir(outputRoot, { recursive: true }); return; } @@ -157,18 +158,18 @@ async function prepareOutputRoot(outputRoot: string): Promise { } async function copyDirectory(sourceRoot: string, targetRoot: string): Promise { - for await (const entry of Deno.readDir(sourceRoot)) { + for (const entry of await readdir(sourceRoot, { withFileTypes: true })) { const sourcePath = `${sourceRoot}/${entry.name}`; const targetPath = joinSnapshotPath(targetRoot, entry.name, OUTPUT_ROOT_OUTSIDE_MESSAGE); - if (entry.isDirectory) { - await Deno.mkdir(targetPath, { recursive: true }); + if (entry.isDirectory()) { + await mkdir(targetPath, { recursive: true }); await copyDirectory(sourcePath, targetPath); continue; } - if (entry.isFile) { - await Deno.copyFile(sourcePath, targetPath); + if (entry.isFile()) { + await copyFile(sourcePath, targetPath); } } } @@ -179,20 +180,14 @@ async function rewriteSimpleActivityStarter(context: ScaffoldRewriteContext): Pr async function rewriteBrowserAutograderStarter(context: ScaffoldRewriteContext): Promise { await rewriteStarterIdentityFiles(context); - await rewriteTextFile( - context.outputRoot, - 'dist/index.html', - (source) => source.replaceAll('Template App', context.title), + await rewriteTextFile(context.outputRoot, 'dist/index.html', (source) => + source.replaceAll('Template App', context.title), ); - await rewriteTextFile( - context.outputRoot, - 'dist/app.js', - (source) => source.replaceAll('"Template App"', JSON.stringify(context.title)), + await rewriteTextFile(context.outputRoot, 'dist/app.js', (source) => + source.replaceAll('"Template App"', JSON.stringify(context.title)), ); - await rewriteTextFile( - context.outputRoot, - 'grading/specs/checks.spec.js', - (source) => source.replaceAll("'Template App'", JSON.stringify(context.title)), + await rewriteTextFile(context.outputRoot, 'grading/specs/checks.spec.js', (source) => + source.replaceAll("'Template App'", JSON.stringify(context.title)), ); } @@ -265,9 +260,9 @@ async function rewriteJsonFile( mutate: (value: T) => T, ): Promise { const absolutePath = joinSnapshotPath(outputRoot, relativePath, OUTPUT_ROOT_OUTSIDE_MESSAGE); - const value = readJsonFile(await Deno.readTextFile(absolutePath)); + const value = readJsonFile(await readFile(absolutePath, 'utf8')); - await Deno.writeTextFile(absolutePath, `${JSON.stringify(mutate(value), null, 2)}\n`); + await writeFile(absolutePath, `${JSON.stringify(mutate(value), null, 2)}\n`); } async function rewriteTextFile( @@ -277,7 +272,7 @@ async function rewriteTextFile( ): Promise { const absolutePath = joinSnapshotPath(outputRoot, relativePath, OUTPUT_ROOT_OUTSIDE_MESSAGE); - await Deno.writeTextFile(absolutePath, mutate(await Deno.readTextFile(absolutePath))); + await writeFile(absolutePath, mutate(await readFile(absolutePath, 'utf8'))); } function readJsonFile(text: string): T { diff --git a/src/authoring/scaffold_test.ts b/src/authoring/scaffold_test.ts index 53e6917..f3adcae 100644 --- a/src/authoring/scaffold_test.ts +++ b/src/authoring/scaffold_test.ts @@ -1,9 +1,13 @@ +import { join } from 'node:path'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { test } from 'vite-plus/test'; +import { tmpdir } from 'node:os'; import { assertEquals, assertStringIncludes } from '@std/assert'; import { validateLocalAppPackage } from './local_app.ts'; import { createLocalPreviewHarness } from './local_preview.ts'; import { listScaffoldStarters, scaffoldLocalAppPackage } from './scaffold.ts'; -Deno.test('listScaffoldStarters exposes the curated starter set', () => { +test('listScaffoldStarters exposes the curated starter set', () => { assertEquals(listScaffoldStarters(), [ { id: 'simple-activity', @@ -18,10 +22,8 @@ Deno.test('listScaffoldStarters exposes the curated starter set', () => { ]); }); -Deno.test('scaffoldLocalAppPackage writes a simple activity package that validates and previews', async () => { - const tempRoot = await Deno.makeTempDir({ - prefix: 'lantern-scaffold-', - }); +test('scaffoldLocalAppPackage writes a simple activity package that validates and previews', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'lantern-scaffold-')); const outputRoot = `${tempRoot}/cell-review`; try { @@ -43,9 +45,9 @@ Deno.test('scaffoldLocalAppPackage writes a simple activity package that validat ); } - const appSource = await Deno.readTextFile(`${outputRoot}/dist/app.js`); - const contentSource = await Deno.readTextFile(`${outputRoot}/content/activity.json`); - const fixturesSource = await Deno.readTextFile(`${outputRoot}/preview/fixtures.json`); + const appSource = await readFile(`${outputRoot}/dist/app.js`, 'utf8'); + const contentSource = await readFile(`${outputRoot}/content/activity.json`, 'utf8'); + const fixturesSource = await readFile(`${outputRoot}/preview/fixtures.json`, 'utf8'); assertEquals(validation.appPackage.reviewData.appId, 'cell-review'); assertEquals(validation.appPackage.manifest.title, 'Cell Review'); @@ -71,14 +73,12 @@ Deno.test('scaffoldLocalAppPackage writes a simple activity package that validat assertStringIncludes(entrypointBody, 'data-test="progress-button"'); assertStringIncludes(entrypointBody, 'data-test="complete-button"'); } finally { - await Deno.remove(tempRoot, { recursive: true }); + await rm(tempRoot, { recursive: true }); } }); -Deno.test('scaffoldLocalAppPackage writes a browser autograder package with coherent metadata artifacts', async () => { - const tempRoot = await Deno.makeTempDir({ - prefix: 'lantern-scaffold-', - }); +test('scaffoldLocalAppPackage writes a browser autograder package with coherent metadata artifacts', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'lantern-scaffold-')); const outputRoot = `${tempRoot}/dom-check`; try { @@ -100,10 +100,10 @@ Deno.test('scaffoldLocalAppPackage writes a browser autograder package with cohe ); } - const previewTestsSource = await Deno.readTextFile(`${outputRoot}/preview/tests.json`); - const graderSpecSource = await Deno.readTextFile(`${outputRoot}/grading/specs/checks.spec.js`); - const entrypointSource = await Deno.readTextFile(`${outputRoot}/dist/index.html`); - const appSource = await Deno.readTextFile(`${outputRoot}/dist/app.js`); + const previewTestsSource = await readFile(`${outputRoot}/preview/tests.json`, 'utf8'); + const graderSpecSource = await readFile(`${outputRoot}/grading/specs/checks.spec.js`, 'utf8'); + const entrypointSource = await readFile(`${outputRoot}/dist/index.html`, 'utf8'); + const appSource = await readFile(`${outputRoot}/dist/app.js`, 'utf8'); assertEquals(validation.appPackage.reviewData.appId, 'dom-check'); assertEquals(validation.appPackage.manifest.title, 'DOM Check'); @@ -140,6 +140,6 @@ Deno.test('scaffoldLocalAppPackage writes a browser autograder package with cohe assertStringIncludes(await entrypointResponse.text(), 'runBrowserGrader'); assertStringIncludes(await runnerResponse.text(), "./reviewed/' + index + '.js"); } finally { - await Deno.remove(tempRoot, { recursive: true }); + await rm(tempRoot, { recursive: true }); } }); diff --git a/src/db/d1_migrations/0005_lti_protocol_cache_tenant.sql b/src/db/d1_migrations/0005_lti_protocol_cache_tenant.sql new file mode 100644 index 0000000..e40427b --- /dev/null +++ b/src/db/d1_migrations/0005_lti_protocol_cache_tenant.sql @@ -0,0 +1,11 @@ +-- The protocol cache belongs to this Lantern installation. Institution identity remains in lms_connections. +ALTER TABLE lti_clients ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'lantern'; +CREATE UNIQUE INDEX lti_clients_tenant_key ON lti_clients (tenant_id, id); +ALTER TABLE lti_deployments ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'lantern'; +CREATE UNIQUE INDEX lti_deployments_tenant_key ON lti_deployments (tenant_id, id); +ALTER TABLE lti_registration_sessions ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'lantern'; +CREATE UNIQUE INDEX lti_registration_sessions_tenant_key ON lti_registration_sessions (tenant_id, id); +ALTER TABLE lti_sessions ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'lantern'; +CREATE UNIQUE INDEX lti_sessions_tenant_key ON lti_sessions (tenant_id, id); +ALTER TABLE lti_nonces ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'lantern'; +CREATE UNIQUE INDEX lti_nonces_tenant_key ON lti_nonces (tenant_id, nonce); diff --git a/src/db/d1_migrations/README.md b/src/db/d1_migrations/README.md index 621b1de..d77526e 100644 --- a/src/db/d1_migrations/README.md +++ b/src/db/d1_migrations/README.md @@ -1,6 +1,6 @@ # D1 Migrations -Wrangler-managed D1 migrations belong here. +Cloudflare CLI-managed D1 migrations belong here. This is Lantern's only checked-in D1 migration path. The numbered files are the current product baseline, not a compatibility history: @@ -9,6 +9,7 @@ the current product baseline, not a compatibility history: 2. administrator authentication 3. the Python autograder lab 4. the governed LMS primitive kernel +5. the tenant namespace required by the LTI protocol-cache adapter There is no production data to migrate. Rewrite this baseline when the domain changes instead of preserving obsolete tables or adding compatibility shims. diff --git a/src/db/d1_row.ts b/src/db/d1_row.ts index 40d4d66..6e441e9 100644 --- a/src/db/d1_row.ts +++ b/src/db/d1_row.ts @@ -79,10 +79,7 @@ export function expectD1NullableStringLiteral( return expectD1StringLiteral(value, fieldName, allowed); } -export function expectD1JsonRecord( - value: unknown, - fieldName: string, -): Record { +export function expectD1JsonRecord(value: unknown, fieldName: string): Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) { throw new TypeError(`Expected D1 ${fieldName} JSON to be an object.`); } diff --git a/src/db/d1_row_test.ts b/src/db/d1_row_test.ts index d0094ce..95bff83 100644 --- a/src/db/d1_row_test.ts +++ b/src/db/d1_row_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertThrows } from '@std/assert'; import { expectD1JsonRecord, @@ -6,7 +7,7 @@ import { parseD1NullableJsonField, } from './d1_row.ts'; -Deno.test('D1 row parser rejects non-JSON text fields clearly', () => { +test('D1 row parser rejects non-JSON text fields clearly', () => { assertThrows( () => parseD1JsonField({ value: true }, 'package_versions.capabilities', expectD1StringArray), TypeError, @@ -14,7 +15,7 @@ Deno.test('D1 row parser rejects non-JSON text fields clearly', () => { ); }); -Deno.test('D1 row parser rejects malformed JSON column shapes', () => { +test('D1 row parser rejects malformed JSON column shapes', () => { assertThrows( () => parseD1JsonField('{"not":"an array"}', 'runtime_sessions.agsScope', expectD1StringArray), TypeError, @@ -22,11 +23,8 @@ Deno.test('D1 row parser rejects malformed JSON column shapes', () => { ); }); -Deno.test('D1 nullable JSON row parser preserves null and parses records', () => { - assertEquals( - parseD1NullableJsonField(null, 'audit_events.detail', expectD1JsonRecord), - null, - ); +test('D1 nullable JSON row parser preserves null and parses records', () => { + assertEquals(parseD1NullableJsonField(null, 'audit_events.detail', expectD1JsonRecord), null); assertEquals( parseD1NullableJsonField('{"code":"ok"}', 'audit_events.detail', expectD1JsonRecord), { code: 'ok' }, diff --git a/src/db/d1_test.ts b/src/db/d1_test.ts index 7d27032..2129132 100644 --- a/src/db/d1_test.ts +++ b/src/db/d1_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertInstanceOf, assertRejects } from '@std/assert'; import type { D1Database, D1PreparedStatement, D1Result } from './d1.ts'; import { @@ -8,7 +9,7 @@ import { splitSqlStatements, } from './d1.ts'; -Deno.test('splitSqlStatements keeps semicolons inside strings and comments', () => { +test('splitSqlStatements keeps semicolons inside strings and comments', () => { assertEquals( splitSqlStatements(` CREATE TABLE notes (body TEXT DEFAULT 'one;two'); @@ -25,7 +26,7 @@ Deno.test('splitSqlStatements keeps semicolons inside strings and comments', () ); }); -Deno.test('normalizeD1Parameter maps booleans, dates, JSON, and bytes to D1-safe values', () => { +test('normalizeD1Parameter maps booleans, dates, JSON, and bytes to D1-safe values', () => { const date = new Date('2026-05-13T12:34:56.000Z'); const bytes = new Uint8Array([1, 2, 3]); @@ -40,7 +41,7 @@ Deno.test('normalizeD1Parameter maps booleans, dates, JSON, and bytes to D1-safe assertInstanceOf(normalizeD1Parameter(bytes), ArrayBuffer); }); -Deno.test('prepareD1 binds normalized parameters', () => { +test('prepareD1 binds normalized parameters', () => { const db = createStubD1Database(); const statement = prepareD1(db, 'select * from launches where active = ?', [ true, @@ -49,7 +50,7 @@ Deno.test('prepareD1 binds normalized parameters', () => { assertEquals(statement.boundValues, [1]); }); -Deno.test('query helpers fail clearly when D1 reports failure', async () => { +test('query helpers fail clearly when D1 reports failure', async () => { const db = createStubD1Database({ runResult: { success: false, @@ -64,7 +65,7 @@ Deno.test('query helpers fail clearly when D1 reports failure', async () => { ); }); -Deno.test('queryD1Objects returns rows from successful D1 result', async () => { +test('queryD1Objects returns rows from successful D1 result', async () => { const db = createStubD1Database({ allResult: { success: true, diff --git a/src/generated/schema_validators.d.mts b/src/generated/schema_validators.d.mts new file mode 100644 index 0000000..5b024a9 --- /dev/null +++ b/src/generated/schema_validators.d.mts @@ -0,0 +1,8 @@ +import type { ValidateFunction } from 'ajv'; +import type { AppManifest } from '../package_review/manifest_contract.ts'; +import type { RawReviewedRubric } from '../grading/types.ts'; + +/** Validate a package manifest against the checked-in JSON schema. */ +export const manifestValidator: ValidateFunction; +/** Validate a reviewed rubric against the checked-in JSON schema. */ +export const reviewedRubricValidator: ValidateFunction; diff --git a/src/generated/schema_validators.mjs b/src/generated/schema_validators.mjs new file mode 100644 index 0000000..b0cc2ee --- /dev/null +++ b/src/generated/schema_validators.mjs @@ -0,0 +1,2642 @@ +// Generated by npm run schemas:generate. Do not edit. +var __getOwnPropNames = Object.getOwnPropertyNames; +var __commonJS = (cb, mod) => function __require() { + try { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; + } catch (e) { + throw mod = 0, e; + } +}; + +// node_modules/ajv/dist/runtime/ucs2length.js +var require_ucs2length = __commonJS({ + "node_modules/ajv/dist/runtime/ucs2length.js"(exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + function ucs2length(str) { + const len = str.length; + let length = 0; + let pos = 0; + let value; + while (pos < len) { + length++; + value = str.charCodeAt(pos++); + if (value >= 55296 && value <= 56319 && pos < len) { + value = str.charCodeAt(pos); + if ((value & 64512) === 56320) + pos++; + } + } + return length; + } + exports.default = ucs2length; + ucs2length.code = 'require("ajv/dist/runtime/ucs2length").default'; + } +}); + +// node_modules/fast-deep-equal/index.js +var require_fast_deep_equal = __commonJS({ + "node_modules/fast-deep-equal/index.js"(exports, module) { + "use strict"; + module.exports = function equal(a, b) { + if (a === b) return true; + if (a && b && typeof a == "object" && typeof b == "object") { + if (a.constructor !== b.constructor) return false; + var length, i, keys; + if (Array.isArray(a)) { + length = a.length; + if (length != b.length) return false; + for (i = length; i-- !== 0; ) + if (!equal(a[i], b[i])) return false; + return true; + } + if (a.constructor === RegExp) return a.source === b.source && a.flags === b.flags; + if (a.valueOf !== Object.prototype.valueOf) return a.valueOf() === b.valueOf(); + if (a.toString !== Object.prototype.toString) return a.toString() === b.toString(); + keys = Object.keys(a); + length = keys.length; + if (length !== Object.keys(b).length) return false; + for (i = length; i-- !== 0; ) + if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false; + for (i = length; i-- !== 0; ) { + var key = keys[i]; + if (!equal(a[key], b[key])) return false; + } + return true; + } + return a !== a && b !== b; + }; + } +}); + +// node_modules/ajv/dist/runtime/equal.js +var require_equal = __commonJS({ + "node_modules/ajv/dist/runtime/equal.js"(exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + var equal = require_fast_deep_equal(); + equal.code = 'require("ajv/dist/runtime/equal").default'; + exports.default = equal; + } +}); + +// +var manifestValidator = validate20; +var schema31 = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://example.org/schemas/app-manifest.schema.json", "title": "Gateway App Manifest", "type": "object", "additionalProperties": false, "required": ["schema_version", "app_id", "version", "title", "owner", "runtime", "capabilities", "roles", "grading"], "properties": { "schema_version": { "const": "2" }, "app_id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$", "minLength": 3, "maxLength": 64 }, "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$" }, "title": { "type": "string", "minLength": 1, "maxLength": 120 }, "description": { "type": "string", "maxLength": 500 }, "owner": { "type": "object", "additionalProperties": false, "required": ["type", "id"], "properties": { "type": { "const": "user" }, "id": { "type": "string", "minLength": 1, "maxLength": 128 } } }, "runtime": { "oneOf": [{ "type": "object", "additionalProperties": false, "required": ["kind", "entrypoint"], "properties": { "kind": { "const": "browser" }, "entrypoint": { "type": "string", "pattern": "^/dist/.+\\.html$" } } }, { "type": "object", "additionalProperties": false, "required": ["kind", "entrypoint"], "properties": { "kind": { "const": "worker" }, "entrypoint": { "type": "string", "pattern": "^/dist/.+\\.js$" } } }] }, "roles": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["learner", "instructor"] } }, "install_scope": { "enum": ["course", "assignment"], "default": "course" }, "capabilities": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["read_launch_context", "read_activity_content", "submit_attempt_event", "submit_evidence_artifact", "finalize_attempt", "run_code_submission", "read_local_state", "write_local_state", "lms.roster.members.read", "lms.roster.contact.read", "lms.roster.profile_photo.read"] } }, "grading": { "type": "object", "additionalProperties": false, "required": ["mode"], "properties": { "mode": { "enum": ["declarative", "manual", "completion", "browser", "sandbox"] }, "rubric_file": { "type": "string", "pattern": "^/scoring/.+\\.json$" }, "max_score": { "type": "number", "minimum": 0 } }, "allOf": [{ "if": { "properties": { "mode": { "const": "declarative" } }, "required": ["mode"] }, "then": { "required": ["rubric_file", "max_score"] } }, { "if": { "properties": { "mode": { "const": "browser" } }, "required": ["mode"] }, "then": { "required": ["max_score"] } }, { "if": { "properties": { "mode": { "const": "sandbox" } }, "required": ["mode"] }, "then": { "properties": { "max_score": { "const": 10 } }, "required": ["max_score"] } }] }, "browser": { "type": "object", "additionalProperties": false, "properties": { "fullscreen": { "type": "boolean" }, "clipboard_write": { "type": "boolean" } } }, "content_files": { "type": "array", "uniqueItems": true, "items": { "type": "string", "pattern": "^/content/.+" } }, "preview": { "type": "object", "additionalProperties": false, "required": ["fixtures_file", "tests_file"], "properties": { "fixtures_file": { "type": "string", "pattern": "^/preview/.+\\.json$" }, "tests_file": { "type": "string", "pattern": "^/preview/.+\\.json$" } } }, "authoring": { "type": "object", "additionalProperties": false, "required": ["kind"], "properties": { "kind": { "enum": ["browser_autograder", "python_function"] }, "grader_spec_files": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "pattern": "^/grading/.+\\.js$" } }, "evidence_example_file": { "type": "string", "pattern": "^/evidence/.+\\.json$" } } }, "icon": { "type": "string", "pattern": "^/.+" } }, "allOf": [{ "if": { "properties": { "runtime": { "type": "object", "properties": { "kind": { "const": "worker" } }, "required": ["kind"] } }, "required": ["runtime"] }, "then": { "properties": { "roles": { "type": "array", "items": { "const": "instructor" } }, "install_scope": { "const": "course" }, "capabilities": { "type": "array", "items": { "enum": ["lms.roster.members.read", "lms.roster.contact.read", "lms.roster.profile_photo.read"] } }, "grading": { "type": "object", "properties": { "mode": { "const": "completion" } }, "not": { "anyOf": [{ "required": ["rubric_file"] }, { "required": ["max_score"] }] } } }, "not": { "anyOf": [{ "required": ["browser"] }, { "required": ["content_files"] }, { "required": ["authoring"] }] } }, "else": { "properties": { "capabilities": { "type": "array", "items": { "enum": ["read_launch_context", "read_activity_content", "submit_attempt_event", "submit_evidence_artifact", "finalize_attempt", "run_code_submission", "read_local_state", "write_local_state"] } } } } }, { "if": { "properties": { "capabilities": { "type": "array", "contains": { "const": "submit_evidence_artifact" } } }, "required": ["capabilities"] }, "then": { "properties": { "capabilities": { "type": "array", "contains": { "const": "finalize_attempt" } } } } }, { "if": { "properties": { "grading": { "type": "object", "properties": { "mode": { "const": "sandbox" } }, "required": ["mode"] } }, "required": ["grading"] }, "then": { "required": ["authoring"], "properties": { "authoring": { "type": "object", "required": ["kind"], "properties": { "kind": { "const": "python_function" } } }, "capabilities": { "type": "array", "contains": { "const": "run_code_submission" } } } } }, { "if": { "properties": { "grading": { "type": "object", "properties": { "mode": { "const": "browser" } }, "required": ["mode"] } }, "required": ["grading"] }, "then": { "required": ["authoring"], "properties": { "authoring": { "type": "object", "required": ["kind", "grader_spec_files", "evidence_example_file"], "properties": { "kind": { "const": "browser_autograder" }, "grader_spec_files": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "pattern": "^/grading/.+\\.js$" } }, "evidence_example_file": { "type": "string", "pattern": "^/evidence/.+\\.json$" } } } } } }] }; +var pattern4 = new RegExp("^/grading/.+\\.js$", "u"); +var pattern5 = new RegExp("^/evidence/.+\\.json$", "u"); +var pattern6 = new RegExp("^[a-z0-9][a-z0-9-]*$", "u"); +var pattern7 = new RegExp("^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$", "u"); +var pattern8 = new RegExp("^/dist/.+\\.html$", "u"); +var pattern9 = new RegExp("^/dist/.+\\.js$", "u"); +var pattern10 = new RegExp("^/scoring/.+\\.json$", "u"); +var pattern11 = new RegExp("^/content/.+", "u"); +var pattern12 = new RegExp("^/preview/.+\\.json$", "u"); +var pattern16 = new RegExp("^/.+", "u"); +var func1 = Object.prototype.hasOwnProperty; +var func2 = require_ucs2length().default; +var func0 = require_equal().default; +function validate20(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + ; + let vErrors = null; + let errors = 0; + const evaluated0 = validate20.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + const _errs2 = errors; + let valid1 = true; + const _errs3 = errors; + if (data && typeof data == "object" && !Array.isArray(data)) { + let missing0; + if (data.runtime === void 0 && (missing0 = "runtime")) { + const err0 = {}; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } else { + if (data.runtime !== void 0) { + let data0 = data.runtime; + const _errs4 = errors; + if (errors === _errs4) { + if (data0 && typeof data0 == "object" && !Array.isArray(data0)) { + let missing1; + if (data0.kind === void 0 && (missing1 = "kind")) { + const err1 = {}; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } else { + if (data0.kind !== void 0) { + if ("worker" !== data0.kind) { + const err2 = {}; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + } + } + } else { + const err3 = {}; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + } + } + } + } + var _valid0 = _errs3 === errors; + errors = _errs2; + if (vErrors !== null) { + if (_errs2) { + vErrors.length = _errs2; + } else { + vErrors = null; + } + } + let ifClause0; + if (_valid0) { + const _errs7 = errors; + const _errs8 = errors; + const _errs9 = errors; + const _errs10 = errors; + let valid5 = false; + const _errs11 = errors; + if (data && typeof data == "object" && !Array.isArray(data)) { + let missing2; + if (data.browser === void 0 && (missing2 = "browser")) { + const err4 = {}; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + } + var _valid1 = _errs11 === errors; + valid5 = valid5 || _valid1; + const _errs12 = errors; + if (data && typeof data == "object" && !Array.isArray(data)) { + let missing3; + if (data.content_files === void 0 && (missing3 = "content_files")) { + const err5 = {}; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + } + var _valid1 = _errs12 === errors; + valid5 = valid5 || _valid1; + const _errs13 = errors; + if (data && typeof data == "object" && !Array.isArray(data)) { + let missing4; + if (data.authoring === void 0 && (missing4 = "authoring")) { + const err6 = {}; + if (vErrors === null) { + vErrors = [err6]; + } else { + vErrors.push(err6); + } + errors++; + } + } + var _valid1 = _errs13 === errors; + valid5 = valid5 || _valid1; + if (!valid5) { + const err7 = {}; + if (vErrors === null) { + vErrors = [err7]; + } else { + vErrors.push(err7); + } + errors++; + } else { + errors = _errs10; + if (vErrors !== null) { + if (_errs10) { + vErrors.length = _errs10; + } else { + vErrors = null; + } + } + } + var valid4 = _errs9 === errors; + if (valid4) { + const err8 = { instancePath, schemaPath: "#/allOf/0/then/not", keyword: "not", params: {}, message: "must NOT be valid" }; + if (vErrors === null) { + vErrors = [err8]; + } else { + vErrors.push(err8); + } + errors++; + } else { + errors = _errs8; + if (vErrors !== null) { + if (_errs8) { + vErrors.length = _errs8; + } else { + vErrors = null; + } + } + } + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.roles !== void 0) { + let data2 = data.roles; + if (Array.isArray(data2)) { + const len0 = data2.length; + for (let i0 = 0; i0 < len0; i0++) { + if ("instructor" !== data2[i0]) { + const err9 = { instancePath: instancePath + "/roles/" + i0, schemaPath: "#/allOf/0/then/properties/roles/items/const", keyword: "const", params: { allowedValue: "instructor" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err9]; + } else { + vErrors.push(err9); + } + errors++; + } + } + } else { + const err10 = { instancePath: instancePath + "/roles", schemaPath: "#/allOf/0/then/properties/roles/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err10]; + } else { + vErrors.push(err10); + } + errors++; + } + } + if (data.install_scope !== void 0) { + if ("course" !== data.install_scope) { + const err11 = { instancePath: instancePath + "/install_scope", schemaPath: "#/allOf/0/then/properties/install_scope/const", keyword: "const", params: { allowedValue: "course" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err11]; + } else { + vErrors.push(err11); + } + errors++; + } + } + if (data.capabilities !== void 0) { + let data5 = data.capabilities; + if (Array.isArray(data5)) { + const len1 = data5.length; + for (let i1 = 0; i1 < len1; i1++) { + let data6 = data5[i1]; + if (!(data6 === "lms.roster.members.read" || data6 === "lms.roster.contact.read" || data6 === "lms.roster.profile_photo.read")) { + const err12 = { instancePath: instancePath + "/capabilities/" + i1, schemaPath: "#/allOf/0/then/properties/capabilities/items/enum", keyword: "enum", params: { allowedValues: schema31.allOf[0].then.properties.capabilities.items.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err12]; + } else { + vErrors.push(err12); + } + errors++; + } + } + } else { + const err13 = { instancePath: instancePath + "/capabilities", schemaPath: "#/allOf/0/then/properties/capabilities/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err13]; + } else { + vErrors.push(err13); + } + errors++; + } + } + if (data.grading !== void 0) { + let data7 = data.grading; + const _errs23 = errors; + const _errs24 = errors; + const _errs25 = errors; + let valid12 = false; + const _errs26 = errors; + if (data7 && typeof data7 == "object" && !Array.isArray(data7)) { + let missing5; + if (data7.rubric_file === void 0 && (missing5 = "rubric_file")) { + const err14 = {}; + if (vErrors === null) { + vErrors = [err14]; + } else { + vErrors.push(err14); + } + errors++; + } + } + var _valid2 = _errs26 === errors; + valid12 = valid12 || _valid2; + const _errs27 = errors; + if (data7 && typeof data7 == "object" && !Array.isArray(data7)) { + let missing6; + if (data7.max_score === void 0 && (missing6 = "max_score")) { + const err15 = {}; + if (vErrors === null) { + vErrors = [err15]; + } else { + vErrors.push(err15); + } + errors++; + } + } + var _valid2 = _errs27 === errors; + valid12 = valid12 || _valid2; + if (!valid12) { + const err16 = {}; + if (vErrors === null) { + vErrors = [err16]; + } else { + vErrors.push(err16); + } + errors++; + } else { + errors = _errs25; + if (vErrors !== null) { + if (_errs25) { + vErrors.length = _errs25; + } else { + vErrors = null; + } + } + } + var valid11 = _errs24 === errors; + if (valid11) { + const err17 = { instancePath: instancePath + "/grading", schemaPath: "#/allOf/0/then/properties/grading/not", keyword: "not", params: {}, message: "must NOT be valid" }; + if (vErrors === null) { + vErrors = [err17]; + } else { + vErrors.push(err17); + } + errors++; + } else { + errors = _errs23; + if (vErrors !== null) { + if (_errs23) { + vErrors.length = _errs23; + } else { + vErrors = null; + } + } + } + if (data7 && typeof data7 == "object" && !Array.isArray(data7)) { + if (data7.mode !== void 0) { + if ("completion" !== data7.mode) { + const err18 = { instancePath: instancePath + "/grading/mode", schemaPath: "#/allOf/0/then/properties/grading/properties/mode/const", keyword: "const", params: { allowedValue: "completion" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err18]; + } else { + vErrors.push(err18); + } + errors++; + } + } + } else { + const err19 = { instancePath: instancePath + "/grading", schemaPath: "#/allOf/0/then/properties/grading/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err19]; + } else { + vErrors.push(err19); + } + errors++; + } + } + } + var _valid0 = _errs7 === errors; + valid1 = _valid0; + if (valid1) { + var props0 = {}; + props0.roles = true; + props0.install_scope = true; + props0.capabilities = true; + props0.grading = true; + props0.runtime = true; + } + ifClause0 = "then"; + } else { + const _errs29 = errors; + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.capabilities !== void 0) { + let data9 = data.capabilities; + if (Array.isArray(data9)) { + const len2 = data9.length; + for (let i2 = 0; i2 < len2; i2++) { + let data10 = data9[i2]; + if (!(data10 === "read_launch_context" || data10 === "read_activity_content" || data10 === "submit_attempt_event" || data10 === "submit_evidence_artifact" || data10 === "finalize_attempt" || data10 === "run_code_submission" || data10 === "read_local_state" || data10 === "write_local_state")) { + const err20 = { instancePath: instancePath + "/capabilities/" + i2, schemaPath: "#/allOf/0/else/properties/capabilities/items/enum", keyword: "enum", params: { allowedValues: schema31.allOf[0].else.properties.capabilities.items.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err20]; + } else { + vErrors.push(err20); + } + errors++; + } + } + } else { + const err21 = { instancePath: instancePath + "/capabilities", schemaPath: "#/allOf/0/else/properties/capabilities/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err21]; + } else { + vErrors.push(err21); + } + errors++; + } + } + } + var _valid0 = _errs29 === errors; + valid1 = _valid0; + if (valid1) { + if (props0 !== true) { + props0 = props0 || {}; + props0.capabilities = true; + } + } + ifClause0 = "else"; + } + if (!valid1) { + const err22 = { instancePath, schemaPath: "#/allOf/0/if", keyword: "if", params: { failingKeyword: ifClause0 }, message: 'must match "' + ifClause0 + '" schema' }; + if (vErrors === null) { + vErrors = [err22]; + } else { + vErrors.push(err22); + } + errors++; + } + const _errs34 = errors; + let valid17 = true; + const _errs35 = errors; + if (data && typeof data == "object" && !Array.isArray(data)) { + let missing7; + if (data.capabilities === void 0 && (missing7 = "capabilities")) { + const err23 = {}; + if (vErrors === null) { + vErrors = [err23]; + } else { + vErrors.push(err23); + } + errors++; + } else { + if (data.capabilities !== void 0) { + let data11 = data.capabilities; + const _errs36 = errors; + if (errors === _errs36) { + if (Array.isArray(data11)) { + const _errs38 = errors; + const len3 = data11.length; + for (let i3 = 0; i3 < len3; i3++) { + const _errs39 = errors; + if ("submit_evidence_artifact" !== data11[i3]) { + const err24 = {}; + if (vErrors === null) { + vErrors = [err24]; + } else { + vErrors.push(err24); + } + errors++; + } + var valid19 = _errs39 === errors; + if (valid19) { + break; + } + } + if (!valid19) { + const err25 = {}; + if (vErrors === null) { + vErrors = [err25]; + } else { + vErrors.push(err25); + } + errors++; + } else { + errors = _errs38; + if (vErrors !== null) { + if (_errs38) { + vErrors.length = _errs38; + } else { + vErrors = null; + } + } + } + } else { + const err26 = {}; + if (vErrors === null) { + vErrors = [err26]; + } else { + vErrors.push(err26); + } + errors++; + } + } + } + } + } + var _valid3 = _errs35 === errors; + errors = _errs34; + if (vErrors !== null) { + if (_errs34) { + vErrors.length = _errs34; + } else { + vErrors = null; + } + } + if (_valid3) { + const _errs40 = errors; + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.capabilities !== void 0) { + let data13 = data.capabilities; + if (Array.isArray(data13)) { + const _errs43 = errors; + const len4 = data13.length; + for (let i4 = 0; i4 < len4; i4++) { + const _errs44 = errors; + if ("finalize_attempt" !== data13[i4]) { + const err27 = { instancePath: instancePath + "/capabilities/" + i4, schemaPath: "#/allOf/1/then/properties/capabilities/contains/const", keyword: "const", params: { allowedValue: "finalize_attempt" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err27]; + } else { + vErrors.push(err27); + } + errors++; + } + var valid21 = _errs44 === errors; + if (valid21) { + break; + } + } + if (!valid21) { + const err28 = { instancePath: instancePath + "/capabilities", schemaPath: "#/allOf/1/then/properties/capabilities/contains", keyword: "contains", params: { minContains: 1 }, message: "must contain at least 1 valid item(s)" }; + if (vErrors === null) { + vErrors = [err28]; + } else { + vErrors.push(err28); + } + errors++; + } else { + errors = _errs43; + if (vErrors !== null) { + if (_errs43) { + vErrors.length = _errs43; + } else { + vErrors = null; + } + } + } + } else { + const err29 = { instancePath: instancePath + "/capabilities", schemaPath: "#/allOf/1/then/properties/capabilities/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err29]; + } else { + vErrors.push(err29); + } + errors++; + } + } + } + var _valid3 = _errs40 === errors; + valid17 = _valid3; + if (valid17) { + var props1 = {}; + props1.capabilities = true; + } + } + if (!valid17) { + const err30 = { instancePath, schemaPath: "#/allOf/1/if", keyword: "if", params: { failingKeyword: "then" }, message: 'must match "then" schema' }; + if (vErrors === null) { + vErrors = [err30]; + } else { + vErrors.push(err30); + } + errors++; + } + if (props0 !== true && props1 !== void 0) { + if (props1 === true) { + props0 = true; + } else { + props0 = props0 || {}; + Object.assign(props0, props1); + } + } + const _errs46 = errors; + let valid22 = true; + const _errs47 = errors; + if (data && typeof data == "object" && !Array.isArray(data)) { + let missing8; + if (data.grading === void 0 && (missing8 = "grading")) { + const err31 = {}; + if (vErrors === null) { + vErrors = [err31]; + } else { + vErrors.push(err31); + } + errors++; + } else { + if (data.grading !== void 0) { + let data15 = data.grading; + const _errs48 = errors; + if (errors === _errs48) { + if (data15 && typeof data15 == "object" && !Array.isArray(data15)) { + let missing9; + if (data15.mode === void 0 && (missing9 = "mode")) { + const err32 = {}; + if (vErrors === null) { + vErrors = [err32]; + } else { + vErrors.push(err32); + } + errors++; + } else { + if (data15.mode !== void 0) { + if ("sandbox" !== data15.mode) { + const err33 = {}; + if (vErrors === null) { + vErrors = [err33]; + } else { + vErrors.push(err33); + } + errors++; + } + } + } + } else { + const err34 = {}; + if (vErrors === null) { + vErrors = [err34]; + } else { + vErrors.push(err34); + } + errors++; + } + } + } + } + } + var _valid4 = _errs47 === errors; + errors = _errs46; + if (vErrors !== null) { + if (_errs46) { + vErrors.length = _errs46; + } else { + vErrors = null; + } + } + if (_valid4) { + const _errs51 = errors; + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.authoring === void 0) { + const err35 = { instancePath, schemaPath: "#/allOf/2/then/required", keyword: "required", params: { missingProperty: "authoring" }, message: "must have required property 'authoring'" }; + if (vErrors === null) { + vErrors = [err35]; + } else { + vErrors.push(err35); + } + errors++; + } + if (data.authoring !== void 0) { + let data17 = data.authoring; + if (data17 && typeof data17 == "object" && !Array.isArray(data17)) { + if (data17.kind === void 0) { + const err36 = { instancePath: instancePath + "/authoring", schemaPath: "#/allOf/2/then/properties/authoring/required", keyword: "required", params: { missingProperty: "kind" }, message: "must have required property 'kind'" }; + if (vErrors === null) { + vErrors = [err36]; + } else { + vErrors.push(err36); + } + errors++; + } + if (data17.kind !== void 0) { + if ("python_function" !== data17.kind) { + const err37 = { instancePath: instancePath + "/authoring/kind", schemaPath: "#/allOf/2/then/properties/authoring/properties/kind/const", keyword: "const", params: { allowedValue: "python_function" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err37]; + } else { + vErrors.push(err37); + } + errors++; + } + } + } else { + const err38 = { instancePath: instancePath + "/authoring", schemaPath: "#/allOf/2/then/properties/authoring/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err38]; + } else { + vErrors.push(err38); + } + errors++; + } + } + if (data.capabilities !== void 0) { + let data19 = data.capabilities; + if (Array.isArray(data19)) { + const _errs57 = errors; + const len5 = data19.length; + for (let i5 = 0; i5 < len5; i5++) { + const _errs58 = errors; + if ("run_code_submission" !== data19[i5]) { + const err39 = { instancePath: instancePath + "/capabilities/" + i5, schemaPath: "#/allOf/2/then/properties/capabilities/contains/const", keyword: "const", params: { allowedValue: "run_code_submission" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err39]; + } else { + vErrors.push(err39); + } + errors++; + } + var valid27 = _errs58 === errors; + if (valid27) { + break; + } + } + if (!valid27) { + const err40 = { instancePath: instancePath + "/capabilities", schemaPath: "#/allOf/2/then/properties/capabilities/contains", keyword: "contains", params: { minContains: 1 }, message: "must contain at least 1 valid item(s)" }; + if (vErrors === null) { + vErrors = [err40]; + } else { + vErrors.push(err40); + } + errors++; + } else { + errors = _errs57; + if (vErrors !== null) { + if (_errs57) { + vErrors.length = _errs57; + } else { + vErrors = null; + } + } + } + } else { + const err41 = { instancePath: instancePath + "/capabilities", schemaPath: "#/allOf/2/then/properties/capabilities/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err41]; + } else { + vErrors.push(err41); + } + errors++; + } + } + } + var _valid4 = _errs51 === errors; + valid22 = _valid4; + if (valid22) { + var props2 = {}; + props2.authoring = true; + props2.capabilities = true; + props2.grading = true; + } + } + if (!valid22) { + const err42 = { instancePath, schemaPath: "#/allOf/2/if", keyword: "if", params: { failingKeyword: "then" }, message: 'must match "then" schema' }; + if (vErrors === null) { + vErrors = [err42]; + } else { + vErrors.push(err42); + } + errors++; + } + if (props0 !== true && props2 !== void 0) { + if (props2 === true) { + props0 = true; + } else { + props0 = props0 || {}; + Object.assign(props0, props2); + } + } + const _errs60 = errors; + let valid28 = true; + const _errs61 = errors; + if (data && typeof data == "object" && !Array.isArray(data)) { + let missing10; + if (data.grading === void 0 && (missing10 = "grading")) { + const err43 = {}; + if (vErrors === null) { + vErrors = [err43]; + } else { + vErrors.push(err43); + } + errors++; + } else { + if (data.grading !== void 0) { + let data21 = data.grading; + const _errs62 = errors; + if (errors === _errs62) { + if (data21 && typeof data21 == "object" && !Array.isArray(data21)) { + let missing11; + if (data21.mode === void 0 && (missing11 = "mode")) { + const err44 = {}; + if (vErrors === null) { + vErrors = [err44]; + } else { + vErrors.push(err44); + } + errors++; + } else { + if (data21.mode !== void 0) { + if ("browser" !== data21.mode) { + const err45 = {}; + if (vErrors === null) { + vErrors = [err45]; + } else { + vErrors.push(err45); + } + errors++; + } + } + } + } else { + const err46 = {}; + if (vErrors === null) { + vErrors = [err46]; + } else { + vErrors.push(err46); + } + errors++; + } + } + } + } + } + var _valid5 = _errs61 === errors; + errors = _errs60; + if (vErrors !== null) { + if (_errs60) { + vErrors.length = _errs60; + } else { + vErrors = null; + } + } + if (_valid5) { + const _errs65 = errors; + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.authoring === void 0) { + const err47 = { instancePath, schemaPath: "#/allOf/3/then/required", keyword: "required", params: { missingProperty: "authoring" }, message: "must have required property 'authoring'" }; + if (vErrors === null) { + vErrors = [err47]; + } else { + vErrors.push(err47); + } + errors++; + } + if (data.authoring !== void 0) { + let data23 = data.authoring; + if (data23 && typeof data23 == "object" && !Array.isArray(data23)) { + if (data23.kind === void 0) { + const err48 = { instancePath: instancePath + "/authoring", schemaPath: "#/allOf/3/then/properties/authoring/required", keyword: "required", params: { missingProperty: "kind" }, message: "must have required property 'kind'" }; + if (vErrors === null) { + vErrors = [err48]; + } else { + vErrors.push(err48); + } + errors++; + } + if (data23.grader_spec_files === void 0) { + const err49 = { instancePath: instancePath + "/authoring", schemaPath: "#/allOf/3/then/properties/authoring/required", keyword: "required", params: { missingProperty: "grader_spec_files" }, message: "must have required property 'grader_spec_files'" }; + if (vErrors === null) { + vErrors = [err49]; + } else { + vErrors.push(err49); + } + errors++; + } + if (data23.evidence_example_file === void 0) { + const err50 = { instancePath: instancePath + "/authoring", schemaPath: "#/allOf/3/then/properties/authoring/required", keyword: "required", params: { missingProperty: "evidence_example_file" }, message: "must have required property 'evidence_example_file'" }; + if (vErrors === null) { + vErrors = [err50]; + } else { + vErrors.push(err50); + } + errors++; + } + if (data23.kind !== void 0) { + if ("browser_autograder" !== data23.kind) { + const err51 = { instancePath: instancePath + "/authoring/kind", schemaPath: "#/allOf/3/then/properties/authoring/properties/kind/const", keyword: "const", params: { allowedValue: "browser_autograder" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err51]; + } else { + vErrors.push(err51); + } + errors++; + } + } + if (data23.grader_spec_files !== void 0) { + let data25 = data23.grader_spec_files; + if (Array.isArray(data25)) { + if (data25.length < 1) { + const err52 = { instancePath: instancePath + "/authoring/grader_spec_files", schemaPath: "#/allOf/3/then/properties/authoring/properties/grader_spec_files/minItems", keyword: "minItems", params: { limit: 1 }, message: "must NOT have fewer than 1 items" }; + if (vErrors === null) { + vErrors = [err52]; + } else { + vErrors.push(err52); + } + errors++; + } + const len6 = data25.length; + for (let i6 = 0; i6 < len6; i6++) { + let data26 = data25[i6]; + if (typeof data26 === "string") { + if (!pattern4.test(data26)) { + const err53 = { instancePath: instancePath + "/authoring/grader_spec_files/" + i6, schemaPath: "#/allOf/3/then/properties/authoring/properties/grader_spec_files/items/pattern", keyword: "pattern", params: { pattern: "^/grading/.+\\.js$" }, message: 'must match pattern "^/grading/.+\\.js$"' }; + if (vErrors === null) { + vErrors = [err53]; + } else { + vErrors.push(err53); + } + errors++; + } + } else { + const err54 = { instancePath: instancePath + "/authoring/grader_spec_files/" + i6, schemaPath: "#/allOf/3/then/properties/authoring/properties/grader_spec_files/items/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err54]; + } else { + vErrors.push(err54); + } + errors++; + } + } + let i7 = data25.length; + let j0; + if (i7 > 1) { + const indices0 = {}; + for (; i7--; ) { + let item0 = data25[i7]; + if (typeof item0 !== "string") { + continue; + } + if (typeof indices0[item0] == "number") { + j0 = indices0[item0]; + const err55 = { instancePath: instancePath + "/authoring/grader_spec_files", schemaPath: "#/allOf/3/then/properties/authoring/properties/grader_spec_files/uniqueItems", keyword: "uniqueItems", params: { i: i7, j: j0 }, message: "must NOT have duplicate items (items ## " + j0 + " and " + i7 + " are identical)" }; + if (vErrors === null) { + vErrors = [err55]; + } else { + vErrors.push(err55); + } + errors++; + break; + } + indices0[item0] = i7; + } + } + } else { + const err56 = { instancePath: instancePath + "/authoring/grader_spec_files", schemaPath: "#/allOf/3/then/properties/authoring/properties/grader_spec_files/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err56]; + } else { + vErrors.push(err56); + } + errors++; + } + } + if (data23.evidence_example_file !== void 0) { + let data27 = data23.evidence_example_file; + if (typeof data27 === "string") { + if (!pattern5.test(data27)) { + const err57 = { instancePath: instancePath + "/authoring/evidence_example_file", schemaPath: "#/allOf/3/then/properties/authoring/properties/evidence_example_file/pattern", keyword: "pattern", params: { pattern: "^/evidence/.+\\.json$" }, message: 'must match pattern "^/evidence/.+\\.json$"' }; + if (vErrors === null) { + vErrors = [err57]; + } else { + vErrors.push(err57); + } + errors++; + } + } else { + const err58 = { instancePath: instancePath + "/authoring/evidence_example_file", schemaPath: "#/allOf/3/then/properties/authoring/properties/evidence_example_file/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err58]; + } else { + vErrors.push(err58); + } + errors++; + } + } + } else { + const err59 = { instancePath: instancePath + "/authoring", schemaPath: "#/allOf/3/then/properties/authoring/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err59]; + } else { + vErrors.push(err59); + } + errors++; + } + } + } + var _valid5 = _errs65 === errors; + valid28 = _valid5; + if (valid28) { + var props3 = {}; + props3.authoring = true; + props3.grading = true; + } + } + if (!valid28) { + const err60 = { instancePath, schemaPath: "#/allOf/3/if", keyword: "if", params: { failingKeyword: "then" }, message: 'must match "then" schema' }; + if (vErrors === null) { + vErrors = [err60]; + } else { + vErrors.push(err60); + } + errors++; + } + if (props0 !== true && props3 !== void 0) { + if (props3 === true) { + props0 = true; + } else { + props0 = props0 || {}; + Object.assign(props0, props3); + } + } + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.schema_version === void 0) { + const err61 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "schema_version" }, message: "must have required property 'schema_version'" }; + if (vErrors === null) { + vErrors = [err61]; + } else { + vErrors.push(err61); + } + errors++; + } + if (data.app_id === void 0) { + const err62 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "app_id" }, message: "must have required property 'app_id'" }; + if (vErrors === null) { + vErrors = [err62]; + } else { + vErrors.push(err62); + } + errors++; + } + if (data.version === void 0) { + const err63 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "version" }, message: "must have required property 'version'" }; + if (vErrors === null) { + vErrors = [err63]; + } else { + vErrors.push(err63); + } + errors++; + } + if (data.title === void 0) { + const err64 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "title" }, message: "must have required property 'title'" }; + if (vErrors === null) { + vErrors = [err64]; + } else { + vErrors.push(err64); + } + errors++; + } + if (data.owner === void 0) { + const err65 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "owner" }, message: "must have required property 'owner'" }; + if (vErrors === null) { + vErrors = [err65]; + } else { + vErrors.push(err65); + } + errors++; + } + if (data.runtime === void 0) { + const err66 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "runtime" }, message: "must have required property 'runtime'" }; + if (vErrors === null) { + vErrors = [err66]; + } else { + vErrors.push(err66); + } + errors++; + } + if (data.capabilities === void 0) { + const err67 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "capabilities" }, message: "must have required property 'capabilities'" }; + if (vErrors === null) { + vErrors = [err67]; + } else { + vErrors.push(err67); + } + errors++; + } + if (data.roles === void 0) { + const err68 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "roles" }, message: "must have required property 'roles'" }; + if (vErrors === null) { + vErrors = [err68]; + } else { + vErrors.push(err68); + } + errors++; + } + if (data.grading === void 0) { + const err69 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "grading" }, message: "must have required property 'grading'" }; + if (vErrors === null) { + vErrors = [err69]; + } else { + vErrors.push(err69); + } + errors++; + } + for (const key0 in data) { + if (!func1.call(schema31.properties, key0)) { + const err70 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err70]; + } else { + vErrors.push(err70); + } + errors++; + } + } + if (data.schema_version !== void 0) { + if ("2" !== data.schema_version) { + const err71 = { instancePath: instancePath + "/schema_version", schemaPath: "#/properties/schema_version/const", keyword: "const", params: { allowedValue: "2" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err71]; + } else { + vErrors.push(err71); + } + errors++; + } + } + if (data.app_id !== void 0) { + let data29 = data.app_id; + if (typeof data29 === "string") { + if (func2(data29) > 64) { + const err72 = { instancePath: instancePath + "/app_id", schemaPath: "#/properties/app_id/maxLength", keyword: "maxLength", params: { limit: 64 }, message: "must NOT have more than 64 characters" }; + if (vErrors === null) { + vErrors = [err72]; + } else { + vErrors.push(err72); + } + errors++; + } + if (func2(data29) < 3) { + const err73 = { instancePath: instancePath + "/app_id", schemaPath: "#/properties/app_id/minLength", keyword: "minLength", params: { limit: 3 }, message: "must NOT have fewer than 3 characters" }; + if (vErrors === null) { + vErrors = [err73]; + } else { + vErrors.push(err73); + } + errors++; + } + if (!pattern6.test(data29)) { + const err74 = { instancePath: instancePath + "/app_id", schemaPath: "#/properties/app_id/pattern", keyword: "pattern", params: { pattern: "^[a-z0-9][a-z0-9-]*$" }, message: 'must match pattern "^[a-z0-9][a-z0-9-]*$"' }; + if (vErrors === null) { + vErrors = [err74]; + } else { + vErrors.push(err74); + } + errors++; + } + } else { + const err75 = { instancePath: instancePath + "/app_id", schemaPath: "#/properties/app_id/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err75]; + } else { + vErrors.push(err75); + } + errors++; + } + } + if (data.version !== void 0) { + let data30 = data.version; + if (typeof data30 === "string") { + if (!pattern7.test(data30)) { + const err76 = { instancePath: instancePath + "/version", schemaPath: "#/properties/version/pattern", keyword: "pattern", params: { pattern: "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$" }, message: 'must match pattern "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$"' }; + if (vErrors === null) { + vErrors = [err76]; + } else { + vErrors.push(err76); + } + errors++; + } + } else { + const err77 = { instancePath: instancePath + "/version", schemaPath: "#/properties/version/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err77]; + } else { + vErrors.push(err77); + } + errors++; + } + } + if (data.title !== void 0) { + let data31 = data.title; + if (typeof data31 === "string") { + if (func2(data31) > 120) { + const err78 = { instancePath: instancePath + "/title", schemaPath: "#/properties/title/maxLength", keyword: "maxLength", params: { limit: 120 }, message: "must NOT have more than 120 characters" }; + if (vErrors === null) { + vErrors = [err78]; + } else { + vErrors.push(err78); + } + errors++; + } + if (func2(data31) < 1) { + const err79 = { instancePath: instancePath + "/title", schemaPath: "#/properties/title/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err79]; + } else { + vErrors.push(err79); + } + errors++; + } + } else { + const err80 = { instancePath: instancePath + "/title", schemaPath: "#/properties/title/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err80]; + } else { + vErrors.push(err80); + } + errors++; + } + } + if (data.description !== void 0) { + let data32 = data.description; + if (typeof data32 === "string") { + if (func2(data32) > 500) { + const err81 = { instancePath: instancePath + "/description", schemaPath: "#/properties/description/maxLength", keyword: "maxLength", params: { limit: 500 }, message: "must NOT have more than 500 characters" }; + if (vErrors === null) { + vErrors = [err81]; + } else { + vErrors.push(err81); + } + errors++; + } + } else { + const err82 = { instancePath: instancePath + "/description", schemaPath: "#/properties/description/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err82]; + } else { + vErrors.push(err82); + } + errors++; + } + } + if (data.owner !== void 0) { + let data33 = data.owner; + if (data33 && typeof data33 == "object" && !Array.isArray(data33)) { + if (data33.type === void 0) { + const err83 = { instancePath: instancePath + "/owner", schemaPath: "#/properties/owner/required", keyword: "required", params: { missingProperty: "type" }, message: "must have required property 'type'" }; + if (vErrors === null) { + vErrors = [err83]; + } else { + vErrors.push(err83); + } + errors++; + } + if (data33.id === void 0) { + const err84 = { instancePath: instancePath + "/owner", schemaPath: "#/properties/owner/required", keyword: "required", params: { missingProperty: "id" }, message: "must have required property 'id'" }; + if (vErrors === null) { + vErrors = [err84]; + } else { + vErrors.push(err84); + } + errors++; + } + for (const key1 in data33) { + if (!(key1 === "type" || key1 === "id")) { + const err85 = { instancePath: instancePath + "/owner", schemaPath: "#/properties/owner/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key1 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err85]; + } else { + vErrors.push(err85); + } + errors++; + } + } + if (data33.type !== void 0) { + if ("user" !== data33.type) { + const err86 = { instancePath: instancePath + "/owner/type", schemaPath: "#/properties/owner/properties/type/const", keyword: "const", params: { allowedValue: "user" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err86]; + } else { + vErrors.push(err86); + } + errors++; + } + } + if (data33.id !== void 0) { + let data35 = data33.id; + if (typeof data35 === "string") { + if (func2(data35) > 128) { + const err87 = { instancePath: instancePath + "/owner/id", schemaPath: "#/properties/owner/properties/id/maxLength", keyword: "maxLength", params: { limit: 128 }, message: "must NOT have more than 128 characters" }; + if (vErrors === null) { + vErrors = [err87]; + } else { + vErrors.push(err87); + } + errors++; + } + if (func2(data35) < 1) { + const err88 = { instancePath: instancePath + "/owner/id", schemaPath: "#/properties/owner/properties/id/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err88]; + } else { + vErrors.push(err88); + } + errors++; + } + } else { + const err89 = { instancePath: instancePath + "/owner/id", schemaPath: "#/properties/owner/properties/id/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err89]; + } else { + vErrors.push(err89); + } + errors++; + } + } + } else { + const err90 = { instancePath: instancePath + "/owner", schemaPath: "#/properties/owner/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err90]; + } else { + vErrors.push(err90); + } + errors++; + } + } + if (data.runtime !== void 0) { + let data36 = data.runtime; + const _errs92 = errors; + let valid38 = false; + let passing0 = null; + const _errs93 = errors; + if (data36 && typeof data36 == "object" && !Array.isArray(data36)) { + if (data36.kind === void 0) { + const err91 = { instancePath: instancePath + "/runtime", schemaPath: "#/properties/runtime/oneOf/0/required", keyword: "required", params: { missingProperty: "kind" }, message: "must have required property 'kind'" }; + if (vErrors === null) { + vErrors = [err91]; + } else { + vErrors.push(err91); + } + errors++; + } + if (data36.entrypoint === void 0) { + const err92 = { instancePath: instancePath + "/runtime", schemaPath: "#/properties/runtime/oneOf/0/required", keyword: "required", params: { missingProperty: "entrypoint" }, message: "must have required property 'entrypoint'" }; + if (vErrors === null) { + vErrors = [err92]; + } else { + vErrors.push(err92); + } + errors++; + } + for (const key2 in data36) { + if (!(key2 === "kind" || key2 === "entrypoint")) { + const err93 = { instancePath: instancePath + "/runtime", schemaPath: "#/properties/runtime/oneOf/0/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key2 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err93]; + } else { + vErrors.push(err93); + } + errors++; + } + } + if (data36.kind !== void 0) { + if ("browser" !== data36.kind) { + const err94 = { instancePath: instancePath + "/runtime/kind", schemaPath: "#/properties/runtime/oneOf/0/properties/kind/const", keyword: "const", params: { allowedValue: "browser" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err94]; + } else { + vErrors.push(err94); + } + errors++; + } + } + if (data36.entrypoint !== void 0) { + let data38 = data36.entrypoint; + if (typeof data38 === "string") { + if (!pattern8.test(data38)) { + const err95 = { instancePath: instancePath + "/runtime/entrypoint", schemaPath: "#/properties/runtime/oneOf/0/properties/entrypoint/pattern", keyword: "pattern", params: { pattern: "^/dist/.+\\.html$" }, message: 'must match pattern "^/dist/.+\\.html$"' }; + if (vErrors === null) { + vErrors = [err95]; + } else { + vErrors.push(err95); + } + errors++; + } + } else { + const err96 = { instancePath: instancePath + "/runtime/entrypoint", schemaPath: "#/properties/runtime/oneOf/0/properties/entrypoint/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err96]; + } else { + vErrors.push(err96); + } + errors++; + } + } + } else { + const err97 = { instancePath: instancePath + "/runtime", schemaPath: "#/properties/runtime/oneOf/0/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err97]; + } else { + vErrors.push(err97); + } + errors++; + } + var _valid6 = _errs93 === errors; + if (_valid6) { + valid38 = true; + passing0 = 0; + var props4 = true; + } + const _errs99 = errors; + if (data36 && typeof data36 == "object" && !Array.isArray(data36)) { + if (data36.kind === void 0) { + const err98 = { instancePath: instancePath + "/runtime", schemaPath: "#/properties/runtime/oneOf/1/required", keyword: "required", params: { missingProperty: "kind" }, message: "must have required property 'kind'" }; + if (vErrors === null) { + vErrors = [err98]; + } else { + vErrors.push(err98); + } + errors++; + } + if (data36.entrypoint === void 0) { + const err99 = { instancePath: instancePath + "/runtime", schemaPath: "#/properties/runtime/oneOf/1/required", keyword: "required", params: { missingProperty: "entrypoint" }, message: "must have required property 'entrypoint'" }; + if (vErrors === null) { + vErrors = [err99]; + } else { + vErrors.push(err99); + } + errors++; + } + for (const key3 in data36) { + if (!(key3 === "kind" || key3 === "entrypoint")) { + const err100 = { instancePath: instancePath + "/runtime", schemaPath: "#/properties/runtime/oneOf/1/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key3 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err100]; + } else { + vErrors.push(err100); + } + errors++; + } + } + if (data36.kind !== void 0) { + if ("worker" !== data36.kind) { + const err101 = { instancePath: instancePath + "/runtime/kind", schemaPath: "#/properties/runtime/oneOf/1/properties/kind/const", keyword: "const", params: { allowedValue: "worker" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err101]; + } else { + vErrors.push(err101); + } + errors++; + } + } + if (data36.entrypoint !== void 0) { + let data40 = data36.entrypoint; + if (typeof data40 === "string") { + if (!pattern9.test(data40)) { + const err102 = { instancePath: instancePath + "/runtime/entrypoint", schemaPath: "#/properties/runtime/oneOf/1/properties/entrypoint/pattern", keyword: "pattern", params: { pattern: "^/dist/.+\\.js$" }, message: 'must match pattern "^/dist/.+\\.js$"' }; + if (vErrors === null) { + vErrors = [err102]; + } else { + vErrors.push(err102); + } + errors++; + } + } else { + const err103 = { instancePath: instancePath + "/runtime/entrypoint", schemaPath: "#/properties/runtime/oneOf/1/properties/entrypoint/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err103]; + } else { + vErrors.push(err103); + } + errors++; + } + } + } else { + const err104 = { instancePath: instancePath + "/runtime", schemaPath: "#/properties/runtime/oneOf/1/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err104]; + } else { + vErrors.push(err104); + } + errors++; + } + var _valid6 = _errs99 === errors; + if (_valid6 && valid38) { + valid38 = false; + passing0 = [passing0, 1]; + } else { + if (_valid6) { + valid38 = true; + passing0 = 1; + if (props4 !== true) { + props4 = true; + } + } + } + if (!valid38) { + const err105 = { instancePath: instancePath + "/runtime", schemaPath: "#/properties/runtime/oneOf", keyword: "oneOf", params: { passingSchemas: passing0 }, message: "must match exactly one schema in oneOf" }; + if (vErrors === null) { + vErrors = [err105]; + } else { + vErrors.push(err105); + } + errors++; + } else { + errors = _errs92; + if (vErrors !== null) { + if (_errs92) { + vErrors.length = _errs92; + } else { + vErrors = null; + } + } + } + } + if (data.roles !== void 0) { + let data41 = data.roles; + if (Array.isArray(data41)) { + if (data41.length < 1) { + const err106 = { instancePath: instancePath + "/roles", schemaPath: "#/properties/roles/minItems", keyword: "minItems", params: { limit: 1 }, message: "must NOT have fewer than 1 items" }; + if (vErrors === null) { + vErrors = [err106]; + } else { + vErrors.push(err106); + } + errors++; + } + const len7 = data41.length; + for (let i8 = 0; i8 < len7; i8++) { + let data42 = data41[i8]; + if (!(data42 === "learner" || data42 === "instructor")) { + const err107 = { instancePath: instancePath + "/roles/" + i8, schemaPath: "#/properties/roles/items/enum", keyword: "enum", params: { allowedValues: schema31.properties.roles.items.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err107]; + } else { + vErrors.push(err107); + } + errors++; + } + } + let i9 = data41.length; + let j1; + if (i9 > 1) { + outer0: for (; i9--; ) { + for (j1 = i9; j1--; ) { + if (func0(data41[i9], data41[j1])) { + const err108 = { instancePath: instancePath + "/roles", schemaPath: "#/properties/roles/uniqueItems", keyword: "uniqueItems", params: { i: i9, j: j1 }, message: "must NOT have duplicate items (items ## " + j1 + " and " + i9 + " are identical)" }; + if (vErrors === null) { + vErrors = [err108]; + } else { + vErrors.push(err108); + } + errors++; + break outer0; + } + } + } + } + } else { + const err109 = { instancePath: instancePath + "/roles", schemaPath: "#/properties/roles/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err109]; + } else { + vErrors.push(err109); + } + errors++; + } + } + if (data.install_scope !== void 0) { + let data43 = data.install_scope; + if (!(data43 === "course" || data43 === "assignment")) { + const err110 = { instancePath: instancePath + "/install_scope", schemaPath: "#/properties/install_scope/enum", keyword: "enum", params: { allowedValues: schema31.properties.install_scope.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err110]; + } else { + vErrors.push(err110); + } + errors++; + } + } + if (data.capabilities !== void 0) { + let data44 = data.capabilities; + if (Array.isArray(data44)) { + if (data44.length < 1) { + const err111 = { instancePath: instancePath + "/capabilities", schemaPath: "#/properties/capabilities/minItems", keyword: "minItems", params: { limit: 1 }, message: "must NOT have fewer than 1 items" }; + if (vErrors === null) { + vErrors = [err111]; + } else { + vErrors.push(err111); + } + errors++; + } + const len8 = data44.length; + for (let i10 = 0; i10 < len8; i10++) { + let data45 = data44[i10]; + if (!(data45 === "read_launch_context" || data45 === "read_activity_content" || data45 === "submit_attempt_event" || data45 === "submit_evidence_artifact" || data45 === "finalize_attempt" || data45 === "run_code_submission" || data45 === "read_local_state" || data45 === "write_local_state" || data45 === "lms.roster.members.read" || data45 === "lms.roster.contact.read" || data45 === "lms.roster.profile_photo.read")) { + const err112 = { instancePath: instancePath + "/capabilities/" + i10, schemaPath: "#/properties/capabilities/items/enum", keyword: "enum", params: { allowedValues: schema31.properties.capabilities.items.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err112]; + } else { + vErrors.push(err112); + } + errors++; + } + } + let i11 = data44.length; + let j2; + if (i11 > 1) { + outer1: for (; i11--; ) { + for (j2 = i11; j2--; ) { + if (func0(data44[i11], data44[j2])) { + const err113 = { instancePath: instancePath + "/capabilities", schemaPath: "#/properties/capabilities/uniqueItems", keyword: "uniqueItems", params: { i: i11, j: j2 }, message: "must NOT have duplicate items (items ## " + j2 + " and " + i11 + " are identical)" }; + if (vErrors === null) { + vErrors = [err113]; + } else { + vErrors.push(err113); + } + errors++; + break outer1; + } + } + } + } + } else { + const err114 = { instancePath: instancePath + "/capabilities", schemaPath: "#/properties/capabilities/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err114]; + } else { + vErrors.push(err114); + } + errors++; + } + } + if (data.grading !== void 0) { + let data46 = data.grading; + const _errs115 = errors; + let valid48 = true; + const _errs116 = errors; + if (data46 && typeof data46 == "object" && !Array.isArray(data46)) { + let missing12; + if (data46.mode === void 0 && (missing12 = "mode")) { + const err115 = {}; + if (vErrors === null) { + vErrors = [err115]; + } else { + vErrors.push(err115); + } + errors++; + } else { + if (data46.mode !== void 0) { + if ("declarative" !== data46.mode) { + const err116 = {}; + if (vErrors === null) { + vErrors = [err116]; + } else { + vErrors.push(err116); + } + errors++; + } + } + } + } + var _valid7 = _errs116 === errors; + errors = _errs115; + if (vErrors !== null) { + if (_errs115) { + vErrors.length = _errs115; + } else { + vErrors = null; + } + } + if (_valid7) { + const _errs118 = errors; + if (data46 && typeof data46 == "object" && !Array.isArray(data46)) { + if (data46.rubric_file === void 0) { + const err117 = { instancePath: instancePath + "/grading", schemaPath: "#/properties/grading/allOf/0/then/required", keyword: "required", params: { missingProperty: "rubric_file" }, message: "must have required property 'rubric_file'" }; + if (vErrors === null) { + vErrors = [err117]; + } else { + vErrors.push(err117); + } + errors++; + } + if (data46.max_score === void 0) { + const err118 = { instancePath: instancePath + "/grading", schemaPath: "#/properties/grading/allOf/0/then/required", keyword: "required", params: { missingProperty: "max_score" }, message: "must have required property 'max_score'" }; + if (vErrors === null) { + vErrors = [err118]; + } else { + vErrors.push(err118); + } + errors++; + } + } + var _valid7 = _errs118 === errors; + valid48 = _valid7; + } + if (!valid48) { + const err119 = { instancePath: instancePath + "/grading", schemaPath: "#/properties/grading/allOf/0/if", keyword: "if", params: { failingKeyword: "then" }, message: 'must match "then" schema' }; + if (vErrors === null) { + vErrors = [err119]; + } else { + vErrors.push(err119); + } + errors++; + } + const _errs120 = errors; + let valid50 = true; + const _errs121 = errors; + if (data46 && typeof data46 == "object" && !Array.isArray(data46)) { + let missing13; + if (data46.mode === void 0 && (missing13 = "mode")) { + const err120 = {}; + if (vErrors === null) { + vErrors = [err120]; + } else { + vErrors.push(err120); + } + errors++; + } else { + if (data46.mode !== void 0) { + if ("browser" !== data46.mode) { + const err121 = {}; + if (vErrors === null) { + vErrors = [err121]; + } else { + vErrors.push(err121); + } + errors++; + } + } + } + } + var _valid8 = _errs121 === errors; + errors = _errs120; + if (vErrors !== null) { + if (_errs120) { + vErrors.length = _errs120; + } else { + vErrors = null; + } + } + if (_valid8) { + const _errs123 = errors; + if (data46 && typeof data46 == "object" && !Array.isArray(data46)) { + if (data46.max_score === void 0) { + const err122 = { instancePath: instancePath + "/grading", schemaPath: "#/properties/grading/allOf/1/then/required", keyword: "required", params: { missingProperty: "max_score" }, message: "must have required property 'max_score'" }; + if (vErrors === null) { + vErrors = [err122]; + } else { + vErrors.push(err122); + } + errors++; + } + } + var _valid8 = _errs123 === errors; + valid50 = _valid8; + } + if (!valid50) { + const err123 = { instancePath: instancePath + "/grading", schemaPath: "#/properties/grading/allOf/1/if", keyword: "if", params: { failingKeyword: "then" }, message: 'must match "then" schema' }; + if (vErrors === null) { + vErrors = [err123]; + } else { + vErrors.push(err123); + } + errors++; + } + const _errs125 = errors; + let valid52 = true; + const _errs126 = errors; + if (data46 && typeof data46 == "object" && !Array.isArray(data46)) { + let missing14; + if (data46.mode === void 0 && (missing14 = "mode")) { + const err124 = {}; + if (vErrors === null) { + vErrors = [err124]; + } else { + vErrors.push(err124); + } + errors++; + } else { + if (data46.mode !== void 0) { + if ("sandbox" !== data46.mode) { + const err125 = {}; + if (vErrors === null) { + vErrors = [err125]; + } else { + vErrors.push(err125); + } + errors++; + } + } + } + } + var _valid9 = _errs126 === errors; + errors = _errs125; + if (vErrors !== null) { + if (_errs125) { + vErrors.length = _errs125; + } else { + vErrors = null; + } + } + if (_valid9) { + const _errs128 = errors; + if (data46 && typeof data46 == "object" && !Array.isArray(data46)) { + if (data46.max_score === void 0) { + const err126 = { instancePath: instancePath + "/grading", schemaPath: "#/properties/grading/allOf/2/then/required", keyword: "required", params: { missingProperty: "max_score" }, message: "must have required property 'max_score'" }; + if (vErrors === null) { + vErrors = [err126]; + } else { + vErrors.push(err126); + } + errors++; + } + if (data46.max_score !== void 0) { + if (10 !== data46.max_score) { + const err127 = { instancePath: instancePath + "/grading/max_score", schemaPath: "#/properties/grading/allOf/2/then/properties/max_score/const", keyword: "const", params: { allowedValue: 10 }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err127]; + } else { + vErrors.push(err127); + } + errors++; + } + } + } + var _valid9 = _errs128 === errors; + valid52 = _valid9; + if (valid52) { + var props5 = {}; + props5.max_score = true; + props5.mode = true; + } + } + if (!valid52) { + const err128 = { instancePath: instancePath + "/grading", schemaPath: "#/properties/grading/allOf/2/if", keyword: "if", params: { failingKeyword: "then" }, message: 'must match "then" schema' }; + if (vErrors === null) { + vErrors = [err128]; + } else { + vErrors.push(err128); + } + errors++; + } + if (props5 !== true) { + props5 = props5 || {}; + props5.mode = true; + } + if (data46 && typeof data46 == "object" && !Array.isArray(data46)) { + if (data46.mode === void 0) { + const err129 = { instancePath: instancePath + "/grading", schemaPath: "#/properties/grading/required", keyword: "required", params: { missingProperty: "mode" }, message: "must have required property 'mode'" }; + if (vErrors === null) { + vErrors = [err129]; + } else { + vErrors.push(err129); + } + errors++; + } + for (const key4 in data46) { + if (!(key4 === "mode" || key4 === "rubric_file" || key4 === "max_score")) { + const err130 = { instancePath: instancePath + "/grading", schemaPath: "#/properties/grading/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key4 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err130]; + } else { + vErrors.push(err130); + } + errors++; + } + } + if (data46.mode !== void 0) { + let data51 = data46.mode; + if (!(data51 === "declarative" || data51 === "manual" || data51 === "completion" || data51 === "browser" || data51 === "sandbox")) { + const err131 = { instancePath: instancePath + "/grading/mode", schemaPath: "#/properties/grading/properties/mode/enum", keyword: "enum", params: { allowedValues: schema31.properties.grading.properties.mode.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err131]; + } else { + vErrors.push(err131); + } + errors++; + } + } + if (data46.rubric_file !== void 0) { + let data52 = data46.rubric_file; + if (typeof data52 === "string") { + if (!pattern10.test(data52)) { + const err132 = { instancePath: instancePath + "/grading/rubric_file", schemaPath: "#/properties/grading/properties/rubric_file/pattern", keyword: "pattern", params: { pattern: "^/scoring/.+\\.json$" }, message: 'must match pattern "^/scoring/.+\\.json$"' }; + if (vErrors === null) { + vErrors = [err132]; + } else { + vErrors.push(err132); + } + errors++; + } + } else { + const err133 = { instancePath: instancePath + "/grading/rubric_file", schemaPath: "#/properties/grading/properties/rubric_file/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err133]; + } else { + vErrors.push(err133); + } + errors++; + } + } + if (data46.max_score !== void 0) { + let data53 = data46.max_score; + if (typeof data53 == "number" && isFinite(data53)) { + if (data53 < 0 || isNaN(data53)) { + const err134 = { instancePath: instancePath + "/grading/max_score", schemaPath: "#/properties/grading/properties/max_score/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }; + if (vErrors === null) { + vErrors = [err134]; + } else { + vErrors.push(err134); + } + errors++; + } + } else { + const err135 = { instancePath: instancePath + "/grading/max_score", schemaPath: "#/properties/grading/properties/max_score/type", keyword: "type", params: { type: "number" }, message: "must be number" }; + if (vErrors === null) { + vErrors = [err135]; + } else { + vErrors.push(err135); + } + errors++; + } + } + } else { + const err136 = { instancePath: instancePath + "/grading", schemaPath: "#/properties/grading/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err136]; + } else { + vErrors.push(err136); + } + errors++; + } + } + if (data.browser !== void 0) { + let data54 = data.browser; + if (data54 && typeof data54 == "object" && !Array.isArray(data54)) { + for (const key5 in data54) { + if (!(key5 === "fullscreen" || key5 === "clipboard_write")) { + const err137 = { instancePath: instancePath + "/browser", schemaPath: "#/properties/browser/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key5 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err137]; + } else { + vErrors.push(err137); + } + errors++; + } + } + if (data54.fullscreen !== void 0) { + if (typeof data54.fullscreen !== "boolean") { + const err138 = { instancePath: instancePath + "/browser/fullscreen", schemaPath: "#/properties/browser/properties/fullscreen/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }; + if (vErrors === null) { + vErrors = [err138]; + } else { + vErrors.push(err138); + } + errors++; + } + } + if (data54.clipboard_write !== void 0) { + if (typeof data54.clipboard_write !== "boolean") { + const err139 = { instancePath: instancePath + "/browser/clipboard_write", schemaPath: "#/properties/browser/properties/clipboard_write/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }; + if (vErrors === null) { + vErrors = [err139]; + } else { + vErrors.push(err139); + } + errors++; + } + } + } else { + const err140 = { instancePath: instancePath + "/browser", schemaPath: "#/properties/browser/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err140]; + } else { + vErrors.push(err140); + } + errors++; + } + } + if (data.content_files !== void 0) { + let data57 = data.content_files; + if (Array.isArray(data57)) { + const len9 = data57.length; + for (let i12 = 0; i12 < len9; i12++) { + let data58 = data57[i12]; + if (typeof data58 === "string") { + if (!pattern11.test(data58)) { + const err141 = { instancePath: instancePath + "/content_files/" + i12, schemaPath: "#/properties/content_files/items/pattern", keyword: "pattern", params: { pattern: "^/content/.+" }, message: 'must match pattern "^/content/.+"' }; + if (vErrors === null) { + vErrors = [err141]; + } else { + vErrors.push(err141); + } + errors++; + } + } else { + const err142 = { instancePath: instancePath + "/content_files/" + i12, schemaPath: "#/properties/content_files/items/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err142]; + } else { + vErrors.push(err142); + } + errors++; + } + } + let i13 = data57.length; + let j3; + if (i13 > 1) { + const indices1 = {}; + for (; i13--; ) { + let item1 = data57[i13]; + if (typeof item1 !== "string") { + continue; + } + if (typeof indices1[item1] == "number") { + j3 = indices1[item1]; + const err143 = { instancePath: instancePath + "/content_files", schemaPath: "#/properties/content_files/uniqueItems", keyword: "uniqueItems", params: { i: i13, j: j3 }, message: "must NOT have duplicate items (items ## " + j3 + " and " + i13 + " are identical)" }; + if (vErrors === null) { + vErrors = [err143]; + } else { + vErrors.push(err143); + } + errors++; + break; + } + indices1[item1] = i13; + } + } + } else { + const err144 = { instancePath: instancePath + "/content_files", schemaPath: "#/properties/content_files/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err144]; + } else { + vErrors.push(err144); + } + errors++; + } + } + if (data.preview !== void 0) { + let data59 = data.preview; + if (data59 && typeof data59 == "object" && !Array.isArray(data59)) { + if (data59.fixtures_file === void 0) { + const err145 = { instancePath: instancePath + "/preview", schemaPath: "#/properties/preview/required", keyword: "required", params: { missingProperty: "fixtures_file" }, message: "must have required property 'fixtures_file'" }; + if (vErrors === null) { + vErrors = [err145]; + } else { + vErrors.push(err145); + } + errors++; + } + if (data59.tests_file === void 0) { + const err146 = { instancePath: instancePath + "/preview", schemaPath: "#/properties/preview/required", keyword: "required", params: { missingProperty: "tests_file" }, message: "must have required property 'tests_file'" }; + if (vErrors === null) { + vErrors = [err146]; + } else { + vErrors.push(err146); + } + errors++; + } + for (const key6 in data59) { + if (!(key6 === "fixtures_file" || key6 === "tests_file")) { + const err147 = { instancePath: instancePath + "/preview", schemaPath: "#/properties/preview/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key6 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err147]; + } else { + vErrors.push(err147); + } + errors++; + } + } + if (data59.fixtures_file !== void 0) { + let data60 = data59.fixtures_file; + if (typeof data60 === "string") { + if (!pattern12.test(data60)) { + const err148 = { instancePath: instancePath + "/preview/fixtures_file", schemaPath: "#/properties/preview/properties/fixtures_file/pattern", keyword: "pattern", params: { pattern: "^/preview/.+\\.json$" }, message: 'must match pattern "^/preview/.+\\.json$"' }; + if (vErrors === null) { + vErrors = [err148]; + } else { + vErrors.push(err148); + } + errors++; + } + } else { + const err149 = { instancePath: instancePath + "/preview/fixtures_file", schemaPath: "#/properties/preview/properties/fixtures_file/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err149]; + } else { + vErrors.push(err149); + } + errors++; + } + } + if (data59.tests_file !== void 0) { + let data61 = data59.tests_file; + if (typeof data61 === "string") { + if (!pattern12.test(data61)) { + const err150 = { instancePath: instancePath + "/preview/tests_file", schemaPath: "#/properties/preview/properties/tests_file/pattern", keyword: "pattern", params: { pattern: "^/preview/.+\\.json$" }, message: 'must match pattern "^/preview/.+\\.json$"' }; + if (vErrors === null) { + vErrors = [err150]; + } else { + vErrors.push(err150); + } + errors++; + } + } else { + const err151 = { instancePath: instancePath + "/preview/tests_file", schemaPath: "#/properties/preview/properties/tests_file/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err151]; + } else { + vErrors.push(err151); + } + errors++; + } + } + } else { + const err152 = { instancePath: instancePath + "/preview", schemaPath: "#/properties/preview/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err152]; + } else { + vErrors.push(err152); + } + errors++; + } + } + if (data.authoring !== void 0) { + let data62 = data.authoring; + if (data62 && typeof data62 == "object" && !Array.isArray(data62)) { + if (data62.kind === void 0) { + const err153 = { instancePath: instancePath + "/authoring", schemaPath: "#/properties/authoring/required", keyword: "required", params: { missingProperty: "kind" }, message: "must have required property 'kind'" }; + if (vErrors === null) { + vErrors = [err153]; + } else { + vErrors.push(err153); + } + errors++; + } + for (const key7 in data62) { + if (!(key7 === "kind" || key7 === "grader_spec_files" || key7 === "evidence_example_file")) { + const err154 = { instancePath: instancePath + "/authoring", schemaPath: "#/properties/authoring/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key7 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err154]; + } else { + vErrors.push(err154); + } + errors++; + } + } + if (data62.kind !== void 0) { + let data63 = data62.kind; + if (!(data63 === "browser_autograder" || data63 === "python_function")) { + const err155 = { instancePath: instancePath + "/authoring/kind", schemaPath: "#/properties/authoring/properties/kind/enum", keyword: "enum", params: { allowedValues: schema31.properties.authoring.properties.kind.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err155]; + } else { + vErrors.push(err155); + } + errors++; + } + } + if (data62.grader_spec_files !== void 0) { + let data64 = data62.grader_spec_files; + if (Array.isArray(data64)) { + if (data64.length < 1) { + const err156 = { instancePath: instancePath + "/authoring/grader_spec_files", schemaPath: "#/properties/authoring/properties/grader_spec_files/minItems", keyword: "minItems", params: { limit: 1 }, message: "must NOT have fewer than 1 items" }; + if (vErrors === null) { + vErrors = [err156]; + } else { + vErrors.push(err156); + } + errors++; + } + const len10 = data64.length; + for (let i14 = 0; i14 < len10; i14++) { + let data65 = data64[i14]; + if (typeof data65 === "string") { + if (!pattern4.test(data65)) { + const err157 = { instancePath: instancePath + "/authoring/grader_spec_files/" + i14, schemaPath: "#/properties/authoring/properties/grader_spec_files/items/pattern", keyword: "pattern", params: { pattern: "^/grading/.+\\.js$" }, message: 'must match pattern "^/grading/.+\\.js$"' }; + if (vErrors === null) { + vErrors = [err157]; + } else { + vErrors.push(err157); + } + errors++; + } + } else { + const err158 = { instancePath: instancePath + "/authoring/grader_spec_files/" + i14, schemaPath: "#/properties/authoring/properties/grader_spec_files/items/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err158]; + } else { + vErrors.push(err158); + } + errors++; + } + } + let i15 = data64.length; + let j4; + if (i15 > 1) { + const indices2 = {}; + for (; i15--; ) { + let item2 = data64[i15]; + if (typeof item2 !== "string") { + continue; + } + if (typeof indices2[item2] == "number") { + j4 = indices2[item2]; + const err159 = { instancePath: instancePath + "/authoring/grader_spec_files", schemaPath: "#/properties/authoring/properties/grader_spec_files/uniqueItems", keyword: "uniqueItems", params: { i: i15, j: j4 }, message: "must NOT have duplicate items (items ## " + j4 + " and " + i15 + " are identical)" }; + if (vErrors === null) { + vErrors = [err159]; + } else { + vErrors.push(err159); + } + errors++; + break; + } + indices2[item2] = i15; + } + } + } else { + const err160 = { instancePath: instancePath + "/authoring/grader_spec_files", schemaPath: "#/properties/authoring/properties/grader_spec_files/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err160]; + } else { + vErrors.push(err160); + } + errors++; + } + } + if (data62.evidence_example_file !== void 0) { + let data66 = data62.evidence_example_file; + if (typeof data66 === "string") { + if (!pattern5.test(data66)) { + const err161 = { instancePath: instancePath + "/authoring/evidence_example_file", schemaPath: "#/properties/authoring/properties/evidence_example_file/pattern", keyword: "pattern", params: { pattern: "^/evidence/.+\\.json$" }, message: 'must match pattern "^/evidence/.+\\.json$"' }; + if (vErrors === null) { + vErrors = [err161]; + } else { + vErrors.push(err161); + } + errors++; + } + } else { + const err162 = { instancePath: instancePath + "/authoring/evidence_example_file", schemaPath: "#/properties/authoring/properties/evidence_example_file/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err162]; + } else { + vErrors.push(err162); + } + errors++; + } + } + } else { + const err163 = { instancePath: instancePath + "/authoring", schemaPath: "#/properties/authoring/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err163]; + } else { + vErrors.push(err163); + } + errors++; + } + } + if (data.icon !== void 0) { + let data67 = data.icon; + if (typeof data67 === "string") { + if (!pattern16.test(data67)) { + const err164 = { instancePath: instancePath + "/icon", schemaPath: "#/properties/icon/pattern", keyword: "pattern", params: { pattern: "^/.+" }, message: 'must match pattern "^/.+"' }; + if (vErrors === null) { + vErrors = [err164]; + } else { + vErrors.push(err164); + } + errors++; + } + } else { + const err165 = { instancePath: instancePath + "/icon", schemaPath: "#/properties/icon/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err165]; + } else { + vErrors.push(err165); + } + errors++; + } + } + } else { + const err166 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err166]; + } else { + vErrors.push(err166); + } + errors++; + } + validate20.errors = vErrors; + return errors === 0; +} +validate20.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +var reviewedRubricValidator = validate21; +function validate21(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + let vErrors = null; + let errors = 0; + const evaluated0 = validate21.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.mode === void 0) { + const err0 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "mode" }, message: "must have required property 'mode'" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } + if (data.max_score === void 0) { + const err1 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "max_score" }, message: "must have required property 'max_score'" }; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } + if (data.rules === void 0) { + const err2 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "rules" }, message: "must have required property 'rules'" }; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + for (const key0 in data) { + if (!(key0 === "mode" || key0 === "max_score" || key0 === "rules")) { + const err3 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + } + if (data.mode !== void 0) { + if ("per-answer" !== data.mode) { + const err4 = { instancePath: instancePath + "/mode", schemaPath: "#/properties/mode/const", keyword: "const", params: { allowedValue: "per-answer" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + } + if (data.max_score !== void 0) { + let data1 = data.max_score; + if (!(typeof data1 == "number" && (!(data1 % 1) && !isNaN(data1)) && isFinite(data1))) { + const err5 = { instancePath: instancePath + "/max_score", schemaPath: "#/properties/max_score/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + if (typeof data1 == "number" && isFinite(data1)) { + if (data1 < 0 || isNaN(data1)) { + const err6 = { instancePath: instancePath + "/max_score", schemaPath: "#/properties/max_score/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }; + if (vErrors === null) { + vErrors = [err6]; + } else { + vErrors.push(err6); + } + errors++; + } + } + } + if (data.rules !== void 0) { + let data2 = data.rules; + if (Array.isArray(data2)) { + if (data2.length < 1) { + const err7 = { instancePath: instancePath + "/rules", schemaPath: "#/properties/rules/minItems", keyword: "minItems", params: { limit: 1 }, message: "must NOT have fewer than 1 items" }; + if (vErrors === null) { + vErrors = [err7]; + } else { + vErrors.push(err7); + } + errors++; + } + const len0 = data2.length; + for (let i0 = 0; i0 < len0; i0++) { + let data3 = data2[i0]; + if (data3 && typeof data3 == "object" && !Array.isArray(data3)) { + if (data3.question_id === void 0) { + const err8 = { instancePath: instancePath + "/rules/" + i0, schemaPath: "#/properties/rules/items/required", keyword: "required", params: { missingProperty: "question_id" }, message: "must have required property 'question_id'" }; + if (vErrors === null) { + vErrors = [err8]; + } else { + vErrors.push(err8); + } + errors++; + } + if (data3.correct_answer === void 0) { + const err9 = { instancePath: instancePath + "/rules/" + i0, schemaPath: "#/properties/rules/items/required", keyword: "required", params: { missingProperty: "correct_answer" }, message: "must have required property 'correct_answer'" }; + if (vErrors === null) { + vErrors = [err9]; + } else { + vErrors.push(err9); + } + errors++; + } + if (data3.points === void 0) { + const err10 = { instancePath: instancePath + "/rules/" + i0, schemaPath: "#/properties/rules/items/required", keyword: "required", params: { missingProperty: "points" }, message: "must have required property 'points'" }; + if (vErrors === null) { + vErrors = [err10]; + } else { + vErrors.push(err10); + } + errors++; + } + for (const key1 in data3) { + if (!(key1 === "question_id" || key1 === "correct_answer" || key1 === "points")) { + const err11 = { instancePath: instancePath + "/rules/" + i0, schemaPath: "#/properties/rules/items/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key1 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err11]; + } else { + vErrors.push(err11); + } + errors++; + } + } + if (data3.question_id !== void 0) { + let data4 = data3.question_id; + if (typeof data4 === "string") { + if (func2(data4) < 1) { + const err12 = { instancePath: instancePath + "/rules/" + i0 + "/question_id", schemaPath: "#/properties/rules/items/properties/question_id/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err12]; + } else { + vErrors.push(err12); + } + errors++; + } + } else { + const err13 = { instancePath: instancePath + "/rules/" + i0 + "/question_id", schemaPath: "#/properties/rules/items/properties/question_id/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err13]; + } else { + vErrors.push(err13); + } + errors++; + } + } + if (data3.correct_answer !== void 0) { + let data5 = data3.correct_answer; + const _errs13 = errors; + let valid4 = false; + const _errs14 = errors; + if (typeof data5 === "string") { + if (func2(data5) < 1) { + const err14 = { instancePath: instancePath + "/rules/" + i0 + "/correct_answer", schemaPath: "#/properties/rules/items/properties/correct_answer/anyOf/0/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err14]; + } else { + vErrors.push(err14); + } + errors++; + } + } else { + const err15 = { instancePath: instancePath + "/rules/" + i0 + "/correct_answer", schemaPath: "#/properties/rules/items/properties/correct_answer/anyOf/0/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err15]; + } else { + vErrors.push(err15); + } + errors++; + } + var _valid0 = _errs14 === errors; + valid4 = valid4 || _valid0; + const _errs16 = errors; + if (Array.isArray(data5)) { + if (data5.length < 1) { + const err16 = { instancePath: instancePath + "/rules/" + i0 + "/correct_answer", schemaPath: "#/properties/rules/items/properties/correct_answer/anyOf/1/minItems", keyword: "minItems", params: { limit: 1 }, message: "must NOT have fewer than 1 items" }; + if (vErrors === null) { + vErrors = [err16]; + } else { + vErrors.push(err16); + } + errors++; + } + const len1 = data5.length; + for (let i1 = 0; i1 < len1; i1++) { + let data6 = data5[i1]; + if (typeof data6 === "string") { + if (func2(data6) < 1) { + const err17 = { instancePath: instancePath + "/rules/" + i0 + "/correct_answer/" + i1, schemaPath: "#/properties/rules/items/properties/correct_answer/anyOf/1/items/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters" }; + if (vErrors === null) { + vErrors = [err17]; + } else { + vErrors.push(err17); + } + errors++; + } + } else { + const err18 = { instancePath: instancePath + "/rules/" + i0 + "/correct_answer/" + i1, schemaPath: "#/properties/rules/items/properties/correct_answer/anyOf/1/items/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err18]; + } else { + vErrors.push(err18); + } + errors++; + } + } + } else { + const err19 = { instancePath: instancePath + "/rules/" + i0 + "/correct_answer", schemaPath: "#/properties/rules/items/properties/correct_answer/anyOf/1/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err19]; + } else { + vErrors.push(err19); + } + errors++; + } + var _valid0 = _errs16 === errors; + valid4 = valid4 || _valid0; + if (!valid4) { + const err20 = { instancePath: instancePath + "/rules/" + i0 + "/correct_answer", schemaPath: "#/properties/rules/items/properties/correct_answer/anyOf", keyword: "anyOf", params: {}, message: "must match a schema in anyOf" }; + if (vErrors === null) { + vErrors = [err20]; + } else { + vErrors.push(err20); + } + errors++; + } else { + errors = _errs13; + if (vErrors !== null) { + if (_errs13) { + vErrors.length = _errs13; + } else { + vErrors = null; + } + } + } + } + if (data3.points !== void 0) { + let data7 = data3.points; + if (!(typeof data7 == "number" && (!(data7 % 1) && !isNaN(data7)) && isFinite(data7))) { + const err21 = { instancePath: instancePath + "/rules/" + i0 + "/points", schemaPath: "#/properties/rules/items/properties/points/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err21]; + } else { + vErrors.push(err21); + } + errors++; + } + if (typeof data7 == "number" && isFinite(data7)) { + if (data7 < 0 || isNaN(data7)) { + const err22 = { instancePath: instancePath + "/rules/" + i0 + "/points", schemaPath: "#/properties/rules/items/properties/points/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }; + if (vErrors === null) { + vErrors = [err22]; + } else { + vErrors.push(err22); + } + errors++; + } + } + } + } else { + const err23 = { instancePath: instancePath + "/rules/" + i0, schemaPath: "#/properties/rules/items/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err23]; + } else { + vErrors.push(err23); + } + errors++; + } + } + } else { + const err24 = { instancePath: instancePath + "/rules", schemaPath: "#/properties/rules/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + if (vErrors === null) { + vErrors = [err24]; + } else { + vErrors.push(err24); + } + errors++; + } + } + } else { + const err25 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err25]; + } else { + vErrors.push(err25); + } + errors++; + } + validate21.errors = vErrors; + return errors === 0; +} +validate21.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +export { + manifestValidator, + reviewedRubricValidator +}; diff --git a/src/grading/rubric.ts b/src/grading/rubric.ts index 4b9868f..0795054 100644 --- a/src/grading/rubric.ts +++ b/src/grading/rubric.ts @@ -1,66 +1,9 @@ -import { Ajv2020, type ErrorObject, type ValidateFunction } from '@ajv2020'; +import type { ErrorObject } from 'ajv/dist/2020.js'; +import { reviewedRubricValidator } from '../generated/schema_validators.mjs'; import { normalizeSnapshotPath, toRelativeSnapshotPath } from '../package_review/snapshot_path.ts'; import type { RuntimeArtifactStore } from '../runtime/artifact_store.ts'; import type { RawReviewedRubric, ReviewedRubric } from './types.ts'; -const ajv = new Ajv2020({ - allErrors: true, - strict: true, - strictRequired: false, -}); - -const reviewedRubricSchema = { - type: 'object', - additionalProperties: false, - required: ['mode', 'max_score', 'rules'], - properties: { - mode: { const: 'per-answer' }, - max_score: { - type: 'integer', - minimum: 0, - }, - rules: { - type: 'array', - minItems: 1, - items: { - type: 'object', - additionalProperties: false, - required: ['question_id', 'correct_answer', 'points'], - properties: { - question_id: { - type: 'string', - minLength: 1, - }, - correct_answer: { - anyOf: [ - { - type: 'string', - minLength: 1, - }, - { - type: 'array', - minItems: 1, - items: { - type: 'string', - minLength: 1, - }, - }, - ], - }, - points: { - type: 'integer', - minimum: 0, - }, - }, - }, - }, - }, -} as const; - -const reviewedRubricValidator: ValidateFunction = ajv.compile( - reviewedRubricSchema, -); - export async function loadReviewedRubric(input: { snapshotRoot: string; rubricFile: string | null; @@ -120,16 +63,12 @@ function explainReviewedRubricIssues( function mapReviewedRubricIssue(error: ErrorObject): string { if (error.keyword === 'required') { - const missingProperty = String( - (error.params as Record).missingProperty ?? 'field', - ); + const missingProperty = String(error.params.missingProperty ?? 'field'); return `Missing required field ${joinJsonPointer(error.instancePath, missingProperty)}.`; } if (error.keyword === 'additionalProperties') { - const property = String( - (error.params as Record).additionalProperty ?? 'field', - ); + const property = String(error.params.additionalProperty ?? 'field'); return `Unsupported field ${property} at ${displayInstancePath(error.instancePath)}.`; } diff --git a/src/grading/service_test.ts b/src/grading/service_test.ts index e2cc9e3..569a064 100644 --- a/src/grading/service_test.ts +++ b/src/grading/service_test.ts @@ -1,3 +1,7 @@ +import { join } from 'node:path'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { test } from 'vite-plus/test'; +import { tmpdir } from 'node:os'; import { assertEquals, assertRejects, assertThrows } from '@std/assert'; import { buildAttemptEventRecord, @@ -10,7 +14,7 @@ import type { ReviewedRubric } from './service.ts'; const EXAMPLE_SNAPSHOT_ROOT = 'examples/apps/chapter-4-asteroids'; const artifactStore = getDefaultRuntimeArtifactStore(); -Deno.test('loadReviewedRubric loads the reviewed rubric from the pinned snapshot', async () => { +test('loadReviewedRubric loads the reviewed rubric from the pinned snapshot', async () => { const grading = await import(`./${'service.ts'}`); const packageVersion = buildPackageVersionRecord({ artifact: { @@ -27,28 +31,25 @@ Deno.test('loadReviewedRubric loads the reviewed rubric from the pinned snapshot artifactStore, }); - assertEquals( - rubric, - { - mode: 'per-answer', - maxScore: 100, - rules: [ - { - questionId: 'q1', - correctAnswer: 'resistance to a change in motion', - points: 50, - }, - { - questionId: 'q2', - correctAnswer: 'speed with direction', - points: 50, - }, - ], - } satisfies ReviewedRubric, - ); + assertEquals(rubric, { + mode: 'per-answer', + maxScore: 100, + rules: [ + { + questionId: 'q1', + correctAnswer: 'resistance to a change in motion', + points: 50, + }, + { + questionId: 'q2', + correctAnswer: 'speed with direction', + points: 50, + }, + ], + } satisfies ReviewedRubric); }); -Deno.test('loadReviewedRubric rejects malformed or unsupported rubric JSON at the trusted boundary', async () => { +test('loadReviewedRubric rejects malformed or unsupported rubric JSON at the trusted boundary', async () => { const grading = await import(`./${'service.ts'}`); const snapshotRoot = await createSnapshotWithRubric({ mode: 'weighted', @@ -74,11 +75,11 @@ Deno.test('loadReviewedRubric rejects malformed or unsupported rubric JSON at th 'Only rubric mode "per-answer" is supported.', ); } finally { - await Deno.remove(snapshotRoot, { recursive: true }); + await rm(snapshotRoot, { recursive: true }); } }); -Deno.test('scoreAttempt computes the authoritative final declarative score from durable attempt events', async () => { +test('scoreAttempt computes the authoritative final declarative score from durable attempt events', async () => { const grading = await import(`./${'service.ts'}`); const rubric = await grading.loadReviewedRubric({ snapshotRoot: EXAMPLE_SNAPSHOT_ROOT, @@ -157,7 +158,7 @@ Deno.test('scoreAttempt computes the authoritative final declarative score from }); }); -Deno.test('scoreAttempt computes completion grading from the finalized attempt state', async () => { +test('scoreAttempt computes completion grading from the finalized attempt state', async () => { const grading = await import(`./${'service.ts'}`); const result = grading.scoreAttempt({ attempt: buildAttemptRecord({ @@ -177,7 +178,7 @@ Deno.test('scoreAttempt computes completion grading from the finalized attempt s }); }); -Deno.test('scoreAttempt fails clearly for unsupported or malformed grading inputs', async () => { +test('scoreAttempt fails clearly for unsupported or malformed grading inputs', async () => { const grading = await import(`./${'service.ts'}`); assertThrows( @@ -248,10 +249,10 @@ Deno.test('scoreAttempt fails clearly for unsupported or malformed grading input }); async function createSnapshotWithRubric(rubric: unknown): Promise { - const root = await Deno.makeTempDir({ prefix: 'lantern-grading-' }); + const root = await mkdtemp(join(tmpdir(), 'lantern-grading-')); - await Deno.mkdir(`${root}/scoring`, { recursive: true }); - await Deno.writeTextFile(`${root}/scoring/rubric.json`, JSON.stringify(rubric, null, 2)); + await mkdir(`${root}/scoring`, { recursive: true }); + await writeFile(`${root}/scoring/rubric.json`, JSON.stringify(rubric, null, 2)); return root; } diff --git a/src/lms_kernel/admin_context.ts b/src/lms_kernel/admin_context.ts index fbe3eea..9215d08 100644 --- a/src/lms_kernel/admin_context.ts +++ b/src/lms_kernel/admin_context.ts @@ -1,4 +1,4 @@ -import type { Context } from '@hono/hono'; +import type { Context } from 'hono'; import type { InstitutionMembershipRecord, InstitutionRecord } from './types.ts'; const KERNEL_ADMIN_CONTEXT = 'lmsKernelAdminContext'; @@ -21,6 +21,7 @@ export function getKernelAdminContext(context: Context): KernelAdminContext { } function isKernelAdminContext(value: unknown): value is KernelAdminContext { - return typeof value === 'object' && value !== null && - 'institution' in value && 'membership' in value; + return ( + typeof value === 'object' && value !== null && 'institution' in value && 'membership' in value + ); } diff --git a/src/lms_kernel/admin_installation.ts b/src/lms_kernel/admin_installation.ts index fa3270e..d0cba6b 100644 --- a/src/lms_kernel/admin_installation.ts +++ b/src/lms_kernel/admin_installation.ts @@ -37,15 +37,14 @@ export function installApp( ); } const installedAt = deps.now().toISOString(); - const existing = (await deps.repository.listInstallations( - command.membership.institutionId, - )).find((installation) => installation.appId === packageVersion.appId); + const existing = ( + await deps.repository.listInstallations(command.membership.institutionId) + ).find((installation) => installation.appId === packageVersion.appId); const installationId = existing?.id ?? deps.createId(); const event = adminGovernanceEvent({ traceId: deps.createId(), - eventType: existing === undefined - ? 'app_installation.created' - : 'app_installation.version_replaced', + eventType: + existing === undefined ? 'app_installation.created' : 'app_installation.version_replaced', institutionId: command.membership.institutionId, appInstallationId: installationId, appId: packageVersion.appId, @@ -53,24 +52,25 @@ export function installApp( actorId: command.membership.userId, occurredAt: installedAt, }); - const installation = existing === undefined - ? await deps.repository.createInstallation({ - id: installationId, - institutionId: command.membership.institutionId, - appId: packageVersion.appId, - packageVersionId: packageVersion.id, - installedByUserId: command.membership.userId, - now: installedAt, - event, - }) - : await deps.repository.replaceInstallationPackageVersion({ - id: existing.id, - institutionId: command.membership.institutionId, - packageVersionId: packageVersion.id, - installedByUserId: command.membership.userId, - now: installedAt, - event, - }); + const installation = + existing === undefined + ? await deps.repository.createInstallation({ + id: installationId, + institutionId: command.membership.institutionId, + appId: packageVersion.appId, + packageVersionId: packageVersion.id, + installedByUserId: command.membership.userId, + now: installedAt, + event, + }) + : await deps.repository.replaceInstallationPackageVersion({ + id: existing.id, + institutionId: command.membership.institutionId, + packageVersionId: packageVersion.id, + installedByUserId: command.membership.userId, + now: installedAt, + event, + }); if (installation === null) { return failure('not_found', 'App installation was not found.'); } @@ -95,10 +95,7 @@ export function grantCapability( ); } const installation = await deps.repository.getInstallationById(command.installationId); - if ( - installation === null || - installation.institutionId !== command.membership.institutionId - ) { + if (installation === null || installation.institutionId !== command.membership.institutionId) { return failure('not_found', 'App installation was not found.'); } const packageVersion = await deps.packageRepository.getPackageVersionById( @@ -112,10 +109,7 @@ export function grantCapability( } if (command.lmsContextId !== null) { const context = await deps.repository.getLmsContextById(command.lmsContextId); - if ( - context === null || - context.institutionId !== command.membership.institutionId - ) { + if (context === null || context.institutionId !== command.membership.institutionId) { return failure('not_found', 'LMS context was not found.'); } } @@ -125,8 +119,7 @@ export function grantCapability( grant.packageVersionId === installation.packageVersionId && grant.capability === command.capability && grant.scope.kind === scopeKind && - (grant.scope.kind === 'institution' || - grant.scope.lmsContextId === command.lmsContextId), + (grant.scope.kind === 'institution' || grant.scope.lmsContextId === command.lmsContextId), ); const grantId = existingGrant?.id ?? deps.createId(); const approvedAt = deps.now().toISOString(); @@ -136,9 +129,10 @@ export function grantCapability( appInstallationId: installation.id, packageVersionId: installation.packageVersionId, capability: command.capability, - scope: command.lmsContextId === null - ? { kind: 'institution' } - : { kind: 'context', lmsContextId: command.lmsContextId }, + scope: + command.lmsContextId === null + ? { kind: 'institution' } + : { kind: 'context', lmsContextId: command.lmsContextId }, approvedByUserId: command.membership.userId, approvedAt, event: adminGovernanceEvent({ @@ -167,16 +161,10 @@ export function activateInstallation( ): Promise> { return (async () => { if (!canGovern(command.membership)) { - return failure( - 'permission_denied', - 'Only institution owners and admins can activate apps.', - ); + return failure('permission_denied', 'Only institution owners and admins can activate apps.'); } const installation = await deps.repository.getInstallationById(command.installationId); - if ( - installation === null || - installation.institutionId !== command.membership.institutionId - ) { + if (installation === null || installation.institutionId !== command.membership.institutionId) { return failure('not_found', 'App installation was not found.'); } const packageVersion = await deps.packageRepository.getPackageVersionById( @@ -187,12 +175,14 @@ export function activateInstallation( } const required = packageVersion.capabilities.filter(isRosterCapability); const grants = await deps.repository.listCapabilityGrants(installation.id); - const missing = required.filter((capability) => - !grants.some((grant) => - grant.capability === capability && - grant.packageVersionId === installation.packageVersionId && - grant.revokedAt === null - ) + const missing = required.filter( + (capability) => + !grants.some( + (grant) => + grant.capability === capability && + grant.packageVersionId === installation.packageVersionId && + grant.revokedAt === null, + ), ); if (missing.length > 0) { return failure( @@ -230,14 +220,11 @@ export function revokeGrant( ): Promise> { return (async () => { if (!canGovern(command.membership)) { - return failure( - 'permission_denied', - 'Only institution owners and admins can revoke access.', - ); + return failure('permission_denied', 'Only institution owners and admins can revoke access.'); } - const existingGrant = (await deps.repository.listCapabilityGrantsForInstitution( - command.membership.institutionId, - )).find((grant) => grant.id === command.grantId && grant.revokedAt === null); + const existingGrant = ( + await deps.repository.listCapabilityGrantsForInstitution(command.membership.institutionId) + ).find((grant) => grant.id === command.grantId && grant.revokedAt === null); if (existingGrant === undefined) { return failure('not_found', 'Capability grant was not found.'); } @@ -305,15 +292,15 @@ export function createSyntheticLaunch( ) { return failure('not_found', 'The installation package is not runnable.'); } - const connection = (await deps.repository.listLmsConnections( - command.membership.institutionId, - )).find((item) => item.platformType === 'synthetic' && item.status === 'active'); + const connection = ( + await deps.repository.listLmsConnections(command.membership.institutionId) + ).find((item) => item.platformType === 'synthetic' && item.status === 'active'); if (connection === undefined) { return failure('not_found', 'Create an active synthetic LMS connection first.'); } - const context = (await deps.repository.listLmsContexts( - command.membership.institutionId, - )).find((item) => item.lmsConnectionId === connection.id); + const context = (await deps.repository.listLmsContexts(command.membership.institutionId)).find( + (item) => item.lmsConnectionId === connection.id, + ); if (context === undefined) { return failure('not_found', 'The synthetic LMS context was not found.'); } diff --git a/src/lms_kernel/admin_middleware.ts b/src/lms_kernel/admin_middleware.ts index c817bdc..637fb32 100644 --- a/src/lms_kernel/admin_middleware.ts +++ b/src/lms_kernel/admin_middleware.ts @@ -1,4 +1,4 @@ -import type { MiddlewareHandler } from '@hono/hono'; +import type { MiddlewareHandler } from 'hono'; import type { AppServices } from '../app_services.ts'; import { getAdminSession } from '../admin_auth/context.ts'; import { setKernelAdminContext } from './admin_context.ts'; @@ -17,7 +17,7 @@ export function createKernelAdminContextMiddleware( if (memberships.length === 0) { if (path === '/admin/onboarding' || path.startsWith('/admin/invites/')) { await next(); - return; + return undefined; } return context.redirect('/admin/onboarding', 303); } @@ -40,5 +40,6 @@ export function createKernelAdminContextMiddleware( return context.redirect('/admin/governance', 303); } await next(); + return undefined; }; } diff --git a/src/lms_kernel/admin_result.ts b/src/lms_kernel/admin_result.ts index 420c23c..c4766eb 100644 --- a/src/lms_kernel/admin_result.ts +++ b/src/lms_kernel/admin_result.ts @@ -7,16 +7,13 @@ export type KernelAdminFailureCode = export type KernelAdminResult = | { readonly ok: true; readonly value: T } | { - readonly ok: false; - readonly error: { - readonly code: KernelAdminFailureCode; - readonly message: string; + readonly ok: false; + readonly error: { + readonly code: KernelAdminFailureCode; + readonly message: string; + }; }; - }; -export function failure( - code: KernelAdminFailureCode, - message: string, -): KernelAdminResult { +export function failure(code: KernelAdminFailureCode, message: string): KernelAdminResult { return { ok: false, error: { code, message } }; } diff --git a/src/lms_kernel/admin_service.ts b/src/lms_kernel/admin_service.ts index 2f89786..f80a35e 100644 --- a/src/lms_kernel/admin_service.ts +++ b/src/lms_kernel/admin_service.ts @@ -99,7 +99,8 @@ export function createLmsKernelAdminService(input: { repository: input.repository, packageRepository: input.packageRepository, createId: input.createId ?? (() => crypto.randomUUID()), - createSecret: input.createSecret ?? + createSecret: + input.createSecret ?? (() => `${crypto.randomUUID()}${crypto.randomUUID()}`.replaceAll('-', '')), now: input.now ?? (() => new Date()), ...(input.syncLtiToolRegistration === undefined diff --git a/src/lms_kernel/admin_service_test.ts b/src/lms_kernel/admin_service_test.ts index 5a18392..e270512 100644 --- a/src/lms_kernel/admin_service_test.ts +++ b/src/lms_kernel/admin_service_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals } from '@std/assert'; import type { RosterCapability } from '../../sdk/worker-app-sdk.ts'; import { @@ -19,13 +20,10 @@ import type { const NOW = '2026-07-20T15:00:00.000Z'; -Deno.test('kernel administration requires explicit approval for every exact app version', async () => { +test('kernel administration requires explicit approval for every exact app version', async () => { const repository = new AdminMemoryLmsKernelRepository(); const packageRepository = createInMemoryPackageReviewRepository({ - packageVersions: [ - buildPhotoRosterVersion(1, '0.1.0'), - buildPhotoRosterVersion(2, '0.2.0'), - ], + packageVersions: [buildPhotoRosterVersion(1, '0.1.0'), buildPhotoRosterVersion(2, '0.2.0')], }); const service = createLmsKernelAdminService({ repository, @@ -55,12 +53,7 @@ Deno.test('kernel administration requires explicit approval for every exact app }, }); - for ( - const capability of [ - 'lms.roster.members.read', - 'lms.roster.profile_photo.read', - ] as const - ) { + for (const capability of ['lms.roster.members.read', 'lms.roster.profile_photo.read'] as const) { const granted = await service.grantCapability({ membership: ownerMembership(), installationId: installed.value.id, @@ -104,7 +97,7 @@ Deno.test('kernel administration requires explicit approval for every exact app ); }); -Deno.test('auditors cannot mutate governance and context grants cannot cross institutions', async () => { +test('auditors cannot mutate governance and context grants cannot cross institutions', async () => { const repository = new AdminMemoryLmsKernelRepository(); repository.contexts.push({ id: 'foreign-context', @@ -271,9 +264,10 @@ class AdminMemoryLmsKernelRepository extends MemoryLmsKernelRepository { institutionId: string | null, update: (current: AppInstallationRecord) => AppInstallationRecord, ): AppInstallationRecord | null { - const index = this.installations.findIndex((installation) => - installation.id === id && - (institutionId === null || installation.institutionId === institutionId) + const index = this.installations.findIndex( + (installation) => + installation.id === id && + (institutionId === null || installation.institutionId === institutionId), ); if (index < 0) return null; const next = update(this.installations[index]!); @@ -290,10 +284,7 @@ function buildPhotoRosterVersion(id: number, version: string) { approvalStatus: 'approved', reviewedAt: NOW, runtime: { kind: 'worker', entrypoint: '/dist/worker.js' }, - capabilities: [ - 'lms.roster.members.read', - 'lms.roster.profile_photo.read', - ], + capabilities: ['lms.roster.members.read', 'lms.roster.profile_photo.read'], }); } diff --git a/src/lms_kernel/admin_support.ts b/src/lms_kernel/admin_support.ts index 1268e91..041d330 100644 --- a/src/lms_kernel/admin_support.ts +++ b/src/lms_kernel/admin_support.ts @@ -26,8 +26,13 @@ export function isHttpsUrl(value: string): boolean { } export function institutionSlug(name: string, suffix: string): string { - const base = name.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '').slice(0, 48) || 'institution'; + const base = + name + .toLowerCase() + .normalize('NFKD') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 48) || 'institution'; return `${base}-${suffix.replaceAll('-', '').slice(0, 8)}`; } diff --git a/src/lms_kernel/connector.ts b/src/lms_kernel/connector.ts index aad3e6c..1d9e5bd 100644 --- a/src/lms_kernel/connector.ts +++ b/src/lms_kernel/connector.ts @@ -11,9 +11,9 @@ import type { export type RosterConnectorResult = | { readonly ok: true; readonly page: RosterConnectorPage } | { - readonly ok: false; - readonly errorCode: Extract; - }; + readonly ok: false; + readonly errorCode: Extract; + }; export interface RosterConnector { listPage(input: { @@ -54,9 +54,10 @@ export function createNrpsRosterConnector( const message = result.error.message.toLowerCase(); return { ok: false, - errorCode: message.includes('429') || message.includes('rate') - ? 'rate_limited' - : 'upstream_unavailable', + errorCode: + message.includes('429') || message.includes('rate') + ? 'rate_limited' + : 'upstream_unavailable', }; } @@ -82,8 +83,10 @@ export function createNrpsRosterConnector( } function sameStrings(left: readonly string[], right: readonly string[]): boolean { - return left.length === right.length && - [...left].sort().every((value, index) => value === [...right].sort()[index]); + return ( + left.length === right.length && + [...left].sort().every((value, index) => value === [...right].sort()[index]) + ); } export function createSyntheticRosterConnector(): RosterConnector { @@ -137,16 +140,7 @@ function parseSyntheticOffset(value: string | null): number | null { } function buildSyntheticMembers(): RosterConnectorMember[] { - const firstNames = [ - 'Avery', - 'Jordan', - 'Morgan', - 'Riley', - 'Cameron', - 'Taylor', - 'Casey', - 'Quinn', - ]; + const firstNames = ['Avery', 'Jordan', 'Morgan', 'Riley', 'Cameron', 'Taylor', 'Casey', 'Quinn']; const lastNames = ['Allen', 'Brooks', 'Chen', 'Diaz', 'Evans', 'Foster', 'Gupta', 'Hall']; return Array.from({ length: 57 }, (_, index) => { const firstName = firstNames[index % firstNames.length]!; diff --git a/src/lms_kernel/connector_test.ts b/src/lms_kernel/connector_test.ts index 6a8afae..b8bcca7 100644 --- a/src/lms_kernel/connector_test.ts +++ b/src/lms_kernel/connector_test.ts @@ -1,8 +1,9 @@ +import { test } from 'vite-plus/test'; import { assertEquals } from '@std/assert'; import { createSyntheticRosterConnector } from './connector.ts'; import type { GovernedRuntimeSession } from './types.ts'; -Deno.test('synthetic roster connector follows the same opaque page seam as NRPS', async () => { +test('synthetic roster connector follows the same opaque page seam as NRPS', async () => { const connector = createSyntheticRosterConnector(); const first = await connector.listPage({ session: buildSyntheticSession(), diff --git a/src/lms_kernel/context_roles.ts b/src/lms_kernel/context_roles.ts index bc1db19..7b3ef42 100644 --- a/src/lms_kernel/context_roles.ts +++ b/src/lms_kernel/context_roles.ts @@ -2,29 +2,35 @@ import type { RosterRole } from '../../sdk/worker-app-sdk.ts'; const MEMBERSHIP = 'http://purl.imsglobal.org/vocab/lis/v2/membership'; const SUBROLES = new Map([ - ['Administrator', [ + [ 'Administrator', - 'Developer', - 'ExternalDeveloper', - 'ExternalSupport', - 'ExternalSystemAdministrator', - 'Support', - 'SystemAdministrator', - ]], - ['Instructor', [ - 'ExternalInstructor', - 'Grader', - 'GuestInstructor', - 'Lecturer', - 'PrimaryInstructor', - 'SecondaryInstructor', - 'TeachingAssistant', - 'TeachingAssistantGroup', - 'TeachingAssistantOffering', - 'TeachingAssistantSection', - 'TeachingAssistantSectionAssociation', - 'TeachingAssistantTemplate', - ]], + [ + 'Administrator', + 'Developer', + 'ExternalDeveloper', + 'ExternalSupport', + 'ExternalSystemAdministrator', + 'Support', + 'SystemAdministrator', + ], + ], + [ + 'Instructor', + [ + 'ExternalInstructor', + 'Grader', + 'GuestInstructor', + 'Lecturer', + 'PrimaryInstructor', + 'SecondaryInstructor', + 'TeachingAssistant', + 'TeachingAssistantGroup', + 'TeachingAssistantOffering', + 'TeachingAssistantSection', + 'TeachingAssistantSectionAssociation', + 'TeachingAssistantTemplate', + ], + ], ['Learner', ['ExternalLearner', 'GuestLearner', 'Instructor', 'Learner', 'NonCreditLearner']], ]); @@ -37,7 +43,9 @@ export function parseLtiContextRole(value: string): RosterRole { const parts = value.slice(MEMBERSHIP.length + 1).split('#'); const [role, subrole] = parts; if ( - parts.length !== 2 || role === undefined || subrole === undefined || + parts.length !== 2 || + role === undefined || + subrole === undefined || !SUBROLES.get(role)?.includes(subrole) ) { return 'other'; diff --git a/src/lms_kernel/context_roles_test.ts b/src/lms_kernel/context_roles_test.ts index 1ad08d7..bde8f44 100644 --- a/src/lms_kernel/context_roles_test.ts +++ b/src/lms_kernel/context_roles_test.ts @@ -1,10 +1,11 @@ +import { test } from 'vite-plus/test'; import { assertEquals } from '@std/assert'; import { parseLtiContextRole } from './context_roles.ts'; import { resolveGovernedLaunchActorType } from './launch_service.ts'; const MEMBERSHIP = 'http://purl.imsglobal.org/vocab/lis/v2/membership'; -Deno.test('governed launches require exact standard instructor or administrator context roles', () => { +test('governed launches require exact standard instructor or administrator context roles', () => { assertEquals(resolveGovernedLaunchActorType([`${MEMBERSHIP}#Instructor`]), 'instructor'); assertEquals(resolveGovernedLaunchActorType([`${MEMBERSHIP}#Administrator`]), 'administrator'); assertEquals( @@ -24,24 +25,22 @@ Deno.test('governed launches require exact standard instructor or administrator ]), null, ); - for ( - const role of [ - 'Instructor', - 'administrator', - `${MEMBERSHIP}#NotAnInstructor`, - `${MEMBERSHIP}/Instructor#Unknown`, - `${MEMBERSHIP}/toString#Instructor`, - `${MEMBERSHIP}/__proto__#Administrator`, - `${MEMBERSHIP}/Administrator#Unknown`, - `${MEMBERSHIP}/Instructor#TeachingAssistant#Learner`, - `${MEMBERSHIP}#Instructor/anything`, - ] - ) { + for (const role of [ + 'Instructor', + 'administrator', + `${MEMBERSHIP}#NotAnInstructor`, + `${MEMBERSHIP}/Instructor#Unknown`, + `${MEMBERSHIP}/toString#Instructor`, + `${MEMBERSHIP}/__proto__#Administrator`, + `${MEMBERSHIP}/Administrator#Unknown`, + `${MEMBERSHIP}/Instructor#TeachingAssistant#Learner`, + `${MEMBERSHIP}#Instructor/anything`, + ]) { assertEquals(resolveGovernedLaunchActorType([role]), null, role); } }); -Deno.test('roster role normalization respects the principal context role', () => { +test('roster role normalization respects the principal context role', () => { assertEquals(parseLtiContextRole(`${MEMBERSHIP}/Learner#Instructor`), 'learner'); assertEquals(parseLtiContextRole(`${MEMBERSHIP}/Instructor#Grader`), 'instructor'); assertEquals(parseLtiContextRole(`${MEMBERSHIP}#Mentor`), 'other'); diff --git a/src/lms_kernel/d1_test_support.ts b/src/lms_kernel/d1_test_support.ts index daf0508..173190d 100644 --- a/src/lms_kernel/d1_test_support.ts +++ b/src/lms_kernel/d1_test_support.ts @@ -1,28 +1,10 @@ -import { Miniflare } from 'miniflare'; +import { env } from 'cloudflare:workers'; +import { applyD1Migrations, reset } from 'cloudflare:test'; import type { D1Database } from '../db/d1.ts'; -import { runD1Statements } from '../db/d1.ts'; -/** Runs a kernel persistence test against isolated D1 with the actual schema. */ +/** Runs a persistence test against fresh D1 storage with the actual migrations. */ export async function withKernelD1(run: (db: D1Database) => Promise): Promise { - const miniflare = new Miniflare({ - modules: true, - script: 'export default { fetch() { return new Response("ok"); } };', - d1Databases: { DB: crypto.randomUUID() }, - }); - try { - const db: D1Database = await miniflare.getD1Database('DB'); - for ( - const migration of [ - '0001_initial_schema.sql', - '0002_auth.sql', - '0003_python_autograder_lab.sql', - '0004_governed_lms_primitive_kernel.sql', - ] - ) { - await runD1Statements(db, await Deno.readTextFile(`src/db/d1_migrations/${migration}`)); - } - await run(db); - } finally { - await miniflare.dispose(); - } + await reset(); + await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); + await run(env.DB); } diff --git a/src/lms_kernel/governance_events.ts b/src/lms_kernel/governance_events.ts index 466c6ad..760496e 100644 --- a/src/lms_kernel/governance_events.ts +++ b/src/lms_kernel/governance_events.ts @@ -4,9 +4,8 @@ export type GovernanceEventInput = Omit; type RequiredKeys = 'traceId' | 'eventType' | 'actorType' | 'outcome' | 'occurredAt'; -export type GovernanceEventBaseInput = - & Pick - & Partial>; +export type GovernanceEventBaseInput = Pick & + Partial>; export function governanceEventBase(input: GovernanceEventBaseInput): GovernanceEventInput { return { diff --git a/src/lms_kernel/launch_service.ts b/src/lms_kernel/launch_service.ts index b8ee675..53f2289 100644 --- a/src/lms_kernel/launch_service.ts +++ b/src/lms_kernel/launch_service.ts @@ -157,9 +157,10 @@ export interface AcceptResourceLinkLaunchInput { readonly ltiToolSessionId: string | null; readonly nrpsContextMembershipsUrl: string | null; readonly nrpsServiceVersions: readonly string[]; - readonly loadPackageVersion: ( - input: { readonly packageVersionId: number; readonly appId: string }, - ) => Promise; + readonly loadPackageVersion: (input: { + readonly packageVersionId: number; + readonly appId: string; + }) => Promise; readonly createId: () => string; readonly now?: Date; readonly traceId?: string; @@ -169,9 +170,8 @@ export async function acceptResourceLinkLaunch( input: AcceptResourceLinkLaunchInput, ): Promise { const installationId = input.installationId.trim(); - const installation = installationId === '' - ? null - : await input.repository.getInstallationById(installationId); + const installation = + installationId === '' ? null : await input.repository.getInstallationById(installationId); if ( installation === null || installation.institutionId !== input.connection.institutionId || diff --git a/src/lms_kernel/lms_origin.ts b/src/lms_kernel/lms_origin.ts index 36240f8..ae319ec 100644 --- a/src/lms_kernel/lms_origin.ts +++ b/src/lms_kernel/lms_origin.ts @@ -3,9 +3,13 @@ export function parseLmsOrigin(value: string): string | null { try { const url = new URL(value.trim()); if ( - url.protocol !== 'https:' || url.username !== '' || url.password !== '' || + url.protocol !== 'https:' || + url.username !== '' || + url.password !== '' || url.hostname.includes('*') || - url.pathname !== '/' || url.search !== '' || url.hash !== '' + url.pathname !== '/' || + url.search !== '' || + url.hash !== '' ) { return null; } diff --git a/src/lms_kernel/lms_origin_test.ts b/src/lms_kernel/lms_origin_test.ts index b66866b..8f87e8a 100644 --- a/src/lms_kernel/lms_origin_test.ts +++ b/src/lms_kernel/lms_origin_test.ts @@ -1,31 +1,30 @@ +import { test } from 'vite-plus/test'; import { assert, assertEquals } from '@std/assert'; import { parseLmsOrigin } from './lms_origin.ts'; import { parseConnectionFields } from './admin_support.ts'; -Deno.test('LMS embedding origins accept only exact HTTPS origins', () => { +test('LMS embedding origins accept only exact HTTPS origins', () => { assertEquals(parseLmsOrigin(' https://Courses.Example.edu:443/ '), 'https://courses.example.edu'); assertEquals( parseLmsOrigin('https://courses.example.edu:8443'), 'https://courses.example.edu:8443', ); - for ( - const value of [ - '', - '*', - 'https://*.example.edu', - 'http://courses.example.edu', - 'https://courses.example.edu/course', - 'https://courses.example.edu?token=x', - 'https://courses.example.edu/#fragment', - 'https://user:password@courses.example.edu', - 'https://courses.example.edu; frame-ancestors *', - ] - ) { + for (const value of [ + '', + '*', + 'https://*.example.edu', + 'http://courses.example.edu', + 'https://courses.example.edu/course', + 'https://courses.example.edu?token=x', + 'https://courses.example.edu/#fragment', + 'https://user:password@courses.example.edu', + 'https://courses.example.edu; frame-ancestors *', + ]) { assertEquals(parseLmsOrigin(value), null, value); } }); -Deno.test('connection registration requires an explicit course-page origin independent of OIDC hosts', () => { +test('connection registration requires an explicit course-page origin independent of OIDC hosts', () => { const fields = { platformType: 'canvas' as const, issuer: 'https://issuer.example', diff --git a/src/lms_kernel/media.ts b/src/lms_kernel/media.ts index e1574cb..c2a7a67 100644 --- a/src/lms_kernel/media.ts +++ b/src/lms_kernel/media.ts @@ -1,10 +1,7 @@ import type { LmsKernelRepository, ProtectedMediaReference } from './repository.ts'; export interface ProtectedMediaService { - fetch(input: { - readonly mediaId: string; - readonly now: Date; - }): Promise; + fetch(input: { readonly mediaId: string; readonly now: Date }): Promise; } export function createProtectedMediaService(input: { @@ -181,11 +178,17 @@ function matchesRasterSignature(bytes: Uint8Array, contentType: string): boolean case 'image/jpeg': return startsWith([255, 216, 255]); case 'image/gif': - return startsWith([71, 73, 70, 56]) && - (bytes[4] === 55 || bytes[4] === 57) && bytes[5] === 97; + return ( + startsWith([71, 73, 70, 56]) && (bytes[4] === 55 || bytes[4] === 57) && bytes[5] === 97 + ); case 'image/webp': - return startsWith([82, 73, 70, 70]) && - bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80; + return ( + startsWith([82, 73, 70, 70]) && + bytes[8] === 87 && + bytes[9] === 69 && + bytes[10] === 66 && + bytes[11] === 80 + ); default: return false; } diff --git a/src/lms_kernel/media_test.ts b/src/lms_kernel/media_test.ts index 9cb65f7..51f8eb2 100644 --- a/src/lms_kernel/media_test.ts +++ b/src/lms_kernel/media_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertStringIncludes } from '@std/assert'; import { createProtectedMediaService } from './media.ts'; import { @@ -14,7 +15,7 @@ const PNG = Uint8Array.from( (char) => char.charCodeAt(0), ); -Deno.test('protected media revalidates the photo grant and records the shared trace', async () => { +test('protected media revalidates the photo grant and records the shared trace', async () => { const session = buildGovernedSyntheticSession(); const repository = new MemoryLmsKernelRepository({ session, @@ -60,26 +61,23 @@ Deno.test('protected media revalidates the photo grant and records the shared tr assertEquals(repository.events[0]?.capability, 'lms.roster.profile_photo.read'); }); -Deno.test('protected media rejects SVG and non-raster types, including MIME-spoofed SVG', async () => { - for ( - const contentType of [ - 'image/svg+xml', - 'image/svg+xml; charset=UTF-8', - 'text/html', - 'image/bmp', - 'image/png', - 'image/jpeg', - ] - ) { +test('protected media rejects SVG and non-raster types, including MIME-spoofed SVG', async () => { + for (const contentType of [ + 'image/svg+xml', + 'image/svg+xml; charset=UTF-8', + 'text/html', + 'image/bmp', + 'image/png', + 'image/jpeg', + ]) { const repository = await mediaRepository(); const service = createProtectedMediaService({ repository, fetchUpstream: () => Promise.resolve( - new Response( - '', - { headers: { 'content-type': contentType } }, - ), + new Response('', { + headers: { 'content-type': contentType }, + }), ), }); const response = await service.fetch({ mediaId: 'media-1', now: NOW }); @@ -91,7 +89,7 @@ Deno.test('protected media rejects SVG and non-raster types, including MIME-spoo } }); -Deno.test('protected media verifies and preserves raster bytes across upstream stream chunks', async () => { +test('protected media verifies and preserves raster bytes across upstream stream chunks', async () => { const formats = [ { type: 'image/png', bytes: PNG }, { @@ -150,15 +148,17 @@ async function mediaRepository(): Promise { return repository; } -Deno.test('protected media denies a reference after photo permission is revoked', async () => { +test('protected media denies a reference after photo permission is revoked', async () => { const session = buildGovernedSyntheticSession(); const repository = new MemoryLmsKernelRepository({ session, - grants: [{ - ...buildKernelGrant('photo-grant', 'lms.roster.profile_photo.read'), - revokedByUserId: 'admin-1', - revokedAt: '2026-07-19T15:59:00.000Z', - }], + grants: [ + { + ...buildKernelGrant('photo-grant', 'lms.roster.profile_photo.read'), + revokedByUserId: 'admin-1', + revokedAt: '2026-07-19T15:59:00.000Z', + }, + ], }); await repository.saveProtectedMediaReference({ id: 'media-1', diff --git a/src/lms_kernel/parsing.ts b/src/lms_kernel/parsing.ts index 18b2206..2ee80ef 100644 --- a/src/lms_kernel/parsing.ts +++ b/src/lms_kernel/parsing.ts @@ -20,7 +20,8 @@ export function parseRosterListInput(value: unknown): RosterListInputParseResult } if ( - value.cursor !== undefined && (typeof value.cursor !== 'string' || value.cursor.length === 0) + value.cursor !== undefined && + (typeof value.cursor !== 'string' || value.cursor.length === 0) ) { return { ok: false }; } diff --git a/src/lms_kernel/parsing_test.ts b/src/lms_kernel/parsing_test.ts index 665de20..e1c80ae 100644 --- a/src/lms_kernel/parsing_test.ts +++ b/src/lms_kernel/parsing_test.ts @@ -1,7 +1,8 @@ +import { test } from 'vite-plus/test'; import { assertEquals } from '@std/assert'; import { parseRosterListInput } from './parsing.ts'; -Deno.test('roster input parser returns a narrowed immutable command', () => { +test('roster input parser returns a narrowed immutable command', () => { assertEquals(parseRosterListInput({ fields: ['photo', 'email', 'photo'], cursor: 'next' }), { ok: true, value: { @@ -11,7 +12,7 @@ Deno.test('roster input parser returns a narrowed immutable command', () => { }); }); -Deno.test('roster input parser rejects unknown fields, keys, and malformed cursors', () => { +test('roster input parser rejects unknown fields, keys, and malformed cursors', () => { assertEquals(parseRosterListInput({ fields: ['pronouns'] }), { ok: false }); assertEquals(parseRosterListInput({ fields: [], pageSize: 100 }), { ok: false }); assertEquals(parseRosterListInput({ fields: [], cursor: '' }), { ok: false }); diff --git a/src/lms_kernel/policy.ts b/src/lms_kernel/policy.ts index 58d066c..2e6fd55 100644 --- a/src/lms_kernel/policy.ts +++ b/src/lms_kernel/policy.ts @@ -3,21 +3,21 @@ import type { CapabilityGrantRecord, GovernedRuntimeSession } from './types.ts'; export type PrimitiveAuthorizationResult = | { - readonly allowed: true; - readonly grants: readonly CapabilityGrantRecord[]; - } + readonly allowed: true; + readonly grants: readonly CapabilityGrantRecord[]; + } | { - readonly allowed: false; - readonly reason: - | 'session_expired' - | 'institution_inactive' - | 'connection_inactive' - | 'installation_inactive' - | 'actor_not_authorized' - | 'service_unavailable' - | 'manifest_capability_missing' - | 'grant_missing'; - }; + readonly allowed: false; + readonly reason: + | 'session_expired' + | 'institution_inactive' + | 'connection_inactive' + | 'installation_inactive' + | 'actor_not_authorized' + | 'service_unavailable' + | 'manifest_capability_missing' + | 'grant_missing'; + }; export function authorizeRosterRead(input: { readonly session: GovernedRuntimeSession; @@ -54,12 +54,13 @@ export function authorizeRosterRead(input: { return { allowed: false, reason: 'manifest_capability_missing' }; } - const grant = input.grants.find((candidate) => - candidate.capability === capability && - candidate.packageVersionId === input.session.packageVersionId && - candidate.revokedAt === null && - (candidate.scope.kind === 'institution' || - candidate.scope.lmsContextId === input.session.lmsContextId) + const grant = input.grants.find( + (candidate) => + candidate.capability === capability && + candidate.packageVersionId === input.session.packageVersionId && + candidate.revokedAt === null && + (candidate.scope.kind === 'institution' || + candidate.scope.lmsContextId === input.session.lmsContextId), ); if (grant === undefined) { diff --git a/src/lms_kernel/policy_test.ts b/src/lms_kernel/policy_test.ts index 4fc832b..fb09bcc 100644 --- a/src/lms_kernel/policy_test.ts +++ b/src/lms_kernel/policy_test.ts @@ -1,10 +1,11 @@ +import { test } from 'vite-plus/test'; import { assertEquals } from '@std/assert'; import type { CapabilityGrantRecord, GovernedRuntimeSession } from './types.ts'; import { authorizeRosterRead, requiredRosterCapabilities } from './policy.ts'; const NOW = new Date('2026-07-19T16:00:00.000Z'); -Deno.test('roster policy requires the base capability plus separately requested fields', () => { +test('roster policy requires the base capability plus separately requested fields', () => { assertEquals(requiredRosterCapabilities([]), ['lms.roster.members.read']); assertEquals(requiredRosterCapabilities(['email', 'photo']), [ 'lms.roster.members.read', @@ -13,29 +14,24 @@ Deno.test('roster policy requires the base capability plus separately requested ]); }); -Deno.test('roster policy allows exact-version grants scoped to the current context', () => { +test('roster policy allows exact-version grants scoped to the current context', () => { const result = authorizeRosterRead({ session: buildSession(), grants: [ buildGrant('members', 'lms.roster.members.read'), buildGrant('photo', 'lms.roster.profile_photo.read'), ], - requestedCapabilities: [ - 'lms.roster.members.read', - 'lms.roster.profile_photo.read', - ], + requestedCapabilities: ['lms.roster.members.read', 'lms.roster.profile_photo.read'], now: NOW, }); assertEquals(result.allowed, true); }); -Deno.test('roster policy denies a grant from another package version or context', () => { +test('roster policy denies a grant from another package version or context', () => { const wrongVersion = authorizeRosterRead({ session: buildSession(), - grants: [ - { ...buildGrant('members', 'lms.roster.members.read'), packageVersionId: 99 }, - ], + grants: [{ ...buildGrant('members', 'lms.roster.members.read'), packageVersionId: 99 }], requestedCapabilities: ['lms.roster.members.read'], now: NOW, }); @@ -55,7 +51,7 @@ Deno.test('roster policy denies a grant from another package version or context' assertEquals(wrongContext, { allowed: false, reason: 'grant_missing' }); }); -Deno.test('roster policy fails closed for missing NRPS claims and expired sessions', () => { +test('roster policy fails closed for missing NRPS claims and expired sessions', () => { const missingNrps = authorizeRosterRead({ session: { ...buildSession(), diff --git a/src/lms_kernel/repository.ts b/src/lms_kernel/repository.ts index 79f9fbd..403087a 100644 --- a/src/lms_kernel/repository.ts +++ b/src/lms_kernel/repository.ts @@ -58,10 +58,7 @@ export interface InstitutionRepository { ): Promise; getInstitutionById(id: string): Promise; listMembershipsForUser(userId: string): Promise; - getMembership( - institutionId: string, - userId: string, - ): Promise; + getMembership(institutionId: string, userId: string): Promise; createInvite( input: GovernedWrite & { readonly id: string; @@ -219,18 +216,13 @@ export interface GovernedRuntimeRepository { } export interface GovernanceEventRepository { - appendGovernanceEvents( - events: readonly Omit[], - ): Promise; - appendGovernanceEvent( - event: Omit, - ): Promise; + appendGovernanceEvents(events: readonly Omit[]): Promise; + appendGovernanceEvent(event: Omit): Promise; listGovernanceEvents(filter: GovernanceEventFilter): Promise; } -export type LmsKernelRepository = - & InstitutionRepository - & LmsConnectionRepository - & AppInstallationRepository - & GovernedRuntimeRepository - & GovernanceEventRepository; +export type LmsKernelRepository = InstitutionRepository & + LmsConnectionRepository & + AppInstallationRepository & + GovernedRuntimeRepository & + GovernanceEventRepository; diff --git a/src/lms_kernel/repository_d1_connections.ts b/src/lms_kernel/repository_d1_connections.ts index ce142bf..0f1e6a5 100644 --- a/src/lms_kernel/repository_d1_connections.ts +++ b/src/lms_kernel/repository_d1_connections.ts @@ -11,39 +11,38 @@ import { export function createD1LmsConnectionRepository(db: D1Database): LmsConnectionRepository { const repository: LmsConnectionRepository = { async createLmsConnection(input) { - await runD1Batch(db, [{ - sql: ` + await runD1Batch(db, [ + { + sql: ` INSERT INTO lms_connections ( id, institution_id, label, platform_type, issuer, lms_origin, client_id, deployment_id, authorization_endpoint, access_token_url, jwks_url, status, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?) `, - parameters: [ - input.id, - input.institutionId, - input.label, - input.platformType, - input.issuer, - input.lmsOrigin, - input.clientId, - input.deploymentId, - input.authorizationEndpoint, - input.accessTokenUrl, - input.jwksUrl, - input.now, - input.now, - ], - }, governanceEventStatement(input.event)]); + parameters: [ + input.id, + input.institutionId, + input.label, + input.platformType, + input.issuer, + input.lmsOrigin, + input.clientId, + input.deploymentId, + input.authorizationEndpoint, + input.accessTokenUrl, + input.jwksUrl, + input.now, + input.now, + ], + }, + governanceEventStatement(input.event), + ]); return requireConnection(await repository.getLmsConnectionById(input.id), input.id); }, async getLmsConnectionById(id) { - const row = await queryD1First( - db, - `SELECT * FROM lms_connections WHERE id = ?`, - [id], - ); + const row = await queryD1First(db, `SELECT * FROM lms_connections WHERE id = ?`, [id]); return row === null ? null : mapConnection(row); }, @@ -165,11 +164,7 @@ export function createD1LmsConnectionRepository(db: D1Database): LmsConnectionRe }, async getLmsContextById(id) { - const row = await queryD1First( - db, - `SELECT * FROM lms_contexts WHERE id = ?`, - [id], - ); + const row = await queryD1First(db, `SELECT * FROM lms_contexts WHERE id = ?`, [id]); return row === null ? null : mapLmsContext(row); }, }; diff --git a/src/lms_kernel/repository_d1_governance.ts b/src/lms_kernel/repository_d1_governance.ts index b04a15c..0cfaf87 100644 --- a/src/lms_kernel/repository_d1_governance.ts +++ b/src/lms_kernel/repository_d1_governance.ts @@ -54,7 +54,10 @@ export function createD1GovernanceEventRepository(db: D1Database): GovernanceEve return { async appendGovernanceEvents(events) { if (events.length === 0) return; - await runD1Batch(db, events.map((event) => governanceEventStatement(event))); + await runD1Batch( + db, + events.map((event) => governanceEventStatement(event)), + ); }, async appendGovernanceEvent(event) { @@ -102,8 +105,8 @@ export function createD1GovernanceEventRepository(db: D1Database): GovernanceEve SELECT * FROM governance_events WHERE ${clauses.join(' AND ')} ORDER BY ${ - filter.traceId === undefined ? 'occurred_at DESC, id DESC' : 'occurred_at ASC, id ASC' - } + filter.traceId === undefined ? 'occurred_at DESC, id DESC' : 'occurred_at ASC, id ASC' + } LIMIT ? `, parameters, diff --git a/src/lms_kernel/repository_d1_installations.ts b/src/lms_kernel/repository_d1_installations.ts index 7b067f5..b42f513 100644 --- a/src/lms_kernel/repository_d1_installations.ts +++ b/src/lms_kernel/repository_d1_installations.ts @@ -11,23 +11,26 @@ import { export function createD1AppInstallationRepository(db: D1Database): AppInstallationRepository { const repository: AppInstallationRepository = { async createInstallation(input) { - await runD1Batch(db, [{ - sql: ` + await runD1Batch(db, [ + { + sql: ` INSERT INTO app_installations ( id, institution_id, app_id, package_version_id, status, installed_by_user_id, installed_at, updated_at ) VALUES (?, ?, ?, ?, 'pending_approval', ?, ?, ?) `, - parameters: [ - input.id, - input.institutionId, - input.appId, - input.packageVersionId, - input.installedByUserId, - input.now, - input.now, - ], - }, governanceEventStatement(input.event)]); + parameters: [ + input.id, + input.institutionId, + input.appId, + input.packageVersionId, + input.installedByUserId, + input.now, + input.now, + ], + }, + governanceEventStatement(input.event), + ]); return requireInstallation(await repository.getInstallationById(input.id), input.id); }, @@ -114,11 +117,9 @@ export function createD1AppInstallationRepository(db: D1Database): AppInstallati }, async getInstallationById(id) { - const row = await queryD1First( - db, - `SELECT * FROM app_installations WHERE id = ?`, - [id], - ); + const row = await queryD1First(db, `SELECT * FROM app_installations WHERE id = ?`, [ + id, + ]); return row === null ? null : mapInstallation(row); }, @@ -180,8 +181,9 @@ export function createD1AppInstallationRepository(db: D1Database): AppInstallati async createCapabilityGrant(input) { const lmsContextId = input.scope.kind === 'context' ? input.scope.lmsContextId : null; - await runD1Batch(db, [{ - sql: ` + await runD1Batch(db, [ + { + sql: ` INSERT INTO capability_grants ( id, institution_id, app_installation_id, package_version_id, capability, scope_kind, lms_context_id, approved_by_user_id, approved_at @@ -192,18 +194,20 @@ export function createD1AppInstallationRepository(db: D1Database): AppInstallati revoked_by_user_id = NULL, revoked_at = NULL `, - parameters: [ - input.id, - input.institutionId, - input.appInstallationId, - input.packageVersionId, - input.capability, - input.scope.kind, - lmsContextId, - input.approvedByUserId, - input.approvedAt, - ], - }, governanceEventStatement(input.event)]); + parameters: [ + input.id, + input.institutionId, + input.appInstallationId, + input.packageVersionId, + input.capability, + input.scope.kind, + lmsContextId, + input.approvedByUserId, + input.approvedAt, + ], + }, + governanceEventStatement(input.event), + ]); const row = await queryD1First( db, ` diff --git a/src/lms_kernel/repository_d1_institutions.ts b/src/lms_kernel/repository_d1_institutions.ts index 69313f5..7682aae 100644 --- a/src/lms_kernel/repository_d1_institutions.ts +++ b/src/lms_kernel/repository_d1_institutions.ts @@ -34,11 +34,7 @@ export function createD1InstitutionRepository(db: D1Database): InstitutionReposi }, async getInstitutionById(id) { - const row = await queryD1First( - db, - `SELECT * FROM institutions WHERE id = ?`, - [id], - ); + const row = await queryD1First(db, `SELECT * FROM institutions WHERE id = ?`, [id]); return row === null ? null : mapInstitution(row); }, @@ -70,23 +66,26 @@ export function createD1InstitutionRepository(db: D1Database): InstitutionReposi }, async createInvite(input) { - await runD1Batch(db, [{ - sql: ` + await runD1Batch(db, [ + { + sql: ` INSERT INTO institution_invites ( id, institution_id, token_hash, role, created_by_user_id, created_at, expires_at ) VALUES (?, ?, ?, ?, ?, ?, ?) `, - parameters: [ - input.id, - input.institutionId, - input.tokenHash, - input.role, - input.createdByUserId, - input.createdAt, - input.expiresAt, - ], - }, governanceEventStatement(input.event)]); + parameters: [ + input.id, + input.institutionId, + input.tokenHash, + input.role, + input.createdByUserId, + input.createdAt, + input.expiresAt, + ], + }, + governanceEventStatement(input.event), + ]); }, async acceptInvite(input) { diff --git a/src/lms_kernel/repository_d1_integration_test.ts b/src/lms_kernel/repository_d1_integration_test.ts index 4425083..32d2be2 100644 --- a/src/lms_kernel/repository_d1_integration_test.ts +++ b/src/lms_kernel/repository_d1_integration_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertRejects } from '@std/assert'; import type { D1Database } from '../db/d1.ts'; import { runD1 } from '../db/d1.ts'; @@ -7,7 +8,7 @@ import { createD1LmsKernelRepository } from './repository_d1.ts'; const NOW = '2026-07-20T15:00:00.000Z'; -Deno.test('D1 rolls back a governed mutation when its audit event cannot commit', async () => { +test('D1 rolls back a governed mutation when its audit event cannot commit', async () => { await withKernelD1(async (db) => { await seedAuthUser(db, 'owner-1'); const repository = createD1LmsKernelRepository(db); @@ -27,7 +28,7 @@ Deno.test('D1 rolls back a governed mutation when its audit event cannot commit' actorId: 'owner-1', occurredAt: NOW, }), - }) + }), ); assertEquals(await repository.getInstitutionById('institution-rollback'), null); @@ -35,7 +36,7 @@ Deno.test('D1 rolls back a governed mutation when its audit event cannot commit' }); }); -Deno.test('D1 invitation acceptance is one-time and appends its audit event atomically', async () => { +test('D1 invitation acceptance is one-time and appends its audit event atomically', async () => { await withKernelD1(async (db) => { await Promise.all([seedAuthUser(db, 'owner-1'), seedAuthUser(db, 'admin-1')]); const repository = createD1LmsKernelRepository(db); @@ -86,10 +87,12 @@ Deno.test('D1 invitation acceptance is one-time and appends its audit event atom assertEquals(accepted?.role, 'admin'); assertEquals(replay, null); assertEquals( - (await repository.listGovernanceEvents({ - institutionId: 'institution-1', - traceId: 'trace-accepted', - })).length, + ( + await repository.listGovernanceEvents({ + institutionId: 'institution-1', + traceId: 'trace-accepted', + }) + ).length, 1, ); await assertRejects( diff --git a/src/lms_kernel/repository_d1_row.ts b/src/lms_kernel/repository_d1_row.ts index 21d5fdd..52e154a 100644 --- a/src/lms_kernel/repository_d1_row.ts +++ b/src/lms_kernel/repository_d1_row.ts @@ -140,11 +140,7 @@ export function mapConnection(row: D1Row): LmsConnectionRecord { authorizationEndpoint: requireString(row, 'authorization_endpoint'), accessTokenUrl: requireString(row, 'access_token_url'), jwksUrl: requireString(row, 'jwks_url'), - status: requireEnum( - row.status, - ['pending', 'active', 'disabled'], - 'lms_connections.status', - ), + status: requireEnum(row.status, ['pending', 'active', 'disabled'], 'lms_connections.status'), validatedAt: optionalString(row, 'validated_at'), createdAt: requireString(row, 'created_at'), updatedAt: requireString(row, 'updated_at'), @@ -198,9 +194,10 @@ export function mapCapabilityGrant(row: D1Row): CapabilityGrantRecord { appInstallationId: requireString(row, 'app_installation_id'), packageVersionId: requireInteger(row, 'package_version_id'), capability: requireRosterCapability(row.capability), - scope: scopeKind === 'institution' - ? { kind: 'institution' } - : { kind: 'context', lmsContextId: contextId! }, + scope: + scopeKind === 'institution' + ? { kind: 'institution' } + : { kind: 'context', lmsContextId: contextId! }, approvedByUserId: requireString(row, 'approved_by_user_id'), approvedAt: requireString(row, 'approved_at'), revokedByUserId: optionalString(row, 'revoked_by_user_id'), @@ -352,10 +349,7 @@ export function mapGovernanceEvent(row: D1Row): GovernanceEventRecord { }; } -export function requireInstitution( - value: InstitutionRecord | null, - id: string, -): InstitutionRecord { +export function requireInstitution(value: InstitutionRecord | null, id: string): InstitutionRecord { if (value === null) { throw new Error(`Institution ${id} was not found after persistence.`); } diff --git a/src/lms_kernel/repository_d1_runtime.ts b/src/lms_kernel/repository_d1_runtime.ts index 9ac05bc..76bd375 100644 --- a/src/lms_kernel/repository_d1_runtime.ts +++ b/src/lms_kernel/repository_d1_runtime.ts @@ -58,8 +58,9 @@ export function createD1GovernedRuntimeRepository(db: D1Database): GovernedRunti async createGovernedRuntimeSession(input) { const session = input.session; - await runD1Batch(db, [{ - sql: ` + await runD1Batch(db, [ + { + sql: ` INSERT INTO governed_runtime_sessions ( runtime_session_id, runtime_session_token, launch_trace_id, runtime_expires_at, institution_id, lms_connection_id, lms_context_id, app_installation_id, @@ -68,28 +69,30 @@ export function createD1GovernedRuntimeRepository(db: D1Database): GovernedRunti nrps_context_memberships_url, nrps_service_versions, connector_kind, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, - parameters: [ - session.runtimeSessionId, - session.runtimeSessionToken, - session.launchTraceId, - session.runtimeExpiresAt, - session.institutionId, - session.lmsConnectionId, - session.lmsContextId, - session.appInstallationId, - session.appId, - session.packageVersionId, - session.packageVersion, - session.packageCapabilities, - session.actorSubjectId, - session.actorType, - session.ltiToolSessionId, - session.nrpsContextMembershipsUrl, - session.nrpsServiceVersions, - session.connectorKind, - input.createdAt, - ], - }, governanceEventStatement(input.event)]); + parameters: [ + session.runtimeSessionId, + session.runtimeSessionToken, + session.launchTraceId, + session.runtimeExpiresAt, + session.institutionId, + session.lmsConnectionId, + session.lmsContextId, + session.appInstallationId, + session.appId, + session.packageVersionId, + session.packageVersion, + session.packageCapabilities, + session.actorSubjectId, + session.actorType, + session.ltiToolSessionId, + session.nrpsContextMembershipsUrl, + session.nrpsServiceVersions, + session.connectorKind, + input.createdAt, + ], + }, + governanceEventStatement(input.event), + ]); const stored = await repository.getGovernedRuntimeSession({ runtimeSessionId: session.runtimeSessionId, runtimeSessionToken: session.runtimeSessionToken, @@ -157,11 +160,9 @@ export function createD1GovernedRuntimeRepository(db: D1Database): GovernedRunti }, async getRosterContinuation(id) { - const row = await queryD1First( - db, - `SELECT * FROM roster_continuations WHERE id = ?`, - [id], - ); + const row = await queryD1First(db, `SELECT * FROM roster_continuations WHERE id = ?`, [ + id, + ]); return row === null ? null : mapContinuation(row); }, diff --git a/src/lms_kernel/repository_d1_runtime_test.ts b/src/lms_kernel/repository_d1_runtime_test.ts index 7ccadad..4145e99 100644 --- a/src/lms_kernel/repository_d1_runtime_test.ts +++ b/src/lms_kernel/repository_d1_runtime_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assert, assertEquals, assertRejects } from '@std/assert'; import type { D1Database } from '../db/d1.ts'; import { runD1 } from '../db/d1.ts'; @@ -26,7 +27,7 @@ const OWNER = { createdAt: NOW, } as const; -Deno.test('D1 version replacement invalidates old sessions, grants, and photos permanently', async () => { +test('D1 version replacement invalidates old sessions, grants, and photos permanently', async () => { await withKernelD1(async (db) => { const repository = await seedRuntime(db); const admin = createLmsKernelAdminService({ @@ -41,7 +42,7 @@ Deno.test('D1 version replacement invalidates old sessions, grants, and photos p approvalStatus: 'approved', runtime: { kind: 'worker', entrypoint: '/dist/worker.js' }, capabilities: [...CAPABILITIES], - }) + }), ), }), }); @@ -59,14 +60,14 @@ Deno.test('D1 version replacement invalidates old sessions, grants, and photos p true, ); assert( - await repository.getAuthorizedProtectedMediaReference({ id: 'media-1', now: NOW }) !== null, + (await repository.getAuthorizedProtectedMediaReference({ id: 'media-1', now: NOW })) !== null, ); assert((await admin.installApp({ membership: OWNER, packageVersionId: 2 })).ok); assertEquals(await repository.getGovernedRuntimeSession(lookup), null); assertEquals( - (await repository.listCapabilityGrants('installation-1')).every((grant) => - grant.revokedAt === NOW + (await repository.listCapabilityGrants('installation-1')).every( + (grant) => grant.revokedAt === NOW, ), true, ); @@ -77,12 +78,14 @@ Deno.test('D1 version replacement invalidates old sessions, grants, and photos p ); for (const capability of CAPABILITIES) { assert( - (await admin.grantCapability({ - membership: OWNER, - installationId: 'installation-1', - capability, - lmsContextId: 'context-1', - })).ok, + ( + await admin.grantCapability({ + membership: OWNER, + installationId: 'installation-1', + capability, + lmsContextId: 'context-1', + }) + ).ok, ); } assert( @@ -104,12 +107,14 @@ Deno.test('D1 version replacement invalidates old sessions, grants, and photos p ); for (const capability of CAPABILITIES) { assert( - (await admin.grantCapability({ - membership: OWNER, - installationId: 'installation-1', - capability, - lmsContextId: 'context-1', - })).ok, + ( + await admin.grantCapability({ + membership: OWNER, + installationId: 'installation-1', + capability, + lmsContextId: 'context-1', + }) + ).ok, ); } assert( @@ -134,7 +139,7 @@ Deno.test('D1 version replacement invalidates old sessions, grants, and photos p }); }); -Deno.test('D1 rolls back session and grant invalidation when version replacement audit fails', async () => { +test('D1 rolls back session and grant invalidation when version replacement audit fails', async () => { await withKernelD1(async (db) => { const repository = await seedRuntime(db); await assertRejects(() => @@ -145,7 +150,7 @@ Deno.test('D1 rolls back session and grant invalidation when version replacement installedByUserId: 'admin-1', now: NOW, event: event('app_installation.version_replaced', 999_999), - }) + }), ); const session = await repository.getGovernedRuntimeSession({ runtimeSessionId: 'runtime-1', @@ -154,13 +159,13 @@ Deno.test('D1 rolls back session and grant invalidation when version replacement assert(session !== null); assertEquals(session.runtimeExpiresAt, '2026-07-20T15:10:00.000Z'); assertEquals( - (await repository.listCapabilityGrants('installation-1')).every((grant) => - grant.revokedAt === null + (await repository.listCapabilityGrants('installation-1')).every( + (grant) => grant.revokedAt === null, ), true, ); assert( - await repository.getAuthorizedProtectedMediaReference({ id: 'media-1', now: NOW }) !== null, + (await repository.getAuthorizedProtectedMediaReference({ id: 'media-1', now: NOW })) !== null, ); }); }); diff --git a/src/lms_kernel/repository_memory_test_support.ts b/src/lms_kernel/repository_memory_test_support.ts index c976bf1..da42f59 100644 --- a/src/lms_kernel/repository_memory_test_support.ts +++ b/src/lms_kernel/repository_memory_test_support.ts @@ -155,8 +155,8 @@ export class MemoryLmsKernelRepository implements LmsKernelRepository { readonly revokedByUserId: string; readonly revokedAt: string; }): Promise { - const index = this.grants.findIndex((grant) => - grant.id === input.id && grant.institutionId === input.institutionId + const index = this.grants.findIndex( + (grant) => grant.id === input.id && grant.institutionId === input.institutionId, ); if (index < 0) { return Promise.resolve(null); @@ -177,9 +177,7 @@ export class MemoryLmsKernelRepository implements LmsKernelRepository { } listCapabilityGrantsForInstitution(institutionId: string): Promise { - return Promise.resolve( - this.grants.filter((grant) => grant.institutionId === institutionId), - ); + return Promise.resolve(this.grants.filter((grant) => grant.institutionId === institutionId)); } getGovernedRuntimeSession(input: { @@ -249,9 +247,8 @@ export class MemoryLmsKernelRepository implements LmsKernelRepository { readonly now: string; }): Promise { const reference = this.media.get(input.id); - const photoGrant = this.grants.find((grant) => - grant.capability === 'lms.roster.profile_photo.read' && - grant.revokedAt === null + const photoGrant = this.grants.find( + (grant) => grant.capability === 'lms.roster.profile_photo.read' && grant.revokedAt === null, ); if ( reference === undefined || @@ -263,44 +260,36 @@ export class MemoryLmsKernelRepository implements LmsKernelRepository { return Promise.resolve(reference); } - appendGovernanceEvent( - event: Omit, - ): Promise { + appendGovernanceEvent(event: Omit): Promise { return Promise.resolve(this.recordGovernanceEvent(event)); } - appendGovernanceEvents( - events: readonly Omit[], - ): Promise { + appendGovernanceEvents(events: readonly Omit[]): Promise { for (const event of events) this.recordGovernanceEvent(event); return Promise.resolve(); } listGovernanceEvents(filter: GovernanceEventFilter): Promise { return Promise.resolve( - this.events.filter((event) => - event.institutionId === filter.institutionId && - (filter.traceId === undefined || event.traceId === filter.traceId) && - (filter.capability === undefined || event.capability === filter.capability) && - (filter.decision === undefined || event.decision === filter.decision) && - (filter.outcome === undefined || event.outcome === filter.outcome) + this.events.filter( + (event) => + event.institutionId === filter.institutionId && + (filter.traceId === undefined || event.traceId === filter.traceId) && + (filter.capability === undefined || event.capability === filter.capability) && + (filter.decision === undefined || event.decision === filter.decision) && + (filter.outcome === undefined || event.outcome === filter.outcome), ), ); } - protected recordGovernanceEvent( - event: Omit, - ): GovernanceEventRecord { + protected recordGovernanceEvent(event: Omit): GovernanceEventRecord { const record = { id: this.events.length + 1, ...event }; this.events.push(record); return record; } } -export function buildKernelGrant( - id: string, - capability: RosterCapability, -): CapabilityGrantRecord { +export function buildKernelGrant(id: string, capability: RosterCapability): CapabilityGrantRecord { return { id, institutionId: 'institution-1', diff --git a/src/lms_kernel/service.ts b/src/lms_kernel/service.ts index 29b5c54..6c1606e 100644 --- a/src/lms_kernel/service.ts +++ b/src/lms_kernel/service.ts @@ -91,9 +91,8 @@ export function createGovernedRosterService(input: { now: observedAt, }); if (!authorization.allowed) { - const code = authorization.reason === 'session_expired' - ? 'session_expired' - : 'permission_denied'; + const code = + authorization.reason === 'session_expired' ? 'session_expired' : 'permission_denied'; await recordDecision({ repository: input.repository, session, @@ -143,9 +142,8 @@ export function createGovernedRosterService(input: { return primitiveFailure('invalid_request', traceId); } - const connector = session.connectorKind === 'synthetic' - ? input.syntheticConnector - : input.nrpsConnector; + const connector = + session.connectorKind === 'synthetic' ? input.syntheticConnector : input.nrpsConnector; const startedAt = performance.now(); const connectorResult = await connector.listPage({ session, @@ -183,16 +181,17 @@ export function createGovernedRosterService(input: { ); } - const nextCursor = connectorResult.page.nextContinuation === null - ? undefined - : await persistContinuation({ - repository: input.repository, - env: input.env, - session, - continuation: connectorResult.page.nextContinuation, - now: observedAt, - createId, - }); + const nextCursor = + connectorResult.page.nextContinuation === null + ? undefined + : await persistContinuation({ + repository: input.repository, + env: input.env, + session, + continuation: connectorResult.page.nextContinuation, + now: observedAt, + createId, + }); await recordCompletion({ repository: input.repository, @@ -356,32 +355,34 @@ async function recordDecision(input: { readonly requestMetadata: Readonly>; readonly occurredAt: string; }): Promise { - await input.repository.appendGovernanceEvents(input.capabilities.map((capability) => { - const grant = input.grants.find((candidate) => candidate.capability === capability); - return { - traceId: input.traceId, - eventType: 'primitive.authorization', - institutionId: input.session.institutionId, - lmsConnectionId: input.session.lmsConnectionId, - lmsContextId: input.session.lmsContextId, - appInstallationId: input.session.appInstallationId, - appId: input.session.appId, - packageVersionId: input.session.packageVersionId, - runtimeSessionId: input.session.runtimeSessionId, - actorType: input.session.actorType, - actorId: input.session.actorSubjectId, - capability, - capabilityGrantId: grant?.id ?? null, - decision: input.decision, - outcome: input.outcome, - connectorKind: input.session.connectorKind, - objectCount: null, - latencyMs: null, - errorCode: input.errorCode, - requestMetadata: input.requestMetadata, - occurredAt: input.occurredAt, - }; - })); + await input.repository.appendGovernanceEvents( + input.capabilities.map((capability) => { + const grant = input.grants.find((candidate) => candidate.capability === capability); + return { + traceId: input.traceId, + eventType: 'primitive.authorization', + institutionId: input.session.institutionId, + lmsConnectionId: input.session.lmsConnectionId, + lmsContextId: input.session.lmsContextId, + appInstallationId: input.session.appInstallationId, + appId: input.session.appId, + packageVersionId: input.session.packageVersionId, + runtimeSessionId: input.session.runtimeSessionId, + actorType: input.session.actorType, + actorId: input.session.actorSubjectId, + capability, + capabilityGrantId: grant?.id ?? null, + decision: input.decision, + outcome: input.outcome, + connectorKind: input.session.connectorKind, + objectCount: null, + latencyMs: null, + errorCode: input.errorCode, + requestMetadata: input.requestMetadata, + occurredAt: input.occurredAt, + }; + }), + ); } async function recordCompletion(input: { @@ -419,10 +420,7 @@ async function recordCompletion(input: { }); } -function primitiveFailure( - code: PrimitiveErrorCode, - traceId: string, -): PrimitiveResult { +function primitiveFailure(code: PrimitiveErrorCode, traceId: string): PrimitiveResult { return { ok: false, error: { code, traceId } }; } diff --git a/src/lms_kernel/service_test.ts b/src/lms_kernel/service_test.ts index fd1bdc0..c41d508 100644 --- a/src/lms_kernel/service_test.ts +++ b/src/lms_kernel/service_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assert, assertEquals, assertNotEquals, assertStringIncludes } from '@std/assert'; import { createObjectEnvReader } from '../platform/env.ts'; import { getTestToolPrivateJwkEnvValue } from '../test_helpers/lti.ts'; @@ -11,7 +12,7 @@ import { const NOW = new Date('2026-07-19T16:00:00.000Z'); -Deno.test('governed roster service returns opaque members, protected media, pagination, and one audit trace', async () => { +test('governed roster service returns opaque members, protected media, pagination, and one audit trace', async () => { const repository = new MemoryLmsKernelRepository({ session: buildGovernedSyntheticSession(), grants: [ @@ -69,7 +70,7 @@ Deno.test('governed roster service returns opaque members, protected media, pagi assertEquals(second.value.nextCursor, undefined); }); -Deno.test('governed roster service rejects a binding from another immutable app version', async () => { +test('governed roster service rejects a binding from another immutable app version', async () => { const repository = new MemoryLmsKernelRepository({ session: buildGovernedSyntheticSession(), grants: [buildKernelGrant('grant-members', 'lms.roster.members.read')], @@ -105,7 +106,7 @@ Deno.test('governed roster service rejects a binding from another immutable app assertEquals(connectorCalled, false); }); -Deno.test('governed roster service denies the complete request after photo permission is revoked', async () => { +test('governed roster service denies the complete request after photo permission is revoked', async () => { const repository = new MemoryLmsKernelRepository({ session: buildGovernedSyntheticSession(), grants: [ @@ -148,10 +149,13 @@ Deno.test('governed roster service denies the complete request after photo permi assertEquals(result.error.code, 'permission_denied'); } assertEquals(connectorCalled, false); - assertEquals(repository.events.map((event) => event.decision), ['denied', 'denied']); + assertEquals( + repository.events.map((event) => event.decision), + ['denied', 'denied'], + ); }); -Deno.test('governed roster service rejects a cursor replayed by another package version', async () => { +test('governed roster service rejects a cursor replayed by another package version', async () => { const repository = new MemoryLmsKernelRepository({ session: buildGovernedSyntheticSession(), grants: [buildKernelGrant('grant-members', 'lms.roster.members.read')], diff --git a/src/lms_kernel/types.ts b/src/lms_kernel/types.ts index 9d94b0a..f1d5072 100644 --- a/src/lms_kernel/types.ts +++ b/src/lms_kernel/types.ts @@ -1,4 +1,4 @@ -import type { PrimitiveErrorCode, RosterCapability, RosterRole } from '../../sdk/worker-app-sdk.ts'; +import type { RosterCapability, RosterRole } from '../../sdk/worker-app-sdk.ts'; export type InstitutionRole = 'owner' | 'admin' | 'auditor'; export type LmsPlatformType = 'canvas' | 'moodle' | 'brightspace' | 'sakai' | 'synthetic'; @@ -138,7 +138,7 @@ export interface GovernanceEventRecord { readonly connectorKind: ConnectorKind | null; readonly objectCount: number | null; readonly latencyMs: number | null; - readonly errorCode: PrimitiveErrorCode | string | null; + readonly errorCode: string | null; readonly requestMetadata: Readonly>; readonly occurredAt: string; } diff --git a/src/lti/hono_handler_adapter.ts b/src/lti/hono_handler_adapter.ts deleted file mode 100644 index c84de53..0000000 --- a/src/lti/hono_handler_adapter.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Context, Next } from '@hono/hono'; - -type LanternHonoHandler = ( - context: Context, - next: Next, -) => Response | Promise; - -export function adaptLtiToolHonoHandler(handler: unknown): LanternHonoHandler { - // lti-tool publishes handlers against npm:hono while Lantern imports Hono from JSR. - // Keep the dependency-source bridge in one adapter instead of route code. - return handler as LanternHonoHandler; -} diff --git a/src/lti/tool_key_test.ts b/src/lti/tool_key_test.ts index a33ce9a..103326b 100644 --- a/src/lti/tool_key_test.ts +++ b/src/lti/tool_key_test.ts @@ -1,8 +1,9 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertRejects } from '@std/assert'; import { getTestToolPrivateJwkEnvValue } from '../test_helpers/lti.ts'; import { getPublicJwkSet, loadToolSigningKey } from './tool_key.ts'; -Deno.test('loadToolSigningKey accepts one RS256 private JWK and projects a public JWKS view', async () => { +test('loadToolSigningKey accepts one RS256 private JWK and projects a public JWKS view', async () => { const env = createEnvReader({ LTI_TOOL_PRIVATE_JWK: getTestToolPrivateJwkEnvValue(), }); @@ -18,7 +19,7 @@ Deno.test('loadToolSigningKey accepts one RS256 private JWK and projects a publi assertEquals('d' in jwks.keys[0]!, false); }); -Deno.test('loadToolSigningKey rejects non-RSA tool keys', async () => { +test('loadToolSigningKey rejects non-RSA tool keys', async () => { const env = createEnvReader({ LTI_TOOL_PRIVATE_JWK: JSON.stringify({ kty: 'EC', diff --git a/src/lti/tool_provider.ts b/src/lti/tool_provider.ts index 2f45ef1..54eb1ef 100644 --- a/src/lti/tool_provider.ts +++ b/src/lti/tool_provider.ts @@ -46,6 +46,7 @@ export interface LanternLtiToolContextInput { export function createD1LtiStorage(database: D1Database, logger?: LtiLogger): LTIStorage { return new D1Storage({ + tenantId: 'lantern', // SAFETY: Cloudflare's D1 binding satisfies the runtime query surface expected by drizzle-orm/d1. database: database as unknown as AnyD1Database, ...(logger === undefined ? {} : { logger }), diff --git a/src/lti/tool_provider_d1_test.ts b/src/lti/tool_provider_d1_test.ts new file mode 100644 index 0000000..db1fc1d --- /dev/null +++ b/src/lti/tool_provider_d1_test.ts @@ -0,0 +1,36 @@ +import { test } from 'vite-plus/test'; +import { deepStrictEqual, equal, ok } from 'node:assert/strict'; +import { upsertLaunchRegistration } from '@longsightgroup/lti-tool'; +import { withKernelD1 } from '../lms_kernel/d1_test_support.ts'; +import { createD1LtiStorage } from './tool_provider.ts'; + +test('upgraded LTI storage persists registrations and rejects replayed nonces on real D1', async () => { + await withKernelD1(async (db) => { + const storage = createD1LtiStorage(db); + const registration = { + iss: 'https://lms.example', + clientId: 'lantern-client', + deploymentId: 'course-deployment', + authUrl: 'https://lms.example/authorize', + tokenUrl: 'https://lms.example/token', + jwksUrl: 'https://lms.example/jwks', + }; + const first = await upsertLaunchRegistration(storage, registration); + const second = await upsertLaunchRegistration(storage, registration); + equal(first.createdClient, true); + equal(second.createdClient, false); + equal(second.createdDeployment, false); + const restored = createD1LtiStorage(db); + deepStrictEqual( + await restored.getLaunchConfig( + registration.iss, + registration.clientId, + registration.deploymentId, + ), + second.launchConfig, + ); + ok(await restored.validateNonce('launch-nonce')); + equal(await restored.validateNonce('launch-nonce'), false); + equal((await restored.listClients()).length, 1); + }); +}); diff --git a/src/package_review/approval_test.ts b/src/package_review/approval_test.ts index 09f5021..bf1b0f5 100644 --- a/src/package_review/approval_test.ts +++ b/src/package_review/approval_test.ts @@ -1,9 +1,10 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertStringIncludes } from '@std/assert'; import { ADMIN_TEST_IDENTITY } from '../admin_auth/page_input.ts'; import { renderPackageDetailPage } from '../admin/package_detail.ts'; import { buildPackageVersionRecord } from '../test_helpers/package_review.ts'; -Deno.test('renderPackageDetailPage includes approve, reject, and structured accessibility controls for pending versions', () => { +test('renderPackageDetailPage includes approve, reject, and structured accessibility controls for pending versions', () => { const pendingVersion = buildPackageVersionRecord(); const body = renderPackageDetailPage({ identity: ADMIN_TEST_IDENTITY, @@ -31,7 +32,7 @@ Deno.test('renderPackageDetailPage includes approve, reject, and structured acce assertStringIncludes(body, 'name="accessibilityEquivalentAlternatives"'); }); -Deno.test('renderPackageDetailPage shows immutable decision history after review', () => { +test('renderPackageDetailPage shows immutable decision history after review', () => { const rejectedVersion = buildPackageVersionRecord({ approvalStatus: 'rejected', reviewNotes: 'Needs a corrected scoring contract.', diff --git a/src/package_review/intake_test.ts b/src/package_review/intake_test.ts index 43ee701..677a062 100644 --- a/src/package_review/intake_test.ts +++ b/src/package_review/intake_test.ts @@ -1,3 +1,7 @@ +import { join } from 'node:path'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { test } from 'vite-plus/test'; +import { tmpdir } from 'node:os'; import { assert, assertEquals, assertRejects, assertThrows } from '@std/assert'; import { getTestToolPrivateJwkEnvValue } from '../test_helpers/lti.ts'; import { @@ -22,23 +26,23 @@ const TEST_RUNTIME_CONTRACT_ENV = { }; async function withToolSigningEnv(run: () => Promise): Promise { - const previousToolKey = Deno.env.get('LTI_TOOL_PRIVATE_JWK'); - Deno.env.set('LTI_TOOL_PRIVATE_JWK', getTestToolPrivateJwkEnvValue()); + const previousToolKey = process.env['LTI_TOOL_PRIVATE_JWK']; + process.env['LTI_TOOL_PRIVATE_JWK'] = getTestToolPrivateJwkEnvValue(); try { await run(); } finally { if (previousToolKey === undefined) { - Deno.env.delete('LTI_TOOL_PRIVATE_JWK'); + delete process.env['LTI_TOOL_PRIVATE_JWK']; } else { - Deno.env.set('LTI_TOOL_PRIVATE_JWK', previousToolKey); + process.env['LTI_TOOL_PRIVATE_JWK'] = previousToolKey; } } } -Deno.test('importDemoPackage snapshots the demo package into Lantern-managed storage', async () => { +test('importDemoPackage snapshots the demo package into Lantern-managed storage', async () => { await withToolSigningEnv(async () => { - const storageRoot = await Deno.makeTempDir({ prefix: 'lantern-storage-' }); + const storageRoot = await mkdtemp(join(tmpdir(), 'lantern-storage-')); try { const result = await importDemoPackage({ @@ -70,26 +74,28 @@ Deno.test('importDemoPackage snapshots the demo package into Lantern-managed sto env: TEST_RUNTIME_CONTRACT_ENV, }); - const sourceManifest = await Deno.readTextFile( + const sourceManifest = await readFile( 'examples/apps/chapter-4-asteroids/manifest.json', + 'utf8', ); - const snapshotManifest = await Deno.readTextFile(result.artifact.manifestPath); - const sourceEntrypoint = await Deno.readTextFile( + const snapshotManifest = await readFile(result.artifact.manifestPath, 'utf8'); + const sourceEntrypoint = await readFile( 'examples/apps/chapter-4-asteroids/dist/index.html', + 'utf8', ); - const snapshotEntrypoint = await Deno.readTextFile(result.artifact.entrypointPath); + const snapshotEntrypoint = await readFile(result.artifact.entrypointPath, 'utf8'); assertEquals(snapshotManifest, sourceManifest); assertEquals(snapshotEntrypoint, sourceEntrypoint); } finally { - await Deno.remove(storageRoot, { recursive: true }); + await rm(storageRoot, { recursive: true }); } }); }); -Deno.test('importDemoPackage refuses to overwrite an existing immutable snapshot', async () => { +test('importDemoPackage refuses to overwrite an existing immutable snapshot', async () => { await withToolSigningEnv(async () => { - const storageRoot = await Deno.makeTempDir({ prefix: 'lantern-storage-' }); + const storageRoot = await mkdtemp(join(tmpdir(), 'lantern-storage-')); try { await importDemoPackage({ @@ -111,14 +117,14 @@ Deno.test('importDemoPackage refuses to overwrite an existing immutable snapshot 'Package version chapter-4-asteroids@0.1.0 already exists and cannot be replaced.', ); } finally { - await Deno.remove(storageRoot, { recursive: true }); + await rm(storageRoot, { recursive: true }); } }); }); -Deno.test('importReferencePackage snapshots each curated reference app into Lantern-managed storage', async () => { +test('importReferencePackage snapshots each curated reference app into Lantern-managed storage', async () => { await withToolSigningEnv(async () => { - const storageRoot = await Deno.makeTempDir({ prefix: 'lantern-storage-' }); + const storageRoot = await mkdtemp(join(tmpdir(), 'lantern-storage-')); try { for (const appId of listReferencePackageIds()) { @@ -147,20 +153,20 @@ Deno.test('importReferencePackage snapshots each curated reference app into Lant env: TEST_RUNTIME_CONTRACT_ENV, }); - const sourceManifest = await Deno.readTextFile(`${sourceRoot}/manifest.json`); - const snapshotManifest = await Deno.readTextFile(result.artifact.manifestPath); + const sourceManifest = await readFile(`${sourceRoot}/manifest.json`, 'utf8'); + const snapshotManifest = await readFile(result.artifact.manifestPath, 'utf8'); assertEquals(snapshotManifest, sourceManifest); } } finally { - await Deno.remove(storageRoot, { recursive: true }); + await rm(storageRoot, { recursive: true }); } }); }); -Deno.test('importPackage and loadPackageSnapshot use manifest-derived package identity', async () => { +test('importPackage and loadPackageSnapshot use manifest-derived package identity', async () => { await withToolSigningEnv(async () => { - const storageRoot = await Deno.makeTempDir({ prefix: 'lantern-storage-' }); + const storageRoot = await mkdtemp(join(tmpdir(), 'lantern-storage-')); const sourceRoot = getReferencePackageSourceRoot('quick-study'); const source = await createMemoryPackageSourceFromDirectory(sourceRoot); @@ -201,16 +207,16 @@ Deno.test('importPackage and loadPackageSnapshot use manifest-derived package id assertEquals(loaded.reviewData.version, imported.reviewData.version); assertEquals(loaded.artifact.digest, imported.artifact.digest); assertEquals( - await Deno.readTextFile(imported.artifact.manifestPath), - await Deno.readTextFile(`${sourceRoot}/manifest.json`), + await readFile(imported.artifact.manifestPath, 'utf8'), + await readFile(`${sourceRoot}/manifest.json`, 'utf8'), ); } finally { - await Deno.remove(storageRoot, { recursive: true }); + await rm(storageRoot, { recursive: true }); } }); }); -Deno.test('curated authoring examples stay explicitly registered in the reference package registry', () => { +test('curated authoring examples stay explicitly registered in the reference package registry', () => { const ids = listReferencePackageIds(); assertEquals(ids.includes('template-app'), true); @@ -236,9 +242,9 @@ Deno.test('curated authoring examples stay explicitly registered in the referenc ); }); -Deno.test('importReferencePackage snapshots web-checkup through the explicit curated registry', async () => { +test('importReferencePackage snapshots web-checkup through the explicit curated registry', async () => { await withToolSigningEnv(async () => { - const storageRoot = await Deno.makeTempDir({ prefix: 'lantern-storage-' }); + const storageRoot = await mkdtemp(join(tmpdir(), 'lantern-storage-')); try { const appId = 'web-checkup'; @@ -266,17 +272,17 @@ Deno.test('importReferencePackage snapshots web-checkup through the explicit cur env: TEST_RUNTIME_CONTRACT_ENV, }); - const sourceManifest = await Deno.readTextFile(`${sourceRoot}/manifest.json`); - const snapshotManifest = await Deno.readTextFile(result.artifact.manifestPath); + const sourceManifest = await readFile(`${sourceRoot}/manifest.json`, 'utf8'); + const snapshotManifest = await readFile(result.artifact.manifestPath, 'utf8'); assertEquals(snapshotManifest, sourceManifest); } finally { - await Deno.remove(storageRoot, { recursive: true }); + await rm(storageRoot, { recursive: true }); } }); }); -Deno.test('createMemoryPackageSource rejects duplicate normalized paths', () => { +test('createMemoryPackageSource rejects duplicate normalized paths', () => { assertThrows( () => createMemoryPackageSource([ @@ -294,7 +300,7 @@ Deno.test('createMemoryPackageSource rejects duplicate normalized paths', () => ); }); -Deno.test('createMemoryPackageSource rejects paths outside the reviewed package root', () => { +test('createMemoryPackageSource rejects paths outside the reviewed package root', () => { assertThrows( () => createMemoryPackageSource([ @@ -315,7 +321,7 @@ async function createMemoryPackageSourceFromDirectory(root: string) { await Promise.all( files.map(async (relativePath) => ({ relativePath, - bytes: await Deno.readFile(`${root}/${relativePath}`), + bytes: await readFile(`${root}/${relativePath}`), })), ), ); @@ -333,15 +339,15 @@ async function listFiles(root: string): Promise { async function walkFiles(root: string, relativeRoot: string, files: string[]): Promise { const absoluteRoot = relativeRoot === '' ? root : `${root}/${relativeRoot}`; - for await (const entry of Deno.readDir(absoluteRoot)) { + for (const entry of await readdir(absoluteRoot, { withFileTypes: true })) { const relativePath = relativeRoot === '' ? entry.name : `${relativeRoot}/${entry.name}`; - if (entry.isDirectory) { + if (entry.isDirectory()) { await walkFiles(root, relativePath, files); continue; } - if (entry.isFile) { + if (entry.isFile()) { files.push(relativePath); } } diff --git a/src/package_review/manifest.ts b/src/package_review/manifest.ts index f512dd7..61b69f5 100644 --- a/src/package_review/manifest.ts +++ b/src/package_review/manifest.ts @@ -1,7 +1,5 @@ -import { Ajv2020, type ValidateFunction } from '@ajv2020'; -import manifestSchema from '../../schemas/app-manifest.schema.json' with { type: 'json' }; +import { manifestValidator as validator } from '../generated/schema_validators.mjs'; import { - type AppManifest, buildManifestReviewData, explainManifestIssues, type ManifestReviewData, @@ -13,14 +11,6 @@ import { readManifestJsonFromSource, } from './manifest_files.ts'; -const ajv = new Ajv2020({ - allErrors: true, - strict: true, - strictRequired: false, -}); - -const validator: ValidateFunction = ajv.compile(manifestSchema); - export { explainManifestIssues }; export type { ManifestReviewData, ManifestValidationResult }; diff --git a/src/package_review/manifest_contract.ts b/src/package_review/manifest_contract.ts index 46752fd..155861a 100644 --- a/src/package_review/manifest_contract.ts +++ b/src/package_review/manifest_contract.ts @@ -1,4 +1,4 @@ -import { type ErrorObject } from '@ajv2020'; +import { type ErrorObject } from 'ajv/dist/2020.js'; import type { BrowserCapability, Capability, UserRole } from '../../sdk/app-sdk.ts'; import type { RosterCapability } from '../../sdk/worker-app-sdk.ts'; import type { @@ -42,15 +42,15 @@ export interface BrowserAppManifest extends ManifestBase { }; authoring?: | { - kind: 'browser_autograder'; - grader_spec_files: string[]; - evidence_example_file: string; - } + kind: 'browser_autograder'; + grader_spec_files: string[]; + evidence_example_file: string; + } | { - kind: 'python_function'; - grader_spec_files?: never; - evidence_example_file?: never; - }; + kind: 'python_function'; + grader_spec_files?: never; + evidence_example_file?: never; + }; } export interface WorkerAppManifest extends ManifestBase { @@ -89,14 +89,14 @@ export interface ManifestReviewData { export type ManifestValidationResult = | { - ok: true; - issues: []; - reviewData: ManifestReviewData; - } + ok: true; + issues: []; + reviewData: ManifestReviewData; + } | { - ok: false; - issues: ValidationIssue[]; - }; + ok: false; + issues: ValidationIssue[]; + }; type RequiredKeywordParams = { missingProperty: string; @@ -175,9 +175,7 @@ function mapManifestIssue(error: ErrorObject): ValidationIssue { } if (error.keyword === 'additionalProperties') { - const additionalProperty = String( - (error.params as Record).additionalProperty ?? '', - ); + const additionalProperty = String(error.params.additionalProperty ?? ''); return { field: error.instancePath || '/', diff --git a/src/package_review/manifest_files.ts b/src/package_review/manifest_files.ts index 99b8573..8b06505 100644 --- a/src/package_review/manifest_files.ts +++ b/src/package_review/manifest_files.ts @@ -135,8 +135,7 @@ export async function collectReferencedFileIssuesFromSource( field: '/runtime/entrypoint', keyword: 'invalid_asset_path', severity: 'error', - message: - `Entrypoint HTML cannot use root-relative reviewed asset URL ${assetUrl}. Use a relative asset path instead.`, + message: `Entrypoint HTML cannot use root-relative reviewed asset URL ${assetUrl}. Use a relative asset path instead.`, }); } } diff --git a/src/package_review/manifest_test.ts b/src/package_review/manifest_test.ts index b8e95c6..7a09f9d 100644 --- a/src/package_review/manifest_test.ts +++ b/src/package_review/manifest_test.ts @@ -1,3 +1,7 @@ +import { join } from 'node:path'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { test } from 'vite-plus/test'; +import { tmpdir } from 'node:os'; import { assert, assertEquals, @@ -62,7 +66,7 @@ const TEST_RUNTIME_CONTRACT_ENV = { }; async function writeJson(path: string, value: unknown): Promise { - await Deno.writeTextFile(path, JSON.stringify(value, null, 2)); + await writeFile(path, JSON.stringify(value, null, 2)); } async function createPackageFixture( @@ -77,7 +81,7 @@ async function createPackageFixture( includeAuthoringEvidence?: boolean; } = {}, ): Promise { - const root = await Deno.makeTempDir({ prefix: 'lantern-manifest-' }); + const root = await mkdtemp(join(tmpdir(), 'lantern-manifest-')); const includeEntrypoint = options.includeEntrypoint ?? true; const entrypointHtml = options.entrypointHtml ?? ''; const includeRubric = options.includeRubric ?? true; @@ -86,33 +90,33 @@ async function createPackageFixture( const includeAuthoringGraderSpecs = options.includeAuthoringGraderSpecs ?? true; const includeAuthoringEvidence = options.includeAuthoringEvidence ?? true; - await Deno.mkdir(`${root}/dist`, { recursive: true }); + await mkdir(`${root}/dist`, { recursive: true }); if (includeEntrypoint) { - await Deno.writeTextFile(`${root}/dist/index.html`, entrypointHtml); + await writeFile(`${root}/dist/index.html`, entrypointHtml); } if (includeRubric) { - await Deno.mkdir(`${root}/scoring`, { recursive: true }); + await mkdir(`${root}/scoring`, { recursive: true }); await writeJson(`${root}/scoring/rubric.json`, { max_score: 100 }); } if (includeContent) { - await Deno.mkdir(`${root}/content`, { recursive: true }); + await mkdir(`${root}/content`, { recursive: true }); await writeJson(`${root}/content/activity.json`, { lesson: 4 }); } if (includePreview) { - await Deno.mkdir(`${root}/preview`, { recursive: true }); + await mkdir(`${root}/preview`, { recursive: true }); await writeJson(`${root}/preview/fixtures.json`, { fixture: true }); await writeJson(`${root}/preview/tests.json`, { test: true }); } if (manifest.authoring && includeAuthoringGraderSpecs) { - await Deno.mkdir(`${root}/grading/specs`, { recursive: true }); + await mkdir(`${root}/grading/specs`, { recursive: true }); for (const graderSpecPath of manifest.authoring.grader_spec_files) { - await Deno.writeTextFile( + await writeFile( `${root}/${graderSpecPath.replace(/^\/+/, '')}`, 'describe("checks", () => {});\n', ); @@ -120,7 +124,7 @@ async function createPackageFixture( } if (manifest.authoring && includeAuthoringEvidence) { - await Deno.mkdir(`${root}/evidence`, { recursive: true }); + await mkdir(`${root}/evidence`, { recursive: true }); await writeJson(`${root}/${manifest.authoring.evidence_example_file.replace(/^\/+/, '')}`, { screenshot: 'example.png', checks: [], @@ -183,7 +187,7 @@ function buildBrowserAutograderManifest(): ManifestFixture { }; } -Deno.test('validateManifest accepts the demo manifest and returns typed review data', async () => { +test('validateManifest accepts the demo manifest and returns typed review data', async () => { const result = await validateManifest(createFileSystemPackageSource(DEMO_SOURCE_ROOT)); assertEquals(result.ok, true); @@ -203,7 +207,7 @@ Deno.test('validateManifest accepts the demo manifest and returns typed review d assertEquals(result.reviewData.validationIssues, []); }); -Deno.test('validateManifest rejects manifest v1 instead of using a compatibility parser', async () => { +test('validateManifest rejects manifest v1 instead of using a compatibility parser', async () => { const root = await createPackageFixture({ ...buildValidManifest(), schema_version: '1', @@ -215,18 +219,18 @@ Deno.test('validateManifest rejects manifest v1 instead of using a compatibility throw new Error('Expected manifest v1 to be rejected.'); } assertEquals( - result.issues.some((issue) => - issue.field === '/schema_version' && - issue.message === 'Schema version must be "2".' + result.issues.some( + (issue) => + issue.field === '/schema_version' && issue.message === 'Schema version must be "2".', ), true, ); } finally { - await Deno.remove(root, { recursive: true }); + await rm(root, { recursive: true }); } }); -Deno.test('reviewed runtime contracts fail closed when the approved artifact, capability manifest, or signature drifts', async () => { +test('reviewed runtime contracts fail closed when the approved artifact, capability manifest, or signature drifts', async () => { const result = await validateManifest(createFileSystemPackageSource(DEMO_SOURCE_ROOT)); assertEquals(result.ok, true); @@ -283,7 +287,7 @@ Deno.test('reviewed runtime contracts fail closed when the approved artifact, ca ); }); -Deno.test('validateManifest accepts each curated reference app manifest and keeps the governed contract narrow', async () => { +test('validateManifest accepts each curated reference app manifest and keeps the governed contract narrow', async () => { for (const appId of listReferencePackageIds()) { const result = await validateManifest( createFileSystemPackageSource(getReferencePackageSourceRoot(appId)), @@ -307,7 +311,7 @@ Deno.test('validateManifest accepts each curated reference app manifest and keep } }); -Deno.test('validateManifest maps schema failures into a plain-language fix list', async () => { +test('validateManifest maps schema failures into a plain-language fix list', async () => { const root = await createPackageFixture({ ...buildValidManifest(), runtime: { kind: 'browser', entrypoint: '/index.html' }, @@ -343,11 +347,11 @@ Deno.test('validateManifest maps schema failures into a plain-language fix list' assert(!issue.message.includes('must have required property')); } } finally { - await Deno.remove(root, { recursive: true }); + await rm(root, { recursive: true }); } }); -Deno.test('validateManifest reports missing referenced files as field-oriented fixes', async () => { +test('validateManifest reports missing referenced files as field-oriented fixes', async () => { const root = await createPackageFixture(buildValidManifest(), { includeRubric: false, includePreview: false, @@ -370,11 +374,11 @@ Deno.test('validateManifest reports missing referenced files as field-oriented f assertStringIncludes(result.issues[1]?.message ?? '', '/preview/fixtures.json'); assertStringIncludes(result.issues[2]?.message ?? '', '/preview/tests.json'); } finally { - await Deno.remove(root, { recursive: true }); + await rm(root, { recursive: true }); } }); -Deno.test('validateManifest rejects root-relative reviewed asset URLs in the entrypoint HTML', async () => { +test('validateManifest rejects root-relative reviewed asset URLs in the entrypoint HTML', async () => { const root = await createPackageFixture(buildValidManifest(), { entrypointHtml: '', }); @@ -398,11 +402,11 @@ Deno.test('validateManifest rejects root-relative reviewed asset URLs in the ent assertStringIncludes(result.issues[0]?.message ?? '', '/dist/app.js'); assertStringIncludes(result.issues[0]?.message ?? '', 'relative asset'); } finally { - await Deno.remove(root, { recursive: true }); + await rm(root, { recursive: true }); } }); -Deno.test('validateManifest accepts browser autograder authoring artifact references', async () => { +test('validateManifest accepts browser autograder authoring artifact references', async () => { const root = await createPackageFixture(buildBrowserAutograderManifest()); try { @@ -420,11 +424,11 @@ Deno.test('validateManifest accepts browser autograder authoring artifact refere assertEquals(result.reviewData.grading.maxScore, 100); assertEquals(result.reviewData.capabilities.includes('submit_evidence_artifact'), true); } finally { - await Deno.remove(root, { recursive: true }); + await rm(root, { recursive: true }); } }); -Deno.test('validateManifest requires finalize_attempt when anonymous evidence submission is enabled', async () => { +test('validateManifest requires finalize_attempt when anonymous evidence submission is enabled', async () => { const manifest = buildBrowserAutograderManifest(); manifest.capabilities = manifest.capabilities.filter( (capability) => capability !== 'finalize_attempt', @@ -449,11 +453,11 @@ Deno.test('validateManifest requires finalize_attempt when anonymous evidence su 'Capability "submit_evidence_artifact" requires "finalize_attempt".', ); } finally { - await Deno.remove(root, { recursive: true }); + await rm(root, { recursive: true }); } }); -Deno.test('validateManifest rejects browser grading when max score is missing', async () => { +test('validateManifest rejects browser grading when max score is missing', async () => { const manifest = buildBrowserAutograderManifest(); delete manifest.grading.max_score; const root = await createPackageFixture(manifest); @@ -472,11 +476,11 @@ Deno.test('validateManifest rejects browser grading when max score is missing', true, ); } finally { - await Deno.remove(root, { recursive: true }); + await rm(root, { recursive: true }); } }); -Deno.test('validateManifest rejects browser grading when authoring kind is absent', async () => { +test('validateManifest rejects browser grading when authoring kind is absent', async () => { const manifest = buildBrowserAutograderManifest(); if (!manifest.authoring) { @@ -506,11 +510,11 @@ Deno.test('validateManifest rejects browser grading when authoring kind is absen true, ); } finally { - await Deno.remove(root, { recursive: true }); + await rm(root, { recursive: true }); } }); -Deno.test('validateManifest reports missing browser autograder authoring files as field-oriented fixes', async () => { +test('validateManifest reports missing browser autograder authoring files as field-oriented fixes', async () => { const root = await createPackageFixture(buildBrowserAutograderManifest(), { includeAuthoringGraderSpecs: false, includeAuthoringEvidence: false, @@ -532,11 +536,11 @@ Deno.test('validateManifest reports missing browser autograder authoring files a assertStringIncludes(result.issues[0]?.message ?? '', '/grading/specs/checks.spec.js'); assertStringIncludes(result.issues[1]?.message ?? '', '/evidence/example-output.json'); } finally { - await Deno.remove(root, { recursive: true }); + await rm(root, { recursive: true }); } }); -Deno.test('validateManifest rejects unsupported browser autograder authoring fields', async () => { +test('validateManifest rejects unsupported browser autograder authoring fields', async () => { const root = await createPackageFixture({ ...buildBrowserAutograderManifest(), authoring: { @@ -560,6 +564,6 @@ Deno.test('validateManifest rejects unsupported browser autograder authoring fie ); assertStringIncludes(result.issues[0]?.message ?? '', 'unsupported_field'); } finally { - await Deno.remove(root, { recursive: true }); + await rm(root, { recursive: true }); } }); diff --git a/src/package_review/manifest_view.ts b/src/package_review/manifest_view.ts index 0d31968..fc03b29 100644 --- a/src/package_review/manifest_view.ts +++ b/src/package_review/manifest_view.ts @@ -1,6 +1,6 @@ export function readUnknownRecord(value: unknown): Record | null { return typeof value === 'object' && value !== null && !Array.isArray(value) - ? value as Record + ? (value as Record) : null; } diff --git a/src/package_review/package_diff.ts b/src/package_review/package_diff.ts index e996a69..1d260ae 100644 --- a/src/package_review/package_diff.ts +++ b/src/package_review/package_diff.ts @@ -148,11 +148,11 @@ function buildFileDiff(input: { text, snippet: text ? buildTextSnippet({ - kind: input.kind, - baseText, - targetText, - maxSnippetLines: input.maxSnippetLines, - }) + kind: input.kind, + baseText, + targetText, + maxSnippetLines: input.maxSnippetLines, + }) : [], }; } diff --git a/src/package_review/package_source_fs.ts b/src/package_review/package_source_fs.ts index 932a8af..cdc38c7 100644 --- a/src/package_review/package_source_fs.ts +++ b/src/package_review/package_source_fs.ts @@ -1,3 +1,4 @@ +import { readFile, readdir, stat as fileStat } from 'node:fs/promises'; import type { PackageSource } from './package_source.ts'; import { trimLeadingSlash } from './snapshot_path.ts'; @@ -5,9 +6,9 @@ export function createFileSystemPackageSource(rootPath: string): PackageSource { return { async readBytes(relativePath) { try { - return await Deno.readFile(joinSourcePath(rootPath, relativePath)); + return await readFile(joinSourcePath(rootPath, relativePath)); } catch (error) { - if (error instanceof Deno.errors.NotFound) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { return null; } @@ -21,11 +22,11 @@ export function createFileSystemPackageSource(rootPath: string): PackageSource { }, async fileExists(relativePath) { try { - const stat = await Deno.stat(joinSourcePath(rootPath, relativePath)); + const stat = await fileStat(joinSourcePath(rootPath, relativePath)); - return stat.isFile; + return stat.isFile(); } catch (error) { - if (error instanceof Deno.errors.NotFound) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { return false; } @@ -50,15 +51,15 @@ function joinSourcePath(rootPath: string, relativePath: string): string { async function walkFiles(rootPath: string, relativeRoot: string, files: string[]): Promise { const absoluteRoot = relativeRoot === '' ? rootPath : joinSourcePath(rootPath, relativeRoot); - for await (const entry of Deno.readDir(absoluteRoot)) { + for (const entry of await readdir(absoluteRoot, { withFileTypes: true })) { const relativePath = relativeRoot === '' ? entry.name : `${relativeRoot}/${entry.name}`; - if (entry.isDirectory) { + if (entry.isDirectory()) { await walkFiles(rootPath, relativePath, files); continue; } - if (entry.isFile) { + if (entry.isFile()) { files.push(relativePath); } } diff --git a/src/package_review/r2_pagination_guard_test.ts b/src/package_review/r2_pagination_guard_test.ts index 9ca02ea..c53b327 100644 --- a/src/package_review/r2_pagination_guard_test.ts +++ b/src/package_review/r2_pagination_guard_test.ts @@ -1,9 +1,10 @@ +import { test } from 'vite-plus/test'; import { assertRejects } from '@std/assert'; import type { RuntimeArtifactBucket } from '../runtime/artifact_store.ts'; import { createBucketPackageSource } from './package_source.ts'; import { createR2PackageSnapshotStore } from './snapshot_store.ts'; -Deno.test('bucket package source fails clearly when truncated R2 listing omits a continuation cursor', async () => { +test('bucket package source fails clearly when truncated R2 listing omits a continuation cursor', async () => { const source = createBucketPackageSource( createMissingCursorBucket(), 'reference-packages/demo/source', @@ -16,7 +17,7 @@ Deno.test('bucket package source fails clearly when truncated R2 listing omits a ); }); -Deno.test('snapshot store fails clearly when truncated R2 listing repeats a cursor', async () => { +test('snapshot store fails clearly when truncated R2 listing repeats a cursor', async () => { const snapshotStore = createR2PackageSnapshotStore(createRepeatedCursorBucket()); await assertRejects( diff --git a/src/package_review/repository_activity_d1.ts b/src/package_review/repository_activity_d1.ts index 80f1001..ccf9250 100644 --- a/src/package_review/repository_activity_d1.ts +++ b/src/package_review/repository_activity_d1.ts @@ -27,11 +27,9 @@ export function createD1ActivityRepositoryMethods( return { async createAttemptEvidenceArtifact(input) { try { - const results = await runD1Batch( - db, - [ - { - sql: ` + const results = await runD1Batch(db, [ + { + sql: ` INSERT INTO attempt_evidence_artifacts ( artifact_id, attempt_id, @@ -69,28 +67,27 @@ export function createD1ActivityRepositoryMethods( sha256, created_at AS createdAt `, - parameters: [ - input.artifactId, - input.kind, - input.contentType, - input.fileName, - input.storageKey, - input.byteSize, - input.sha256, - input.createdAt, - input.attemptId, - ], - }, - { - sql: ` + parameters: [ + input.artifactId, + input.kind, + input.contentType, + input.fileName, + input.storageKey, + input.byteSize, + input.sha256, + input.createdAt, + input.attemptId, + ], + }, + { + sql: ` UPDATE attempts SET next_evidence_artifact_sequence = next_evidence_artifact_sequence + 1 WHERE attempt_id = ? `, - parameters: [input.attemptId], - }, - ], - ); + parameters: [input.attemptId], + }, + ]); const row = results[0]?.results?.[0] ?? null; if (row === null) { diff --git a/src/package_review/repository_authoring_d1_test.ts b/src/package_review/repository_authoring_d1_test.ts index bf3b7ab..31d8f47 100644 --- a/src/package_review/repository_authoring_d1_test.ts +++ b/src/package_review/repository_authoring_d1_test.ts @@ -1,8 +1,9 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertRejects } from '@std/assert'; import type { D1Database, D1Parameter, D1PreparedStatement, D1Result } from '../db/d1.ts'; import { createD1AuthoringRepositoryMethods } from './repository_authoring_d1.ts'; -Deno.test('D1 authoring repository creates drafts from approved browser autograder packages', async () => { +test('D1 authoring repository creates drafts from approved browser autograder packages', async () => { const db = createPlannedD1Database({ firstResults: [buildPackageVersionRow(), null, buildAuthoringDraftRow()], allResults: [[]], @@ -19,7 +20,7 @@ Deno.test('D1 authoring repository creates drafts from approved browser autograd assertEquals(db.statements[2]?.parameters[6], '["/grader/spec.json","/examples/evidence.json"]'); }); -Deno.test('D1 authoring repository rejects unapproved package versions', async () => { +test('D1 authoring repository rejects unapproved package versions', async () => { const db = createPlannedD1Database({ firstResults: [buildPackageVersionRow({ approvalStatus: 'pending' })], }); @@ -37,7 +38,7 @@ Deno.test('D1 authoring repository rejects unapproved package versions', async ( ); }); -Deno.test('D1 authoring repository saves only approved draft paths', async () => { +test('D1 authoring repository saves only approved draft paths', async () => { const db = createPlannedD1Database({ firstResults: [ buildAuthoringDraftRow(), @@ -66,7 +67,7 @@ Deno.test('D1 authoring repository saves only approved draft paths', async () => assertEquals(db.statements[1]?.parameters, ['draft-1', '/grader/spec.json', '{}', 1]); }); -Deno.test('D1 authoring repository rejects files outside the authoring contract', async () => { +test('D1 authoring repository rejects files outside the authoring contract', async () => { const db = createPlannedD1Database({ firstResults: [buildAuthoringDraftRow()], }); @@ -87,7 +88,7 @@ Deno.test('D1 authoring repository rejects files outside the authoring contract' ); }); -Deno.test('D1 authoring repository marks drafts previewed', async () => { +test('D1 authoring repository marks drafts previewed', async () => { const db = createPlannedD1Database({ firstResults: [ buildAuthoringDraftRow({ diff --git a/src/package_review/repository_launch_state_d1.ts b/src/package_review/repository_launch_state_d1.ts index a666152..545a50b 100644 --- a/src/package_review/repository_launch_state_d1.ts +++ b/src/package_review/repository_launch_state_d1.ts @@ -203,11 +203,9 @@ export function createD1LaunchStateRepositoryMethods( }, async appendAttemptEvent(input) { - const results = await runD1Batch( - db, - [ - { - sql: ` + const results = await runD1Batch(db, [ + { + sql: ` INSERT INTO attempt_events ( attempt_id, sequence, @@ -243,27 +241,26 @@ export function createD1LaunchStateRepositoryMethods( event, received_at AS receivedAt `, - parameters: [ - input.event.type, - input.normalizedEvent.learningVerb, - input.normalizedEvent.objectId, - input.normalizedEvent.objectType, - input.normalizedEvent.result, - input.event, - input.receivedAt, - input.attemptId, - ], - }, - { - sql: ` + parameters: [ + input.event.type, + input.normalizedEvent.learningVerb, + input.normalizedEvent.objectId, + input.normalizedEvent.objectType, + input.normalizedEvent.result, + input.event, + input.receivedAt, + input.attemptId, + ], + }, + { + sql: ` UPDATE attempts SET next_attempt_event_sequence = next_attempt_event_sequence + 1 WHERE attempt_id = ? `, - parameters: [input.attemptId], - }, - ], - ); + parameters: [input.attemptId], + }, + ]); const row = results[0]?.results?.[0] ?? null; if (row === null) { diff --git a/src/package_review/repository_launch_state_d1_mapping.ts b/src/package_review/repository_launch_state_d1_mapping.ts index 291a792..7ba4121 100644 --- a/src/package_review/repository_launch_state_d1_mapping.ts +++ b/src/package_review/repository_launch_state_d1_mapping.ts @@ -82,11 +82,7 @@ export function mapD1RuntimeSessionFields(row: D1RuntimeSessionRow): RuntimeSess appId: expectD1String(row.appId, 'appId'), packageVersionId: expectD1Number(row.packageVersionId, 'packageVersionId'), packageVersion: expectD1String(row.packageVersion, 'packageVersion'), - capabilities: parseD1JsonField( - row.capabilities, - 'capabilities', - parseCapabilityArray, - ), + capabilities: parseD1JsonField(row.capabilities, 'capabilities', parseCapabilityArray), snapshotRoot: expectD1String(row.snapshotRoot, 'snapshotRoot'), entrypointPath: expectD1String(row.entrypointPath, 'entrypointPath'), contentPath: expectD1String(row.contentPath, 'contentPath'), @@ -175,7 +171,7 @@ function parseCapabilityArray(value: unknown, fieldName: string): Capability[] { 'finalize_attempt', 'read_local_state', 'write_local_state', - ]) + ]), ); } @@ -190,9 +186,7 @@ function parseAttemptEventResult( completionState: expectD1StringLiteral( record.completionState, `${fieldName}.completionState`, - [ - 'completed', - ], + ['completed'], ), }; } diff --git a/src/package_review/repository_mappers_package.ts b/src/package_review/repository_mappers_package.ts index 3bec83e..52895f6 100644 --- a/src/package_review/repository_mappers_package.ts +++ b/src/package_review/repository_mappers_package.ts @@ -46,9 +46,8 @@ export function mapPackageVersionRow(row: PackageVersionRow | undefined): Packag catalogVisibility: row.catalogVisibility, approvalStatus: row.approvalStatus, reviewNotes: row.reviewNotes, - accessibilityReview: row.accessibilityReview === null - ? null - : parseAccessibilityReview(row.accessibilityReview), + accessibilityReview: + row.accessibilityReview === null ? null : parseAccessibilityReview(row.accessibilityReview), reviewedAt: normalizeOptionalTimestamp(row.reviewedAt), validationIssues: row.validationIssues ?? [], manifestJson: row.manifestJson, diff --git a/src/package_review/repository_mappers_sessions.ts b/src/package_review/repository_mappers_sessions.ts index 80767b5..d1af9b2 100644 --- a/src/package_review/repository_mappers_sessions.ts +++ b/src/package_review/repository_mappers_sessions.ts @@ -47,23 +47,23 @@ export function mapRuntimeSessionRow(row: RuntimeSessionRow | undefined): Runtim } function mapLaunchServices(row: RuntimeSessionRow): RuntimeSessionRecord['services'] { - const hasAgs = row.agsScope.length > 0 || row.agsLineitemsUrl !== null || - row.agsLineitemUrl !== null; + const hasAgs = + row.agsScope.length > 0 || row.agsLineitemsUrl !== null || row.agsLineitemUrl !== null; const hasNrps = row.nrpsContextMembershipsUrl !== null; return { ags: hasAgs ? { - scope: row.agsScope, - lineitemsUrl: row.agsLineitemsUrl, - lineitemUrl: row.agsLineitemUrl, - } + scope: row.agsScope, + lineitemsUrl: row.agsLineitemsUrl, + lineitemUrl: row.agsLineitemUrl, + } : null, nrps: hasNrps ? { - contextMembershipsUrl: row.nrpsContextMembershipsUrl!, - serviceVersions: row.nrpsServiceVersions, - } + contextMembershipsUrl: row.nrpsContextMembershipsUrl!, + serviceVersions: row.nrpsServiceVersions, + } : null, }; } diff --git a/src/package_review/repository_mappers_sessions_test.ts b/src/package_review/repository_mappers_sessions_test.ts index 864332f..00dbf72 100644 --- a/src/package_review/repository_mappers_sessions_test.ts +++ b/src/package_review/repository_mappers_sessions_test.ts @@ -1,8 +1,9 @@ +import { test } from 'vite-plus/test'; import { assertEquals } from '@std/assert'; import { mapRuntimeSessionRow } from './repository_mappers_sessions.ts'; import type { RuntimeSessionRow } from './repository_row_types.ts'; -Deno.test('runtime session mapper preserves preview session markers for review launches', () => { +test('runtime session mapper preserves preview session markers for review launches', () => { const session = mapRuntimeSessionRow( buildRuntimeSessionRow({ previewSessionId: 'preview-session-123', diff --git a/src/package_review/repository_package_versions_d1.ts b/src/package_review/repository_package_versions_d1.ts index 6bda9b2..67f34a9 100644 --- a/src/package_review/repository_package_versions_d1.ts +++ b/src/package_review/repository_package_versions_d1.ts @@ -130,11 +130,13 @@ export function createD1PackageVersionRepositoryMethods( throw new Error(`Python Lab package version ${id} is unexpectedly installed.`); } - await runD1Batch(db, [{ - sql: `DELETE FROM package_versions + await runD1Batch(db, [ + { + sql: `DELETE FROM package_versions WHERE id = ? AND catalog_visibility = 'python_lab_trial'`, - parameters: [id], - }]); + parameters: [id], + }, + ]); }, async approvePackageVersion(input) { @@ -474,7 +476,7 @@ function isD1UniqueViolation(error: unknown): boolean { function parseUserRoleArray(value: unknown, fieldName: string): UserRole[] { return expectD1StringArray(value, fieldName).map((role, index) => - expectD1StringLiteral(role, `${fieldName}[${index}]`, ['learner', 'instructor']) + expectD1StringLiteral(role, `${fieldName}[${index}]`, ['learner', 'instructor']), ); } @@ -489,7 +491,7 @@ function parseCapabilityArray(value: unknown, fieldName: string): Capability[] { 'read_local_state', 'write_local_state', 'run_code_submission', - ]) + ]), ); } @@ -534,10 +536,7 @@ function parseValidationIssues(value: unknown, fieldName: string): ValidationIss })); } -function parseReviewedRuntimeContract( - value: unknown, - fieldName: string, -): ReviewedRuntimeContract { +function parseReviewedRuntimeContract(value: unknown, fieldName: string): ReviewedRuntimeContract { const record = expectD1JsonRecord(value, fieldName); return { @@ -554,10 +553,7 @@ function parseReviewedRuntimeContract( }; } -function parseAppRuntime( - value: unknown, - fieldName: string, -): ReviewedRuntimeContract['runtime'] { +function parseAppRuntime(value: unknown, fieldName: string): ReviewedRuntimeContract['runtime'] { const record = expectD1JsonRecord(value, fieldName); const kind = expectD1StringLiteral(record.kind, `${fieldName}.kind`, ['browser', 'worker']); return { @@ -566,10 +562,7 @@ function parseAppRuntime( }; } -function expectJsonRecordArray( - value: unknown, - fieldName: string, -): Array> { +function expectJsonRecordArray(value: unknown, fieldName: string): Array> { if (!Array.isArray(value)) { throw new TypeError(`Expected D1 ${fieldName} JSON to be an object array.`); } diff --git a/src/package_review/repository_package_versions_d1_test.ts b/src/package_review/repository_package_versions_d1_test.ts index 460f546..4e4b32a 100644 --- a/src/package_review/repository_package_versions_d1_test.ts +++ b/src/package_review/repository_package_versions_d1_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertRejects } from '@std/assert'; import type { D1Database, D1Parameter, D1PreparedStatement, D1Result } from '../db/d1.ts'; import { @@ -7,7 +8,7 @@ import { import { createD1PackageVersionRepositoryMethods } from './repository_package_versions_d1.ts'; import type { PackageVersionRecord } from './types.ts'; -Deno.test('D1 package version repository registers and refetches a package version', async () => { +test('D1 package version repository registers and refetches a package version', async () => { const imported = buildImportedPackageVersion(); const db = createRecordingD1Database([ toD1PackageVersionRow(buildPackageVersionRecordFromImport(imported)), @@ -26,7 +27,7 @@ Deno.test('D1 package version repository registers and refetches a package versi ]); }); -Deno.test('D1 package version repository lists parsed package version rows', async () => { +test('D1 package version repository lists parsed package version rows', async () => { const db = createRecordingD1Database([ toD1PackageVersionRow( buildPackageVersionRecordFromImport( @@ -48,7 +49,7 @@ Deno.test('D1 package version repository lists parsed package version rows', asy assertEquals(db.statements[0]?.parameters, ['chapter-4-asteroids']); }); -Deno.test('D1 package version repository persists sandbox grading through the explicit sandbox marker', async () => { +test('D1 package version repository persists sandbox grading through the explicit sandbox marker', async () => { const imported = buildImportedPackageVersion({ appId: 'python-lab-123', version: '1.0.0', @@ -66,7 +67,7 @@ Deno.test('D1 package version repository persists sandbox grading through the ex assertEquals(db.statements[0]?.parameters.slice(10, 14), ['browser', null, 10, 1]); }); -Deno.test('D1 package version repository registers a Python Lab package as non-catalog in its insert', async () => { +test('D1 package version repository registers a Python Lab package as non-catalog in its insert', async () => { const imported = buildImportedPackageVersion({ appId: 'python-lab-123', version: '1.0.0', @@ -88,7 +89,7 @@ Deno.test('D1 package version repository registers a Python Lab package as non-c assertEquals(db.statements[0]?.parameters[14], 'python_lab_trial'); }); -Deno.test('D1 package version repository reports duplicate package versions clearly', async () => { +test('D1 package version repository reports duplicate package versions clearly', async () => { const db = createRecordingD1Database([], { runError: new Error( 'UNIQUE constraint failed: package_versions.app_id, package_versions.version', @@ -103,7 +104,7 @@ Deno.test('D1 package version repository reports duplicate package versions clea ); }); -Deno.test('D1 package version repository reviews package versions with JSON accessibility review', async () => { +test('D1 package version repository reviews package versions with JSON accessibility review', async () => { const reviewed = buildPackageVersionRecordFromImport(buildImportedPackageVersion()); const db = createRecordingD1Database([ toD1PackageVersionRow({ @@ -247,9 +248,8 @@ function toD1PackageVersionRow(record: PackageVersionRecord): D1Row { catalogVisibility: record.catalogVisibility, approvalStatus: record.approvalStatus, reviewNotes: record.reviewNotes, - accessibilityReview: record.accessibilityReview === null - ? null - : JSON.stringify(record.accessibilityReview), + accessibilityReview: + record.accessibilityReview === null ? null : JSON.stringify(record.accessibilityReview), reviewedAt: record.reviewedAt, validationIssues: JSON.stringify(record.validationIssues), manifestJson: JSON.stringify(record.manifestJson), diff --git a/src/package_review/repository_preview_d1.ts b/src/package_review/repository_preview_d1.ts index ea67dcc..fbd75ee 100644 --- a/src/package_review/repository_preview_d1.ts +++ b/src/package_review/repository_preview_d1.ts @@ -133,11 +133,9 @@ export function createD1PreviewRepositoryMethods( }, async appendPreviewEvidence(input) { - const results = await runD1Batch( - db, - [ - { - sql: ` + const results = await runD1Batch(db, [ + { + sql: ` INSERT INTO preview_evidence ( preview_session_id, sequence, @@ -167,25 +165,24 @@ export function createD1PreviewRepositoryMethods( detail, occurred_at AS occurredAt `, - parameters: [ - input.eventType, - input.capability, - input.summary, - input.detail, - input.occurredAt, - input.previewSessionId, - ], - }, - { - sql: ` + parameters: [ + input.eventType, + input.capability, + input.summary, + input.detail, + input.occurredAt, + input.previewSessionId, + ], + }, + { + sql: ` UPDATE preview_sessions SET next_evidence_sequence = next_evidence_sequence + 1 WHERE session_id = ? `, - parameters: [input.previewSessionId], - }, - ], - ); + parameters: [input.previewSessionId], + }, + ]); const row = results[0]?.results?.[0] ?? null; if (row === null) { @@ -302,16 +299,9 @@ function mapD1PreviewSessionFields(row: D1PreviewSessionRow): PreviewSessionRow appId: expectD1String(row.appId, 'appId'), packageVersion: expectD1String(row.packageVersion, 'packageVersion'), packageTitle: expectD1String(row.packageTitle, 'packageTitle'), - origin: expectD1StringLiteral(row.origin, 'origin', [ - 'adminTestLaunch', - 'adminAuthoringDraft', - ]), + origin: expectD1StringLiteral(row.origin, 'origin', ['adminTestLaunch', 'adminAuthoringDraft']), contentPath: expectD1String(row.contentPath, 'contentPath'), - capabilities: parseD1JsonField( - row.capabilities, - 'capabilities', - parseCapabilityArray, - ), + capabilities: parseD1JsonField(row.capabilities, 'capabilities', parseCapabilityArray), snapshotRoot: expectD1String(row.snapshotRoot, 'snapshotRoot'), entrypointPath: expectD1String(row.entrypointPath, 'entrypointPath'), launchUserId: expectD1String(row.launchUserId, 'launchUserId'), @@ -356,7 +346,7 @@ function parseCapabilityArray(value: unknown, fieldName: string): Capability[] { 'finalize_attempt', 'read_local_state', 'write_local_state', - ]) + ]), ); } @@ -365,19 +355,15 @@ function parseNullableCapability(value: unknown, fieldName: string): Capability return null; } - return expectD1StringLiteral( - value, - fieldName, - [ - 'read_launch_context', - 'read_activity_content', - 'submit_attempt_event', - 'submit_evidence_artifact', - 'finalize_attempt', - 'read_local_state', - 'write_local_state', - ] as const, - ); + return expectD1StringLiteral(value, fieldName, [ + 'read_launch_context', + 'read_activity_content', + 'submit_attempt_event', + 'submit_evidence_artifact', + 'finalize_attempt', + 'read_local_state', + 'write_local_state', + ] as const); } function parsePreviewFixtureData(value: unknown, fieldName: string): PreviewFixtureData { @@ -398,8 +384,9 @@ function parsePreviewFixtureData(value: unknown, fieldName: string): PreviewFixt activity_id: expectD1String(launch.activity_id, `${fieldName}.launch.activity_id`), }, attempt_id: expectD1String(record.attempt_id, `${fieldName}.attempt_id`), - local_state: record.local_state === null - ? null - : expectD1JsonRecord(record.local_state, `${fieldName}.local_state`), + local_state: + record.local_state === null + ? null + : expectD1JsonRecord(record.local_state, `${fieldName}.local_state`), }; } diff --git a/src/package_review/repository_preview_d1_test.ts b/src/package_review/repository_preview_d1_test.ts index a17c099..7d6c59a 100644 --- a/src/package_review/repository_preview_d1_test.ts +++ b/src/package_review/repository_preview_d1_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertRejects } from '@std/assert'; import type { D1Database, D1Parameter, D1PreparedStatement, D1Result } from '../db/d1.ts'; import { @@ -6,7 +7,7 @@ import { } from '../test_helpers/package_review_test_builder_preview.ts'; import { createD1PreviewRepositoryMethods } from './repository_preview_d1.ts'; -Deno.test('D1 preview repository creates sessions with JSON capabilities and fixtures', async () => { +test('D1 preview repository creates sessions with JSON capabilities and fixtures', async () => { const db = createPlannedD1Database({ firstResults: [{ id: 1 }, buildPreviewSessionRow()], }); @@ -20,7 +21,7 @@ Deno.test('D1 preview repository creates sessions with JSON capabilities and fix assertEquals(typeof db.statements[1]?.parameters[18], 'string'); }); -Deno.test('D1 preview repository reports missing package versions clearly', async () => { +test('D1 preview repository reports missing package versions clearly', async () => { const db = createPlannedD1Database({ firstResults: [null], }); @@ -33,7 +34,7 @@ Deno.test('D1 preview repository reports missing package versions clearly', asyn ); }); -Deno.test('D1 preview repository gets latest preview with optional origin filter', async () => { +test('D1 preview repository gets latest preview with optional origin filter', async () => { const db = createPlannedD1Database({ firstResults: [buildPreviewSessionRow({ origin: 'adminAuthoringDraft' })], }); @@ -48,7 +49,7 @@ Deno.test('D1 preview repository gets latest preview with optional origin filter assertEquals(db.statements[0]?.parameters, [1, 'adminAuthoringDraft']); }); -Deno.test('D1 preview repository appends evidence with the next sequence', async () => { +test('D1 preview repository appends evidence with the next sequence', async () => { const db = createPlannedD1Database({ firstResults: [buildPreviewEvidenceRow({ sequence: 3 })], }); @@ -78,7 +79,7 @@ Deno.test('D1 preview repository appends evidence with the next sequence', async assertEquals(db.statements[1]?.parameters, ['preview-session-123']); }); -Deno.test('D1 preview repository lists parsed evidence in sequence order', async () => { +test('D1 preview repository lists parsed evidence in sequence order', async () => { const db = createPlannedD1Database({ allResults: [ [buildPreviewEvidenceRow({ sequence: 1 }), buildPreviewEvidenceRow({ id: 2, sequence: 2 })], diff --git a/src/package_review/repository_resource_options.ts b/src/package_review/repository_resource_options.ts index 69dcf1c..3d9e01a 100644 --- a/src/package_review/repository_resource_options.ts +++ b/src/package_review/repository_resource_options.ts @@ -1,4 +1,4 @@ -import { compare, parse } from '@std/semver'; +import { compare } from 'semver'; import type { PackageVersionRecord } from './types.ts'; export function sortPackageVersions(records: PackageVersionRecord[]): PackageVersionRecord[] { @@ -7,7 +7,7 @@ export function sortPackageVersions(records: PackageVersionRecord[]): PackageVer return left.appId.localeCompare(right.appId); } - const versionComparison = compare(parse(right.version), parse(left.version)); + const versionComparison = compare(right.version, left.version); if (versionComparison !== 0) { return versionComparison; diff --git a/src/package_review/runtime_contract.ts b/src/package_review/runtime_contract.ts index 59bb429..abd262f 100644 --- a/src/package_review/runtime_contract.ts +++ b/src/package_review/runtime_contract.ts @@ -95,7 +95,7 @@ export async function verifyReviewedRuntimeContractSignature(input: { if ( serializeReviewedRuntimeContract(signedRuntimeContract) !== - serializeReviewedRuntimeContract(input.runtimeContract) + serializeReviewedRuntimeContract(input.runtimeContract) ) { throw new Error('Reviewed runtime contract payload drifted.'); } diff --git a/src/package_review/snapshot_store_fs.ts b/src/package_review/snapshot_store_fs.ts index 02cd702..a8bd0af 100644 --- a/src/package_review/snapshot_store_fs.ts +++ b/src/package_review/snapshot_store_fs.ts @@ -1,3 +1,4 @@ +import { mkdir, readFile, readdir, stat as fileStat, writeFile } from 'node:fs/promises'; import { assertPathInsideSnapshot, joinSnapshotPath, @@ -14,7 +15,7 @@ const FILE_SYSTEM_PACKAGE_SNAPSHOT_STORE: PackageSnapshotStore = { assertPathInsideSnapshot(snapshotRoot, absolutePath, SNAPSHOT_OUTSIDE_MESSAGE); - return await Deno.readFile(absolutePath); + return await readFile(absolutePath); }, async writeBytes(snapshotRoot, relativePath, bytes) { const absolutePath = joinSnapshotPath( @@ -23,18 +24,18 @@ const FILE_SYSTEM_PACKAGE_SNAPSHOT_STORE: PackageSnapshotStore = { SNAPSHOT_OUTSIDE_MESSAGE, ); - await Deno.mkdir(parentDirectory(absolutePath), { recursive: true }); - await Deno.writeFile(absolutePath, bytes); + await mkdir(parentDirectory(absolutePath), { recursive: true }); + await writeFile(absolutePath, bytes); }, async fileExists(snapshotRoot, relativePath) { try { - const stat = await Deno.stat( + const stat = await fileStat( joinSnapshotPath(snapshotRoot, relativePath, SNAPSHOT_OUTSIDE_MESSAGE), ); - return stat.isFile; + return stat.isFile(); } catch (error) { - if (error instanceof Deno.errors.NotFound) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { return false; } @@ -71,15 +72,15 @@ async function walkSnapshotFiles( ): Promise { const absoluteRoot = relativeRoot === '' ? snapshotRoot : `${snapshotRoot}/${relativeRoot}`; - for await (const entry of Deno.readDir(absoluteRoot)) { + for (const entry of await readdir(absoluteRoot, { withFileTypes: true })) { const relativePath = relativeRoot === '' ? entry.name : `${relativeRoot}/${entry.name}`; - if (entry.isDirectory) { + if (entry.isDirectory()) { await walkSnapshotFiles(snapshotRoot, relativePath, files); continue; } - if (entry.isFile) { + if (entry.isFile()) { files.push(relativePath); } } diff --git a/src/package_review/summary.ts b/src/package_review/summary.ts index 9a2bcc1..fc32f09 100644 --- a/src/package_review/summary.ts +++ b/src/package_review/summary.ts @@ -223,14 +223,14 @@ export function summarizeGrading(grading: GradingSettings): { detail: string; } { if (grading.mode === 'declarative') { - const maximumScore = grading.maxScore === null - ? 'Maximum score not recorded yet.' - : `Maximum score ${grading.maxScore}.`; + const maximumScore = + grading.maxScore === null + ? 'Maximum score not recorded yet.' + : `Maximum score ${grading.maxScore}.`; return { label: 'Automatic scoring', - detail: - `Lantern scores learner work automatically using reviewed scoring rules saved with this app. ${maximumScore}`, + detail: `Lantern scores learner work automatically using reviewed scoring rules saved with this app. ${maximumScore}`, }; } @@ -265,8 +265,7 @@ export function summarizeValidation( if (packageVersion.validationIssues.length === 0) { return { label: 'Checks passed', - detail: - `Lantern checked the manifest and saved files for this version in ${packageVersion.artifact.snapshotRoot}.`, + detail: `Lantern checked the manifest and saved files for this version in ${packageVersion.artifact.snapshotRoot}.`, issues: [], }; } @@ -304,9 +303,10 @@ export function summarizeAccessibilityReview( return { label: failedChecks.length === 0 ? 'Passed review' : 'Flagged review', - detail: failedChecks.length === 0 - ? 'All recorded checks passed or were marked not applicable.' - : `Failed checks: ${failedChecks.join(', ')}.`, + detail: + failedChecks.length === 0 + ? 'All recorded checks passed or were marked not applicable.' + : `Failed checks: ${failedChecks.join(', ')}.`, failedChecks, exceptionNote: packageVersion.accessibilityReview.exceptionNote, }; diff --git a/src/package_review/types.ts b/src/package_review/types.ts index 65d1bf3..ba7edc3 100644 --- a/src/package_review/types.ts +++ b/src/package_review/types.ts @@ -104,13 +104,13 @@ export interface PackageArtifactRecord { export type AppRuntime = | { - readonly kind: 'browser'; - readonly entrypoint: string; - } + readonly kind: 'browser'; + readonly entrypoint: string; + } | { - readonly kind: 'worker'; - readonly entrypoint: string; - }; + readonly kind: 'worker'; + readonly entrypoint: string; + }; export interface ReviewedRuntimeContract { runtimeContractVersion: '2'; @@ -313,9 +313,7 @@ export interface AuthoringDraftRecord { files: AuthoringDraftFileRecord[]; } -export type PreviewSessionOrigin = - | 'adminTestLaunch' - | 'adminAuthoringDraft'; +export type PreviewSessionOrigin = 'adminTestLaunch' | 'adminAuthoringDraft'; export interface PreviewSessionRecord { sessionId: string; diff --git a/src/pages/home_test.ts b/src/pages/home_test.ts index f1620f7..2b45e4d 100644 --- a/src/pages/home_test.ts +++ b/src/pages/home_test.ts @@ -1,7 +1,8 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertStringIncludes } from '@std/assert'; import { renderHomePage } from './home.ts'; -Deno.test('renderHomePage includes only the operational app entry points', () => { +test('renderHomePage includes only the operational app entry points', () => { const html = renderHomePage(); assertStringIncludes(html, 'Lantern runtime'); @@ -14,7 +15,7 @@ Deno.test('renderHomePage includes only the operational app entry points', () => assertEquals(html.includes('Why IT can say yes'), false); }); -Deno.test('renderHomePage excludes tenant-private identifiers from public output', () => { +test('renderHomePage excludes tenant-private identifiers from public output', () => { const html = renderHomePage(); for (const forbidden of ['issuer', 'client_id', 'user_id', 'deployment_id', 'resource_link_id']) { diff --git a/src/pages/layout_test.ts b/src/pages/layout_test.ts index 014691a..bd1f1d4 100644 --- a/src/pages/layout_test.ts +++ b/src/pages/layout_test.ts @@ -1,7 +1,8 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertStringIncludes } from '@std/assert'; import { renderLayout } from './layout.ts'; -Deno.test('public layout uses the navy accent palette instead of indigo', () => { +test('public layout uses the navy accent palette instead of indigo', () => { const html = renderLayout('Lantern', '
    Home
    '); assertStringIncludes(html, '--accent: #153a61;'); diff --git a/src/platform/deno_env.ts b/src/platform/deno_env.ts deleted file mode 100644 index 523bf0c..0000000 --- a/src/platform/deno_env.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { type EnvReader, setDefaultEnvReader } from './env.ts'; - -const DENO_ENV_READER: EnvReader = { - get(name: string): string | undefined { - return Deno.env.get(name) ?? undefined; - }, -}; - -export function getDenoEnvReader(): EnvReader { - return DENO_ENV_READER; -} - -export function installDenoEnvReader(): void { - setDefaultEnvReader(DENO_ENV_READER); -} diff --git a/src/platform/install_deno_env.ts b/src/platform/install_deno_env.ts deleted file mode 100644 index 198ee3d..0000000 --- a/src/platform/install_deno_env.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { installDenoEnvReader } from './deno_env.ts'; - -installDenoEnvReader(); diff --git a/src/platform/node_env.ts b/src/platform/node_env.ts new file mode 100644 index 0000000..1efb635 --- /dev/null +++ b/src/platform/node_env.ts @@ -0,0 +1,15 @@ +import { type EnvReader, setDefaultEnvReader } from './env.ts'; + +const NODE_ENV_READER: EnvReader = { + get(name: string): string | undefined { + return process.env[name] ?? undefined; + }, +}; + +export function getNodeEnvReader(): EnvReader { + return NODE_ENV_READER; +} + +export function installNodeEnvReader(): void { + setDefaultEnvReader(NODE_ENV_READER); +} diff --git a/src/platform/workflow_checkpoint.ts b/src/platform/workflow_checkpoint.ts new file mode 100644 index 0000000..f6c7f9a --- /dev/null +++ b/src/platform/workflow_checkpoint.ts @@ -0,0 +1,27 @@ +/** Validate a JSON-shaped workflow record before it crosses a durable checkpoint. */ +export function workflowCheckpoint(value: T): T & Rpc.Serializable { + assertCheckpointValue(value); + // SAFETY: The recursive boundary check permits only plain JSON-shaped data + // and undefined, all of which the Workers checkpoint serializer supports. + return value as T & Rpc.Serializable; +} + +function assertCheckpointValue(value: unknown): void { + if ( + value === null || + value === undefined || + typeof value === 'string' || + typeof value === 'boolean' + ) + return; + if (typeof value === 'number' && Number.isFinite(value)) return; + if (Array.isArray(value)) { + for (const item of value) assertCheckpointValue(item); + return; + } + if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) { + for (const item of Object.values(value)) assertCheckpointValue(item); + return; + } + throw new Error('Workflow checkpoints must contain plain JSON data.'); +} diff --git a/src/platform/workflow_checkpoint_test.ts b/src/platform/workflow_checkpoint_test.ts new file mode 100644 index 0000000..3b669c3 --- /dev/null +++ b/src/platform/workflow_checkpoint_test.ts @@ -0,0 +1,17 @@ +import { test } from 'vite-plus/test'; +import { assertEquals, assertThrows } from '@std/assert'; +import { workflowCheckpoint } from './workflow_checkpoint.ts'; + +test('workflow checkpoints preserve JSON records across structured cloning', () => { + const record = { + ok: true, + value: { files: [{ path: 'app.ts', contents: 'code' }], optional: undefined, plan: null }, + }; + assertEquals(structuredClone(workflowCheckpoint(record)), record); +}); + +test('workflow checkpoints reject values that cannot be stored as JSON records', () => { + for (const value of [() => {}, Symbol('unsupported'), new Map(), new Date(), Number.NaN]) { + assertThrows(() => workflowCheckpoint({ nested: [value] }), Error, 'plain JSON data'); + } +}); diff --git a/src/preview/runtime_diagnostics.ts b/src/preview/runtime_diagnostics.ts index 0f32c94..bf5e626 100644 --- a/src/preview/runtime_diagnostics.ts +++ b/src/preview/runtime_diagnostics.ts @@ -39,14 +39,17 @@ export async function loadPreviewRuntimeDiagnostics(input: { input.session.fakeAttemptId, `${input.session.fakeAttemptId}:${input.session.sessionId}`, ]); - const auditEvents = (await Promise.all( - [...attemptIds].map((attemptId) => input.repository.listAuditEventsByAttemptId(attemptId)), - )).flat(); + const auditEvents = ( + await Promise.all( + [...attemptIds].map((attemptId) => input.repository.listAuditEventsByAttemptId(attemptId)), + ) + ).flat(); return auditEvents - .filter((event) => - event.packageVersionId === input.session.packageVersionId && - PREVIEW_RUNTIME_FAILURE_EVENT_TYPES.has(event.eventType) + .filter( + (event) => + event.packageVersionId === input.session.packageVersionId && + PREVIEW_RUNTIME_FAILURE_EVENT_TYPES.has(event.eventType), ) .sort((left, right) => left.occurredAt.localeCompare(right.occurredAt) || left.id - right.id) .map(toPreviewRuntimeDiagnostic); diff --git a/src/preview/service.ts b/src/preview/service.ts index 013f8ce..e9977be 100644 --- a/src/preview/service.ts +++ b/src/preview/service.ts @@ -310,8 +310,9 @@ function buildPreviewLaunchDetail( runtimeSessionId, origin: previewSession.origin, contentPath: previewSession.contentPath, - route: previewSession.origin === 'adminAuthoringDraft' - ? `/admin/packages/${previewSession.appId}/versions/${previewSession.packageVersion}/authoring` - : `/admin/packages/${previewSession.appId}/versions/${previewSession.packageVersion}/preview`, + route: + previewSession.origin === 'adminAuthoringDraft' + ? `/admin/packages/${previewSession.appId}/versions/${previewSession.packageVersion}/authoring` + : `/admin/packages/${previewSession.appId}/versions/${previewSession.packageVersion}/preview`, }; } diff --git a/src/preview/service_test.ts b/src/preview/service_test.ts index 130ec66..a3c7116 100644 --- a/src/preview/service_test.ts +++ b/src/preview/service_test.ts @@ -1,3 +1,7 @@ +import { join } from 'node:path'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { test } from 'vite-plus/test'; +import { tmpdir } from 'node:os'; import { assertEquals, assertRejects } from '@std/assert'; import { getReferencePackageSourceRoot } from '../package_review/intake.ts'; import { getDefaultRuntimeArtifactStore } from '../runtime/artifact_store_fs.ts'; @@ -11,13 +15,13 @@ import { const artifactStore = getDefaultRuntimeArtifactStore(); -Deno.test('preview service loads preview.fixtures_file and validates required fields before preparing launch context', async () => { - const snapshotRoot = await Deno.makeTempDir({ prefix: 'lantern-preview-' }); +test('preview service loads preview.fixtures_file and validates required fields before preparing launch context', async () => { + const snapshotRoot = await mkdtemp(join(tmpdir(), 'lantern-preview-')); try { await writePreviewManifest(snapshotRoot, '/preview/fixtures.json'); - await Deno.mkdir(`${snapshotRoot}/preview`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${snapshotRoot}/preview`, { recursive: true }); + await writeFile( `${snapshotRoot}/preview/fixtures.json`, JSON.stringify({ launch: { @@ -60,17 +64,17 @@ Deno.test('preview service loads preview.fixtures_file and validates required fi assertEquals(prepared.launch.activityId, 'activity-preview-9'); assertEquals(prepared.fixtureData.attempt_id, 'attempt-preview-123'); } finally { - await Deno.remove(snapshotRoot, { recursive: true }); + await rm(snapshotRoot, { recursive: true }); } }); -Deno.test('preview service prepares pending package versions for review test launch', async () => { - const snapshotRoot = await Deno.makeTempDir({ prefix: 'lantern-preview-' }); +test('preview service prepares pending package versions for review test launch', async () => { + const snapshotRoot = await mkdtemp(join(tmpdir(), 'lantern-preview-')); try { await writePreviewManifest(snapshotRoot, '/preview/fixtures.json'); - await Deno.mkdir(`${snapshotRoot}/preview`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${snapshotRoot}/preview`, { recursive: true }); + await writeFile( `${snapshotRoot}/preview/fixtures.json`, JSON.stringify({ launch: { @@ -111,12 +115,12 @@ Deno.test('preview service prepares pending package versions for review test lau assertEquals(prepared.launch.userRole, 'learner'); assertEquals(prepared.fixtureData.attempt_id, 'attempt-preview-pending'); } finally { - await Deno.remove(snapshotRoot, { recursive: true }); + await rm(snapshotRoot, { recursive: true }); } }); -Deno.test('preview service fails clearly when preview fixtures are missing and does not fall back to runtime defaults', async () => { - const snapshotRoot = await Deno.makeTempDir({ prefix: 'lantern-preview-' }); +test('preview service fails clearly when preview fixtures are missing and does not fall back to runtime defaults', async () => { + const snapshotRoot = await mkdtemp(join(tmpdir(), 'lantern-preview-')); try { await writePreviewManifest(snapshotRoot, '/preview/missing.json'); @@ -149,17 +153,17 @@ Deno.test('preview service fails clearly when preview fixtures are missing and d 'Saved test launch file /preview/missing.json is missing from the reviewed app files.', ); } finally { - await Deno.remove(snapshotRoot, { recursive: true }); + await rm(snapshotRoot, { recursive: true }); } }); -Deno.test('preview service applies explicit test-launch overrides over saved defaults', async () => { - const snapshotRoot = await Deno.makeTempDir({ prefix: 'lantern-preview-' }); +test('preview service applies explicit test-launch overrides over saved defaults', async () => { + const snapshotRoot = await mkdtemp(join(tmpdir(), 'lantern-preview-')); try { await writePreviewManifest(snapshotRoot, '/preview/fixtures.json'); - await Deno.mkdir(`${snapshotRoot}/preview`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${snapshotRoot}/preview`, { recursive: true }); + await writeFile( `${snapshotRoot}/preview/fixtures.json`, JSON.stringify({ launch: { @@ -205,17 +209,17 @@ Deno.test('preview service applies explicit test-launch overrides over saved def assertEquals(prepared.fixtureData.launch.user_role, 'learner'); assertEquals(prepared.fixtureData.launch.course_id, 'course-preview-42'); } finally { - await Deno.remove(snapshotRoot, { recursive: true }); + await rm(snapshotRoot, { recursive: true }); } }); -Deno.test('preview service returns fake identity/session defaults shaped for runtime bootstrap and evidence capture', async () => { - const snapshotRoot = await Deno.makeTempDir({ prefix: 'lantern-preview-' }); +test('preview service returns fake identity/session defaults shaped for runtime bootstrap and evidence capture', async () => { + const snapshotRoot = await mkdtemp(join(tmpdir(), 'lantern-preview-')); try { await writePreviewManifest(snapshotRoot, '/preview/fixtures.json'); - await Deno.mkdir(`${snapshotRoot}/preview`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${snapshotRoot}/preview`, { recursive: true }); + await writeFile( `${snapshotRoot}/preview/fixtures.json`, JSON.stringify({ launch: { @@ -274,17 +278,17 @@ Deno.test('preview service returns fake identity/session defaults shaped for run assertEquals(created.fakeScoring.activityProgress, 'Completed'); assertEquals(created.fakeScoring.gradingProgress, 'FullyGraded'); } finally { - await Deno.remove(snapshotRoot, { recursive: true }); + await rm(snapshotRoot, { recursive: true }); } }); -Deno.test('preview service defaults admin launches to admin origin and canonical preview content', async () => { - const snapshotRoot = await Deno.makeTempDir({ prefix: 'lantern-preview-' }); +test('preview service defaults admin launches to admin origin and canonical preview content', async () => { + const snapshotRoot = await mkdtemp(join(tmpdir(), 'lantern-preview-')); try { await writePreviewManifest(snapshotRoot, '/preview/fixtures.json'); - await Deno.mkdir(`${snapshotRoot}/preview`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${snapshotRoot}/preview`, { recursive: true }); + await writeFile( `${snapshotRoot}/preview/fixtures.json`, JSON.stringify({ launch: { @@ -325,17 +329,17 @@ Deno.test('preview service defaults admin launches to admin origin and canonical assertEquals(prepared.origin, 'adminTestLaunch'); assertEquals(prepared.contentPath, '/content/activity.json'); } finally { - await Deno.remove(snapshotRoot, { recursive: true }); + await rm(snapshotRoot, { recursive: true }); } }); -Deno.test('preview service keeps explicit authoring origin and selected content in the runtime session', async () => { - const snapshotRoot = await Deno.makeTempDir({ prefix: 'lantern-preview-' }); +test('preview service keeps explicit authoring origin and selected content in the runtime session', async () => { + const snapshotRoot = await mkdtemp(join(tmpdir(), 'lantern-preview-')); try { await writePreviewManifest(snapshotRoot, '/preview/fixtures.json'); - await Deno.mkdir(`${snapshotRoot}/preview`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${snapshotRoot}/preview`, { recursive: true }); + await writeFile( `${snapshotRoot}/preview/fixtures.json`, JSON.stringify({ launch: { @@ -404,17 +408,17 @@ Deno.test('preview service keeps explicit authoring origin and selected content assertEquals(launched.runtimeSession.launch.activityId, '/content/bonus.json'); assertEquals(launched.runtimeSession.launch.submissionMode, 'anonymous_submission'); } finally { - await Deno.remove(snapshotRoot, { recursive: true }); + await rm(snapshotRoot, { recursive: true }); } }); -Deno.test('preview runtime sessions from admin and authoring origins both keep live LMS services disabled', async () => { - const snapshotRoot = await Deno.makeTempDir({ prefix: 'lantern-preview-' }); +test('preview runtime sessions from admin and authoring origins both keep live LMS services disabled', async () => { + const snapshotRoot = await mkdtemp(join(tmpdir(), 'lantern-preview-')); try { await writePreviewManifest(snapshotRoot, '/preview/fixtures.json'); - await Deno.mkdir(`${snapshotRoot}/preview`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${snapshotRoot}/preview`, { recursive: true }); + await writeFile( `${snapshotRoot}/preview/fixtures.json`, JSON.stringify({ launch: { @@ -477,17 +481,17 @@ Deno.test('preview runtime sessions from admin and authoring origins both keep l assertEquals(authoringLaunch.runtimeSession.services.ags, null); assertEquals(authoringLaunch.runtimeSession.services.nrps, null); } finally { - await Deno.remove(snapshotRoot, { recursive: true }); + await rm(snapshotRoot, { recursive: true }); } }); -Deno.test('preview service records admin authoring draft launches with the authoring route detail', async () => { - const snapshotRoot = await Deno.makeTempDir({ prefix: 'lantern-preview-' }); +test('preview service records admin authoring draft launches with the authoring route detail', async () => { + const snapshotRoot = await mkdtemp(join(tmpdir(), 'lantern-preview-')); try { await writePreviewManifest(snapshotRoot, '/preview/fixtures.json'); - await Deno.mkdir(`${snapshotRoot}/preview`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${snapshotRoot}/preview`, { recursive: true }); + await writeFile( `${snapshotRoot}/preview/fixtures.json`, JSON.stringify({ launch: { @@ -540,11 +544,11 @@ Deno.test('preview service records admin authoring draft launches with the autho '/admin/packages/template-app/versions/0.1.0/authoring', ); } finally { - await Deno.remove(snapshotRoot, { recursive: true }); + await rm(snapshotRoot, { recursive: true }); } }); -Deno.test('preview service reads committed quick-study fixtures without inventing a second preview path', async () => { +test('preview service reads committed quick-study fixtures without inventing a second preview path', async () => { const snapshotRoot = getReferencePackageSourceRoot('quick-study'); const approvedPackage = buildPackageVersionRecord({ id: 18, @@ -592,7 +596,7 @@ Deno.test('preview service reads committed quick-study fixtures without inventin }); async function writePreviewManifest(snapshotRoot: string, fixturesFile: string): Promise { - await Deno.writeTextFile( + await writeFile( `${snapshotRoot}/manifest.json`, JSON.stringify({ schema_version: '2', diff --git a/src/public_origin.ts b/src/public_origin.ts index 4355c6f..c7d394f 100644 --- a/src/public_origin.ts +++ b/src/public_origin.ts @@ -39,7 +39,8 @@ export function normalizeHttpOrigin(value: string, invalidMessage: string): stri export function resolvePublicOriginFromRequest(input: PublicOriginRequestInput): string { const requestUrl = new URL(input.requestUrl); const forwarded = parseForwardedHeader(input.forwardedHeader); - const proto = forwarded.proto ?? + const proto = + forwarded.proto ?? readForwardedValue(input.xForwardedProto) ?? requestUrl.protocol.slice(0, -1); const host = forwarded.host ?? readForwardedValue(input.xForwardedHost) ?? requestUrl.host; diff --git a/src/python_lab/access.ts b/src/python_lab/access.ts index 7fb5aa4..79f29f5 100644 --- a/src/python_lab/access.ts +++ b/src/python_lab/access.ts @@ -26,9 +26,9 @@ export function appendCookie( ): void { headers.append( 'set-cookie', - `${name}=${ - encodeURIComponent(value) - }; Path=${path}; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`, + `${name}=${encodeURIComponent( + value, + )}; Path=${path}; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`, ); } diff --git a/src/python_lab/artifact_store.ts b/src/python_lab/artifact_store.ts index cb7f20b..58ce583 100644 --- a/src/python_lab/artifact_store.ts +++ b/src/python_lab/artifact_store.ts @@ -90,9 +90,10 @@ export function buildPythonLabPrivateArtifactKey(labId: string): string { return `var/private-python-lab/${labId}/assignment.json`; } -export function buildPythonSubmissionArtifactKey( - input: { attemptId: string; runId: string }, -): string { +export function buildPythonSubmissionArtifactKey(input: { + attemptId: string; + runId: string; +}): string { return `var/private-python-lab/submissions/${input.attemptId}/${input.runId}.py`; } @@ -104,7 +105,8 @@ function parsePrivateAssignmentArtifact(input: unknown): PythonLabPrivateArtifac const record = input as Record; if ( - record.kind !== 'assignment' || typeof record.functionName !== 'string' || + record.kind !== 'assignment' || + typeof record.functionName !== 'string' || typeof record.referenceCode !== 'string' || !Array.isArray(record.cases) ) { diff --git a/src/python_lab/cloudflare_sandbox.ts b/src/python_lab/cloudflare_sandbox.ts index 3ef64a3..f6e3bc9 100644 --- a/src/python_lab/cloudflare_sandbox.ts +++ b/src/python_lab/cloudflare_sandbox.ts @@ -1,66 +1,99 @@ -import { getSandbox, Sandbox } from '@cloudflare/sandbox'; +import { Files } from '@cloudflare/sandbox'; +import { DurableObject } from 'cloudflare:workers'; import { PYTHON_GRADER_COMMAND, PYTHON_GRADER_OUTPUT_LIMIT_BYTES, - type PythonGraderSandbox as PythonGraderSandboxPort, type PythonGraderSandboxFactory, type SandboxCommandResult, } from './runner.ts'; -export class PythonGraderSandbox extends Sandbox { - override enableInternet = false; +/** Owns one disposable Python interpreter with no outbound network access. */ +export class PythonGraderSandbox extends DurableObject { + private getContainer(): Container { + const container = this.ctx.container; + if (!container) throw new Error('Python grader container is not configured.'); + if (!container.running) { + const image = container.images.sandbox; + if (!image) throw new Error('Python grader image is not configured.'); + container.start({ image, enableInternet: false }); + } + return container; + } + + /** Writes the runner-owned source and input files into the disposable workspace. */ + async writeFile(path: string, contents: string): Promise { + if ( + !['/workspace/solution.py', '/workspace/invoke.py', '/workspace/request.json'].includes(path) + ) { + throw new Error('Python grader file path is not allowed.'); + } + await new Files(this.getContainer()).writeFile(path, contents); + } + + /** Runs the fixed grading command with bounded time and combined output. */ + async exec(input: { + command: typeof PYTHON_GRADER_COMMAND; + timeoutMs: number; + }): Promise { + if ( + input.command !== PYTHON_GRADER_COMMAND || + !Number.isFinite(input.timeoutMs) || + input.timeoutMs <= 0 + ) { + throw new Error('Python grader command or timeout is invalid.'); + } + const controller = new AbortController(); + const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(input.timeoutMs)]); + const process = await this.getContainer().exec(['sh', '-c', PYTHON_GRADER_COMMAND], { signal }); + let outputSize = 0; + async function readOutput(stream: ReadableStream | null): Promise { + const decoder = new TextDecoder(); + let output = ''; + if (stream !== null) { + for await (const chunk of stream) { + outputSize += chunk.byteLength; + if (outputSize > PYTHON_GRADER_OUTPUT_LIMIT_BYTES) { + controller.abort(); + throw new Error('Python output exceeded the allowed limit.'); + } + output += decoder.decode(chunk, { stream: true }); + } + } + return output + decoder.decode(); + } + try { + const [stdout, stderr, exitCode] = await Promise.all([ + readOutput(process.stdout), + readOutput(process.stderr), + process.exitCode, + ]); + return { stdout, stderr, exitCode }; + } catch (error) { + process.kill(); + throw error; + } + } + + /** Stops the container after the single grading invocation. */ + async destroy(): Promise { + await this.ctx.container?.destroy(); + } } -type PythonGraderSandboxNamespace = Parameters>[0]; +export type PythonGraderSandboxNamespace = Pick< + DurableObjectNamespace, + 'getByName' +>; +/** Provides one freshly named grader per execution through the Worker binding. */ export function createCloudflarePythonGraderSandboxFactory( namespace: PythonGraderSandboxNamespace, ): PythonGraderSandboxFactory { return { create(oneTimeId) { - const sandbox = getSandbox(namespace, oneTimeId); - - return Promise.resolve( - { - async writeFile(path, contents) { - await sandbox.writeFile(path, contents); - }, - async exec(input) { - const controller = new AbortController(); - let outputSize = 0; - const result = await sandbox.exec(input.command, { - timeout: input.timeoutMs, - stream: true, - signal: controller.signal, - onOutput(_stream, chunk) { - outputSize += new TextEncoder().encode(chunk).byteLength; - if (outputSize > PYTHON_GRADER_OUTPUT_LIMIT_BYTES) { - controller.abort('Python output exceeded the allowed limit.'); - } - }, - }); - - return normalizeCommandResult(result); - }, - destroy() { - return sandbox.destroy(); - }, - } satisfies PythonGraderSandboxPort, - ); + return Promise.resolve(namespace.getByName(oneTimeId)); }, }; } -function normalizeCommandResult(input: { - readonly exitCode: number; - readonly stdout: string; - readonly stderr: string; -}): SandboxCommandResult { - return { - exitCode: input.exitCode, - stdout: input.stdout, - stderr: input.stderr, - }; -} - export { PYTHON_GRADER_COMMAND }; diff --git a/src/python_lab/contract.ts b/src/python_lab/contract.ts index a441683..7a1053d 100644 --- a/src/python_lab/contract.ts +++ b/src/python_lab/contract.ts @@ -144,7 +144,9 @@ export function parseGeneratedCodeTestInputs(input: unknown): GeneratedCodeTestI export function parseJsonValue(input: unknown, field = 'Value'): JsonValue { if ( - input === null || typeof input === 'string' || typeof input === 'boolean' || + input === null || + typeof input === 'string' || + typeof input === 'boolean' || (typeof input === 'number' && Number.isFinite(input)) ) { return input; @@ -172,13 +174,13 @@ export function buildPublicPythonAssignmentContent(input: { readonly draft: PythonAssignmentDraft; readonly cases: readonly EvaluatedCodeTestCase[]; }): PublicPythonAssignmentContent { - const publicCases = input.cases.filter((testCase) => testCase.visibility === 'public').map( - (testCase) => ({ + const publicCases = input.cases + .filter((testCase) => testCase.visibility === 'public') + .map((testCase) => ({ label: testCase.label, args: testCase.args, expected: testCase.expected, - }), - ); + })); if (publicCases.length !== PYTHON_PUBLIC_CASE_COUNT) { throw new PythonContractError('A Python assignment requires exactly three public cases.'); diff --git a/src/python_lab/contract_test.ts b/src/python_lab/contract_test.ts index 3211a50..cd52ca6 100644 --- a/src/python_lab/contract_test.ts +++ b/src/python_lab/contract_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertThrows } from '@std/assert'; import { parseGeneratedCodeTestInputs, @@ -5,7 +6,7 @@ import { PythonContractError, } from './contract.ts'; -Deno.test('Python contract accepts only a named function draft and JSON test vectors', () => { +test('Python contract accepts only a named function draft and JSON test vectors', () => { const draft = parsePythonAssignmentDraft({ title: 'Add numbers', prompt: 'Write a function that returns the sum of two integers.', @@ -30,7 +31,7 @@ Deno.test('Python contract accepts only a named function draft and JSON test vec assertEquals(cases.length, 8); }); -Deno.test('Python contract rejects wrong case counts and non JSON values', () => { +test('Python contract rejects wrong case counts and non JSON values', () => { assertThrows( () => parseGeneratedCodeTestInputs({ cases: [] }), PythonContractError, diff --git a/src/python_lab/forms.ts b/src/python_lab/forms.ts index b8c9087..dd42ae4 100644 --- a/src/python_lab/forms.ts +++ b/src/python_lab/forms.ts @@ -4,11 +4,7 @@ export function formValue(value: string | null): string { return value ?? ''; } -export function requireFormText( - value: string | null, - label: string, - maxLength: number, -): string { +export function requireFormText(value: string | null, label: string, maxLength: number): string { const text = formValue(value); if (text.trim() === '' || text.length > maxLength) { throw new Error(`${label} is required and must contain at most ${maxLength} characters.`); @@ -45,7 +41,8 @@ export async function readBoundedLabForm(request: Request): Promise LAB_FORM_MAX_BYTES ) { throw new Error('Python Lab form is too large.'); diff --git a/src/python_lab/lab_service.ts b/src/python_lab/lab_service.ts index 622586b..d77ff9a 100644 --- a/src/python_lab/lab_service.ts +++ b/src/python_lab/lab_service.ts @@ -25,9 +25,7 @@ export async function generatePythonLab(input: { readonly importPackageFromSource: ( source: PackageSource, options: { storageRoot: string }, - ) => Promise< - ImportedPackageVersion - >; + ) => Promise; readonly createId: () => string; readonly now?: () => Date; }): Promise { @@ -170,6 +168,6 @@ async function cleanFailedPythonLabMaterialization(input: { } return (await Promise.allSettled(cleanup)).flatMap((result) => - result.status === 'rejected' ? [result.reason] : [] + result.status === 'rejected' ? [result.reason] : [], ); } diff --git a/src/python_lab/lab_service_test.ts b/src/python_lab/lab_service_test.ts index 0ce7ff7..b144be0 100644 --- a/src/python_lab/lab_service_test.ts +++ b/src/python_lab/lab_service_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals } from '@std/assert'; import { buildImportedPackageVersion, @@ -13,7 +14,7 @@ import type { PythonLabRecord, PythonLabRepository } from './repository.ts'; import { createInMemoryPythonLabRepository } from './repository_memory.ts'; import type { PythonGraderSandboxFactory } from './runner.ts'; -Deno.test('failed Lab materialization removes its private artifact and non-catalog package', async () => { +test('failed Lab materialization removes its private artifact and non-catalog package', async () => { const packageRepository = createInMemoryPackageReviewRepository(); const baseRepository = createInMemoryPythonLabRepository(); const lab = generatingLab(); diff --git a/src/python_lab/package_source_test.ts b/src/python_lab/package_source_test.ts index b92bdc8..66fc141 100644 --- a/src/python_lab/package_source_test.ts +++ b/src/python_lab/package_source_test.ts @@ -1,8 +1,9 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertStringIncludes } from '@std/assert'; import { validateManifest } from '../package_review/manifest.ts'; import { createPythonLabPackageSource } from './package_source.ts'; -Deno.test('Python Lab package contains only public assignment content and a sandbox grading contract', async () => { +test('Python Lab package contains only public assignment content and a sandbox grading contract', async () => { const source = createPythonLabPackageSource({ labId: 'lab-123', content: { diff --git a/src/python_lab/repository_d1.ts b/src/python_lab/repository_d1.ts index 8b551fe..8aff67a 100644 --- a/src/python_lab/repository_d1.ts +++ b/src/python_lab/repository_d1.ts @@ -25,16 +25,16 @@ export function createD1PythonLabRepository(db: D1Database): PythonLabRepository return { async reserveLab(input) { const statements: Array<{ sql: string; parameters: readonly unknown[] }> = - input.ipWindowStartedAt === null ? [] : [{ - sql: `INSERT INTO python_lab_rate_limits ( + input.ipWindowStartedAt === null + ? [] + : [ + { + sql: `INSERT INTO python_lab_rate_limits ( request_ip_hash, window_started_at, created_at ) VALUES (?, ?, ?)`, - parameters: [ - input.lab.requestIpHash, - input.ipWindowStartedAt, - input.lab.createdAt, - ], - }]; + parameters: [input.lab.requestIpHash, input.ipWindowStartedAt, input.lab.createdAt], + }, + ]; statements.push({ sql: `INSERT INTO python_lab_trials ( lab_id, visitor_hash, visitor_day, request_ip_hash, access_token_hash, state, author_draft, @@ -103,12 +103,13 @@ export function createD1PythonLabRepository(db: D1Database): PythonLabRepository return await requireLab(db, input.labId); }, async listExpiredLabs(now) { - return (await queryD1Objects>( - db, - 'SELECT * FROM python_lab_trials WHERE expires_at <= ?', - [now], - )) - .map(mapLabRow); + return ( + await queryD1Objects>( + db, + 'SELECT * FROM python_lab_trials WHERE expires_at <= ?', + [now], + ) + ).map(mapLabRow); }, async deleteLab(labId) { await runD1(db, 'DELETE FROM python_lab_trials WHERE lab_id = ?', [labId]); @@ -202,7 +203,7 @@ export function createD1PythonLabRepository(db: D1Database): PythonLabRepository ); return rows.map((row) => - expectD1String(row.source_artifact_key, 'code_submission_runs.source_artifact_key') + expectD1String(row.source_artifact_key, 'code_submission_runs.source_artifact_key'), ); }, async createAccessRequest(record) { @@ -226,10 +227,12 @@ export function createD1PythonLabRepository(db: D1Database): PythonLabRepository return record; }, async listAccessRequests() { - return (await queryD1Objects>( - db, - 'SELECT * FROM python_lab_access_requests ORDER BY created_at DESC', - )).map(mapAccessRequestRow); + return ( + await queryD1Objects>( + db, + 'SELECT * FROM python_lab_access_requests ORDER BY created_at DESC', + ) + ).map(mapAccessRequestRow); }, async updateAccessRequestStatus(input) { await runD1( @@ -250,11 +253,13 @@ export function createD1PythonLabRepository(db: D1Database): PythonLabRepository return mapAccessRequestRow(row); }, async listExpiredAccessRequests(now) { - return (await queryD1Objects>( - db, - 'SELECT * FROM python_lab_access_requests WHERE expires_at <= ?', - [now], - )).map(mapAccessRequestRow); + return ( + await queryD1Objects>( + db, + 'SELECT * FROM python_lab_access_requests WHERE expires_at <= ?', + [now], + ) + ).map(mapAccessRequestRow); }, async deleteAccessRequest(requestId) { await runD1(db, 'DELETE FROM python_lab_access_requests WHERE request_id = ?', [requestId]); @@ -263,7 +268,7 @@ export function createD1PythonLabRepository(db: D1Database): PythonLabRepository } async function requireLab(db: D1Database, labId: string): Promise { - const lab = await mapOptionalLab( + const lab = mapOptionalLab( await queryD1First>( db, 'SELECT * FROM python_lab_trials WHERE lab_id = ?', @@ -342,10 +347,7 @@ function mapLabRow(row: Record): PythonLabRecord { return { labId: expectD1String(row.lab_id, 'python_lab_trials.lab_id'), visitorHash: expectD1String(row.visitor_hash, 'python_lab_trials.visitor_hash'), - requestIpHash: expectD1NullableString( - row.request_ip_hash, - 'python_lab_trials.request_ip_hash', - ), + requestIpHash: expectD1NullableString(row.request_ip_hash, 'python_lab_trials.request_ip_hash'), accessTokenHash: expectD1String(row.access_token_hash, 'python_lab_trials.access_token_hash'), state: expectD1StringLiteral(row.state, 'python_lab_trials.state', LAB_STATES), authorDraft, @@ -423,8 +425,11 @@ function mapOptionalRun(row: Record | null): CodeSubmissionRunR } function isFinalRunUniqueViolation(error: unknown): boolean { - return error instanceof Error && error.message.includes('UNIQUE constraint failed') && - error.message.includes('code_submission_runs.attempt_id'); + return ( + error instanceof Error && + error.message.includes('UNIQUE constraint failed') && + error.message.includes('code_submission_runs.attempt_id') + ); } function parsePublicOutcomes( @@ -437,11 +442,10 @@ function parsePublicOutcomes( return value.map((item) => { const record = expectD1JsonRecord(item, fieldName); - const status = expectD1StringLiteral( - record.status, - `${fieldName}.status`, - ['passed', 'failed'] as const, - ); + const status = expectD1StringLiteral(record.status, `${fieldName}.status`, [ + 'passed', + 'failed', + ] as const); const message = expectD1NullableString(record.message, `${fieldName}.message`); return { diff --git a/src/python_lab/repository_d1_test.ts b/src/python_lab/repository_d1_test.ts index f60ffc9..92f7c5d 100644 --- a/src/python_lab/repository_d1_test.ts +++ b/src/python_lab/repository_d1_test.ts @@ -1,9 +1,11 @@ +import { readFile } from 'node:fs/promises'; +import { test } from 'vite-plus/test'; import { assertEquals, assertStringIncludes } from '@std/assert'; import type { D1Database, D1Parameter, D1PreparedStatement, D1Result } from '../db/d1.ts'; import { createD1PythonLabRepository } from './repository_d1.ts'; import type { PythonLabRecord } from './repository.ts'; -Deno.test('D1 Python Lab admission reserves the IP window and daily visitor in one batch', async () => { +test('D1 Python Lab admission reserves the IP window and daily visitor in one batch', async () => { const db = createRecordingDatabase(); const repository = createD1PythonLabRepository(db); @@ -25,7 +27,7 @@ Deno.test('D1 Python Lab admission reserves the IP window and daily visitor in o ]); }); -Deno.test('D1 Python Lab admission translates a unique visitor reservation into a rate-limit result', async () => { +test('D1 Python Lab admission translates a unique visitor reservation into a rate-limit result', async () => { const db = createRecordingDatabase( 'UNIQUE constraint failed: python_lab_trials.visitor_hash, python_lab_trials.visitor_day', ); @@ -40,10 +42,8 @@ Deno.test('D1 Python Lab admission translates a unique visitor reservation into assertEquals(admission, { status: 'visitor_limited' }); }); -Deno.test('Python Lab migration persists the daily visitor and final-run recovery constraints', async () => { - const migration = await Deno.readTextFile( - 'src/db/d1_migrations/0003_python_autograder_lab.sql', - ); +test('Python Lab migration persists the daily visitor and final-run recovery constraints', async () => { + const migration = await readFile('src/db/d1_migrations/0003_python_autograder_lab.sql', 'utf8'); assertStringIncludes(migration, 'visitor_day TEXT NOT NULL'); assertStringIncludes(migration, 'UNIQUE (visitor_hash, visitor_day)'); @@ -92,10 +92,14 @@ function createRecordingDatabase(batchError?: string): D1Database & { batch(statements) { const recorded = statements as RecordingStatement[]; batches.push(recorded); - return Promise.resolve(recorded.map((_statement, index) => ({ - success: batchError === undefined || index !== recorded.length - 1, - ...(batchError === undefined || index !== recorded.length - 1 ? {} : { error: batchError }), - }))); + return Promise.resolve( + recorded.map((_statement, index) => ({ + success: batchError === undefined || index !== recorded.length - 1, + ...(batchError === undefined || index !== recorded.length - 1 + ? {} + : { error: batchError }), + })), + ); }, exec() { return Promise.resolve({ count: 0, duration: 0 }); diff --git a/src/python_lab/repository_memory.ts b/src/python_lab/repository_memory.ts index bce10e9..6553d1e 100644 --- a/src/python_lab/repository_memory.ts +++ b/src/python_lab/repository_memory.ts @@ -6,12 +6,14 @@ import type { PythonLabRepository, } from './repository.ts'; -export function createInMemoryPythonLabRepository(input: { - readonly labs?: readonly PythonLabRecord[]; - readonly definitions?: readonly CodeAssignmentDefinitionRecord[]; - readonly runs?: readonly CodeSubmissionRunRecord[]; - readonly accessRequests?: readonly PythonLabAccessRequestRecord[]; -} = {}): PythonLabRepository { +export function createInMemoryPythonLabRepository( + input: { + readonly labs?: readonly PythonLabRecord[]; + readonly definitions?: readonly CodeAssignmentDefinitionRecord[]; + readonly runs?: readonly CodeSubmissionRunRecord[]; + readonly accessRequests?: readonly PythonLabAccessRequestRecord[]; + } = {}, +): PythonLabRepository { const labs = [...(input.labs ?? [])]; const definitions = [...(input.definitions ?? [])]; const runs = [...(input.runs ?? [])]; @@ -22,9 +24,10 @@ export function createInMemoryPythonLabRepository(input: { async reserveLab(input) { await Promise.resolve(); if ( - labs.some((lab) => - lab.visitorHash === input.lab.visitorHash && - lab.createdAt.slice(0, 10) === input.visitorDay + labs.some( + (lab) => + lab.visitorHash === input.lab.visitorHash && + lab.createdAt.slice(0, 10) === input.visitorDay, ) ) { return { status: 'visitor_limited' }; @@ -94,8 +97,8 @@ export function createInMemoryPythonLabRepository(input: { }, async reserveCodeSubmissionRun(record) { await Promise.resolve(); - const existing = runs.find((run) => - run.attemptId === record.attemptId && run.phase === 'final' + const existing = runs.find( + (run) => run.attemptId === record.attemptId && run.phase === 'final', ); if (existing !== undefined) { return { status: 'existing', run: structuredClone(existing) }; @@ -129,9 +132,9 @@ export function createInMemoryPythonLabRepository(input: { }, async listAccessRequests() { await Promise.resolve(); - return accessRequests.map((request) => structuredClone(request)).sort((left, right) => - right.createdAt.localeCompare(left.createdAt) - ); + return accessRequests + .map((request) => structuredClone(request)) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)); }, async updateAccessRequestStatus(input) { await Promise.resolve(); @@ -146,9 +149,9 @@ export function createInMemoryPythonLabRepository(input: { }, async listExpiredAccessRequests(now) { await Promise.resolve(); - return accessRequests.filter((request) => request.expiresAt <= now).map((request) => - structuredClone(request) - ); + return accessRequests + .filter((request) => request.expiresAt <= now) + .map((request) => structuredClone(request)); }, async deleteAccessRequest(requestId) { await Promise.resolve(); diff --git a/src/python_lab/retention.ts b/src/python_lab/retention.ts index a6e041d..6368c8a 100644 --- a/src/python_lab/retention.ts +++ b/src/python_lab/retention.ts @@ -41,8 +41,9 @@ async function purgeExpiredLab( if (lab.packageVersionId !== null) { const packageVersion = await input.repository.getPackageVersionById(lab.packageVersionId); - const sourceKeys = await input.pythonLabRepository - .listSubmissionArtifactKeysByPackageVersionId(lab.packageVersionId); + const sourceKeys = await input.pythonLabRepository.listSubmissionArtifactKeysByPackageVersionId( + lab.packageVersionId, + ); for (const sourceKey of sourceKeys) { privateKeys.add(sourceKey); diff --git a/src/python_lab/retention_test.ts b/src/python_lab/retention_test.ts index de101b3..4391db6 100644 --- a/src/python_lab/retention_test.ts +++ b/src/python_lab/retention_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals } from '@std/assert'; import { buildPackageVersionRecord, @@ -7,40 +8,44 @@ import type { PythonLabArtifactStore } from './artifact_store.ts'; import { createInMemoryPythonLabRepository } from './repository_memory.ts'; import { purgeExpiredPythonLabs } from './retention.ts'; -Deno.test('Python Lab retention deletes trial artifacts and packages while preserving live access requests', async () => { +test('Python Lab retention deletes trial artifacts and packages while preserving live access requests', async () => { const pythonLabRepository = createInMemoryPythonLabRepository({ - labs: [{ - labId: 'lab-expired', - visitorHash: 'visitor', - requestIpHash: null, - accessTokenHash: 'token', - state: 'ready', - authorDraft: { - title: 'Add', - prompt: 'Add two numbers.', - starterCode: 'def add(left, right):\n return 0\n', - functionName: 'add', + labs: [ + { + labId: 'lab-expired', + visitorHash: 'visitor', + requestIpHash: null, + accessTokenHash: 'token', + state: 'ready', + authorDraft: { + title: 'Add', + prompt: 'Add two numbers.', + starterCode: 'def add(left, right):\n return 0\n', + functionName: 'add', + }, + packageVersionId: 1, + privateArtifactKey: 'var/private-python-lab/lab-expired/assignment.json', + failureMessage: null, + createdAt: '2026-07-01T00:00:00Z', + updatedAt: '2026-07-01T00:00:00Z', + expiresAt: '2026-07-08T00:00:00Z', + }, + ], + runs: [ + { + runId: 'run-1', + attemptId: 'attempt-1', + sourceArtifactKey: 'var/private-python-lab/submissions/attempt-1/run-1.py', + phase: 'final', + publicOutcomes: [], + hiddenSummary: { passed: 0, total: 5 }, + scoreGiven: 0, + scoreMaximum: 10, + createdAt: '2026-07-01T00:00:00Z', + sourcePersistedAt: '2026-07-01T00:00:00Z', + finalizedAt: '2026-07-01T00:00:00Z', }, - packageVersionId: 1, - privateArtifactKey: 'var/private-python-lab/lab-expired/assignment.json', - failureMessage: null, - createdAt: '2026-07-01T00:00:00Z', - updatedAt: '2026-07-01T00:00:00Z', - expiresAt: '2026-07-08T00:00:00Z', - }], - runs: [{ - runId: 'run-1', - attemptId: 'attempt-1', - sourceArtifactKey: 'var/private-python-lab/submissions/attempt-1/run-1.py', - phase: 'final', - publicOutcomes: [], - hiddenSummary: { passed: 0, total: 5 }, - scoreGiven: 0, - scoreMaximum: 10, - createdAt: '2026-07-01T00:00:00Z', - sourcePersistedAt: '2026-07-01T00:00:00Z', - finalizedAt: '2026-07-01T00:00:00Z', - }], + ], accessRequests: [ { requestId: 'keep-request', @@ -113,14 +118,9 @@ Deno.test('Python Lab retention deletes trial artifacts and packages while prese assertEquals(await repository.getPackageVersionById(1), null); assertEquals(deleted.includes('var/private-python-lab/lab-expired/assignment.json'), true); assertEquals(deleted.includes('var/private-python-lab/submissions/attempt-1/run-1.py'), true); - assertEquals( - deleted.includes('var/packages/chapter-4-asteroids/0.1.0/dist/index.html'), - true, - ); + assertEquals(deleted.includes('var/packages/chapter-4-asteroids/0.1.0/dist/index.html'), true); assertEquals( (await pythonLabRepository.listAccessRequests()).map((request) => request.requestId), - [ - 'keep-request', - ], + ['keep-request'], ); }); diff --git a/src/python_lab/runner.ts b/src/python_lab/runner.ts index 0f7bc9a..86984c5 100644 --- a/src/python_lab/runner.ts +++ b/src/python_lab/runner.ts @@ -36,10 +36,10 @@ export function createUnavailablePythonGraderSandboxFactory( export type PythonExecutionOutcome = | { readonly status: 'passed'; readonly value: JsonValue } | { - readonly status: 'failed'; - readonly kind: 'submission' | 'runner'; - readonly message: string; - }; + readonly status: 'failed'; + readonly kind: 'submission' | 'runner'; + readonly message: string; + }; export async function executePythonFunction(input: { readonly sandboxFactory: PythonGraderSandboxFactory; diff --git a/src/python_lab/runner_test.ts b/src/python_lab/runner_test.ts index d750387..2bc78c4 100644 --- a/src/python_lab/runner_test.ts +++ b/src/python_lab/runner_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertStringIncludes } from '@std/assert'; import { executePythonFunction, @@ -6,7 +7,7 @@ import { type PythonGraderSandboxFactory, } from './runner.ts'; -Deno.test('executePythonFunction stages a fixed wrapper, parses JSON, and destroys the sandbox', async () => { +test('executePythonFunction stages a fixed wrapper, parses JSON, and destroys the sandbox', async () => { const writes: Array<{ path: string; contents: string }> = []; let command = ''; let destroyed = false; @@ -32,11 +33,10 @@ Deno.test('executePythonFunction stages a fixed wrapper, parses JSON, and destro }); assertEquals(result, { status: 'passed', value: 3 }); - assertEquals(writes.map((item) => item.path), [ - '/workspace/solution.py', - '/workspace/invoke.py', - '/workspace/request.json', - ]); + assertEquals( + writes.map((item) => item.path), + ['/workspace/solution.py', '/workspace/invoke.py', '/workspace/request.json'], + ); assertEquals(command, PYTHON_GRADER_COMMAND); assertStringIncludes(PYTHON_GRADER_COMMAND, 'python3 -I -S'); assertStringIncludes(writes[1]?.contents ?? '', 'class BoundedOutput'); @@ -44,7 +44,7 @@ Deno.test('executePythonFunction stages a fixed wrapper, parses JSON, and destro assertEquals(destroyed, true); }); -Deno.test('executePythonFunction rejects malformed output and still destroys the sandbox', async () => { +test('executePythonFunction rejects malformed output and still destroys the sandbox', async () => { let destroyed = false; const result = await executePythonFunction({ sandboxFactory: fakeSandboxFactory({ @@ -73,7 +73,7 @@ Deno.test('executePythonFunction rejects malformed output and still destroys the assertEquals(destroyed, true); }); -Deno.test('executePythonFunction reports sandbox cleanup failure as a runner failure', async () => { +test('executePythonFunction reports sandbox cleanup failure as a runner failure', async () => { const result = await executePythonFunction({ sandboxFactory: fakeSandboxFactory({ writeFile() { diff --git a/src/python_lab/runtime_submission.ts b/src/python_lab/runtime_submission.ts index 4a49947..aebdcab 100644 --- a/src/python_lab/runtime_submission.ts +++ b/src/python_lab/runtime_submission.ts @@ -117,7 +117,8 @@ export async function runRuntimeCodeSubmission(input: { } if ( - evaluation.hiddenSummary === null || evaluation.scoreGiven === null || + evaluation.hiddenSummary === null || + evaluation.scoreGiven === null || evaluation.scoreMaximum === null ) { throw new Error('Final Python evaluation did not produce a trusted score.'); @@ -226,11 +227,11 @@ async function finalizeTrustedRun(input: { const finalizedNow = input.attempt.finalizedAt === null; const finalizedAttempt = finalizedNow ? await input.repository.finalizeAttempt({ - attemptId: input.attempt.attemptId, - status: 'completed', - completionState: 'completed', - finalizedAt: input.now().toISOString(), - }) + attemptId: input.attempt.attemptId, + status: 'completed', + completionState: 'completed', + finalizedAt: input.now().toISOString(), + }) : input.attempt; if (finalizedNow) { @@ -266,9 +267,10 @@ async function finalizeTrustedRun(input: { }; } -function parseCodeSubmissionRequest( - input: unknown, -): { phase: 'practice' | 'final'; source: string } { +function parseCodeSubmissionRequest(input: unknown): { + phase: 'practice' | 'final'; + source: string; +} { if (input === null || typeof input !== 'object' || Array.isArray(input)) { denyRuntimeBroker({ category: 'specInvalid', @@ -292,7 +294,8 @@ function parseCodeSubmissionRequest( } if ( - typeof record.source !== 'string' || record.source.trim() === '' || + typeof record.source !== 'string' || + record.source.trim() === '' || record.source.length > 32_000 ) { denyRuntimeBroker({ diff --git a/src/python_lab/runtime_submission_test.ts b/src/python_lab/runtime_submission_test.ts index e619ee9..ceff675 100644 --- a/src/python_lab/runtime_submission_test.ts +++ b/src/python_lab/runtime_submission_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertRejects } from '@std/assert'; import { buildAttemptRecord, @@ -11,15 +12,17 @@ import { createInMemoryPythonLabRepository } from './repository_memory.ts'; import type { PythonGraderSandboxFactory } from './runner.ts'; import { runRuntimeCodeSubmission } from './runtime_submission.ts'; -Deno.test('Python runtime submissions keep practice unfinalized and make final runs idempotent', async () => { +test('Python runtime submissions keep practice unfinalized and make final runs idempotent', async () => { const repository = createRepository(); const pythonLabRepository = createInMemoryPythonLabRepository({ - definitions: [{ - packageVersionId: 1, - functionName: 'add', - privateArtifactKey: 'var/private-python-lab/lab-123/assignment.json', - createdAt: '2026-07-13T00:00:00Z', - }], + definitions: [ + { + packageVersionId: 1, + functionName: 'add', + privateArtifactKey: 'var/private-python-lab/lab-123/assignment.json', + createdAt: '2026-07-13T00:00:00Z', + }, + ], }); const artifacts = createArtifactStore(privateArtifact()); const session = runtimeSession(); @@ -69,15 +72,17 @@ Deno.test('Python runtime submissions keep practice unfinalized and make final r assertEquals((await repository.getAttemptById('attempt-123'))?.status, 'completed'); }); -Deno.test('Python runtime submission never finalizes or publishes a grade when the runner fails', async () => { +test('Python runtime submission never finalizes or publishes a grade when the runner fails', async () => { const repository = createRepository(); const pythonLabRepository = createInMemoryPythonLabRepository({ - definitions: [{ - packageVersionId: 1, - functionName: 'add', - privateArtifactKey: 'var/private-python-lab/lab-123/assignment.json', - createdAt: '2026-07-13T00:00:00Z', - }], + definitions: [ + { + packageVersionId: 1, + functionName: 'add', + privateArtifactKey: 'var/private-python-lab/lab-123/assignment.json', + createdAt: '2026-07-13T00:00:00Z', + }, + ], }); await assertRejects( @@ -103,7 +108,7 @@ Deno.test('Python runtime submission never finalizes or publishes a grade when t assertEquals(await pythonLabRepository.getFinalCodeSubmissionRunByAttemptId('attempt-123'), null); }); -Deno.test('concurrent final submissions reserve one source artifact and recover after the winner finishes', async () => { +test('concurrent final submissions reserve one source artifact and recover after the winner finishes', async () => { const repository = createRepository(); const pythonLabRepository = createInMemoryPythonLabRepository({ definitions: [definition()] }); const blockingArtifacts = createBlockingArtifactStore(privateArtifact()); @@ -156,7 +161,7 @@ Deno.test('concurrent final submissions reserve one source artifact and recover ); }); -Deno.test('a final run whose source was stored before its database update is recovered on retry', async () => { +test('a final run whose source was stored before its database update is recovered on retry', async () => { const repository = createRepository(); const baseRepository = createInMemoryPythonLabRepository({ definitions: [definition()] }); let failPersistence = true; @@ -208,11 +213,7 @@ function createRepository() { buildPackageVersionRecord({ appId: 'python-lab-lab-123', version: '1.0.0', - capabilities: [ - 'read_launch_context', - 'read_activity_content', - 'run_code_submission', - ], + capabilities: ['read_launch_context', 'read_activity_content', 'run_code_submission'], grading: { mode: 'sandbox', rubricFile: null, maxScore: 10 }, catalogVisibility: 'python_lab_trial', }), @@ -239,11 +240,7 @@ function runtimeSession() { return buildRuntimeSessionRecord({ appId: 'python-lab-lab-123', packageVersion: '1.0.0', - capabilities: [ - 'read_launch_context', - 'read_activity_content', - 'run_code_submission', - ], + capabilities: ['read_launch_context', 'read_activity_content', 'run_code_submission'], preview: { previewSessionId: 'preview-session-123' }, }); } diff --git a/src/python_lab/service.ts b/src/python_lab/service.ts index dac6af1..9151d41 100644 --- a/src/python_lab/service.ts +++ b/src/python_lab/service.ts @@ -141,9 +141,10 @@ export async function evaluatePythonSubmission(input: { readonly sandboxFactory: PythonGraderSandboxFactory; readonly createOneTimeId: () => string; }): Promise { - const cases = input.phase === 'practice' - ? input.cases.filter((testCase) => testCase.visibility === 'public') - : input.cases; + const cases = + input.phase === 'practice' + ? input.cases.filter((testCase) => testCase.visibility === 'public') + : input.cases; const executions = await mapWithConcurrency( cases, PYTHON_SANDBOX_CONCURRENCY, @@ -167,9 +168,9 @@ export async function evaluatePythonSubmission(input: { (execution) => execution.status === 'failed' && execution.kind === 'runner', ); - const publicOutcomes = outcomes.filter((outcome) => outcome.visibility === 'public').map( - ({ label, status, message }) => ({ label, status, message }), - ); + const publicOutcomes = outcomes + .filter((outcome) => outcome.visibility === 'public') + .map(({ label, status, message }) => ({ label, status, message })); const hiddenOutcomes = outcomes.filter((outcome) => outcome.visibility === 'hidden'); const passed = outcomes.filter((outcome) => outcome.status === 'passed').length; const isScored = input.phase !== 'practice'; @@ -177,10 +178,13 @@ export async function evaluatePythonSubmission(input: { return { phase: input.phase, publicOutcomes, - hiddenSummary: input.phase === 'practice' ? null : { - passed: hiddenOutcomes.filter((outcome) => outcome.status === 'passed').length, - total: hiddenOutcomes.length, - }, + hiddenSummary: + input.phase === 'practice' + ? null + : { + passed: hiddenOutcomes.filter((outcome) => outcome.status === 'passed').length, + total: hiddenOutcomes.length, + }, scoreGiven: isScored ? calculatePythonScore(passed, outcomes.length) : null, scoreMaximum: isScored ? PYTHON_ASSIGNMENT_MAX_SCORE : null, runnerFailed, @@ -192,26 +196,31 @@ async function mapWithConcurrency( concurrency: number, map: (value: T) => Promise, ): Promise { - const results = new Array(values.length); + const results = Array.from({ length: values.length }); let nextIndex = 0; const workerCount = Math.min(concurrency, values.length); - await Promise.all(Array.from({ length: workerCount }, async () => { - while (true) { - const index = nextIndex; - nextIndex += 1; + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (true) { + const index = nextIndex; + nextIndex += 1; - if (index >= values.length) return; - results[index] = await map(values[index]!); - } - })); + if (index >= values.length) return; + results[index] = await map(values[index]!); + } + }), + ); return results; } export function calculatePythonScore(passed: number, total: number): number { if ( - !Number.isInteger(passed) || !Number.isInteger(total) || total <= 0 || passed < 0 || + !Number.isInteger(passed) || + !Number.isInteger(total) || + total <= 0 || + passed < 0 || passed > total ) { throw new PythonAssignmentValidationError('Python test score inputs are invalid.'); @@ -232,9 +241,10 @@ function toCodeTestOutcome( return { label: testCase.label, status: 'failed', - message: testCase.visibility === 'public' - ? 'Returned a different value than expected.' - : 'Hidden test failed.', + message: + testCase.visibility === 'public' + ? 'Returned a different value than expected.' + : 'Hidden test failed.', }; } @@ -251,18 +261,23 @@ function jsonValuesEqual(left: JsonValue, right: JsonValue): boolean { } if (Array.isArray(left) || Array.isArray(right)) { - return Array.isArray(left) && Array.isArray(right) && - left.length === right.length && left.every((value, index) => - jsonValuesEqual(value, right[index]!) - ); + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => jsonValuesEqual(value, right[index]!)) + ); } if (typeof left === 'object' && typeof right === 'object') { const leftKeys = Object.keys(left).sort(); const rightKeys = Object.keys(right).sort(); - return leftKeys.length === rightKeys.length && leftKeys.every( - (key, index) => key === rightKeys[index] && jsonValuesEqual(left[key]!, right[key]!), + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key, index) => key === rightKeys[index] && jsonValuesEqual(left[key]!, right[key]!), + ) ); } diff --git a/src/python_lab/service_test.ts b/src/python_lab/service_test.ts index ced1854..a849522 100644 --- a/src/python_lab/service_test.ts +++ b/src/python_lab/service_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertRejects } from '@std/assert'; import { type JsonValue } from './contract.ts'; import { @@ -9,7 +10,7 @@ import { } from './service.ts'; import type { PythonGraderSandbox, PythonGraderSandboxFactory } from './runner.ts'; -Deno.test('generateAndValidatePythonAssignment derives expected values and requires diagnostic starter code', async () => { +test('generateAndValidatePythonAssignment derives expected values and requires diagnostic starter code', async () => { const assignment = await generateAndValidatePythonAssignment({ draft: draft(), generator: { generate: () => Promise.resolve(generatedCases()) }, @@ -22,7 +23,7 @@ Deno.test('generateAndValidatePythonAssignment derives expected values and requi assertEquals(assignment.privateCases[0]?.expected, 3); }); -Deno.test('generateAndValidatePythonAssignment rejects starter code that passes all public cases', async () => { +test('generateAndValidatePythonAssignment rejects starter code that passes all public cases', async () => { await assertRejects( () => generateAndValidatePythonAssignment({ @@ -36,7 +37,7 @@ Deno.test('generateAndValidatePythonAssignment rejects starter code that passes ); }); -Deno.test('generateAndValidatePythonAssignment rejects an unavailable runner instead of accepting the starter failure', async () => { +test('generateAndValidatePythonAssignment rejects an unavailable runner instead of accepting the starter failure', async () => { await assertRejects( () => generateAndValidatePythonAssignment({ @@ -54,7 +55,7 @@ Deno.test('generateAndValidatePythonAssignment rejects an unavailable runner ins ); }); -Deno.test('evaluatePythonSubmission redacts hidden detail and calculates a ten point final score', async () => { +test('evaluatePythonSubmission redacts hidden detail and calculates a ten point final score', async () => { const assignment = await generateAndValidatePythonAssignment({ draft: draft(), generator: { generate: () => Promise.resolve(generatedCases()) }, @@ -77,7 +78,7 @@ Deno.test('evaluatePythonSubmission redacts hidden detail and calculates a ten p assertEquals(calculatePythonScore(7, 8), 8.75); }); -Deno.test('evaluatePythonSubmission bounds concurrent Sandbox execution', async () => { +test('evaluatePythonSubmission bounds concurrent Sandbox execution', async () => { const assignment = await generateAndValidatePythonAssignment({ draft: draft(), generator: { generate: () => Promise.resolve(generatedCases()) }, @@ -130,39 +131,37 @@ function functionSandboxFactory(): PythonGraderSandboxFactory { let source = ''; let request = ''; - return Promise.resolve( - { - writeFile(path, contents) { - if (path === '/workspace/solution.py') { - source = contents; - } + return Promise.resolve({ + writeFile(path, contents) { + if (path === '/workspace/solution.py') { + source = contents; + } - if (path === '/workspace/request.json') { - request = contents; - } + if (path === '/workspace/request.json') { + request = contents; + } - return Promise.resolve(); - }, - exec() { - const payload = JSON.parse(request) as { args: JsonValue[] }; - const left = payload.args[0]; - const right = payload.args[1]; - const value = source.includes('return 0') - ? 0 - : typeof left === 'number' && typeof right === 'number' + return Promise.resolve(); + }, + exec() { + const payload = JSON.parse(request) as { args: JsonValue[] }; + const left = payload.args[0]; + const right = payload.args[1]; + const value = source.includes('return 0') + ? 0 + : typeof left === 'number' && typeof right === 'number' ? left + right : null; - return Promise.resolve({ - exitCode: 0, - stdout: JSON.stringify({ ok: true, value }), - stderr: '', - }); - }, - destroy() { - return Promise.resolve(); - }, - } satisfies PythonGraderSandbox, - ); + return Promise.resolve({ + exitCode: 0, + stdout: JSON.stringify({ ok: true, value }), + stderr: '', + }); + }, + destroy() { + return Promise.resolve(); + }, + } satisfies PythonGraderSandbox); }, }; } @@ -188,27 +187,25 @@ function createConcurrencyTrackingSandboxFactory(): { maxActive = Math.max(maxActive, active); let request = ''; - return Promise.resolve( - { - writeFile(path, contents) { - if (path === '/workspace/request.json') request = contents; - return Promise.resolve(); - }, - async exec() { - await new Promise((resolve) => setTimeout(resolve, 1)); - const args = (JSON.parse(request) as { args: number[] }).args; - return { - exitCode: 0, - stdout: JSON.stringify({ ok: true, value: args[0]! + args[1]! }), - stderr: '', - }; - }, - destroy() { - active -= 1; - return Promise.resolve(); - }, - } satisfies PythonGraderSandbox, - ); + return Promise.resolve({ + writeFile(path, contents) { + if (path === '/workspace/request.json') request = contents; + return Promise.resolve(); + }, + async exec() { + await new Promise((resolve) => setTimeout(resolve, 1)); + const args = (JSON.parse(request) as { args: number[] }).args; + return { + exitCode: 0, + stdout: JSON.stringify({ ok: true, value: args[0]! + args[1]! }), + stderr: '', + }; + }, + destroy() { + active -= 1; + return Promise.resolve(); + }, + } satisfies PythonGraderSandbox); }, }, }; diff --git a/src/python_lab/turnstile.ts b/src/python_lab/turnstile.ts index e8cb277..af907f1 100644 --- a/src/python_lab/turnstile.ts +++ b/src/python_lab/turnstile.ts @@ -26,7 +26,8 @@ export function createTurnstilePythonLabHumanVerifier(secret: string): PythonLab const body = await response.json(); if ( - body === null || typeof body !== 'object' || + body === null || + typeof body !== 'object' || (body as Record).success !== true ) { throw new Error('Verification was not accepted.'); diff --git a/src/python_lab/views.ts b/src/python_lab/views.ts index dbfe88b..ff8ea57 100644 --- a/src/python_lab/views.ts +++ b/src/python_lab/views.ts @@ -8,11 +8,12 @@ export function labUrl(labId: string): string { } export function renderLabCreateForm(turnstileSiteKey: string): string { - const widget = turnstileSiteKey === '' - ? `` - : `
    `; + const widget = + turnstileSiteKey === '' + ? `` + : `
    `; return `
    @@ -28,23 +29,23 @@ export function renderLabStatus(lab: PythonLabRecord): string { return `

    Your test inputs are being prepared. Refresh this page shortly.

    `; } if (lab.state === 'failed') { - return `

    ${ - escapeHtml(lab.failureMessage ?? 'Python Lab generation failed.') - }

    `; + return `

    ${escapeHtml( + lab.failureMessage ?? 'Python Lab generation failed.', + )}

    `; } if (lab.state !== 'ready') { return `

    This Python Lab has expired.

    `; } - return `

    Python function contract: ${ - escapeHtml(lab.authorDraft.functionName) - }(...). Public tests and hidden final checks are ready.

    -
    Verify candidate
    + return `

    Python function contract: ${escapeHtml( + lab.authorDraft.functionName, + )}(...). Public tests and hidden final checks are ready.

    +
    Verify candidate
    ${renderCandidatePage(lab, lab.authorDraft.starterCode, null)} -

    Canvas access

    `; +

    Canvas access

    `; } export function renderCandidatePage( @@ -52,22 +53,24 @@ export function renderCandidatePage( source: string, result: Awaited> | null, ): string { - const resultHtml = result === null - ? '' - : `
      ${ - result.publicOutcomes.map((outcome) => - `
    • ${escapeHtml(outcome.label)}: ${escapeHtml(outcome.status)}${ - outcome.message === null ? '' : ` (${escapeHtml(outcome.message)})` - }
    • ` - ).join('') - }

    Hidden tests: ${result.hiddenSummary?.passed ?? 0} / ${ - result.hiddenSummary?.total ?? 0 - }. Score: ${result.scoreGiven ?? 0} / ${result.scoreMaximum ?? 10}.

    `; - return `

    Verify a candidate submission

    ${resultHtml}
    `; + const resultHtml = + result === null + ? '' + : `
      ${result.publicOutcomes + .map( + (outcome) => + `
    • ${escapeHtml(outcome.label)}: ${escapeHtml(outcome.status)}${ + outcome.message === null ? '' : ` (${escapeHtml(outcome.message)})` + }
    • `, + ) + .join('')}

    Hidden tests: ${result.hiddenSummary?.passed ?? 0} / ${ + result.hiddenSummary?.total ?? 0 + }. Score: ${result.scoreGiven ?? 0} / ${result.scoreMaximum ?? 10}.

    `; + return `

    Verify a candidate submission

    ${resultHtml}
    `; } export function renderPythonLabPage(input: { @@ -76,13 +79,13 @@ export function renderPythonLabPage(input: { readonly body: string; readonly notice?: string; }): string { - return `${ - escapeHtml(input.title) - }

    Lantern Lab

    ${ - escapeHtml(input.heading) - }

    ${ +

    Lantern Lab

    ${escapeHtml( + input.heading, + )}

    ${ input.notice ? `

    ${escapeHtml(input.notice)}

    ` : '' }${input.body}
    `; } diff --git a/src/python_lab/workflow.ts b/src/python_lab/workflow.ts index 702f9d4..c0b8951 100644 --- a/src/python_lab/workflow.ts +++ b/src/python_lab/workflow.ts @@ -40,7 +40,8 @@ export function isPythonLabGenerationWorkflowBinding( value: unknown, ): value is PythonLabGenerationWorkflowBinding { return ( - typeof value === 'object' && value !== null && + typeof value === 'object' && + value !== null && typeof (value as Partial).create === 'function' ); } diff --git a/src/request_audit.ts b/src/request_audit.ts index 1fac23c..eaf48e5 100644 --- a/src/request_audit.ts +++ b/src/request_audit.ts @@ -1,4 +1,4 @@ -import type { Context } from '@hono/hono'; +import type { Context } from 'hono'; const MAX_HEADER_VALUE_LENGTH = 160; const MAX_KEY_COUNT = 12; @@ -118,11 +118,10 @@ function collectLimitedKeys(keys: Iterable): string[] { function readMaskedClientIp(context: Context): string | null { const forwardedFor = context.req.header('x-forwarded-for'); - const firstForwardedIp = forwardedFor === undefined - ? null - : (forwardedFor.split(',')[0]?.trim() ?? null); - const candidate = context.req.header('cf-connecting-ip') ?? context.req.header('x-real-ip') ?? - firstForwardedIp; + const firstForwardedIp = + forwardedFor === undefined ? null : (forwardedFor.split(',')[0]?.trim() ?? null); + const candidate = + context.req.header('cf-connecting-ip') ?? context.req.header('x-real-ip') ?? firstForwardedIp; if (candidate === null || candidate === undefined || candidate.trim() === '') { return null; diff --git a/src/runtime/artifact_store_fs.ts b/src/runtime/artifact_store_fs.ts index 308b823..8576667 100644 --- a/src/runtime/artifact_store_fs.ts +++ b/src/runtime/artifact_store_fs.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises'; import { assertPathInsideSnapshot, joinSnapshotPath } from '../package_review/snapshot_path.ts'; import type { RuntimeArtifactStore } from './artifact_store.ts'; @@ -13,7 +14,7 @@ const FILE_SYSTEM_RUNTIME_ARTIFACT_STORE: RuntimeArtifactStore = { assertPathInsideSnapshot(snapshotRoot, absolutePath, RUNTIME_SNAPSHOT_OUTSIDE_MESSAGE); - return await Deno.readFile(absolutePath); + return await readFile(absolutePath); }, }; diff --git a/src/runtime/browser_grader.ts b/src/runtime/browser_grader.ts index b871d18..e08225d 100644 --- a/src/runtime/browser_grader.ts +++ b/src/runtime/browser_grader.ts @@ -214,12 +214,10 @@ export function buildBrowserGraderRunnerSource(input: { })); return `(() => { - const browserGraderConfig = ${ - JSON.stringify({ - reviewedSpecFiles: specEntries, - scoreMaximum: input.scoreMaximum, - }) - }; + const browserGraderConfig = ${JSON.stringify({ + reviewedSpecFiles: specEntries, + scoreMaximum: input.scoreMaximum, + })}; const runnerScriptElement = document.currentScript || window.__LanternCurrentScript; if (!(runnerScriptElement instanceof HTMLScriptElement) || runnerScriptElement.src === '') { @@ -315,12 +313,13 @@ function readAuthoringFromManifestJson( const kind = typeof authoringRecord.kind === 'string' ? authoringRecord.kind : null; const graderSpecFiles = Array.isArray(authoringRecord.grader_spec_files) ? authoringRecord.grader_spec_files.filter( - (value: unknown): value is string => typeof value === 'string', - ) + (value: unknown): value is string => typeof value === 'string', + ) : []; - const evidenceExampleFile = typeof authoringRecord.evidence_example_file === 'string' - ? authoringRecord.evidence_example_file - : ''; + const evidenceExampleFile = + typeof authoringRecord.evidence_example_file === 'string' + ? authoringRecord.evidence_example_file + : ''; if (kind !== 'browser_autograder' || evidenceExampleFile === '') { return null; diff --git a/src/runtime/browser_grader_test.ts b/src/runtime/browser_grader_test.ts index a24f40f..0b4a5a3 100644 --- a/src/runtime/browser_grader_test.ts +++ b/src/runtime/browser_grader_test.ts @@ -1,7 +1,8 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertStringIncludes } from '@std/assert'; import { buildBrowserGraderAssetUrls, buildBrowserGraderRunnerSource } from './browser_grader.ts'; -Deno.test('browser grader assets stay on the runtime session origin', () => { +test('browser grader assets stay on the runtime session origin', () => { const urls = buildBrowserGraderAssetUrls({ runtimeBaseUrl: 'https://runtime.lantern.example/runtime/sessions/runtime-session-123', }); @@ -16,7 +17,7 @@ Deno.test('browser grader assets stay on the runtime session origin', () => { ); }); -Deno.test('browser grader runner source only points at reviewed same-origin specs', () => { +test('browser grader runner source only points at reviewed same-origin specs', () => { const source = buildBrowserGraderRunnerSource({ reviewedSpecFiles: ['/grading/specs/structure.spec.js', '/grading/specs/behavior.spec.js'], scoreMaximum: 100, diff --git a/src/runtime/dynamic_worker_delivery.ts b/src/runtime/dynamic_worker_delivery.ts index 4980291..87027b8 100644 --- a/src/runtime/dynamic_worker_delivery.ts +++ b/src/runtime/dynamic_worker_delivery.ts @@ -141,24 +141,24 @@ export function createDynamicWorkerRuntimeDelivery(input: { runtimeContractSignature: delivery.reviewedPackage.runtimeContractSignature, envelopeVersion, }); - const worker = input.loader.get( - workerId, - () => - buildReviewedAppWorkerCode({ - ...delivery, - snapshotStore: input.snapshotStore, - lmsBinding: input.createLmsBinding!({ - appId: delivery.reviewedPackage.appId, - packageVersionId: delivery.reviewedPackage.id, - }), + const worker = input.loader.get(workerId, () => + buildReviewedAppWorkerCode({ + ...delivery, + snapshotStore: input.snapshotStore, + lmsBinding: input.createLmsBinding!({ + appId: delivery.reviewedPackage.appId, + packageVersionId: delivery.reviewedPackage.id, }), + }), ); - return await worker.getEntrypoint(null, { - limits: { - cpuMs: REVIEWED_WORKER_CPU_MS, - subRequests: REVIEWED_WORKER_SUBREQUESTS, - }, - }).fetch(delivery.request); + return await worker + .getEntrypoint(null, { + limits: { + cpuMs: REVIEWED_WORKER_CPU_MS, + subRequests: REVIEWED_WORKER_SUBREQUESTS, + }, + }) + .fetch(delivery.request); }, }; } @@ -341,8 +341,8 @@ async function readRuntimeWorkerAssetResponse(input: { return { bytes: new Uint8Array(await input.response.arrayBuffer()), - contentType: input.response.headers.get('content-type') ?? - contentTypeForRuntimePath(input.relativePath), + contentType: + input.response.headers.get('content-type') ?? contentTypeForRuntimePath(input.relativePath), }; } diff --git a/src/runtime/dynamic_worker_delivery_integration_test.ts b/src/runtime/dynamic_worker_delivery_integration_test.ts new file mode 100644 index 0000000..c6e5f88 --- /dev/null +++ b/src/runtime/dynamic_worker_delivery_integration_test.ts @@ -0,0 +1,19 @@ +import { env } from 'cloudflare:workers'; +import { test } from 'vite-plus/test'; +import { assertEquals, assertStringIncludes } from '@std/assert'; + +test('Photo Roster bundle runs in workerd and reaches roster data only through RPC', async () => { + const response = await env.PHOTO_ROSTER.fetch('https://photo-roster.internal/', { + headers: { + 'x-lantern-runtime-session-id': 'session-1', + 'x-lantern-runtime-session-token': 'token-1', + 'x-lantern-trace-id': 'trace-1', + }, + }); + const html = await response.text(); + + assertEquals(response.status, 200); + assertEquals(response.headers.get('cache-control'), 'private, no-store'); + assertStringIncludes(html, 'Ada Lovelace'); + assertStringIncludes(html, 'ada@example.edu'); +}); diff --git a/src/runtime/dynamic_worker_delivery_test.ts b/src/runtime/dynamic_worker_delivery_test.ts index b2ad63b..a377b7a 100644 --- a/src/runtime/dynamic_worker_delivery_test.ts +++ b/src/runtime/dynamic_worker_delivery_test.ts @@ -1,5 +1,5 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertNotEquals, assertRejects, assertStringIncludes } from '@std/assert'; -import { Miniflare } from 'miniflare'; import { buildReviewedRuntimeAssetMap, buildReviewedRuntimeWorkerCode, @@ -12,7 +12,7 @@ import { buildRuntimeSessionRecord, } from '../test_helpers/package_review.ts'; -Deno.test('Dynamic Worker delivery worker ids stay deterministic and reviewed-identity keyed', () => { +test('Dynamic Worker delivery worker ids stay deterministic and reviewed-identity keyed', () => { const firstId = buildReviewedRuntimeWorkerId({ runtimeContractSignature: 'sig-a', }); @@ -32,7 +32,7 @@ Deno.test('Dynamic Worker delivery worker ids stay deterministic and reviewed-id assertNotEquals(firstId, differentEnvelopeId); }); -Deno.test('Dynamic Worker delivery describes the reviewed envelope using the deterministic worker id', () => { +test('Dynamic Worker delivery describes the reviewed envelope using the deterministic worker id', () => { const delivery = createDynamicWorkerRuntimeDelivery({ loader: { get() { @@ -59,7 +59,7 @@ Deno.test('Dynamic Worker delivery describes the reviewed envelope using the det ); }); -Deno.test('Dynamic Worker delivery embeds immutable reviewed assets plus browser grader files', async () => { +test('Dynamic Worker delivery embeds immutable reviewed assets plus browser grader files', async () => { const session = buildRuntimeSessionRecord({ snapshotRoot: 'var/packages/template-app/0.1.0', entrypointPath: 'var/packages/template-app/0.1.0/dist/index.html', @@ -118,7 +118,7 @@ Deno.test('Dynamic Worker delivery embeds immutable reviewed assets plus browser ); }); -Deno.test('Dynamic Worker delivery code blocks outbound access and carries no extra bindings', async () => { +test('Dynamic Worker delivery code blocks outbound access and carries no extra bindings', async () => { const session = buildRuntimeSessionRecord({ snapshotRoot: 'var/packages/chapter-4-asteroids/0.1.0', entrypointPath: 'var/packages/chapter-4-asteroids/0.1.0/dist/index.html', @@ -142,7 +142,7 @@ Deno.test('Dynamic Worker delivery code blocks outbound access and carries no ex assertEquals((code.modules['index.js'] ?? '').includes('globalThis.Deno'), false); }); -Deno.test('reviewed app Workers receive only the LMS binding and hard invocation limits', async () => { +test('reviewed app Workers receive only the LMS binding and hard invocation limits', async () => { let loadedCode: DynamicWorkerCode | null = null; let invocationLimits: { readonly cpuMs: number; readonly subRequests: number } | null = null; const lmsBinding = { listRoster: () => Promise.resolve({ ok: true }) }; @@ -197,69 +197,7 @@ Deno.test('reviewed app Workers receive only the LMS binding and hard invocation assertEquals(Object.keys(code.env ?? {}), ['LANTERN_LMS']); }); -Deno.test('Photo Roster bundle runs in workerd and reaches roster data only through RPC', async () => { - const appScript = await Deno.readTextFile('examples/apps/photo-roster/dist/worker.js'); - const common = { modules: true, compatibilityDate: '2026-07-15' } as const; - const miniflare = new Miniflare({ - workers: [ - { - ...common, - name: 'photo-roster', - script: appScript, - serviceBindings: { - LANTERN_LMS: { name: 'lantern-lms', entrypoint: 'LanternLms' }, - }, - }, - { - ...common, - name: 'lantern-lms', - script: ` - import { WorkerEntrypoint } from 'cloudflare:workers'; - export class LanternLms extends WorkerEntrypoint { - listRoster(input) { - if (input.runtimeSessionId !== 'session-1' || input.runtimeSessionToken !== 'token-1') { - return { ok: false, error: { code: 'session_expired', traceId: 'trace-denied' } }; - } - return { - ok: true, - value: { - observedAt: '2026-07-20T15:00:00.000Z', - members: [{ - subjectId: 'subject-1', - displayName: 'Ada Lovelace', - roles: ['learner'], - status: 'active', - email: 'ada@example.edu' - }] - } - }; - } - } - export default { fetch() { return new Response('RPC only', { status: 405 }); } }; - `, - }, - ], - }); - try { - const response = await miniflare.dispatchFetch('https://photo-roster.internal/', { - headers: { - 'x-lantern-runtime-session-id': 'session-1', - 'x-lantern-runtime-session-token': 'token-1', - 'x-lantern-trace-id': 'trace-1', - }, - }); - const html = await response.text(); - - assertEquals(response.status, 200); - assertEquals(response.headers.get('cache-control'), 'private, no-store'); - assertStringIncludes(html, 'Ada Lovelace'); - assertStringIncludes(html, 'ada@example.edu'); - } finally { - await miniflare.dispose(); - } -}); - -Deno.test('Dynamic Worker delivery keeps missing reviewed assets distinct from loader delivery failures', async () => { +test('Dynamic Worker delivery keeps missing reviewed assets distinct from loader delivery failures', async () => { const session = buildRuntimeSessionRecord({ snapshotRoot: 'var/packages/chapter-4-asteroids/0.1.0', entrypointPath: 'var/packages/chapter-4-asteroids/0.1.0/dist/index.html', @@ -342,8 +280,8 @@ function createLoaderStub() { callback: () => | Promise<{ modules: Record }> | { - modules: Record; - }, + modules: Record; + }, ) { return { getEntrypoint() { diff --git a/src/runtime/evidence_artifact_store_fs.ts b/src/runtime/evidence_artifact_store_fs.ts index a331b86..628375b 100644 --- a/src/runtime/evidence_artifact_store_fs.ts +++ b/src/runtime/evidence_artifact_store_fs.ts @@ -1,3 +1,4 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { assertEvidenceArtifactStorageKey, type EvidenceArtifactStore, @@ -8,12 +9,12 @@ const FILE_SYSTEM_EVIDENCE_ARTIFACT_STORE: EvidenceArtifactStore = { const safeStorageKey = assertEvidenceArtifactStorageKey(storageKey); const directory = safeStorageKey.split('/').slice(0, -1).join('/'); - await Deno.mkdir(directory, { recursive: true }); - await Deno.writeFile(safeStorageKey, bytes); + await mkdir(directory, { recursive: true }); + await writeFile(safeStorageKey, bytes); }, async readBytes(storageKey) { - return await Deno.readFile(assertEvidenceArtifactStorageKey(storageKey)); + return await readFile(assertEvidenceArtifactStorageKey(storageKey)); }, }; diff --git a/src/runtime/gateway.ts b/src/runtime/gateway.ts index 697b259..70af222 100644 --- a/src/runtime/gateway.ts +++ b/src/runtime/gateway.ts @@ -294,57 +294,61 @@ export async function finalizeRuntimeAttempt(input: { const finalizedNow = attempt.finalizedAt === null; const finalizedAttempt = finalizedNow ? await input.repository.finalizeAttempt({ - attemptId: attempt.attemptId, - status: completionStateToAttemptStatus(finalizeInput.completionState), - completionState: finalizeInput.completionState, - finalizedAt: occurredAt, - }) + attemptId: attempt.attemptId, + status: completionStateToAttemptStatus(finalizeInput.completionState), + completionState: finalizeInput.completionState, + finalizedAt: occurredAt, + }) : attempt; - const browserGraderResult = packageVersion.grading.mode === 'browser' - ? validateBrowserGraderResult({ - browserGraderResult: finalizeInput.browserGraderResult, - packageVersion, - }) - : null; - const score: AttemptScoreResult = browserGraderResult === null - ? { - scoreGiven: 0, - scoreMaximum: previewSession.fakeScoreMaximum, - } - : { - scoreGiven: browserGraderResult.scoreGiven, - scoreMaximum: browserGraderResult.scoreMaximum, - }; + const browserGraderResult = + packageVersion.grading.mode === 'browser' + ? validateBrowserGraderResult({ + browserGraderResult: finalizeInput.browserGraderResult, + packageVersion, + }) + : null; + const score: AttemptScoreResult = + browserGraderResult === null + ? { + scoreGiven: 0, + scoreMaximum: previewSession.fakeScoreMaximum, + } + : { + scoreGiven: browserGraderResult.scoreGiven, + scoreMaximum: browserGraderResult.scoreMaximum, + }; await input.repository.appendPreviewEvidence({ previewSessionId: previewSession.sessionId, eventType: 'preview.finalize', capability: 'finalize_attempt', - summary: browserGraderResult === null - ? 'Finished the test attempt with simulated scoring and no LMS writes.' - : 'Finished the test attempt with browser grader output and no LMS writes.', - detail: browserGraderResult === null - ? { - attemptId: finalizedAttempt.attemptId, - completionState: finalizedAttempt.completionState, - scoreGiven: score.scoreGiven, - scoreMaximum: score.scoreMaximum, - submissionMode: input.session.launch.submissionMode, - evidenceArtifactCount: evidenceArtifacts.length, - evidenceArtifacts, - alreadyFinalized: !finalizedNow, - } - : { - attemptId: finalizedAttempt.attemptId, - completionState: finalizedAttempt.completionState, - scoreGiven: score.scoreGiven, - scoreMaximum: score.scoreMaximum, - submissionMode: input.session.launch.submissionMode, - evidenceArtifactCount: evidenceArtifacts.length, - evidenceArtifacts, - alreadyFinalized: !finalizedNow, - browserGraderResult, - }, + summary: + browserGraderResult === null + ? 'Finished the test attempt with simulated scoring and no LMS writes.' + : 'Finished the test attempt with browser grader output and no LMS writes.', + detail: + browserGraderResult === null + ? { + attemptId: finalizedAttempt.attemptId, + completionState: finalizedAttempt.completionState, + scoreGiven: score.scoreGiven, + scoreMaximum: score.scoreMaximum, + submissionMode: input.session.launch.submissionMode, + evidenceArtifactCount: evidenceArtifacts.length, + evidenceArtifacts, + alreadyFinalized: !finalizedNow, + } + : { + attemptId: finalizedAttempt.attemptId, + completionState: finalizedAttempt.completionState, + scoreGiven: score.scoreGiven, + scoreMaximum: score.scoreMaximum, + submissionMode: input.session.launch.submissionMode, + evidenceArtifactCount: evidenceArtifacts.length, + evidenceArtifacts, + alreadyFinalized: !finalizedNow, + browserGraderResult, + }, occurredAt, }); @@ -504,11 +508,9 @@ function buildEvidenceArtifactStorageKey(input: { artifactId: string; fileName: string; }): string { - return `var/attempt-evidence/${input.attemptId}/${input.artifactId}-${ - sanitizeEvidenceArtifactFileName( - input.fileName, - ) - }`; + return `var/attempt-evidence/${input.attemptId}/${input.artifactId}-${sanitizeEvidenceArtifactFileName( + input.fileName, + )}`; } function sanitizeEvidenceArtifactFileName(fileName: string): string { diff --git a/src/runtime/gateway_attempt_events_test.ts b/src/runtime/gateway_attempt_events_test.ts index 05ea4dd..0eb47de 100644 --- a/src/runtime/gateway_attempt_events_test.ts +++ b/src/runtime/gateway_attempt_events_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertObjectMatch, assertRejects } from '@std/assert'; import { createTestApp as createApp } from '../test_helpers/app.ts'; import { withRuntimeOriginEnv } from '../app_test_support.ts'; @@ -10,7 +11,7 @@ import { createInMemoryPackageReviewRepository, } from '../test_helpers/package_review.ts'; -Deno.test('runtime gateway accepts authenticated attempt-event writes and persists append-only attempt events', async () => { +test('runtime gateway accepts authenticated attempt-event writes and persists append-only attempt events', async () => { await withRuntimeOriginEnv(async () => { const repository = createInMemoryPackageReviewRepository({ attempts: [buildAttemptRecord()], @@ -61,7 +62,7 @@ Deno.test('runtime gateway accepts authenticated attempt-event writes and persis }); }); -Deno.test('runtime gateway blocks missing capability, bad payloads, and bad tokens before any write', async () => { +test('runtime gateway blocks missing capability, bad payloads, and bad tokens before any write', async () => { await withRuntimeOriginEnv(async () => { const repository = createInMemoryPackageReviewRepository({ attempts: [buildAttemptRecord()], @@ -128,7 +129,7 @@ Deno.test('runtime gateway blocks missing capability, bad payloads, and bad toke }); }); -Deno.test('runtime gateway validates attempt event payloads and capabilities', async () => { +test('runtime gateway validates attempt event payloads and capabilities', async () => { assertEquals( parseAttemptEvent({ type: 'complete', @@ -160,7 +161,7 @@ Deno.test('runtime gateway validates attempt event payloads and capabilities', a type: 'answer', answer: 'asteroid', timestamp: '2026-03-24T02:30:00Z', - }) + }), ), RuntimeBrokerDenialError, ); @@ -172,7 +173,7 @@ Deno.test('runtime gateway validates attempt event payloads and capabilities', a capabilities: ['read_launch_context'], }), 'submit_attempt_event', - ) + ), ), RuntimeBrokerDenialError, )) as RuntimeBrokerDenialError; @@ -181,7 +182,7 @@ Deno.test('runtime gateway validates attempt event payloads and capabilities', a assertEquals(capabilityError.capability, 'submit_attempt_event'); }); -Deno.test('runtime gateway helper appends attempt events directly against the durable ledger', async () => { +test('runtime gateway helper appends attempt events directly against the durable ledger', async () => { const repository = createInMemoryPackageReviewRepository({ attempts: [buildAttemptRecord()], previewSessions: [buildPreviewSessionRecord({ fakeAttemptId: 'attempt-123' })], diff --git a/src/runtime/gateway_broker_denials_test.ts b/src/runtime/gateway_broker_denials_test.ts index fb22163..d51b693 100644 --- a/src/runtime/gateway_broker_denials_test.ts +++ b/src/runtime/gateway_broker_denials_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertExists, assertObjectMatch, assertRejects } from '@std/assert'; import { requireRuntimeCapability, submitScoreProposal } from './gateway.ts'; import { RuntimeBrokerDenialError, toRuntimeBrokerResult } from './gateway_errors.ts'; @@ -7,7 +8,7 @@ import { createInMemoryPackageReviewRepository, } from '../test_helpers/package_review.ts'; -Deno.test('runtime broker exposes stable typed denial results for missing capabilities', async () => { +test('runtime broker exposes stable typed denial results for missing capabilities', async () => { const error = (await assertRejects( () => Promise.resolve().then(() => @@ -16,7 +17,7 @@ Deno.test('runtime broker exposes stable typed denial results for missing capabi capabilities: ['read_launch_context'], }), 'finalize_attempt', - ) + ), ), RuntimeBrokerDenialError, )) as RuntimeBrokerDenialError; @@ -37,7 +38,7 @@ Deno.test('runtime broker exposes stable typed denial results for missing capabi }); }); -Deno.test('runtime broker accepts score proposals without creating a direct grade publication path', async () => { +test('runtime broker accepts score proposals without creating a direct grade publication path', async () => { const repository = createInMemoryPackageReviewRepository({ attempts: [buildAttemptRecord()], }); @@ -64,7 +65,7 @@ Deno.test('runtime broker accepts score proposals without creating a direct grad assertEquals((await repository.getAttemptById('attempt-123'))?.finalizedAt, null); }); -Deno.test('runtime broker rejects invalid score proposals with typed spec-invalid denials', async () => { +test('runtime broker rejects invalid score proposals with typed spec-invalid denials', async () => { const repository = createInMemoryPackageReviewRepository({ attempts: [buildAttemptRecord()], }); diff --git a/src/runtime/gateway_evidence_test.ts b/src/runtime/gateway_evidence_test.ts index e5258e8..db56e2b 100644 --- a/src/runtime/gateway_evidence_test.ts +++ b/src/runtime/gateway_evidence_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertObjectMatch } from '@std/assert'; import { RuntimeBrokerDenialError } from './gateway_errors.ts'; import { submitEvidenceArtifact } from './gateway.ts'; @@ -9,7 +10,7 @@ import { } from '../test_helpers/package_review.ts'; import type { EvidenceArtifactStore } from './evidence_artifact_store.ts'; -Deno.test('runtime gateway accepts an allowlisted evidence artifact and records preview evidence', async () => { +test('runtime gateway accepts an allowlisted evidence artifact and records preview evidence', async () => { const bytesByStorageKey = new Map(); const repository = createInMemoryPackageReviewRepository({ previewSessions: [ @@ -111,7 +112,7 @@ Deno.test('runtime gateway accepts an allowlisted evidence artifact and records assertEquals(typeof previewEvidence[0]?.detail.sha256, 'string'); }); -Deno.test('runtime gateway accepts screenshot evidence through the same governed artifact path', async () => { +test('runtime gateway accepts screenshot evidence through the same governed artifact path', async () => { const pngBytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const bytesByStorageKey = new Map(); const repository = createInMemoryPackageReviewRepository({ @@ -203,7 +204,7 @@ Deno.test('runtime gateway accepts screenshot evidence through the same governed assertEquals(typeof previewEvidence[0]?.detail.sha256, 'string'); }); -Deno.test('runtime gateway rejects unsupported evidence kind and content-type pairs', async () => { +test('runtime gateway rejects unsupported evidence kind and content-type pairs', async () => { const repository = createInMemoryPackageReviewRepository({ attempts: [buildAttemptRecord()], }); diff --git a/src/runtime/gateway_local_state_test.ts b/src/runtime/gateway_local_state_test.ts index f4b3244..b2147c2 100644 --- a/src/runtime/gateway_local_state_test.ts +++ b/src/runtime/gateway_local_state_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertRejects } from '@std/assert'; import { readAttemptLocalState, writeAttemptLocalState } from './gateway.ts'; import { buildRuntimeSessionRecord } from '../test_helpers/lti.ts'; @@ -6,7 +7,7 @@ import { createInMemoryPackageReviewRepository, } from '../test_helpers/package_review.ts'; -Deno.test('runtime gateway reads null local state by default and round-trips JSON objects on the durable attempt', async () => { +test('runtime gateway reads null local state by default and round-trips JSON objects on the durable attempt', async () => { const repository = createInMemoryPackageReviewRepository({ attempts: [buildAttemptRecord()], }); @@ -44,7 +45,7 @@ Deno.test('runtime gateway reads null local state by default and round-trips JSO ); }); -Deno.test('runtime gateway keeps attempt-local state isolated to the matching attempt context', async () => { +test('runtime gateway keeps attempt-local state isolated to the matching attempt context', async () => { const repository = createInMemoryPackageReviewRepository({ attempts: [ buildAttemptRecord(), diff --git a/src/runtime/gateway_parsing.ts b/src/runtime/gateway_parsing.ts index 032ee0a..6bdbe65 100644 --- a/src/runtime/gateway_parsing.ts +++ b/src/runtime/gateway_parsing.ts @@ -435,6 +435,6 @@ function requireFailureMessages(value: unknown, index: number): string[] { requireString( candidate, `Browser grader spec result ${index + 1} failure ${failureIndex + 1} must be a string.`, - ) + ), ); } diff --git a/src/runtime/gateway_preview_test.ts b/src/runtime/gateway_preview_test.ts index 1bbd59d..8d6c929 100644 --- a/src/runtime/gateway_preview_test.ts +++ b/src/runtime/gateway_preview_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assert, assertEquals, assertRejects } from '@std/assert'; import { acceptAttemptEvent, finalizeRuntimeAttempt } from './gateway.ts'; import { RuntimeBrokerDenialError } from './gateway_errors.ts'; @@ -19,7 +20,7 @@ import { const WEB_CHECKUP_SNAPSHOT_ROOT = 'examples/apps/web-checkup'; -Deno.test('preview gateway finalize returns fake scoring and never calls Canvas side-effect services', async () => { +test('preview gateway finalize returns fake scoring and never calls Canvas side-effect services', async () => { const repository = createInMemoryPackageReviewRepository({ packageVersions: [ buildPackageVersionRecord({ @@ -89,7 +90,7 @@ Deno.test('preview gateway finalize returns fake scoring and never calls Canvas ); }); -Deno.test('preview gateway enforces declared capabilities and records bounded blocked-capability evidence', async () => { +test('preview gateway enforces declared capabilities and records bounded blocked-capability evidence', async () => { const repository = createInMemoryPackageReviewRepository({ previewSessions: [ buildPreviewSessionRecord({ @@ -142,7 +143,7 @@ Deno.test('preview gateway enforces declared capabilities and records bounded bl assertEquals(evidence[0]?.capability, 'submit_attempt_event'); }); -Deno.test('preview gateway records allowed quick-study attempt events through the governed runtime seam', async () => { +test('preview gateway records allowed quick-study attempt events through the governed runtime seam', async () => { const snapshotRoot = getReferenceAppSnapshotRoot('quick-study'); const repository = createInMemoryPackageReviewRepository({ previewSessions: [ @@ -239,7 +240,7 @@ Deno.test('preview gateway records allowed quick-study attempt events through th assertEquals(evidence[0]?.capability, 'submit_attempt_event'); }); -Deno.test('preview gateway blocks live-service finalize paths clearly and records failure evidence', async () => { +test('preview gateway blocks live-service finalize paths clearly and records failure evidence', async () => { const repository = createInMemoryPackageReviewRepository({ previewSessions: [ buildPreviewSessionRecord({ @@ -286,7 +287,7 @@ Deno.test('preview gateway blocks live-service finalize paths clearly and record assertEquals(evidence[0]?.capability, 'finalize_attempt'); }); -Deno.test('preview gateway records browser grader evidence instead of fake scoring for browser-graded packages', async () => { +test('preview gateway records browser grader evidence instead of fake scoring for browser-graded packages', async () => { const repository = createInMemoryPackageReviewRepository({ packageVersions: [ buildPackageVersionRecord({ diff --git a/src/runtime/gateway_test_helpers.ts b/src/runtime/gateway_test_helpers.ts index 2a8ed85..0108f16 100644 --- a/src/runtime/gateway_test_helpers.ts +++ b/src/runtime/gateway_test_helpers.ts @@ -16,11 +16,11 @@ export function getReferenceAppSnapshotRoot(appId: 'chapter-4-asteroids' | 'quic export function restoreEnv(name: string, value: string | undefined): void { if (value === undefined) { - Deno.env.delete(name); + delete process.env[name]; return; } - Deno.env.set(name, value); + process.env[name] = value; } export async function withFetchStub( diff --git a/src/runtime/reviewed_worker_http.ts b/src/runtime/reviewed_worker_http.ts index cff8294..ba8ea6c 100644 --- a/src/runtime/reviewed_worker_http.ts +++ b/src/runtime/reviewed_worker_http.ts @@ -1,4 +1,4 @@ -import type { Context } from '@hono/hono'; +import type { Context } from 'hono'; /** Builds the sanitized internal request passed to an immutable reviewed Worker. */ export function buildReviewedWorkerRequest( @@ -82,12 +82,6 @@ function applyRuntimeDocumentHeaders(headers: Headers, lmsOrigin: string): void ); headers.set( 'permissions-policy', - [ - 'camera=()', - 'geolocation=()', - 'microphone=()', - 'payment=()', - 'usb=()', - ].join(', '), + ['camera=()', 'geolocation=()', 'microphone=()', 'payment=()', 'usb=()'].join(', '), ); } diff --git a/src/runtime/route_commands.ts b/src/runtime/route_commands.ts index 168527a..a7352e8 100644 --- a/src/runtime/route_commands.ts +++ b/src/runtime/route_commands.ts @@ -102,9 +102,11 @@ export async function finalizeRuntimeSession(input: { session: input.session, payload: input.payload, env: input.env, - ...(input.getLtiToolContext === undefined ? {} : { - getLtiToolContext: input.getLtiToolContext, - }), + ...(input.getLtiToolContext === undefined + ? {} + : { + getLtiToolContext: input.getLtiToolContext, + }), artifactStore: input.artifactStore, now, }); diff --git a/src/runtime/session.ts b/src/runtime/session.ts index 3beb58f..4b339e9 100644 --- a/src/runtime/session.ts +++ b/src/runtime/session.ts @@ -82,31 +82,27 @@ export async function renderRuntimeSessionPage( }) ).bytes, ); - const runtimeOrigin = input.runtimeOrigin ?? - requireConfiguredRuntimeOrigin(input.env.get('APP_RUNTIME_ORIGIN')); + const runtimeOrigin = + input.runtimeOrigin ?? requireConfiguredRuntimeOrigin(input.env.get('APP_RUNTIME_ORIGIN')); const runtimeBaseUrl = buildRuntimeSessionBaseUrl({ runtimeOrigin, sessionId: session.sessionId, }); const entrypointDirectory = entrypointDirectoryPath(session); - const assetBaseUrl = `${runtimeBaseUrl}/files/__token__/${ - encodeURIComponent( - session.sessionToken, - ) - }/${entrypointDirectory}`; + const assetBaseUrl = `${runtimeBaseUrl}/files/__token__/${encodeURIComponent( + session.sessionToken, + )}/${entrypointDirectory}`; const bootstrap = await buildRuntimeBootstrap({ session, runtimeContractSignature: input.reviewedPackage.runtimeContractSignature, env: input.env, }); const headInjection = ``; - const bodyInjection = ``; + const bodyInjection = ``; return injectBeforeFirstScriptOrClosingBody( injectAfterOpeningTag(entrypointHtml, 'head', headInjection), diff --git a/src/runtime/session_html.ts b/src/runtime/session_html.ts index 26b721b..585954f 100644 --- a/src/runtime/session_html.ts +++ b/src/runtime/session_html.ts @@ -22,9 +22,11 @@ export function buildRuntimeBootstrapScript(input: { const browserGraderJasmineUrl = serializeForInlineScript(browserGraderUrls.jasmineUrl); const browserGraderRunnerUrl = serializeForInlineScript(browserGraderUrls.runnerUrl); const previewJson = serializeForInlineScript( - input.previewSessionId === null ? null : { - previewSessionId: input.previewSessionId, - }, + input.previewSessionId === null + ? null + : { + previewSessionId: input.previewSessionId, + }, ); return `window.GatewayBootstrap = ${bootstrapJson}; diff --git a/src/runtime/session_test.ts b/src/runtime/session_test.ts index 39372a8..5e8fb42 100644 --- a/src/runtime/session_test.ts +++ b/src/runtime/session_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertRejects, assertStringIncludes } from '@std/assert'; import { compactVerify, createLocalJWKSet } from 'jose'; import type { BootstrapPayload } from '../../sdk/app-sdk.ts'; @@ -27,12 +28,12 @@ const TEST_SIGNING_ENV = { return name === 'LTI_TOOL_PRIVATE_JWK' ? getTestToolPrivateJwkEnvValue() : name === 'APP_RUNTIME_ORIGIN' - ? 'https://runtime.lantern.example' - : undefined; + ? 'https://runtime.lantern.example' + : undefined; }, }; -Deno.test('runtime session route serves the pinned reviewed entrypoint with an injected signed bootstrap payload', async () => { +test('runtime session route serves the pinned reviewed entrypoint with an injected signed bootstrap payload', async () => { const html = await renderRuntimeSessionPage( buildRuntimeSessionRecord({ snapshotRoot: EXAMPLE_SNAPSHOT_ROOT, @@ -109,7 +110,7 @@ Deno.test('runtime session route serves the pinned reviewed entrypoint with an i await assertBootstrapSignature(bootstrap); }); -Deno.test('runtime content route serves reviewed activity content through the Lantern bridge', async () => { +test('runtime content route serves reviewed activity content through the Lantern bridge', async () => { const content = (await loadRuntimeActivityContent( buildRuntimeSessionRecord({ snapshotRoot: EXAMPLE_SNAPSHOT_ROOT, @@ -123,7 +124,7 @@ Deno.test('runtime content route serves reviewed activity content through the La assertEquals(content.questions[0]?.id, 'q1'); }); -Deno.test('runtime session helpers can load reviewed artifacts from an R2-backed store', async () => { +test('runtime session helpers can load reviewed artifacts from an R2-backed store', async () => { const artifactStore = createR2RuntimeArtifactStore( createTestRuntimeArtifactBucket({ [`${WORKER_SNAPSHOT_ROOT}/dist/index.html`]: @@ -157,7 +158,7 @@ Deno.test('runtime session helpers can load reviewed artifacts from an R2-backed assertEquals(content.questions[0]?.id, 'worker-q1'); }); -Deno.test('missing or expired runtime session tokens are blocked before artifact bytes are served', async () => { +test('missing or expired runtime session tokens are blocked before artifact bytes are served', async () => { await assertRejects( () => Promise.resolve().then(() => @@ -167,7 +168,7 @@ Deno.test('missing or expired runtime session tokens are blocked before artifact expiresAt: '2026-03-23T22:40:00Z', }), now: () => new Date('2026-03-23T22:45:00Z'), - }) + }), ), Error, 'Runtime session token did not match the requested session.', @@ -181,7 +182,7 @@ Deno.test('missing or expired runtime session tokens are blocked before artifact expiresAt: '2026-03-23T22:40:00Z', }), now: () => new Date('2026-03-23T22:45:00Z'), - }) + }), ), Error, 'Runtime session has expired.', diff --git a/src/test_helpers/admin_auth.ts b/src/test_helpers/admin_auth.ts index 9f22f8c..83f4fca 100644 --- a/src/test_helpers/admin_auth.ts +++ b/src/test_helpers/admin_auth.ts @@ -32,11 +32,11 @@ export function createAdminTestApp(services: Partial = {}) { ...restServices, ...(explicitEnv ? { - env: createObjectEnvReader({ - ADMIN_AUTH_ALLOW_SIGNUP: '1', - APP_ORIGIN: explicitEnv.get('APP_ORIGIN') ?? 'http://localhost', - }), - } + env: createObjectEnvReader({ + ADMIN_AUTH_ALLOW_SIGNUP: '1', + APP_ORIGIN: explicitEnv.get('APP_ORIGIN') ?? 'http://localhost', + }), + } : {}), }); @@ -152,7 +152,7 @@ export function cookieHeaderFromResponse(response: Response): string | null { const cookie = response.headers.get('set-cookie'); - return cookie ? cookie.split(';')[0] ?? cookie : null; + return cookie ? (cookie.split(';')[0] ?? cookie) : null; } function withDefaultAdminSessionCookie(app: ReturnType) { diff --git a/src/test_helpers/app.ts b/src/test_helpers/app.ts index d9957b1..60e4217 100644 --- a/src/test_helpers/app.ts +++ b/src/test_helpers/app.ts @@ -1,9 +1,9 @@ -import type { Hono } from '@hono/hono'; +import type { Hono } from 'hono'; import { buildApp } from '../app_core.ts'; import { type AppServices, resolveTestServices } from '../app_services.ts'; -import { installDenoEnvReader } from '../platform/deno_env.ts'; +import { installNodeEnvReader } from '../platform/node_env.ts'; -installDenoEnvReader(); +installNodeEnvReader(); export function createTestApp(services: Partial = {}): Hono { return buildApp(resolveTestServices(services), { diff --git a/src/test_helpers/app_writer_workspace_runner.ts b/src/test_helpers/app_writer_workspace_runner.ts index a2ea82b..b59e3f0 100644 --- a/src/test_helpers/app_writer_workspace_runner.ts +++ b/src/test_helpers/app_writer_workspace_runner.ts @@ -25,7 +25,7 @@ export function createStaticAppWriterWorkspaceRunner( options = optionsOrRepairResults; } const repairResults = (options.repairResults ?? []).map((repairResult) => - structuredClone(repairResult) + structuredClone(repairResult), ); return { diff --git a/src/test_helpers/cloudflare_test_env.d.ts b/src/test_helpers/cloudflare_test_env.d.ts new file mode 100644 index 0000000..f18c9ee --- /dev/null +++ b/src/test_helpers/cloudflare_test_env.d.ts @@ -0,0 +1,12 @@ +import type { D1Migration } from '@cloudflare/vitest-plugin'; + +// Cloudflare's runtime types intentionally merge test binding declarations here. +declare global { + namespace Cloudflare { + interface Env { + TEST_MIGRATIONS: D1Migration[]; + BROWSER: unknown; + PHOTO_ROSTER: Fetcher; + } + } +} diff --git a/src/test_helpers/fetch_stub.ts b/src/test_helpers/fetch_stub.ts index 7026293..c06c808 100644 --- a/src/test_helpers/fetch_stub.ts +++ b/src/test_helpers/fetch_stub.ts @@ -16,9 +16,9 @@ export async function withFetchStub( export function restoreEnv(name: string, value: string | undefined): void { if (value === undefined) { - Deno.env.delete(name); + delete process.env[name]; return; } - Deno.env.set(name, value); + process.env[name] = value; } diff --git a/src/test_helpers/lti_identity_builders.ts b/src/test_helpers/lti_identity_builders.ts index 0a8e3c1..8abeebf 100644 --- a/src/test_helpers/lti_identity_builders.ts +++ b/src/test_helpers/lti_identity_builders.ts @@ -69,8 +69,8 @@ export function buildMoodleDeploymentBinding( issuer: overrides.issuer ?? 'https://moodle.example', clientId: overrides.clientId ?? 'moodle-client-123', deploymentId: overrides.deploymentId ?? 'moodle-deployment-123', - authorizationEndpoint: overrides.authorizationEndpoint ?? - 'https://moodle.example/mod/lti/auth.php', + authorizationEndpoint: + overrides.authorizationEndpoint ?? 'https://moodle.example/mod/lti/auth.php', accessTokenUrl: overrides.accessTokenUrl ?? 'https://moodle.example/mod/lti/token.php', jwksUrl: overrides.jwksUrl ?? 'https://moodle.example/mod/lti/certs.php', }; @@ -84,8 +84,8 @@ export function buildSakaiDeploymentBinding( issuer: overrides.issuer ?? 'https://sakai.example', clientId: overrides.clientId ?? 'sakai-client-123', deploymentId: overrides.deploymentId ?? 'sakai-deployment-123', - authorizationEndpoint: overrides.authorizationEndpoint ?? - 'https://sakai.example/imsoidc/lti13/oidc_auth', + authorizationEndpoint: + overrides.authorizationEndpoint ?? 'https://sakai.example/imsoidc/lti13/oidc_auth', accessTokenUrl: overrides.accessTokenUrl ?? 'https://sakai.example/imsblis/lti13/token/3', jwksUrl: overrides.jwksUrl ?? 'https://sakai.example/imsblis/lti13/keyset', }; @@ -179,9 +179,10 @@ export function buildCanvasLoginRequest(overrides: Partial = {}): return { iss: identity.issuer, loginHint: overrides.loginHint ?? 'opaque-login-hint', - targetLinkUri: overrides.targetLinkUri === undefined - ? 'http://localhost:8417/lti/launch' - : overrides.targetLinkUri, + targetLinkUri: + overrides.targetLinkUri === undefined + ? 'http://localhost:8417/lti/launch' + : overrides.targetLinkUri, clientId: overrides.clientId === undefined ? identity.clientId : overrides.clientId, deploymentId: identity.deploymentId, ltiMessageHint: overrides.ltiMessageHint ?? 'message-hint-123', @@ -201,9 +202,10 @@ export function buildSakaiLoginRequest(overrides: Partial = {}): L return { iss: identity.issuer, loginHint: overrides.loginHint ?? 'opaque-login-hint', - targetLinkUri: overrides.targetLinkUri === undefined - ? 'http://localhost:8417/lti/launch' - : overrides.targetLinkUri, + targetLinkUri: + overrides.targetLinkUri === undefined + ? 'http://localhost:8417/lti/launch' + : overrides.targetLinkUri, clientId: overrides.clientId === undefined ? identity.clientId : overrides.clientId, deploymentId: identity.deploymentId, ltiMessageHint: overrides.ltiMessageHint ?? 'message-hint-123', diff --git a/src/test_helpers/lti_service_claim_builders.ts b/src/test_helpers/lti_service_claim_builders.ts index 9f79304..f85b80e 100644 --- a/src/test_helpers/lti_service_claim_builders.ts +++ b/src/test_helpers/lti_service_claim_builders.ts @@ -61,12 +61,14 @@ export function buildAgsLaunchService( return { scope: serviceOverrides.scope ?? [DEFAULT_AGS_SCORE_SCOPE, DEFAULT_AGS_LINEITEM_SCOPE], - lineitemsUrl: shape === 'lineitem' || shape === 'none' - ? null - : (serviceOverrides.lineitemsUrl ?? defaults.lineitemsUrl), - lineitemUrl: shape === 'lineitems' || shape === 'none' - ? null - : (serviceOverrides.lineitemUrl ?? defaults.lineitemUrl), + lineitemsUrl: + shape === 'lineitem' || shape === 'none' + ? null + : (serviceOverrides.lineitemsUrl ?? defaults.lineitemsUrl), + lineitemUrl: + shape === 'lineitems' || shape === 'none' + ? null + : (serviceOverrides.lineitemUrl ?? defaults.lineitemUrl), }; } @@ -76,8 +78,8 @@ export function buildNrpsLaunchService( const { lms = 'canvas', ...serviceOverrides } = overrides; return { - contextMembershipsUrl: serviceOverrides.contextMembershipsUrl ?? - TEST_NRPS_SERVICE_DEFAULTS[lms], + contextMembershipsUrl: + serviceOverrides.contextMembershipsUrl ?? TEST_NRPS_SERVICE_DEFAULTS[lms], serviceVersions: serviceOverrides.serviceVersions ?? ['2.0'], }; } @@ -88,17 +90,23 @@ export function buildLaunchServiceClaims( const lms = overrides.lms; return { - ags: overrides.ags === null ? null : buildAgsLaunchService( - { - ...(lms === undefined ? {} : { lms }), - ...overrides.ags, - }, - overrides.agsShape, - ), - nrps: overrides.nrps === null ? null : buildNrpsLaunchService({ - ...(lms === undefined ? {} : { lms }), - ...overrides.nrps, - }), + ags: + overrides.ags === null + ? null + : buildAgsLaunchService( + { + ...(lms === undefined ? {} : { lms }), + ...overrides.ags, + }, + overrides.agsShape, + ), + nrps: + overrides.nrps === null + ? null + : buildNrpsLaunchService({ + ...(lms === undefined ? {} : { lms }), + ...overrides.nrps, + }), }; } diff --git a/src/test_helpers/lti_session_builders.ts b/src/test_helpers/lti_session_builders.ts index 2156b86..5096c41 100644 --- a/src/test_helpers/lti_session_builders.ts +++ b/src/test_helpers/lti_session_builders.ts @@ -49,11 +49,13 @@ export function buildRuntimeSessionRecord( 'write_local_state', ], snapshotRoot: sessionOverrides.snapshotRoot ?? 'var/packages/chapter-4-asteroids/0.1.0', - entrypointPath: sessionOverrides.entrypointPath ?? - 'var/packages/chapter-4-asteroids/0.1.0/dist/index.html', - contentPath: sessionOverrides.contentPath ?? + entrypointPath: + sessionOverrides.entrypointPath ?? 'var/packages/chapter-4-asteroids/0.1.0/dist/index.html', + contentPath: + sessionOverrides.contentPath ?? 'var/packages/chapter-4-asteroids/0.1.0/content/activity.json', - services: sessionOverrides.services ?? + services: + sessionOverrides.services ?? buildLaunchServiceClaims({ lms: servicesLms, }), diff --git a/src/test_helpers/lti_signing.ts b/src/test_helpers/lti_signing.ts index 1cd87f8..9052463 100644 --- a/src/test_helpers/lti_signing.ts +++ b/src/test_helpers/lti_signing.ts @@ -17,12 +17,9 @@ export const TEST_TOOL_PRIVATE_JWK = { d: 'G38vJo5BaBye4K_Ft6S8C1sjujCQLFwPjAwZbldprHwAejnNmhkMVLf7dwTrQlTRN0O-LANg91GwvHxG4GWIo0Hs99uE6JQsqbbJSopsRhJJ0-QKzdTAB7jeGScmb_H6qaSHc_4Vt4rzfvg1flpL5gy4nN-KUk5KG-qPtTGh4v-aqkfzkxVnEBb-VfB_o5nafMimKp1TMXSPcL-4NjjwFioP8YcQDYxwKJGHHm7_CqSfCu5AMx7UByVxsVFNTsaua0lFUQDaVR13eHFFOdUZiAUe97Ua_JYec6Gxe4-3g1bCmGbGm9U_35fGM5kt4NXWND0ULgJfI8alAUDjxblQQQ', p: '2WhfV1qBXbQwL5tW6GaD-nKaZYfGoOOgyKQKjtV7UIBWXECOiciIHxUMoSQrmtbtNLg64K58FLBZ11loOyoso1351MfsQB6FzZfF_zkHlfcNKuWLphwTizTv61TC4_r-5lsoQ5CI34UzzSZaY50AjHg2n6_ahUbTqsYyCmG9Rzc', q: '28EDIGEyaKjMnMdD0NJgQSTxrMzwQujRpioPGWRD6y3ADVwA7p-u4XhiekuOwRjbmyJrJ_HcUna0J0ZydFwo9KRVfIhDhcUGioEnUatWWaMEZDBl6RzyEutR1H4oNx9fjJykNcP13O-fMgHzsZ5k8Oc_lZy1wyJGNnQGQChxzRU', - dp: - 'r3QWDY9S--ZhROpeduvU8xfuFqY-3LUXmxUYGDGddVg9WfIXloappDv-l0Vzk2CEypkrmwv1w1SXDL5w6d6da7J53wkBVrXLUiJ8ff7uak6Y59ecng_mjd_JB-i95_M2J33FvtE0RP9g0N108RNR0AtsOe9XsVt5k0akN9CtSn0', - dq: - '2KD7kQbf53ZHRmHUw10vz-g4aa0ZSAw054Xcnp5Nqd_OzByfOpyli9Td10r2rfnwOo0Cbz0ogQ5NZ841c-mJ4ijBsOKvFYZ1fUH2Xbb2h6SA5rcjL1r-c5IQd9XplPVTfszHv8yuaR66o1RzQ-wt-6Eq-DSkpXj7GCDmLIbyMEU', - qi: - 'X9i7s0LnslcujfZlwBXIbpEU6lz7P11_DPID3j24OhzSTXCadpL7tLLOGG3LuHKx4-izFsVVcrCJ4xnYAG7rwvOVrGm-DTyPrA7AmopnZJ9xxJC2NmDbQc-xVykeSdgUGRl7YkitHdfsmUn6Biy738ihxbGYaZMDd3fRW9hssio', + dp: 'r3QWDY9S--ZhROpeduvU8xfuFqY-3LUXmxUYGDGddVg9WfIXloappDv-l0Vzk2CEypkrmwv1w1SXDL5w6d6da7J53wkBVrXLUiJ8ff7uak6Y59ecng_mjd_JB-i95_M2J33FvtE0RP9g0N108RNR0AtsOe9XsVt5k0akN9CtSn0', + dq: '2KD7kQbf53ZHRmHUw10vz-g4aa0ZSAw054Xcnp5Nqd_OzByfOpyli9Td10r2rfnwOo0Cbz0ogQ5NZ841c-mJ4ijBsOKvFYZ1fUH2Xbb2h6SA5rcjL1r-c5IQd9XplPVTfszHv8yuaR66o1RzQ-wt-6Eq-DSkpXj7GCDmLIbyMEU', + qi: 'X9i7s0LnslcujfZlwBXIbpEU6lz7P11_DPID3j24OhzSTXCadpL7tLLOGG3LuHKx4-izFsVVcrCJ4xnYAG7rwvOVrGm-DTyPrA7AmopnZJ9xxJC2NmDbQc-xVykeSdgUGRl7YkitHdfsmUn6Biy738ihxbGYaZMDd3fRW9hssio', use: 'sig', } as const; @@ -85,9 +82,8 @@ export interface ToolClientAssertionInput { export async function signCanvasIdToken(input: CanvasLaunchTokenInput = {}): Promise { const binding = buildDeploymentBinding(input.deploymentBinding ?? {}); - const agsClaim = input.ags === null - ? null - : buildAgsLaunchClaimValue(input.ags ?? {}, input.agsShape); + const agsClaim = + input.ags === null ? null : buildAgsLaunchClaimValue(input.ags ?? {}, input.agsShape); const nrpsClaim = input.nrps === null ? null : buildNrpsLaunchClaimValue(input.nrps ?? {}); const claims = { ...(input.nonce === null ? {} : { nonce: input.nonce ?? 'nonce-123' }), @@ -98,12 +94,12 @@ export async function signCanvasIdToken(input: CanvasLaunchTokenInput = {}): Pro : { preferred_username: input.preferredUsername }), ...(input.givenName === undefined ? {} : { given_name: input.givenName }), ...(input.familyName === undefined ? {} : { family_name: input.familyName }), - 'https://purl.imsglobal.org/spec/lti/claim/message_type': input.messageType ?? - 'LtiResourceLinkRequest', + 'https://purl.imsglobal.org/spec/lti/claim/message_type': + input.messageType ?? 'LtiResourceLinkRequest', 'https://purl.imsglobal.org/spec/lti/claim/version': input.version ?? '1.3.0', 'https://purl.imsglobal.org/spec/lti/claim/deployment_id': binding.deploymentId, - 'https://purl.imsglobal.org/spec/lti/claim/target_link_uri': input.targetLinkUri ?? - 'http://localhost:8417/lti/launch', + 'https://purl.imsglobal.org/spec/lti/claim/target_link_uri': + input.targetLinkUri ?? 'http://localhost:8417/lti/launch', 'https://purl.imsglobal.org/spec/lti/claim/resource_link': { id: input.resourceLinkId ?? 'resource-link-123', title: input.resourceLinkTitle ?? 'Chapter 4 Asteroids', @@ -119,15 +115,21 @@ export async function signCanvasIdToken(input: CanvasLaunchTokenInput = {}): Pro 'https://purl.imsglobal.org/spec/lti/claim/roles': input.roles ?? [ 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner', ], - ...(input.custom === undefined ? {} : { - 'https://purl.imsglobal.org/spec/lti/claim/custom': input.custom, - }), - ...(agsClaim === null ? {} : { - 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint': agsClaim, - }), - ...(nrpsClaim === null ? {} : { - 'https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice': nrpsClaim, - }), + ...(input.custom === undefined + ? {} + : { + 'https://purl.imsglobal.org/spec/lti/claim/custom': input.custom, + }), + ...(agsClaim === null + ? {} + : { + 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint': agsClaim, + }), + ...(nrpsClaim === null + ? {} + : { + 'https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice': nrpsClaim, + }), }; const signingKey = await importJWK(TEST_CANVAS_PRIVATE_JWK, 'ES256'); let token = new SignJWT(claims) @@ -149,13 +151,15 @@ export async function signCanvasIdToken(input: CanvasLaunchTokenInput = {}): Pro return await token.sign(signingKey); } -export async function signLtiToolState(input: { - nonce?: string; - issuer?: string; - clientId?: string; - targetLinkUri?: string; - expiresAt?: string; -} = {}): Promise { +export async function signLtiToolState( + input: { + nonce?: string; + issuer?: string; + clientId?: string; + targetLinkUri?: string; + expiresAt?: string; + } = {}, +): Promise { return await new SignJWT({ nonce: input.nonce ?? 'nonce-123', iss: input.issuer ?? 'https://canvas.instructure.com', diff --git a/src/test_helpers/lti_tool_context.ts b/src/test_helpers/lti_tool_context.ts index f317adf..2ae3eaa 100644 --- a/src/test_helpers/lti_tool_context.ts +++ b/src/test_helpers/lti_tool_context.ts @@ -16,9 +16,7 @@ export interface TestLtiToolContext { lineItemRequests: unknown[]; } -export function createTestLtiToolContext( - input: TestLtiToolContextInput = {}, -): TestLtiToolContext { +export function createTestLtiToolContext(input: TestLtiToolContextInput = {}): TestLtiToolContext { const submittedScores: unknown[] = []; const lineItemRequests: unknown[] = []; const session = buildTestLtiSession({ @@ -52,18 +50,18 @@ export function createTestLtiToolContext( return Promise.resolve( input.submitScoreSuccess === false ? { - success: false, - error: { - message: input.submitScoreMessage ?? 'score publish failed', - code: 'score_publish_failed', - serviceKind: 'ags', - operation: 'submitScore', - }, - } + success: false, + error: { + message: input.submitScoreMessage ?? 'score publish failed', + code: 'score_publish_failed', + serviceKind: 'ags', + operation: 'submitScore', + }, + } : { - success: true, - data: undefined, - }, + success: true, + data: undefined, + }, ); }, getMembers: () => diff --git a/src/test_helpers/package_review_in_memory_app_generation_test.ts b/src/test_helpers/package_review_in_memory_app_generation_test.ts index a08d80d..39e857c 100644 --- a/src/test_helpers/package_review_in_memory_app_generation_test.ts +++ b/src/test_helpers/package_review_in_memory_app_generation_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertRejects } from '@std/assert'; import { createInMemoryPackageReviewRepository } from './package_review.ts'; import { @@ -5,7 +6,7 @@ import { buildAppGenerationWorkspaceRecord, } from './package_review_in_memory_app_generation.ts'; -Deno.test('in-memory repository creates, reads, and updates app generation runs', async () => { +test('in-memory repository creates, reads, and updates app generation runs', async () => { const repository = createInMemoryPackageReviewRepository(); const created = await repository.createAppGenerationRun(buildAppGenerationRunRecord()); @@ -26,7 +27,7 @@ Deno.test('in-memory repository creates, reads, and updates app generation runs' assertEquals(fetched?.updatedAt, '2026-05-14T12:05:00.000Z'); }); -Deno.test('in-memory repository rejects duplicate app generation runs', async () => { +test('in-memory repository rejects duplicate app generation runs', async () => { const repository = createInMemoryPackageReviewRepository({ appGenerationRuns: [buildAppGenerationRunRecord()], }); @@ -38,7 +39,7 @@ Deno.test('in-memory repository rejects duplicate app generation runs', async () ); }); -Deno.test('in-memory repository saves current app generation workspace snapshots', async () => { +test('in-memory repository saves current app generation workspace snapshots', async () => { const repository = createInMemoryPackageReviewRepository({ appGenerationRuns: [buildAppGenerationRunRecord()], }); diff --git a/src/test_helpers/package_review_in_memory_attempts.ts b/src/test_helpers/package_review_in_memory_attempts.ts index 030f59f..87a89d2 100644 --- a/src/test_helpers/package_review_in_memory_attempts.ts +++ b/src/test_helpers/package_review_in_memory_attempts.ts @@ -66,9 +66,10 @@ export function createInMemoryAttemptRepository(state: InMemoryRepositoryState): ); } - const sequence = state.attemptEvidenceArtifacts - .filter((candidate) => candidate.attemptId === input.attemptId) - .reduce((max, candidate) => Math.max(max, candidate.sequence), 0) + 1; + const sequence = + state.attemptEvidenceArtifacts + .filter((candidate) => candidate.attemptId === input.attemptId) + .reduce((max, candidate) => Math.max(max, candidate.sequence), 0) + 1; const nextRecord = cloneRecord({ ...input, sequence, @@ -123,9 +124,10 @@ export function createInMemoryAttemptRepository(state: InMemoryRepositoryState): throw new Error(`Attempt ${input.attemptId} was not found.`); } - const sequence = state.attemptEvents - .filter((candidate) => candidate.attemptId === input.attemptId) - .reduce((max, candidate) => Math.max(max, candidate.sequence), 0) + 1; + const sequence = + state.attemptEvents + .filter((candidate) => candidate.attemptId === input.attemptId) + .reduce((max, candidate) => Math.max(max, candidate.sequence), 0) + 1; const nextRecord = cloneRecord({ id: nextId(state.attemptEvents), attemptId: input.attemptId, diff --git a/src/test_helpers/package_review_in_memory_preview.ts b/src/test_helpers/package_review_in_memory_preview.ts index af5d955..d207617 100644 --- a/src/test_helpers/package_review_in_memory_preview.ts +++ b/src/test_helpers/package_review_in_memory_preview.ts @@ -64,9 +64,10 @@ export function createInMemoryPreviewRepository(state: InMemoryRepositoryState): throw new Error(`Preview session ${input.previewSessionId} was not found.`); } - const sequence = state.previewEvidence - .filter((candidate) => candidate.previewSessionId === input.previewSessionId) - .reduce((max, candidate) => Math.max(max, candidate.sequence), 0) + 1; + const sequence = + state.previewEvidence + .filter((candidate) => candidate.previewSessionId === input.previewSessionId) + .reduce((max, candidate) => Math.max(max, candidate.sequence), 0) + 1; const nextRecord = buildPreviewEvidenceRecord({ id: nextId(state.previewEvidence), previewSessionId: input.previewSessionId, diff --git a/src/test_helpers/package_review_in_memory_preview_test.ts b/src/test_helpers/package_review_in_memory_preview_test.ts index 5b42555..ca89888 100644 --- a/src/test_helpers/package_review_in_memory_preview_test.ts +++ b/src/test_helpers/package_review_in_memory_preview_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals } from '@std/assert'; import { buildAdminPreviewSessionRecord, @@ -6,7 +7,7 @@ import { createInMemoryPackageReviewRepository, } from './package_review.ts'; -Deno.test('in-memory preview helpers seed admin and authoring sessions with typed defaults', async () => { +test('in-memory preview helpers seed admin and authoring sessions with typed defaults', async () => { const packageVersion = buildPackageVersionRecord({ id: 11, appId: 'chapter-4-asteroids', @@ -42,7 +43,7 @@ Deno.test('in-memory preview helpers seed admin and authoring sessions with type ); }); -Deno.test('in-memory preview lookup honors the same origin filter as the repository contract', async () => { +test('in-memory preview lookup honors the same origin filter as the repository contract', async () => { const packageVersion = buildPackageVersionRecord({ id: 12, appId: 'chapter-4-asteroids', @@ -90,7 +91,7 @@ Deno.test('in-memory preview lookup honors the same origin filter as the reposit ); }); -Deno.test('preview test builders expose explicit origin and selected content defaults', () => { +test('preview test builders expose explicit origin and selected content defaults', () => { const adminSession = buildAdminPreviewSessionRecord(); const authoringSession = buildAuthoringPreviewSessionRecord(); diff --git a/src/test_helpers/package_review_in_memory_sessions.ts b/src/test_helpers/package_review_in_memory_sessions.ts index 0b90c41..c85bb28 100644 --- a/src/test_helpers/package_review_in_memory_sessions.ts +++ b/src/test_helpers/package_review_in_memory_sessions.ts @@ -4,8 +4,7 @@ import { cloneRecord } from './package_review_in_memory_shared.ts'; type SessionRepository = Pick< PackageReviewRepository, - | 'createRuntimeSession' - | 'getRuntimeSessionById' + 'createRuntimeSession' | 'getRuntimeSessionById' >; export function createInMemorySessionRepository(state: InMemoryRepositoryState): SessionRepository { diff --git a/src/test_helpers/package_review_in_memory_shared.ts b/src/test_helpers/package_review_in_memory_shared.ts index da67229..0e8d463 100644 --- a/src/test_helpers/package_review_in_memory_shared.ts +++ b/src/test_helpers/package_review_in_memory_shared.ts @@ -1,4 +1,4 @@ -import { compare, parse } from '@std/semver'; +import { compare } from 'semver'; import type { RuntimeSessionRecord } from '../lti/types.ts'; import type { PackageReviewRepository } from '../package_review/repository.ts'; import type { @@ -70,7 +70,7 @@ export function sortPackageVersions( return left.appId.localeCompare(right.appId); } - const versionComparison = compare(parse(right.version), parse(left.version)); + const versionComparison = compare(right.version, left.version); if (versionComparison !== 0) { return versionComparison; @@ -105,11 +105,12 @@ export function reviewPackageVersion( ); } - const normalizedAccessibilityReview = accessibilityReview === null - ? (() => { - throw new Error('Accessibility review is required for new review decisions.'); - })() - : parseAccessibilityReview(accessibilityReview); + const normalizedAccessibilityReview = + accessibilityReview === null + ? (() => { + throw new Error('Accessibility review is required for new review decisions.'); + })() + : parseAccessibilityReview(accessibilityReview); const nextRecord = cloneRecord({ ...existing, diff --git a/src/test_helpers/package_review_in_memory_versions.ts b/src/test_helpers/package_review_in_memory_versions.ts index 2cb591b..9b6f8e0 100644 --- a/src/test_helpers/package_review_in_memory_versions.ts +++ b/src/test_helpers/package_review_in_memory_versions.ts @@ -46,9 +46,7 @@ export function createInMemoryVersionRepository(state: InMemoryRepositoryState): state.packageVersions.filter( (record) => record.appId === appId && record.catalogVisibility === 'catalog', ), - ).map( - cloneRecord, - ), + ).map(cloneRecord), ); }, @@ -88,16 +86,12 @@ export function createInMemoryVersionRepository(state: InMemoryRepositoryState): .map((session) => session.sessionId), ); - removeWhere( - state.runtimeSessions, - (session) => session.packageVersionId === id, - ); + removeWhere(state.runtimeSessions, (session) => session.packageVersionId === id); removeWhere(state.attempts, (attempt) => attemptIds.has(attempt.attemptId)); removeWhere(state.attemptEvents, (event) => attemptIds.has(event.attemptId)); removeWhere(state.attemptEvidenceArtifacts, (artifact) => attemptIds.has(artifact.attemptId)); - removeWhere( - state.previewEvidence, - (evidence) => previewSessionIds.has(evidence.previewSessionId), + removeWhere(state.previewEvidence, (evidence) => + previewSessionIds.has(evidence.previewSessionId), ); removeWhere(state.previewSessions, (session) => previewSessionIds.has(session.sessionId)); removeWhere(state.packageVersions, (record) => record.id === id); diff --git a/src/test_helpers/package_review_test_builder_base.ts b/src/test_helpers/package_review_test_builder_base.ts index 5c6a342..582e688 100644 --- a/src/test_helpers/package_review_test_builder_base.ts +++ b/src/test_helpers/package_review_test_builder_base.ts @@ -77,7 +77,8 @@ export function buildPackageVersionRecord( return { ...record, - runtimeContract: overrides.runtimeContract ?? + runtimeContract: + overrides.runtimeContract ?? createReviewedRuntimeContract({ reviewData: { appId: record.appId, @@ -87,8 +88,8 @@ export function buildPackageVersionRecord( }, artifactDigest: record.artifact.digest, }), - runtimeContractSignature: overrides.runtimeContractSignature ?? - 'test-reviewed-runtime-contract-signature', + runtimeContractSignature: + overrides.runtimeContractSignature ?? 'test-reviewed-runtime-contract-signature', }; } @@ -177,8 +178,8 @@ export function buildAttemptEvidenceArtifactRecord( kind: overrides.kind ?? 'structured_json', contentType: overrides.contentType ?? 'application/json', fileName: overrides.fileName ?? 'submission.json', - storageKey: overrides.storageKey ?? - 'var/attempt-evidence/attempt-123/artifact-001-submission.json', + storageKey: + overrides.storageKey ?? 'var/attempt-evidence/attempt-123/artifact-001-submission.json', byteSize: overrides.byteSize ?? 128, sha256: overrides.sha256 ?? 'sha256:artifact-001', createdAt: overrides.createdAt ?? DEFAULT_PHASE3_AT, diff --git a/src/test_helpers/package_review_test_builder_preview.ts b/src/test_helpers/package_review_test_builder_preview.ts index 17fc120..c4e5514 100644 --- a/src/test_helpers/package_review_test_builder_preview.ts +++ b/src/test_helpers/package_review_test_builder_preview.ts @@ -19,8 +19,8 @@ export function buildPreviewSessionRecord( 'finalize_attempt', ], snapshotRoot: overrides.snapshotRoot ?? 'var/packages/chapter-4-asteroids/0.1.0', - entrypointPath: overrides.entrypointPath ?? - 'var/packages/chapter-4-asteroids/0.1.0/dist/index.html', + entrypointPath: + overrides.entrypointPath ?? 'var/packages/chapter-4-asteroids/0.1.0/dist/index.html', launch: overrides.launch ?? { userId: 'preview-user-123', userRole: 'instructor', diff --git a/src/test_helpers/roster_rpc_fixture.mjs b/src/test_helpers/roster_rpc_fixture.mjs new file mode 100644 index 0000000..3300549 --- /dev/null +++ b/src/test_helpers/roster_rpc_fixture.mjs @@ -0,0 +1,28 @@ +import { WorkerEntrypoint } from 'cloudflare:workers'; +export class LanternLms extends WorkerEntrypoint { + listRoster(input) { + if (input.runtimeSessionId !== 'session-1' || input.runtimeSessionToken !== 'token-1') { + return { ok: false, error: { code: 'session_expired', traceId: 'trace-denied' } }; + } + return { + ok: true, + value: { + observedAt: '2026-07-20T15:00:00.000Z', + members: [ + { + subjectId: 'subject-1', + displayName: 'Ada Lovelace', + roles: ['learner'], + status: 'active', + email: 'ada@example.edu', + }, + ], + }, + }; + } +} +export default { + fetch() { + return new Response('RPC only', { status: 405 }); + }, +}; diff --git a/src/worker.ts b/src/worker.ts index 2a50ff4..6e4ce2f 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,3 +1,10 @@ +import { workflowCheckpoint } from './platform/workflow_checkpoint.ts'; +import { + WorkflowEntrypoint, + WorkerEntrypoint, + type WorkflowEvent, + type WorkflowStep, +} from 'cloudflare:workers'; export { AppWriterAgent } from './app_writer/agent.ts'; export { createCloudflarePythonGraderSandboxFactory, @@ -18,43 +25,6 @@ import type { PythonLabGenerationWorkflowParams } from './python_lab/workflow.ts import type { PrimitiveResult, RosterPage } from '../sdk/worker-app-sdk.ts'; import { createWorkerHandler } from './worker_handler.ts'; -interface WorkflowEntrypointRuntimeClass { - new (): object; -} - -declare const WorkflowEntrypoint: WorkflowEntrypointRuntimeClass | undefined; -declare const WorkerEntrypoint: WorkflowEntrypointRuntimeClass | undefined; - -interface WorkflowEvent { - readonly payload: Readonly; - readonly timestamp: Date; - readonly instanceId: string; -} - -interface WorkflowStep { - do(name: string, callback: () => Result | Promise): Promise; - do( - name: string, - config: WorkflowStepConfig, - callback: () => Result | Promise, - ): Promise; -} - -interface WorkflowStepConfig { - retries?: { - limit: number; - delay: string | number; - backoff?: 'constant' | 'linear' | 'exponential'; - }; - timeout?: string | number; -} - -const WorkflowEntrypointBase: WorkflowEntrypointRuntimeClass = - typeof WorkflowEntrypoint === 'function' ? WorkflowEntrypoint : Object; -const WorkerEntrypointBase: WorkflowEntrypointRuntimeClass = typeof WorkerEntrypoint === 'function' - ? WorkerEntrypoint - : Object; - interface LanternLmsBindingProps { readonly appId: string; readonly packageVersionId: number; @@ -62,10 +32,13 @@ interface LanternLmsBindingProps { export default createWorkerHandler(createCloudflarePythonGraderSandboxFactory); -export class AppGenerationWorkflow extends WorkflowEntrypointBase { +export class AppGenerationWorkflow extends WorkflowEntrypoint< + WorkerBindings, + AppGenerationWorkflowParams +> { declare readonly env: WorkerBindings; - async run( + override async run( event: WorkflowEvent, step: WorkflowStep, ): Promise { @@ -79,11 +52,13 @@ export class AppGenerationWorkflow extends WorkflowEntrypointBase { }, timeout: '2 minutes', }, - () => - runAppGenerationInitializationWorkflowStep({ - bindings: this.env, - params: event.payload, - }), + async () => + workflowCheckpoint( + await runAppGenerationInitializationWorkflowStep({ + bindings: this.env, + params: event.payload, + }), + ), ); if (!initialized.ok) { @@ -100,11 +75,13 @@ export class AppGenerationWorkflow extends WorkflowEntrypointBase { }, timeout: '5 minutes', }, - () => - runAppGenerationPlanningWorkflowStep({ - bindings: this.env, - initialized: initialized.value, - }), + async () => + workflowCheckpoint( + await runAppGenerationPlanningWorkflowStep({ + bindings: this.env, + initialized: initialized.value, + }), + ), ); if (!planned.ok) { @@ -121,11 +98,13 @@ export class AppGenerationWorkflow extends WorkflowEntrypointBase { }, timeout: '45 minutes', }, - () => - runAppGenerationFileWorkflowStep({ - bindings: this.env, - planned: planned.value, - }), + async () => + workflowCheckpoint( + await runAppGenerationFileWorkflowStep({ + bindings: this.env, + planned: planned.value, + }), + ), ); if (!generated.ok) { @@ -142,36 +121,43 @@ export class AppGenerationWorkflow extends WorkflowEntrypointBase { }, timeout: '15 minutes', }, - () => - runAppGenerationFinishWorkflowStep({ - bindings: this.env, - generated: generated.value, - }), + async () => + workflowCheckpoint( + await runAppGenerationFinishWorkflowStep({ + bindings: this.env, + generated: generated.value, + }), + ), ); } } -export class PythonLabGenerationWorkflow extends WorkflowEntrypointBase { +export class PythonLabGenerationWorkflow extends WorkflowEntrypoint< + WorkerBindings, + PythonLabGenerationWorkflowParams +> { declare readonly env: WorkerBindings; - async run( + override async run( event: WorkflowEvent, step: WorkflowStep, ): Promise { - return await step.do( - 'generate Python autograder Lab', - { timeout: '10 minutes' }, - () => - runPythonLabGenerationWorkflow({ + return await step.do('generate Python autograder Lab', { timeout: '10 minutes' }, async () => + workflowCheckpoint( + await runPythonLabGenerationWorkflow({ bindings: this.env, params: event.payload, createPythonSandboxFactory: createCloudflarePythonGraderSandboxFactory, }), + ), ); } } -export class LanternLmsCapabilityBinding extends WorkerEntrypointBase { +export class LanternLmsCapabilityBinding extends WorkerEntrypoint< + WorkerBindings, + LanternLmsBindingProps +> { declare readonly env: WorkerBindings; declare readonly props: LanternLmsBindingProps; @@ -204,11 +190,13 @@ function isLmsBindingRosterEnvelope(value: unknown): value is { return false; } const record = value as Record; - return typeof record.runtimeSessionId === 'string' && + return ( + typeof record.runtimeSessionId === 'string' && record.runtimeSessionId.length > 0 && typeof record.runtimeSessionToken === 'string' && record.runtimeSessionToken.length > 0 && (record.traceId === undefined || (typeof record.traceId === 'string' && record.traceId.length > 0)) && - 'request' in record; + 'request' in record + ); } diff --git a/src/worker_handler.ts b/src/worker_handler.ts index 76d5d61..dd7d46d 100644 --- a/src/worker_handler.ts +++ b/src/worker_handler.ts @@ -1,4 +1,4 @@ -import type { ExecutionContext } from '@hono/hono'; +import type { ExecutionContext } from 'hono'; import { buildApp } from './app_core.ts'; import { type PythonGraderSandboxFactoryProvider, @@ -39,7 +39,7 @@ export function createWorkerHandler( executionContext?.exports === undefined ? undefined : (identity) => - executionContext.exports!.LanternLmsCapabilityBinding({ props: identity }), + executionContext.exports!.LanternLmsCapabilityBinding({ props: identity }), createPythonSandboxFactory, ), { kernelAdminContext: true }, diff --git a/tools/app_dev.ts b/tools/app_dev.ts index b608c3c..c28e055 100644 --- a/tools/app_dev.ts +++ b/tools/app_dev.ts @@ -1,3 +1,6 @@ +import { watchLocalPackage } from './local_watch.ts'; +import { serve } from '@hono/node-server'; +import { once } from 'node:events'; import { createLocalDevRequestHandler, loadLocalDevState, @@ -6,18 +9,15 @@ import { import { getDefaultWatchDebounceMs, runWatchMode } from '../src/authoring/watch.ts'; try { - const args = readArgs(Deno.args); + const args = readArgs(process.argv.slice(2)); let state = await loadLocalDevState(args.packageRoot, { logger: logPreviewEntry, }); - const server = Deno.serve( - { - hostname: '127.0.0.1', - port: args.port, - onListen() {}, - }, - createLocalDevRequestHandler({ + const server = serve({ + hostname: '127.0.0.1', + port: args.port, + fetch: createLocalDevRequestHandler({ getState() { return state; }, @@ -25,22 +25,24 @@ try { return baseUrl; }, }), - ); - const address = server.addr as Deno.NetAddr; - const baseUrl = `http://${address.hostname}:${address.port}/`; + }); + await once(server, 'listening'); + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('Lantern preview did not bind a TCP port.'); + } + const baseUrl = `http://${address.address}:${address.port}/`; console.log(`Lantern authoring dev loop listening on ${baseUrl}`); console.log(renderLocalDevStateSummary({ state, baseUrl })); let exitCode = currentExitCode(state); let initialWatchRun = true; - const watcher = Deno.watchFs(args.packageRoot, { - recursive: true, - }); + const watcher = watchLocalPackage(args.packageRoot); try { await runWatchMode({ - iterator: watcher[Symbol.asyncIterator](), + iterator: watcher.iterator, once: args.once, debounceMs: args.debounceMs, log: (message) => console.log(message), @@ -66,11 +68,13 @@ try { }); } finally { watcher.close(); - await server.shutdown(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); } } catch (error) { console.error(error instanceof Error ? error.message : 'Lantern authoring dev loop failed.'); - Deno.exit(1); + process.exit(1); } interface AppDevArgs { @@ -105,7 +109,7 @@ function readArgs(args: string[]): AppDevArgs { } if (packageRoot === null) { - throw new Error('Usage: deno task app:dev [--port=8420] [--once]'); + throw new Error('Usage: npm run app:dev -- [--port=8420] [--once]'); } return { diff --git a/tools/app_new.ts b/tools/app_new.ts index a8e724c..fa3e836 100644 --- a/tools/app_new.ts +++ b/tools/app_new.ts @@ -5,13 +5,13 @@ import { } from '../src/authoring/scaffold.ts'; try { - const args = readArgs(Deno.args); + const args = readArgs(process.argv.slice(2)); if (args.listStarters) { printStarterList(); if (args.outputRoot === null) { - Deno.exit(0); + process.exit(0); } } @@ -35,12 +35,12 @@ try { console.log(`Created ${result.starter.id} package at ${result.outputRoot}`); console.log(`- Starter: ${result.starter.label}`); - console.log(`- Next: deno task app:validate ${args.outputRoot}`); - console.log(`- Next: deno task app:test-preview ${args.outputRoot}`); - console.log(`- Next: deno task app:preview ${args.outputRoot}`); + console.log(`- Next: npm run app:validate -- ${args.outputRoot}`); + console.log(`- Next: npm run app:test-preview -- ${args.outputRoot}`); + console.log(`- Next: npm run app:preview -- ${args.outputRoot}`); } catch (error) { console.error(error instanceof Error ? error.message : 'Lantern app scaffolding failed.'); - Deno.exit(1); + process.exit(1); } interface AppNewArgs { @@ -118,7 +118,7 @@ function buildUsageMessage(): string { const starterSection = starterLines.length > 0 ? ['Available starters:', ...starterLines] : []; return [ - 'Usage: deno task app:new --starter= --app-id= --title= [--owner-id=<owner-id>]', + 'Usage: npm run app:new -- <output-root> --starter=<id> --app-id=<app-id> --title=<title> [--owner-id=<owner-id>]', '', ...starterSection, '', diff --git a/tools/app_preview.ts b/tools/app_preview.ts index a1a6be5..2fd1c0d 100644 --- a/tools/app_preview.ts +++ b/tools/app_preview.ts @@ -1,3 +1,4 @@ +import { serve } from '@hono/node-server'; import { createLocalPreviewHarness, type LocalPreviewLogEntry, @@ -5,7 +6,7 @@ import { import { validateLocalAppPackage } from '../src/authoring/local_app.ts'; try { - const { packageRoot, port } = readArgs(Deno.args); + const { packageRoot, port } = readArgs(process.argv.slice(2)); const result = await validateLocalAppPackage(packageRoot); if (!result.ok || !result.appPackage) { @@ -15,7 +16,7 @@ try { console.error(`- ${issue}`); } - Deno.exit(1); + process.exit(1); } const harness = createLocalPreviewHarness({ @@ -27,10 +28,10 @@ try { `Lantern authoring preview listening on http://localhost:${port}${harness.entrypointPath}`, ); - Deno.serve({ port }, (request) => harness.handle(request)); + serve({ port, fetch: (request) => harness.handle(request) }); } catch (error) { console.error(error instanceof Error ? error.message : 'Lantern app preview failed.'); - Deno.exit(1); + process.exit(1); } function readArgs(args: string[]): { @@ -55,7 +56,7 @@ function readArgs(args: string[]): { } if (packageRoot === null) { - throw new Error('Usage: deno task app:preview <package-root> [--port=8420]'); + throw new Error('Usage: npm run app:preview -- <package-root> [--port=8420]'); } return { diff --git a/tools/app_test_preview.ts b/tools/app_test_preview.ts index acf5e4d..1bd222b 100644 --- a/tools/app_test_preview.ts +++ b/tools/app_test_preview.ts @@ -1,3 +1,4 @@ +import { watchLocalPackage } from './local_watch.ts'; import { runLocalPreviewAssertions } from '../src/authoring/local_preview_assertions.ts'; import { type RenderedPreviewAssertionResult, @@ -14,16 +15,16 @@ export interface AppTestPreviewArgs { if (import.meta.main) { try { - const args = parseArgs(Deno.args); + const args = parseArgs(process.argv.slice(2)); const exitCode = args.watch ? await runWatchCommand(args) : await runOneShotCommand(args.packageRoot); - Deno.exit(exitCode); + process.exit(exitCode); } catch (error) { console.error( error instanceof Error ? error.message : 'Lantern preview assertion command failed.', ); - Deno.exit(1); + process.exit(1); } } @@ -52,7 +53,7 @@ export function parseArgs(args: string[]): AppTestPreviewArgs { } if (packageRoot === null) { - throw new Error('Usage: deno task app:test-preview <package-root> [--watch] [--once]'); + throw new Error('Usage: npm run app:test-preview -- <package-root> [--watch] [--once]'); } if (once && !watch) { @@ -77,20 +78,17 @@ async function runOneShotCommand(packageRoot: string): Promise<number> { async function runWatchCommand(args: AppTestPreviewArgs): Promise<number> { console.log(`Lantern preview watch started for ${args.packageRoot}.`); - const watcher = Deno.watchFs(args.packageRoot, { - recursive: true, - }); + const watcher = watchLocalPackage(args.packageRoot); try { return await runWatchMode({ - iterator: watcher[Symbol.asyncIterator](), + iterator: watcher.iterator, once: args.once, debounceMs: args.debounceMs, log: (message) => console.log(message), runCycle: async (changedPaths) => { - const label = changedPaths.length === 0 - ? 'initial' - : `rerun for ${changedPaths.join(', ')}`; + const label = + changedPaths.length === 0 ? 'initial' : `rerun for ${changedPaths.join(', ')}`; console.log(`\n[lantern-preview-test] ${label}`); const rendered = await runOneShotCycle(args.packageRoot); diff --git a/tools/app_validate.ts b/tools/app_validate.ts index d2fb932..da66409 100644 --- a/tools/app_validate.ts +++ b/tools/app_validate.ts @@ -50,7 +50,7 @@ export function readArgs(args: string[]): AppValidateArgs { } if (packageRoot === null) { - throw new Error('Usage: deno task app:validate <package-root> [--json]'); + throw new Error('Usage: npm run app:validate -- <package-root> [--json]'); } return { @@ -95,7 +95,7 @@ export function renderValidationFailure( ` - ${diagnostic.message}`, ` Fix: ${diagnostic.fix}`, ]), - ].join('\n') + ].join('\n'), ) .join('\n'); } @@ -133,5 +133,5 @@ export function serializeValidationResult(result: LocalAppValidationResult): str } if (import.meta.main) { - Deno.exit(await runAppValidate(Deno.args)); + process.exit(await runAppValidate(process.argv.slice(2))); } diff --git a/tools/app_writer_smoke.ts b/tools/app_writer_smoke.ts index 8055805..98e26bd 100644 --- a/tools/app_writer_smoke.ts +++ b/tools/app_writer_smoke.ts @@ -48,8 +48,8 @@ export function parseAppWriterSmokeArgs(args: string[]): AppWriterSmokeOptions { promptIds, timeoutMs: readPositiveInteger(values, 'timeout-ms') ?? DEFAULT_TIMEOUT_MS, pollMs: readPositiveInteger(values, 'poll-ms') ?? DEFAULT_POLL_MS, - requestedAppIdPrefix: readSingle(values, 'requested-app-id-prefix') ?? - `app-writer-smoke-${Date.now()}`, + requestedAppIdPrefix: + readSingle(values, 'requested-app-id-prefix') ?? `app-writer-smoke-${Date.now()}`, }; } @@ -121,8 +121,7 @@ async function submitPrompt(input: { if (response.status !== 303 || location === null) { throw new Error( - `App writer smoke submit failed for ${input.prompt.id}: ${response.status} ${await response - .text()}`, + `App writer smoke submit failed for ${input.prompt.id}: ${response.status} ${await response.text()}`, ); } @@ -180,7 +179,7 @@ function readPromptIds(values: ReadonlyMap<string, string[]>): string[] { value .split(',') .map((item) => item.trim()) - .filter((item) => item !== '') + .filter((item) => item !== ''), ); } @@ -246,5 +245,5 @@ function sleep(durationMs: number): Promise<void> { } if (import.meta.main) { - await runAppWriterSmoke(parseAppWriterSmokeArgs(Deno.args)); + await runAppWriterSmoke(parseAppWriterSmokeArgs(process.argv.slice(2))); } diff --git a/tools/app_writer_smoke_test.ts b/tools/app_writer_smoke_test.ts index 171fa31..75f51c0 100644 --- a/tools/app_writer_smoke_test.ts +++ b/tools/app_writer_smoke_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assertEquals, assertThrows } from '@std/assert'; import { assertSmokeRunHtml, @@ -5,7 +6,7 @@ import { selectSmokePrompts, } from './app_writer_smoke.ts'; -Deno.test('app writer smoke runner selects the production prompt set', () => { +test('app writer smoke runner selects the production prompt set', () => { const options = parseAppWriterSmokeArgs([ '--origin=https://lantern.example', '--production-set=1', @@ -23,7 +24,7 @@ Deno.test('app writer smoke runner selects the production prompt set', () => { assertEquals(options.pollMs, 100); }); -Deno.test('app writer smoke runner rejects unknown prompt ids', () => { +test('app writer smoke runner rejects unknown prompt ids', () => { assertThrows( () => selectSmokePrompts(['unknown-prompt']), Error, @@ -31,7 +32,7 @@ Deno.test('app writer smoke runner rejects unknown prompt ids', () => { ); }); -Deno.test('app writer smoke runner asserts the full proof loop page evidence', () => { +test('app writer smoke runner asserts the full proof loop page evidence', () => { assertSmokeRunHtml(` <h2>saved pending version</h2> <article>initialize workspace <span>succeeded</span></article> diff --git a/tools/build_app_writer_platform_bundle.ts b/tools/build_app_writer_platform_bundle.ts deleted file mode 100644 index a0e9862..0000000 --- a/tools/build_app_writer_platform_bundle.ts +++ /dev/null @@ -1,20 +0,0 @@ -const root = new URL('../', import.meta.url); -const outputDir = new URL('output/', root); -const sourcePath = new URL('src/app_writer/platform_worker.ts', root).pathname; -const bundlePath = new URL('app-writer-platform.bundle.mjs', outputDir); -const decoder = new TextDecoder(); - -await Deno.mkdir(outputDir, { recursive: true }); - -const command = new Deno.Command(Deno.execPath(), { - args: ['bundle', '--platform=browser', sourcePath, '-o', bundlePath.pathname], -}); -const result = await command.output(); - -if (!result.success) { - throw new Error( - `App writer platform service bundle build failed:\n${decoder.decode(result.stderr).trim()}`, - ); -} - -console.log(`Built app writer platform service bundle at ${bundlePath.pathname}.`); diff --git a/tools/build_reviewed_worker_app.ts b/tools/build_reviewed_worker_app.ts index b4a6453..e21174f 100644 --- a/tools/build_reviewed_worker_app.ts +++ b/tools/build_reviewed_worker_app.ts @@ -1,7 +1,9 @@ -const appId = Deno.args[0]?.trim(); +import { build } from 'esbuild'; +import { mkdir, stat } from 'node:fs/promises'; +const appId = process.argv.slice(2)[0]?.trim(); if (!appId || !/^[a-z0-9][a-z0-9-]*$/.test(appId)) { - throw new Error('Usage: deno task app:build-worker <app-id>'); + throw new Error('Usage: npm run app:build-worker -- <app-id>'); } const root = new URL('../', import.meta.url); @@ -10,28 +12,23 @@ const source = new URL('source/worker.ts', appRoot); const output = new URL('dist/worker.js', appRoot); try { - const sourceInfo = await Deno.stat(source); - if (!sourceInfo.isFile) { + const sourceInfo = await stat(source); + if (!sourceInfo.isFile()) { throw new Error(); } } catch { throw new Error(`Reviewed Worker source was not found for ${appId}.`); } -await Deno.mkdir(new URL('dist/', appRoot), { recursive: true }); -const command = new Deno.Command(Deno.execPath(), { - args: [ - 'bundle', - '--platform=browser', - '--minify', - source.pathname, - '-o', - output.pathname, - ], +await mkdir(new URL('dist/', appRoot), { recursive: true }); +await build({ + entryPoints: [source.pathname], + outfile: output.pathname, + bundle: true, + minify: true, + format: 'esm', + platform: 'browser', + target: 'esnext', }); -const result = await command.output(); -if (!result.success) { - throw new Error(new TextDecoder().decode(result.stderr).trim()); -} console.log(`Built reviewed Worker app ${appId} at ${output.pathname}.`); diff --git a/tools/build_worker_bundle.ts b/tools/build_worker_bundle.ts deleted file mode 100644 index 0c34e86..0000000 --- a/tools/build_worker_bundle.ts +++ /dev/null @@ -1,48 +0,0 @@ -const root = new URL('../', import.meta.url); -const outputDir = new URL('output/', root); -const sourcePath = new URL('src/worker.ts', root).pathname; -const rawBundlePath = new URL('worker.bundle.raw.mjs', outputDir); -const bundlePath = new URL('worker.bundle.mjs', outputDir); -const decoder = new TextDecoder(); - -await Deno.mkdir(outputDir, { recursive: true }); - -const command = new Deno.Command(Deno.execPath(), { - args: [ - 'bundle', - '--platform=browser', - '--external', - 'cloudflare:workers', - sourcePath, - '-o', - rawBundlePath.pathname, - ], -}); -const result = await command.output(); - -if (!result.success) { - throw new Error(`Worker bundle build failed:\n${decoder.decode(result.stderr).trim()}`); -} - -const rawBundle = await Deno.readTextFile(rawBundlePath); -const workflowImport = - 'import { WorkflowEntrypoint, WorkerEntrypoint } from "cloudflare:workers";\n'; -const workerSafeBundle = neutralizeBundledDenoRuntimeDetection(rawBundle); - -await Deno.writeTextFile(bundlePath, `${workflowImport}${workerSafeBundle}`); -await Deno.remove(rawBundlePath); - -console.log(`Built Worker bundle at ${bundlePath.pathname}.`); - -function neutralizeBundledDenoRuntimeDetection(bundleText: string): string { - return bundleText - .replaceAll( - 'if (typeof Deno !== "undefined") return Deno.env.get(key) ?? fallback;', - 'if (false) return fallback;', - ) - .replaceAll('globalThis.Deno?.env.toObject() || ', '') - .replaceAll( - 'if (typeof Deno !== "undefined") return {\n name: "deno",\n version: Deno?.version?.deno ?? null\n };', - 'if (false) return null;', - ); -} diff --git a/tools/check_worker_bundle.ts b/tools/check_worker_bundle.ts index 3c7be61..1770cf7 100644 --- a/tools/check_worker_bundle.ts +++ b/tools/check_worker_bundle.ts @@ -1,5 +1,22 @@ -const bundlePath = new URL('../output/worker.bundle.mjs', import.meta.url); -const bundleText = await Deno.readTextFile(bundlePath); +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { readBuildOutput } from '@cloudflare/build-output-utils'; +const output = await readBuildOutput(process.cwd()); +const moduleTexts: string[] = []; +for (const worker of Object.values(output.workers)) { + if (!worker.bundleDir || !worker.config.manifest) + throw new Error('Expected a built Worker module manifest.'); + for (const [path, module] of Object.entries(worker.config.manifest.modules)) { + if (module.type === 'esm' || module.type === 'cjs') { + moduleTexts.push(await readFile(join(worker.bundleDir, path), 'utf8')); + } + } +} +if (Object.keys(output.workers).length !== 2) + throw new Error('Expected the gateway and platform service in the build.'); +if (!output.containers.some((container) => container.config.name === 'lantern-python-grader-v1')) + throw new Error('Python grader container is absent from the build.'); +const bundleText = moduleTexts.join('\n'); const forbiddenPatterns = [ { description: 'direct Deno global', @@ -29,9 +46,9 @@ const failures = forbiddenPatterns if (failures.length > 0) { throw new Error( - `Worker bundle includes Deno-only runtime references:\n${ - failures.map((value) => `- ${value}`).join('\n') - }`, + `Worker bundle includes Deno-only runtime references:\n${failures + .map((value) => `- ${value}`) + .join('\n')}`, ); } diff --git a/tools/check_worker_config.ts b/tools/check_worker_config.ts index 7974d5b..546f1b9 100644 --- a/tools/check_worker_config.ts +++ b/tools/check_worker_config.ts @@ -1,106 +1,67 @@ -export function validateWorkerConfigBindings(configText: string): string[] { - const missing = REQUIRED_BINDINGS.filter(({ pattern }) => !pattern.test(configText)).map( - ({ description }) => description, - ); - const prohibited = PROHIBITED_BINDINGS.filter(({ pattern }) => pattern.test(configText)).map( - ({ description }) => description, - ); +import type { WorkerConfig } from 'cf/config'; +import config from '../cloudflare.config.ts'; +import { appWriterPlatform } from '../cloudflare.platform.ts'; - return [...missing, ...prohibited]; +/** Check the deployment configuration for Lantern's required platform boundaries. */ +export function validateWorkerConfigBindings(worker: WorkerConfig): string[] { + const failures: string[] = []; + const env = worker.env ?? {}; + if (!worker.domains?.includes('lantern.appboundary.com')) + failures.push('production custom domain'); + const required = { + DB: 'd1', + PACKAGE_ARTIFACTS: 'r2', + LOADER: 'worker-loader', + AI: 'ai', + APP_WRITER_SOURCE_COMPILER: 'worker', + APP_WRITER_PREVIEWER: 'worker', + APP_WRITER_AGENT: 'durable-object', + PYTHON_GRADER_SANDBOX: 'durable-object', + APP_GENERATION_WORKFLOW: 'workflow', + PYTHON_LAB_GENERATION_WORKFLOW: 'workflow', + }; + for (const [name, type] of Object.entries(required)) { + const binding = env[name]; + if (!binding || binding.type !== type) failures.push(`${name} binding (${type})`); + } + for (const name of ['AppWriterAgent', 'PythonGraderSandbox']) { + const declaration = worker.exports?.[name]; + if ( + declaration?.type !== 'durable-object' || + !('storage' in declaration) || + declaration.storage !== 'sqlite' + ) { + failures.push(`${name} SQLite export`); + } + } + for (const [name, workflowName] of Object.entries({ + AppGenerationWorkflow: 'lantern-app-generation', + PythonLabGenerationWorkflow: 'lantern-python-lab-generation', + })) { + const declaration = worker.exports?.[name]; + if (declaration?.type !== 'workflow' || declaration.name !== workflowName) + failures.push(`${name} workflow export`); + } + if ('HYPERDRIVE' in env) failures.push('prohibited legacy HYPERDRIVE binding'); + return failures; } -export function validateAppWriterPlatformConfigBindings(configText: string): string[] { - return APP_WRITER_PLATFORM_REQUIRED_BINDINGS.filter( - ({ pattern }) => !pattern.test(configText), - ).map(({ description }) => description); +/** Check that the auxiliary platform service has Browser Rendering enabled. */ +export function validateAppWriterPlatformConfigBindings(worker: WorkerConfig): string[] { + const binding = worker.env?.BROWSER; + return binding && binding.type === 'browser' ? [] : ['BROWSER binding']; } -const REQUIRED_BINDINGS = [ - { - description: 'custom domain route lantern.appboundary.com', - pattern: - /"routes"\s*:\s*\[[\s\S]*?"pattern"\s*:\s*"lantern\.appboundary\.com"[\s\S]*?"custom_domain"\s*:\s*true/, - }, - { - description: 'worker_loaders binding LOADER', - pattern: /"worker_loaders"\s*:\s*\[[\s\S]*?"binding"\s*:\s*"LOADER"/, - }, - { - description: 'r2_buckets binding PACKAGE_ARTIFACTS', - pattern: /"r2_buckets"\s*:\s*\[[\s\S]*?"binding"\s*:\s*"PACKAGE_ARTIFACTS"/, - }, - { - description: 'd1_databases binding DB', - pattern: /"d1_databases"\s*:\s*\[[\s\S]*?"binding"\s*:\s*"DB"/, - }, - { - description: 'workflows binding APP_GENERATION_WORKFLOW', - pattern: /"workflows"\s*:\s*\[[\s\S]*?"binding"\s*:\s*"APP_GENERATION_WORKFLOW"/, - }, - { - description: 'workflows class AppGenerationWorkflow', - pattern: /"workflows"\s*:\s*\[[\s\S]*?"class_name"\s*:\s*"AppGenerationWorkflow"/, - }, - { - description: 'durable_objects binding APP_WRITER_AGENT', - pattern: /"durable_objects"\s*:\s*\{[\s\S]*?"name"\s*:\s*"APP_WRITER_AGENT"/, - }, - { - description: 'durable_objects class AppWriterAgent', - pattern: /"durable_objects"\s*:\s*\{[\s\S]*?"class_name"\s*:\s*"AppWriterAgent"/, - }, - { - description: 'durable_objects migration AppWriterAgent', - pattern: /"migrations"\s*:\s*\[[\s\S]*?"new_sqlite_classes"\s*:\s*\[[\s\S]*?"AppWriterAgent"/, - }, - { - description: 'services binding APP_WRITER_SOURCE_COMPILER', - pattern: /"services"\s*:\s*\[[\s\S]*?"binding"\s*:\s*"APP_WRITER_SOURCE_COMPILER"/, - }, - { - description: 'services binding APP_WRITER_PREVIEWER', - pattern: /"services"\s*:\s*\[[\s\S]*?"binding"\s*:\s*"APP_WRITER_PREVIEWER"/, - }, -] as const; - -const PROHIBITED_BINDINGS = [ - { - description: 'legacy hyperdrive binding HYPERDRIVE', - pattern: /"hyperdrive"\s*:\s*\[[\s\S]*?"binding"\s*:\s*"HYPERDRIVE"/, - }, -] as const; - -const APP_WRITER_PLATFORM_REQUIRED_BINDINGS = [ - { - description: 'browser rendering binding BROWSER', - pattern: /"browser"\s*:\s*\{[\s\S]*?"binding"\s*:\s*"BROWSER"/, - }, -] as const; - if (import.meta.main) { - const configPath = new URL('../wrangler.jsonc', import.meta.url); - const appWriterPlatformConfigPath = new URL( - '../wrangler.app-writer-platform.jsonc', - import.meta.url, - ); - const configText = await Deno.readTextFile(configPath); - const appWriterPlatformConfigText = await Deno.readTextFile(appWriterPlatformConfigPath); const failures = [ - ...validateWorkerConfigBindings(configText), - ...validateAppWriterPlatformConfigBindings(appWriterPlatformConfigText), + ...validateWorkerConfigBindings(config.worker), + ...validateAppWriterPlatformConfigBindings(appWriterPlatform), ]; - - if (failures.length > 0) { + if (failures.length > 0) throw new Error( - `wrangler.jsonc is missing required Worker bindings:\n${ - failures - .map((failure) => `- ${failure}`) - .join('\n') - }`, + `Cloudflare configuration is missing required boundaries: ${failures.join(', ')}`, ); - } - console.log( - 'Worker config keeps the production custom domain, DB, LOADER, PACKAGE_ARTIFACTS, APP_GENERATION_WORKFLOW, APP_WRITER_AGENT, app-writer service bindings, and platform Browser Rendering binding wired.', + 'Cloudflare configuration preserves Lantern bindings, exports, and the production domain.', ); } diff --git a/tools/check_worker_config_test.ts b/tools/check_worker_config_test.ts index 7fb8abe..ae2b5d0 100644 --- a/tools/check_worker_config_test.ts +++ b/tools/check_worker_config_test.ts @@ -1,155 +1,34 @@ +import { test } from 'vite-plus/test'; import { assertEquals } from '@std/assert'; +import { bindings } from 'cf/config'; +import config from '../cloudflare.config.ts'; +import { appWriterPlatform } from '../cloudflare.platform.ts'; import { - validateAppWriterPlatformConfigBindings, validateWorkerConfigBindings, + validateAppWriterPlatformConfigBindings, } from './check_worker_config.ts'; -Deno.test('validateWorkerConfigBindings accepts wrangler config with DB, LOADER, PACKAGE_ARTIFACTS, APP_GENERATION_WORKFLOW, APP_WRITER_AGENT, and app-writer services', () => { - const config = `{ - "routes": [ - { - "pattern": "lantern.appboundary.com", - "custom_domain": true - } - ], - "workflows": [ - { - "binding": "APP_GENERATION_WORKFLOW", - "class_name": "AppGenerationWorkflow" - } - ], - "durable_objects": { - "bindings": [ - { - "name": "APP_WRITER_AGENT", - "class_name": "AppWriterAgent" - } - ] - }, - "migrations": [ - { - "new_sqlite_classes": ["AppWriterAgent"] - } - ], - "services": [ - { - "binding": "APP_WRITER_SOURCE_COMPILER" - }, - { - "binding": "APP_WRITER_PREVIEWER" - } - ], - "worker_loaders": [ - { - "binding": "LOADER" - } - ], - "r2_buckets": [ - { - "binding": "PACKAGE_ARTIFACTS" - } - ], - "d1_databases": [ - { - "binding": "DB" - } - ] - }`; - - assertEquals(validateWorkerConfigBindings(config), []); +test('Cloudflare configuration preserves all required platform boundaries', () => { + assertEquals(validateWorkerConfigBindings(config.worker), []); + assertEquals(validateAppWriterPlatformConfigBindings(appWriterPlatform), []); }); -Deno.test('validateWorkerConfigBindings reports missing required Worker bindings', () => { - const config = `{ - "worker_loaders": [], - "r2_buckets": [] - }`; - - assertEquals(validateWorkerConfigBindings(config), [ - 'custom domain route lantern.appboundary.com', - 'worker_loaders binding LOADER', - 'r2_buckets binding PACKAGE_ARTIFACTS', - 'd1_databases binding DB', - 'workflows binding APP_GENERATION_WORKFLOW', - 'workflows class AppGenerationWorkflow', - 'durable_objects binding APP_WRITER_AGENT', - 'durable_objects class AppWriterAgent', - 'durable_objects migration AppWriterAgent', - 'services binding APP_WRITER_SOURCE_COMPILER', - 'services binding APP_WRITER_PREVIEWER', - ]); -}); - -Deno.test('validateWorkerConfigBindings rejects legacy Hyperdrive production binding', () => { - const config = `{ - "routes": [ - { - "pattern": "lantern.appboundary.com", - "custom_domain": true - } - ], - "workflows": [ - { - "binding": "APP_GENERATION_WORKFLOW", - "class_name": "AppGenerationWorkflow" - } - ], - "durable_objects": { - "bindings": [ - { - "name": "APP_WRITER_AGENT", - "class_name": "AppWriterAgent" - } - ] - }, - "migrations": [ - { - "new_sqlite_classes": ["AppWriterAgent"] - } - ], - "services": [ - { - "binding": "APP_WRITER_SOURCE_COMPILER" - }, - { - "binding": "APP_WRITER_PREVIEWER" - } - ], - "worker_loaders": [ - { - "binding": "LOADER" - } - ], - "r2_buckets": [ - { - "binding": "PACKAGE_ARTIFACTS" - } - ], - "d1_databases": [ - { - "binding": "DB" - } - ], - "hyperdrive": [ - { - "binding": "HYPERDRIVE" - } - ] - }`; - - assertEquals(validateWorkerConfigBindings(config), ['legacy hyperdrive binding HYPERDRIVE']); +test('Cloudflare configuration check detects a missing gateway binding', () => { + assertEquals( + validateWorkerConfigBindings({ + ...config.worker, + env: { ...config.worker.env, LOADER: bindings.text('wrong') }, + }), + ['LOADER binding (worker-loader)'], + ); }); -Deno.test('validateAppWriterPlatformConfigBindings requires Cloudflare Browser Rendering', () => { +test('Cloudflare configuration check rejects legacy Hyperdrive access', () => { assertEquals( - validateAppWriterPlatformConfigBindings(`{ - "browser": { - "binding": "BROWSER" - } - }`), - [], + validateWorkerConfigBindings({ + ...config.worker, + env: { ...config.worker.env, HYPERDRIVE: bindings.hyperdrive({ id: 'unused' }) }, + }), + ['prohibited legacy HYPERDRIVE binding'], ); - assertEquals(validateAppWriterPlatformConfigBindings(`{}`), [ - 'browser rendering binding BROWSER', - ]); }); diff --git a/tools/generate_schema_validators.ts b/tools/generate_schema_validators.ts new file mode 100644 index 0000000..d0191ee --- /dev/null +++ b/tools/generate_schema_validators.ts @@ -0,0 +1,40 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { Ajv2020 } from 'ajv/dist/2020.js'; +import standalone from 'ajv/dist/standalone/index.js'; +const standaloneCode = standalone.default; +import { build } from 'esbuild'; +import manifest from '../schemas/app-manifest.schema.json' with { type: 'json' }; +import rubric from '../schemas/reviewed-rubric.schema.json' with { type: 'json' }; + +const ajv = new Ajv2020({ + allErrors: true, + strict: true, + strictRequired: false, + code: { source: true, esm: true }, +}); +ajv.addSchema(manifest, 'manifest'); +ajv.addSchema(rubric, 'rubric'); +const result = await build({ + stdin: { + contents: standaloneCode(ajv, { + manifestValidator: 'manifest', + reviewedRubricValidator: 'rubric', + }), + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, + format: 'esm', + target: 'es2022', + write: false, + banner: { js: '// Generated by npm run schemas:generate. Do not edit.' }, +}); +const output = result.outputFiles[0]; +if (!output) throw new Error('Schema validator generation produced no module.'); +const target = new URL('../src/generated/schema_validators.mjs', import.meta.url); +if (process.argv.includes('--check')) { + if ((await readFile(target, 'utf8')) !== output.text) + throw new Error('Schema validators are stale. Run npm run schemas:generate.'); +} else { + await writeFile(target, output.text); +} diff --git a/tools/install_preview_browser.ts b/tools/install_preview_browser.ts new file mode 100644 index 0000000..bbf4c28 --- /dev/null +++ b/tools/install_preview_browser.ts @@ -0,0 +1,4 @@ +import { installLocalPreviewBrowser } from '../src/authoring/local_browser.ts'; + +await installLocalPreviewBrowser(); +console.log('Installed the pinned Chrome version for Lantern previews.'); diff --git a/tools/local_init.ts b/tools/local_init.ts index f52b2e2..de83cc9 100644 --- a/tools/local_init.ts +++ b/tools/local_init.ts @@ -1,3 +1,4 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { appendMissingEnvAssignments, createLocalBetterAuthSecret, @@ -10,7 +11,7 @@ import { const LOCAL_DIRECTORIES = ['var/packages', 'var/attempt-evidence', 'var/authoring-drafts'] as const; if (import.meta.main) { - const outputPath = readOutputPath(Deno.args) ?? DEFAULT_LOCAL_ENV_PATH; + const outputPath = readOutputPath(process.argv.slice(2)) ?? DEFAULT_LOCAL_ENV_PATH; const existingText = await readOptionalTextFile(outputPath); const assignments = defaultLocalEnvAssignments(); const generatedJwk = await createLocalToolPrivateJwkString(); @@ -24,10 +25,10 @@ if (import.meta.main) { }, }); - await Deno.writeTextFile(outputPath, next.text); + await writeFile(outputPath, next.text); for (const path of LOCAL_DIRECTORIES) { - await Deno.mkdir(path, { recursive: true }); + await mkdir(path, { recursive: true }); } console.log( @@ -44,8 +45,8 @@ if (import.meta.main) { console.log(''); console.log('Next:'); - console.log('1. Run `deno task local:bootstrap` to apply local D1 migrations.'); - console.log('2. Run `deno task local:start` and open the Wrangler localhost URL.'); + console.log('1. Run `npm run local:bootstrap` to apply local D1 migrations.'); + console.log('2. Run `npm run local:start` and open the Cloudflare Vite localhost URL.'); } function readOutputPath(args: string[]): string | null { @@ -66,9 +67,9 @@ function readOutputPath(args: string[]): string | null { async function readOptionalTextFile(path: string): Promise<string | null> { try { - return await Deno.readTextFile(path); + return await readFile(path, 'utf8'); } catch (error) { - if (error instanceof Deno.errors.NotFound) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { return null; } diff --git a/tools/local_seed_support.ts b/tools/local_seed_support.ts index 192a390..01ba29b 100644 --- a/tools/local_seed_support.ts +++ b/tools/local_seed_support.ts @@ -30,8 +30,8 @@ export async function seedReferencePackages(input: { }): Promise<LocalSeedSummary> { const storageRoot = input.storageRoot; const snapshotStore = input.snapshotStore ?? getDefaultPackageSnapshotStore(); - const reviewNotes = input.reviewNotes ?? - 'Approved by Lantern local seed for shipped reference packages.'; + const reviewNotes = + input.reviewNotes ?? 'Approved by Lantern local seed for shipped reference packages.'; const accessibilityReview = input.accessibilityReview ?? defaultApprovedAccessibilityReview(); let importedCount = 0; let reusedSnapshotCount = 0; diff --git a/tools/local_seed_support_test.ts b/tools/local_seed_support_test.ts index f713426..525e803 100644 --- a/tools/local_seed_support_test.ts +++ b/tools/local_seed_support_test.ts @@ -1,3 +1,7 @@ +import { join } from 'node:path'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { test } from 'vite-plus/test'; +import { tmpdir } from 'node:os'; import { assert, assertEquals } from '@std/assert'; import { createObjectEnvReader } from '../src/platform/env.ts'; import { @@ -11,8 +15,8 @@ import { getTestToolPrivateJwkEnvValue } from '../src/test_helpers/lti.ts'; import { createInMemoryPackageReviewRepository } from '../src/test_helpers/package_review.ts'; import { seedReferencePackages } from './local_seed_support.ts'; -Deno.test("seedReferencePackages imports and approves Lantern's shipped reference packages", async () => { - const storageRoot = await Deno.makeTempDir({ prefix: 'lantern-local-seed-' }); +test("seedReferencePackages imports and approves Lantern's shipped reference packages", async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'lantern-local-seed-')); const repository = createInMemoryPackageReviewRepository(); try { @@ -45,6 +49,6 @@ Deno.test("seedReferencePackages imports and approves Lantern's shipped referenc assert(packageVersion?.accessibilityReview !== null); } } finally { - await Deno.remove(storageRoot, { recursive: true }); + await rm(storageRoot, { recursive: true }); } }); diff --git a/tools/local_support.ts b/tools/local_support.ts index cb71637..227bd48 100644 --- a/tools/local_support.ts +++ b/tools/local_support.ts @@ -108,7 +108,7 @@ function encodeBase64Url(bytes: Uint8Array): string { function defaultLocalEnvHeader(): string { return [ '# Local Lantern development defaults.', - '# Full Lantern persistence runs through Cloudflare D1 via Wrangler.', + '# Full Lantern persistence runs through Cloudflare D1 via cf.', '# APP_RUNTIME_ORIGIN defaults to the same localhost origin for local development.', '', ].join('\n'); diff --git a/tools/local_support_test.ts b/tools/local_support_test.ts index 93c3a70..b8db9cf 100644 --- a/tools/local_support_test.ts +++ b/tools/local_support_test.ts @@ -1,3 +1,4 @@ +import { test } from 'vite-plus/test'; import { assert, assertEquals } from '@std/assert'; import { appendMissingEnvAssignments, @@ -8,7 +9,7 @@ import { listDeclaredEnvKeys, } from './local_support.ts'; -Deno.test('appendMissingEnvAssignments creates the default local env file', async () => { +test('appendMissingEnvAssignments creates the default local env file', async () => { const generatedJwk = await createLocalToolPrivateJwkString(); const result = appendMissingEnvAssignments({ existingText: null, @@ -36,7 +37,7 @@ Deno.test('appendMissingEnvAssignments creates the default local env file', asyn assert(result.text.includes('LTI_TOOL_PRIVATE_JWK="{')); }); -Deno.test('appendMissingEnvAssignments preserves existing keys and appends only missing ones', () => { +test('appendMissingEnvAssignments preserves existing keys and appends only missing ones', () => { const result = appendMissingEnvAssignments({ existingText: ['APP_ORIGIN=http://localhost:9000', ''].join('\n'), assignments: { @@ -59,7 +60,7 @@ Deno.test('appendMissingEnvAssignments preserves existing keys and appends only assert(result.text.includes('APP_ORIGIN=http://localhost:9000')); }); -Deno.test('listDeclaredEnvKeys finds assignment keys in local env text', () => { +test('listDeclaredEnvKeys finds assignment keys in local env text', () => { const keys = listDeclaredEnvKeys( ['# comment', 'APP_ORIGIN=http://localhost:8417', ''].join('\n'), ); diff --git a/tools/local_watch.ts b/tools/local_watch.ts new file mode 100644 index 0000000..9f76594 --- /dev/null +++ b/tools/local_watch.ts @@ -0,0 +1,21 @@ +import { watch } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import type { WatchEventLike } from '../src/authoring/watch.ts'; + +/** Adapts Node filesystem notifications to the authoring watch loop. */ +export function watchLocalPackage(root: string): { + iterator: AsyncIterator<WatchEventLike>; + close(): void; +} { + const controller = new AbortController(); + async function* events(): AsyncGenerator<WatchEventLike> { + try { + for await (const event of watch(root, { recursive: true, signal: controller.signal })) { + yield { paths: [event.filename === null ? resolve(root) : resolve(root, event.filename)] }; + } + } catch (error) { + if (!controller.signal.aborted) throw error; + } + } + return { iterator: events(), close: () => controller.abort() }; +} diff --git a/tools/local_watch_test.ts b/tools/local_watch_test.ts new file mode 100644 index 0000000..cd22bda --- /dev/null +++ b/tools/local_watch_test.ts @@ -0,0 +1,23 @@ +import { test } from 'vite-plus/test'; +import { equal, ok } from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { watchLocalPackage } from './local_watch.ts'; + +test('Node package watcher reports nested file changes with absolute paths and closes', async () => { + const root = await mkdtemp(join(tmpdir(), 'lantern-watch-')); + await mkdir(join(root, 'content')); + const watcher = watchLocalPackage(root); + try { + const event = watcher.iterator.next(); + await writeFile(join(root, 'content/activity.json'), '{}'); + const result = await event; + equal(result.done, false); + ok(result.value?.paths.every((path: string) => path.startsWith(resolve(root)))); + } finally { + watcher.close(); + await watcher.iterator.return?.(); + await rm(root, { recursive: true }); + } +}); diff --git a/tools/patch_cf_helpers.mjs b/tools/patch_cf_helpers.mjs new file mode 100644 index 0000000..a4d376f --- /dev/null +++ b/tools/patch_cf_helpers.mjs @@ -0,0 +1,30 @@ +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +// cf beta.27 still imports helpers removed from the latest Miniflare. Use the +// replacements documented by Cloudflare, keeping one current Miniflare runtime. +const require = createRequire(import.meta.url); +const target = new URL( + './dist/containers-DINLx5_i.mjs', + pathToFileURL(require.resolve('cf/package.json')), +); +const originalHash = 'fa8f380186497b3c5ad88dceab4e8dba090cdf845ebc6d0a3b834c30321503c3'; +const patchedHash = 'af5ec541a39ef418f123b05c3f6d5d3873731a8c62f5528dd8fedce85d2c80e6'; +const source = await readFile(target, 'utf8'); +const hash = createHash('sha256').update(source).digest('hex'); +if (hash === patchedHash) process.exit(0); +if (hash !== originalHash) + throw new Error( + 'The cf helper patch does not match the installed artifact. Review the cf upgrade.', + ); +const output = source.replace( + 'import{getFreshSourceMapSupport as Mt,getNodeCompat as Nt}from"miniflare";', + 'import{getFreshSourceMapSupport as Mt}from"@cloudflare/workers-utils/sourcemap";import{getNodeCompat as Nt}from"@cloudflare/workers-utils/node-compat";', +); +if (output === source) throw new Error('The cf helper imports were not found.'); +if (createHash('sha256').update(output).digest('hex') !== patchedHash) + throw new Error('The cf helper patch produced an unexpected artifact.'); +await writeFile(target, output); +console.log('Applied cf helper compatibility patch.'); diff --git a/tools/patch_miniflare_browser.mjs b/tools/patch_miniflare_browser.mjs new file mode 100644 index 0000000..7d45793 --- /dev/null +++ b/tools/patch_miniflare_browser.mjs @@ -0,0 +1,60 @@ +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { transform } from 'esbuild'; + +// Miniflare bundles @puppeteer/browsers 2.10.6 (including extract-zip 2.0.1), +// so npm overrides cannot fix it. Replace that embedded module with the audited +// installed dependency, then remove its now-unreachable legacy dependency tree. +// This patch is deliberately bound to one published artifact: upgrades require review. +const require = createRequire(import.meta.url); +const entrypoint = require.resolve('miniflare'); +const upstreamHash = '81ea9e0536bc38f4b66a60bf70223cf49c1375dada94aee2aadf79735f0c29bc'; +const patchedHash = '4dc1dc33f6a1fbc14d33d5e21d1e370552ff8db4e38f27ab836023622395595b'; +const source = await readFile(entrypoint, 'utf8'); +const hash = createHash('sha256').update(source).digest('hex'); +if (hash === patchedHash) process.exit(0); +if (hash !== upstreamHash) { + throw new Error( + 'Miniflare browser security patch does not match the installed artifact. Review the dependency upgrade.', + ); +} +const start = source.indexOf('// ../../node_modules/.pnpm/@puppeteer+browsers@'); +const end = source.indexOf('// embed-worker:', start); +if (start < 0 || end < start) throw new Error('Miniflare embedded browser module was not found.'); +let patched = + source.slice(0, start) + + 'var { Browser, detectBrowserPlatform, resolveBuildId: resolveBuildId4, install, launch, CDP_WEBSOCKET_ENDPOINT_REGEX } = require("@puppeteer/browsers");\n' + + source.slice(end); +patched = patched + .replace( + 'var BROWSER_VERSION = "126.0.6478.182";', + 'var BROWSER_VERSION = require("puppeteer-core").PUPPETEER_REVISIONS.chrome;', + ) + .replace( + 'args: process.env.CI ? [...launchArgs, "--no-sandbox"] : launchArgs,', + 'args: launchArgs,', + ); +// esbuild's __commonJS helper only constructs a lazy closure; marking these +// declarations pure lets its tree shaker discard unused embedded dependencies. +patched = patched.replaceAll('= __commonJS({', '= /* @__PURE__ */ __commonJS({'); +const result = await transform(patched, { + format: 'cjs', + platform: 'node', + target: 'node26', + treeShaking: true, + minifySyntax: true, + legalComments: 'inline', +}); +// Preserve esbuild's static CommonJS export annotation for Node's ESM loader. +const annotationStart = source.indexOf('// Annotate the CommonJS export names'); +const annotationEnd = source.indexOf('\n});', annotationStart) + 5; +const output = result.code + source.slice(annotationStart, annotationEnd) + '\n'; +if (/extract-zip|basic-ftp|require_extract_zip|126\.0\.6478\.182|--no-sandbox/.test(output)) { + throw new Error('Miniflare browser security patch retained legacy code or disabled sandboxing.'); +} +if (createHash('sha256').update(output).digest('hex') !== patchedHash) { + throw new Error('Miniflare browser security patch produced an unexpected artifact.'); +} +await writeFile(entrypoint, output); +console.log('Applied Miniflare browser security patch.'); diff --git a/tools/reference_sync.ts b/tools/reference_sync.ts index febcd53..2afca12 100644 --- a/tools/reference_sync.ts +++ b/tools/reference_sync.ts @@ -1,7 +1,7 @@ import { readArgs, syncReferencePackageSources } from './reference_sync_support.ts'; try { - const args = readArgs(Deno.args); + const args = readArgs(process.argv.slice(2)); const summary = await syncReferencePackageSources(args); console.log( @@ -25,5 +25,5 @@ try { } } catch (error) { console.error(error instanceof Error ? error.message : 'Reference package source sync failed.'); - Deno.exit(1); + process.exit(1); } diff --git a/tools/reference_sync_support.ts b/tools/reference_sync_support.ts index 5e058f4..0607b76 100644 --- a/tools/reference_sync_support.ts +++ b/tools/reference_sync_support.ts @@ -1,3 +1,7 @@ +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import type { Dirent } from 'node:fs'; +import { readdir } from 'node:fs/promises'; import { getReferencePackageBucketSourceRoot, getReferencePackageSourceRoot, @@ -12,8 +16,7 @@ export interface ReferenceSyncArgs { bucket: string; appIds: ReferencePackageId[]; mode: ReferenceSyncMode; - configPath: string | null; - envName: string | null; + cloudflareMode: string | null; persistTo: string | null; dryRun: boolean; } @@ -39,17 +42,16 @@ interface ReferenceSyncSourceOptions { } interface ReferenceSyncOptions extends ReferenceSyncSourceOptions { - runWranglerCommand?: WranglerCommandRunner; + runCloudflareCommand?: CloudflareCommandRunner; } -export type WranglerCommandRunner = (args: string[]) => Promise<void>; +export type CloudflareCommandRunner = (args: string[]) => Promise<void>; export function readArgs(args: string[]): ReferenceSyncArgs { let bucket: string | null = null; const appIds: ReferencePackageId[] = []; let mode: ReferenceSyncMode = 'remote'; - let configPath: string | null = null; - let envName: string | null = null; + let cloudflareMode: string | null = null; let persistTo: string | null = null; let dryRun = false; @@ -88,13 +90,8 @@ export function readArgs(args: string[]): ReferenceSyncArgs { continue; } - if (arg.startsWith('--config=')) { - configPath = arg.slice('--config='.length); - continue; - } - - if (arg.startsWith('--env=')) { - envName = arg.slice('--env='.length); + if (arg.startsWith('--mode=')) { + cloudflareMode = arg.slice('--mode='.length); continue; } @@ -122,9 +119,8 @@ export function readArgs(args: string[]): ReferenceSyncArgs { bucket, appIds: appIds.length > 0 ? appIds : listReferencePackageIds(), mode, - configPath, - envName, - persistTo, + cloudflareMode, + persistTo: mode === 'local' ? (persistTo ?? '.cloudflare/state') : null, dryRun, }; } @@ -155,23 +151,22 @@ export async function listReferencePackageSourceUploads( return uploads; } -export function buildWranglerR2PutArgs( +export function buildCloudflareR2PutArgs( input: ReferenceSyncArgs, upload: ReferencePackageSourceUpload, ): string[] { return [ - 'wrangler', - ...(input.configPath === null ? [] : ['--config', input.configPath]), - ...(input.envName === null ? [] : ['--env', input.envName]), 'r2', - 'object', + 'objects', 'put', - `${input.bucket}/${upload.objectKey}`, + upload.objectKey, + '--bucket-name', + input.bucket, '--file', upload.sourcePath, - input.mode === 'local' ? '--local' : '--remote', + ...(input.cloudflareMode === null ? [] : ['--mode', input.cloudflareMode]), + ...(input.mode === 'local' ? ['--local'] : []), ...(input.persistTo === null ? [] : ['--persist-to', input.persistTo]), - '--force', ]; } @@ -182,10 +177,10 @@ export async function syncReferencePackageSources( const uploads = await listReferencePackageSourceUploads(input.appIds, options); if (!input.dryRun) { - const runWranglerCommand = options.runWranglerCommand ?? runWranglerCommandWithNpx; + const runCommand = options.runCloudflareCommand ?? runCloudflareCommand; for (const upload of uploads) { - await runWranglerCommand(buildWranglerR2PutArgs(input, upload)); + await runCommand(buildCloudflareR2PutArgs(input, upload)); } } @@ -200,7 +195,7 @@ export async function syncReferencePackageSources( function buildUsageMessage(): string { return [ - 'Usage: deno task reference:sync --bucket=<bucket-name> [--app-id=<id>] [--remote|--local] [--persist-to=<dir>] [--config=<path>] [--env=<name>] [--dry-run]', + 'Usage: npm run reference:sync -- --bucket=<bucket-name> [--app-id=<id>] [--remote|--local] [--persist-to=<dir>] [--mode=<name>] [--dry-run]', '', 'Curated reference app ids:', ...listReferencePackageIds().map((appId) => `- ${appId}`), @@ -221,9 +216,9 @@ async function collectDirectoryFiles( relativePrefix: string, files: string[], ): Promise<void> { - const entries: Deno.DirEntry[] = []; + const entries: Dirent[] = []; - for await (const entry of Deno.readDir(root)) { + for (const entry of await readdir(root, { withFileTypes: true })) { entries.push(entry); } @@ -233,12 +228,12 @@ async function collectDirectoryFiles( const relativePath = relativePrefix === '' ? entry.name : `${relativePrefix}/${entry.name}`; const absolutePath = joinLocalPath(root, entry.name); - if (entry.isDirectory) { + if (entry.isDirectory()) { await collectDirectoryFiles(absolutePath, relativePath, files); continue; } - if (entry.isFile) { + if (entry.isFile()) { files.push(relativePath); } } @@ -248,15 +243,14 @@ function joinLocalPath(root: string, relativePath: string): string { return `${root.replaceAll(/\/+$/g, '')}/${relativePath.replaceAll(/^\/+/g, '')}`; } -async function runWranglerCommandWithNpx(args: string[]): Promise<void> { - const command = new Deno.Command('npx', { - args, - stdout: 'inherit', - stderr: 'inherit', +async function runCloudflareCommand(args: string[]): Promise<void> { + await new Promise<void>((resolve, reject) => { + const cli = fileURLToPath(new URL('../node_modules/cf/bin/cf', import.meta.url)); + const child = spawn(process.execPath, [cli, ...args], { stdio: 'inherit' }); + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`Cloudflare command failed: cf ${args.join(' ')}`)); + }); }); - const result = await command.spawn().status; - - if (!result.success) { - throw new Error(`Wrangler command failed: npx ${args.join(' ')}`); - } } diff --git a/tools/reference_sync_support_test.ts b/tools/reference_sync_support_test.ts index 82b8942..1d81a83 100644 --- a/tools/reference_sync_support_test.ts +++ b/tools/reference_sync_support_test.ts @@ -1,12 +1,16 @@ +import { join } from 'node:path'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { test } from 'vite-plus/test'; +import { tmpdir } from 'node:os'; import { assertEquals, assertThrows } from '@std/assert'; import { - buildWranglerR2PutArgs, + buildCloudflareR2PutArgs, listReferencePackageSourceUploads, readArgs, syncReferencePackageSources, } from './reference_sync_support.ts'; -Deno.test('readArgs defaults to remote sync across all curated reference apps', () => { +test('readArgs defaults to remote sync across all curated reference apps', () => { const args = readArgs(['--bucket=lantern-artifacts']); assertEquals(args.bucket, 'lantern-artifacts'); @@ -15,34 +19,34 @@ Deno.test('readArgs defaults to remote sync across all curated reference apps', assertEquals(args.appIds.length > 0, true); }); -Deno.test('readArgs accepts local filtered sync and rejects persist-to without local', () => { +test('readArgs accepts local filtered sync and rejects persist-to without local', () => { const parsed = readArgs([ '--bucket=lantern-artifacts', '--app-id=template-app', '--local', - '--persist-to=.wrangler/state/v3/r2', + '--persist-to=.cloudflare/state', '--dry-run', ]); assertEquals(parsed.appIds, ['template-app']); assertEquals(parsed.mode, 'local'); - assertEquals(parsed.persistTo, '.wrangler/state/v3/r2'); + assertEquals(parsed.persistTo, '.cloudflare/state'); assertEquals(parsed.dryRun, true); assertThrows( - () => readArgs(['--bucket=lantern-artifacts', '--persist-to=.wrangler/state/v3/r2']), + () => readArgs(['--bucket=lantern-artifacts', '--persist-to=.cloudflare/state']), Error, '--persist-to requires --local.', ); }); -Deno.test('listReferencePackageSourceUploads maps source files into the curated bucket layout', async () => { - const sourceRoot = await Deno.makeTempDir({ prefix: 'lantern-reference-sync-' }); +test('listReferencePackageSourceUploads maps source files into the curated bucket layout', async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), 'lantern-reference-sync-')); try { - await Deno.mkdir(`${sourceRoot}/dist`, { recursive: true }); - await Deno.writeTextFile(`${sourceRoot}/manifest.json`, '{}\n'); - await Deno.writeTextFile(`${sourceRoot}/dist/app.js`, "console.log('ok');\n"); + await mkdir(`${sourceRoot}/dist`, { recursive: true }); + await writeFile(`${sourceRoot}/manifest.json`, '{}\n'); + await writeFile(`${sourceRoot}/dist/app.js`, "console.log('ok');\n"); const uploads = await listReferencePackageSourceUploads(['template-app'], { resolveSourceRoot: () => sourceRoot, @@ -66,19 +70,18 @@ Deno.test('listReferencePackageSourceUploads maps source files into the curated ], ); } finally { - await Deno.remove(sourceRoot, { recursive: true }); + await rm(sourceRoot, { recursive: true }); } }); -Deno.test('buildWranglerR2PutArgs keeps wrangler scoped to one explicit object write', () => { - const args = buildWranglerR2PutArgs( +test('buildCloudflareR2PutArgs keeps Cloudflare scoped to one explicit object write', () => { + const args = buildCloudflareR2PutArgs( { bucket: 'lantern-artifacts', appIds: ['template-app'], mode: 'local', - configPath: 'wrangler.jsonc', - envName: 'staging', - persistTo: '.wrangler/state/v3/r2', + cloudflareMode: 'staging', + persistTo: '.cloudflare/state', dryRun: false, }, { @@ -90,45 +93,42 @@ Deno.test('buildWranglerR2PutArgs keeps wrangler scoped to one explicit object w ); assertEquals(args, [ - 'wrangler', - '--config', - 'wrangler.jsonc', - '--env', - 'staging', 'r2', - 'object', + 'objects', 'put', - 'lantern-artifacts/reference-packages/template-app/source/manifest.json', + 'reference-packages/template-app/source/manifest.json', + '--bucket-name', + 'lantern-artifacts', '--file', 'examples/apps/template/manifest.json', + '--mode', + 'staging', '--local', '--persist-to', - '.wrangler/state/v3/r2', - '--force', + '.cloudflare/state', ]); }); -Deno.test('syncReferencePackageSources dry-run plans uploads without invoking wrangler', async () => { - const sourceRoot = await Deno.makeTempDir({ prefix: 'lantern-reference-sync-' }); +test('syncReferencePackageSources dry-run plans uploads without invoking Cloudflare', async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), 'lantern-reference-sync-')); const commands: string[][] = []; try { - await Deno.writeTextFile(`${sourceRoot}/manifest.json`, '{}\n'); + await writeFile(`${sourceRoot}/manifest.json`, '{}\n'); const summary = await syncReferencePackageSources( { bucket: 'lantern-artifacts', appIds: ['template-app'], mode: 'remote', - configPath: null, - envName: null, + cloudflareMode: null, persistTo: null, dryRun: true, }, { resolveSourceRoot: () => sourceRoot, resolveBucketRoot: () => 'reference-packages/template-app/source', - runWranglerCommand(args) { + runCloudflareCommand(args) { commands.push(args); return Promise.resolve(); @@ -143,6 +143,6 @@ Deno.test('syncReferencePackageSources dry-run plans uploads without invoking wr 'reference-packages/template-app/source/manifest.json', ); } finally { - await Deno.remove(sourceRoot, { recursive: true }); + await rm(sourceRoot, { recursive: true }); } }); diff --git a/tools/tsconfig.json b/tools/tsconfig.json new file mode 100644 index 0000000..ff12439 --- /dev/null +++ b/tools/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": ["./**/*.ts"] +} diff --git a/tools/worker_bindings_typecheck_test.ts b/tools/worker_bindings_typecheck_test.ts new file mode 100644 index 0000000..7c80678 --- /dev/null +++ b/tools/worker_bindings_typecheck_test.ts @@ -0,0 +1,77 @@ +import { deepStrictEqual, equal, ok } from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'vite-plus/test'; + +test('generated Worker bindings resolve and reject invalid names and values', async () => { + const projectRoot = fileURLToPath(new URL('../', import.meta.url)); + // Keep the generated declaration's relative config import at its original depth. + const root = await mkdtemp(join(projectRoot, '.cloudflare/typecheck-')); + try { + // Check our generated declarations as source so skipLibCheck cannot hide errors + // in them. Third-party declarations retain the project's normal checking policy. + await writeFile( + join(root, 'bindings.ts'), + await readFile(join(projectRoot, '.cloudflare/types/index.d.ts'), 'utf8'), + ); + await writeFile( + join(root, 'tsconfig.json'), + JSON.stringify({ + extends: '../../tsconfig.json', + include: ['bindings.ts', 'probe.ts'], + exclude: [], + }), + ); + const compilerPath = fileURLToPath( + new URL('./bin/tsc', import.meta.resolve('typescript/package.json')), + ); + const compile = () => { + const result = spawnSync( + process.execPath, + [compilerPath, '--project', join(root, 'tsconfig.json'), '--pretty', 'false'], + { cwd: projectRoot, encoding: 'utf8', timeout: 30000 }, + ); + if (result.error) throw result.error; + return result; + }; + + await writeFile( + join(root, 'probe.ts'), + `import { env } from 'cloudflare:workers'; +const secret: string = env.BETTER_AUTH_SECRET; +env.DB.prepare('SELECT 1'); +void secret; +`, + ); + const valid = compile(); + equal(valid.status, 0, valid.stdout + valid.stderr); + + await writeFile( + join(root, 'probe.ts'), + `import { env } from 'cloudflare:workers'; +const wrongSecret: number = env.BETTER_AUTH_SECRET; +env.DEFINITELY_NOT_A_REAL_LANTERN_BINDING; +env.DB.notARealD1Method(); +void wrongSecret; +`, + ); + const invalid = compile(); + ok(invalid.status !== null && invalid.status !== 0, invalid.stdout + invalid.stderr); + deepStrictEqual( + [...invalid.stdout.matchAll(/probe\.ts\((\d+),\d+\): error TS(\d+):/g)].map((diagnostic) => [ + Number(diagnostic[1]), + Number(diagnostic[2]), + ]), + [ + [2, 2322], + [3, 2339], + [4, 2339], + ], + invalid.stdout + invalid.stderr, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a6d9b42 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["node", "@cloudflare/workers-types/experimental", "@cloudflare/vitest-plugin/types"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "sdk/**/*.ts", + "tools/**/*.ts", + "vite.config.ts", + "cloudflare*.ts", + ".cloudflare/types/index.d.ts" + ], + "exclude": ["node_modules", "private", "output"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..8333ecf --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,127 @@ +import { readFile } from 'node:fs/promises'; +import { cloudflareTest, readD1Migrations } from '@cloudflare/vitest-plugin'; +import { cloudflare } from '@cloudflare/vite-plugin'; +import { appWriterPlatform } from './cloudflare.platform.ts'; +import { defineConfig, loadEnv } from 'vite-plus'; + +const workerTests = [ + 'src/runtime/dynamic_worker_delivery_integration_test.ts', + 'src/admin_auth/service_test.ts', + 'src/lti/tool_provider_d1_test.ts', + 'src/lms_kernel/repository_d1_runtime_test.ts', + 'src/lms_kernel/repository_d1_integration_test.ts', + 'src/app_writer/cloudflare_browser_preview_integration_test.ts', +]; + +export default defineConfig(({ mode, command }) => ({ + plugins: + mode === 'test' + ? [] + : [ + cloudflare({ + auxiliaryWorkers: [{ config: appWriterPlatform }], + remoteBindings: false, + config(loaded) { + if (command !== 'serve') return loaded; + const local = loadEnv(mode, process.cwd(), ''); + const env = { ...loaded.worker.env }; + // Bind only Lantern settings; never expose the host environment to a Worker. + for (const name of [ + 'APP_ORIGIN', + 'APP_RUNTIME_ORIGIN', + 'APP_ADMIN_TIME_ZONE', + 'ADMIN_AUTH_ALLOW_SIGNUP', + 'APP_WRITER_MODEL', + 'BETTER_AUTH_SECRET', + 'LTI_STATE_SECRET', + 'LTI_TOOL_PRIVATE_JWK', + 'GOOGLE_CLIENT_ID', + 'GOOGLE_CLIENT_SECRET', + 'AUTOGRADER_TURNSTILE_SITE_KEY', + 'AUTOGRADER_TURNSTILE_SECRET', + ]) { + const value = local[name]; + if (value !== undefined) env[name] = { type: 'text', value }; + } + return { ...loaded, worker: { ...loaded.worker, env } }; + }, + types: { includeRuntime: false }, + }), + ], + server: { port: 8417, strictPort: true }, + define: { Deno: 'undefined', 'globalThis.Deno': 'undefined' }, + fmt: { + singleQuote: true, + printWidth: 100, + proseWrap: 'preserve', + ignorePatterns: [ + 'private/**', + 'src/generated/**', + 'output/**', + '.wrangler/**', + '.playwright-cli/**', + 'examples/apps/**/dist/**', + ], + }, + lint: { + options: { typeAware: true, typeCheck: true }, + ignorePatterns: [ + 'private/**', + 'src/generated/**', + 'output/**', + '.wrangler/**', + '.playwright-cli/**', + 'examples/apps/**/dist/**', + ], + rules: { 'typescript/no-explicit-any': 'error' }, + }, + test: { + projects: [ + { + extends: false, + test: { + name: 'node', + include: ['src/**/*_test.ts', 'tools/**/*_test.ts', 'sdk/**/*_test.ts'], + exclude: workerTests, + }, + }, + { + extends: false, + plugins: [ + cloudflareTest(async () => ({ + config: './cloudflare.test.config.ts', + miniflare: { + serviceBindings: { PHOTO_ROSTER: 'photo-roster' }, + workers: [ + { + name: 'photo-roster', + compatibilityDate: '2026-11-02', + modules: true, + scriptPath: 'examples/apps/photo-roster/dist/worker.js', + serviceBindings: { + LANTERN_LMS: { + name: 'lantern-lms', + compatibilityDate: '2026-11-02', + entrypoint: 'LanternLms', + }, + }, + }, + { + name: 'lantern-lms', + compatibilityDate: '2026-11-02', + modules: true, + script: await readFile('src/test_helpers/roster_rpc_fixture.mjs', 'utf8'), + }, + ], + bindings: { TEST_MIGRATIONS: await readD1Migrations('src/db/d1_migrations') }, + }, + })), + ], + test: { name: 'workers', include: workerTests }, + }, + ], + fileParallelism: false, + testTimeout: 30000, + hookTimeout: 30000, + }, +})); diff --git a/wrangler.app-writer-platform.jsonc b/wrangler.app-writer-platform.jsonc deleted file mode 100644 index b6177a8..0000000 --- a/wrangler.app-writer-platform.jsonc +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "lantern-app-writer-platform-services", - "main": "output/app-writer-platform.bundle.mjs", - "compatibility_date": "2026-04-06", - "compatibility_flags": ["nodejs_compat"], - "browser": { - "binding": "BROWSER" - }, - "build": { - "command": "deno task build:app-writer-platform", - "watch_dir": ["src", "sdk", "schemas", "deno.json", "deno.lock"] - }, - "no_bundle": true -} diff --git a/wrangler.jsonc b/wrangler.jsonc deleted file mode 100644 index 95eeaba..0000000 --- a/wrangler.jsonc +++ /dev/null @@ -1,111 +0,0 @@ -{ - // Cloudflare Workers starter config. - // Replace the placeholder binding values with your account values before deploy. - "name": "lantern", - "main": "output/worker.bundle.mjs", - "compatibility_date": "2026-07-13", - "compatibility_flags": ["nodejs_compat"], - "build": { - // Wrangler cannot consume Deno import-map specifiers directly, so build one - // bundled Worker artifact before local dev or deploy. - "command": "deno task build:worker", - "watch_dir": ["src", "sdk", "schemas", "deno.json", "deno.lock"] - }, - // The build step already emits a single Worker module. - "no_bundle": true, - "placement": { - "mode": "smart" - }, - "routes": [ - { - "pattern": "lantern.appboundary.com", - "custom_domain": true - } - ], - "vars": { - "APP_ADMIN_TIME_ZONE": "America/New_York", - "APP_ORIGIN": "https://lantern.appboundary.com", - "APP_RUNTIME_ORIGIN": "https://lantern.appboundary.com", - "ADMIN_AUTH_ALLOW_SIGNUP": "1", - "APP_WRITER_MODEL": "@cf/moonshotai/kimi-k2.6" - }, - "ai": { - "binding": "AI" - }, - "workflows": [ - { - "name": "lantern-app-generation", - "binding": "APP_GENERATION_WORKFLOW", - "class_name": "AppGenerationWorkflow" - }, - { - "name": "lantern-python-lab-generation", - "binding": "PYTHON_LAB_GENERATION_WORKFLOW", - "class_name": "PythonLabGenerationWorkflow" - } - ], - "containers": [ - { - "class_name": "PythonGraderSandbox", - "image": "./Dockerfile.python-grader" - } - ], - "durable_objects": { - "bindings": [ - { - "name": "APP_WRITER_AGENT", - "class_name": "AppWriterAgent" - }, - { - "name": "PYTHON_GRADER_SANDBOX", - "class_name": "PythonGraderSandbox" - } - ] - }, - "migrations": [ - { - "tag": "v1_app_writer_agent", - "new_sqlite_classes": ["AppWriterAgent"] - }, - { - "tag": "v2_python_grader_sandbox", - "new_sqlite_classes": ["PythonGraderSandbox"] - } - ], - "services": [ - { - "binding": "APP_WRITER_SOURCE_COMPILER", - "service": "lantern-app-writer-platform-services" - }, - { - "binding": "APP_WRITER_PREVIEWER", - "service": "lantern-app-writer-platform-services" - } - ], - "worker_loaders": [ - { - "binding": "LOADER" - } - ], - "d1_databases": [ - { - "binding": "DB", - "database_name": "lantern", - "database_id": "0f0a956c-c1f8-43dd-bcdb-80577be94856", - "migrations_dir": "src/db/d1_migrations" - } - ], - "r2_buckets": [ - { - "binding": "PACKAGE_ARTIFACTS", - // Store reviewed snapshots under var/packages/... and curated - // reference package sources under reference-packages/<app-id>/source/... - // after bucket creation run: - // deno task reference:sync --bucket=<bucket_name> - "bucket_name": "lantern-prod-artifacts" - } - ], - "triggers": { - "crons": ["17 3 * * *"] - } -} From 75988c185af51af878ac0e9f9b55e202bc5f3bcb Mon Sep 17 00:00:00 2001 From: Sam Ottenhoff <ottenhoff@longsight.com> Date: Tue, 3 Nov 2026 16:54:12 -0500 Subject: [PATCH 11/12] Configure CI browser caches after the runner starts --- .github/workflows/ci.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59eff58..8aaa66e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,6 @@ permissions: jobs: validate: runs-on: ubuntu-26.04 - env: - PUPPETEER_CACHE_DIR: ${{ runner.temp }}/lantern-browser - XDG_CACHE_HOME: ${{ runner.temp }}/lantern-worker-cache steps: - uses: actions/checkout@v7.0.1 - uses: actions/setup-node@v7.1.0 @@ -23,6 +20,10 @@ jobs: run: npm install --global npm@12.2.1 - name: Install dependencies run: npm ci + - name: Configure browser caches + run: | + echo "PUPPETEER_CACHE_DIR=$RUNNER_TEMP/lantern-browser" >> "$GITHUB_ENV" + echo "XDG_CACHE_HOME=$RUNNER_TEMP/lantern-worker-cache" >> "$GITHUB_ENV" - name: Enable Chrome sandbox user namespaces run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Install Chrome from a clean cache From 534457c9256e6dbffa10ae625384d9db8e8e1427 Mon Sep 17 00:00:00 2001 From: Sam Ottenhoff <ottenhoff@longsight.com> Date: Tue, 3 Nov 2026 16:56:07 -0500 Subject: [PATCH 12/12] Use a fresh Python grader namespace for the container policy cutover --- cloudflare.config.ts | 6 ++++-- src/python_lab/cloudflare_sandbox.ts | 7 +++++-- src/worker.ts | 1 + tools/check_worker_config.ts | 9 ++++++++- tools/check_worker_config_test.ts | 24 +++++++++++++++++++++++- 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/cloudflare.config.ts b/cloudflare.config.ts index c56f75a..f349092 100644 --- a/cloudflare.config.ts +++ b/cloudflare.config.ts @@ -50,7 +50,7 @@ export default defineConfig({ }), PYTHON_GRADER_SANDBOX: bindings.durableObject({ worker: 'lantern', - exportName: 'PythonGraderSandbox', + exportName: 'PythonGraderRuntime', }), APP_GENERATION_WORKFLOW: bindings.workflow({ name: 'lantern-app-generation', @@ -68,7 +68,9 @@ export default defineConfig({ exports: { LanternLmsCapabilityBinding: exports.worker(), AppWriterAgent: exports.durableObject({ storage: 'sqlite' }), - PythonGraderSandbox: exports.durableObject({ storage: 'sqlite', container: pythonGrader }), + // Cloudflare requires a new namespace when changing the Container scheduling policy. + PythonGraderSandbox: exports.durableObject({ storage: 'sqlite' }), + PythonGraderRuntime: exports.durableObject({ storage: 'sqlite', container: pythonGrader }), AppGenerationWorkflow: exports.workflow({ name: 'lantern-app-generation', }), diff --git a/src/python_lab/cloudflare_sandbox.ts b/src/python_lab/cloudflare_sandbox.ts index f6e3bc9..b436af7 100644 --- a/src/python_lab/cloudflare_sandbox.ts +++ b/src/python_lab/cloudflare_sandbox.ts @@ -8,7 +8,7 @@ import { } from './runner.ts'; /** Owns one disposable Python interpreter with no outbound network access. */ -export class PythonGraderSandbox extends DurableObject { +export class PythonGraderRuntime extends DurableObject { private getContainer(): Container { const container = this.ctx.container; if (!container) throw new Error('Python grader container is not configured.'); @@ -80,8 +80,11 @@ export class PythonGraderSandbox extends DurableObject { } } +/** Retains the previous namespace; new grading uses the replacement runtime only. */ +export class PythonGraderSandbox extends DurableObject {} + export type PythonGraderSandboxNamespace = Pick< - DurableObjectNamespace<PythonGraderSandbox>, + DurableObjectNamespace<PythonGraderRuntime>, 'getByName' >; diff --git a/src/worker.ts b/src/worker.ts index 6e4ce2f..e893fd6 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -8,6 +8,7 @@ import { export { AppWriterAgent } from './app_writer/agent.ts'; export { createCloudflarePythonGraderSandboxFactory, + PythonGraderRuntime, PythonGraderSandbox, } from './python_lab/cloudflare_sandbox.ts'; import { createCloudflarePythonGraderSandboxFactory } from './python_lab/cloudflare_sandbox.ts'; diff --git a/tools/check_worker_config.ts b/tools/check_worker_config.ts index 546f1b9..c715f31 100644 --- a/tools/check_worker_config.ts +++ b/tools/check_worker_config.ts @@ -24,7 +24,14 @@ export function validateWorkerConfigBindings(worker: WorkerConfig): string[] { const binding = env[name]; if (!binding || binding.type !== type) failures.push(`${name} binding (${type})`); } - for (const name of ['AppWriterAgent', 'PythonGraderSandbox']) { + const pythonBinding = env.PYTHON_GRADER_SANDBOX; + if ( + pythonBinding && + pythonBinding.type === 'durable-object' && + pythonBinding.exportName !== 'PythonGraderRuntime' + ) + failures.push('PYTHON_GRADER_SANDBOX replacement namespace'); + for (const name of ['AppWriterAgent', 'PythonGraderSandbox', 'PythonGraderRuntime']) { const declaration = worker.exports?.[name]; if ( declaration?.type !== 'durable-object' || diff --git a/tools/check_worker_config_test.ts b/tools/check_worker_config_test.ts index ae2b5d0..5ea9abc 100644 --- a/tools/check_worker_config_test.ts +++ b/tools/check_worker_config_test.ts @@ -1,5 +1,5 @@ import { test } from 'vite-plus/test'; -import { assertEquals } from '@std/assert'; +import { assert, assertEquals } from '@std/assert'; import { bindings } from 'cf/config'; import config from '../cloudflare.config.ts'; import { appWriterPlatform } from '../cloudflare.platform.ts'; @@ -32,3 +32,25 @@ test('Cloudflare configuration check rejects legacy Hyperdrive access', () => { ['prohibited legacy HYPERDRIVE binding'], ); }); + +test('Python container cutover uses a fresh namespace and preserves the previous namespace', () => { + assertEquals(config.worker.env.PYTHON_GRADER_SANDBOX.exportName, 'PythonGraderRuntime'); + const replacement = config.worker.exports.PythonGraderRuntime; + assert(replacement.storage === 'sqlite'); + assertEquals(replacement.container, config.containers[0]); + assertEquals(config.containers[0]?.schedulingPolicy, 'durable-object'); + assertEquals('container' in config.worker.exports.PythonGraderSandbox, false); + assertEquals( + validateWorkerConfigBindings({ + ...config.worker, + env: { + ...config.worker.env, + PYTHON_GRADER_SANDBOX: bindings.durableObject({ + worker: 'lantern', + exportName: 'PythonGraderSandbox', + }), + }, + }), + ['PYTHON_GRADER_SANDBOX replacement namespace'], + ); +});