From 59da2b1918bd3c99ca160e76416640a28a59212b Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:16:58 +0000 Subject: [PATCH 1/4] refactor(hub): start the TypeScript app without the old backend Co-authored-by: Nishiki Liu Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/dependabot.yml | 7 +- .../extensions/replica-prototype/README.md | 12 +- .../create-hub-client/index.mjs | 47 - .../create-hub-client/index.mts | 47 - .../create-hub-client/index.test.mjs | 59 - .../start-canvas-server/index.mjs | 35 +- .../start-canvas-server/index.mts | 35 +- .../start-canvas-server/index.test.mjs | 31 +- .github/workflows/ci-changes.yml | 7 +- .github/workflows/dependency-age.yml | 13 +- .github/workflows/hub-ci.yml | 310 +--- .github/workflows/hub-release.yml | 180 --- AGENTS.md | 8 +- CONTRIBUTING.md | 57 +- README.md | 18 +- docs/README.md | 6 +- .../0001-learning-extension-architecture.md | 6 +- docs/documentation-conventions.md | 8 +- docs/getting-started.md | 102 +- docs/prototype-environment.md | 112 +- docs/prototype-repository-setup.md | 6 +- docs/testing-and-review.md | 22 +- hub/.annotaterb.yml | 8 - hub/.devcontainer/Dockerfile | 21 - hub/.devcontainer/cli.Dockerfile | 11 - hub/.devcontainer/devcontainer-lock.json | 14 - hub/.devcontainer/devcontainer.json | 37 - hub/.dockerignore | 44 - hub/.env.example | 24 +- hub/.gitignore | 19 +- hub/.prettierignore | 3 +- hub/.rubocop.yml | 8 - hub/.ruby-version | 1 - hub/.storybook/analyze-usage/index.test.ts | 12 +- hub/.storybook/analyze-usage/index.ts | 4 +- hub/.storybook/main.test.ts | 8 +- hub/.storybook/main.ts | 83 +- .../template-sources}/field-notes.css | 0 .../template-sources}/field-notes.tsx | 0 .../template-sources}/signal-room.css | 0 .../template-sources}/signal-room.tsx | 0 .../template-sources}/welcome.css | 0 .../template-sources}/welcome.tsx | 0 hub/.storybook/usage-plugin/index.test.ts | 4 +- hub/.storybook/usage-plugin/index.ts | 2 +- hub/AGENTS.md | 24 +- hub/Dockerfile | 111 -- hub/Gemfile | 44 - hub/Gemfile.lock | 627 -------- hub/Procfile.dev | 3 - hub/README.md | 68 +- hub/Rakefile | 11 - hub/app/assets/images/.keep | 0 hub/app/channels/application_cable/channel.rb | 4 - .../channels/application_cable/connection.rb | 71 - hub/app/channels/review_comments_channel.rb | 62 - hub/app/controllers/analytics_controller.rb | 20 - .../analytics_destinations_controller.rb | 128 -- hub/app/controllers/application_controller.rb | 10 - .../branch_banner_dismissals_controller.rb | 30 - .../build_screenshots_controller.rb | 30 - hub/app/controllers/builds_controller.rb | 126 -- .../controllers/canvas_api/base_controller.rb | 65 - .../canvas_api/cable_tickets_controller.rb | 14 - .../canvas_api/connections_controller.rb | 33 - .../improvement_notes_controller.rb | 49 - .../canvas_api/learning_recalls_controller.rb | 41 - .../mention_candidates_controller.rb | 4 - .../canvas_api/pairings_controller.rb | 58 - .../canvas_api/prototypes_controller.rb | 131 -- .../canvas_api/renewals_controller.rb | 48 - .../repository_review_comments_controller.rb | 19 - .../review_comment_reactions_controller.rb | 35 - .../review_comment_replies_controller.rb | 14 - .../review_comment_resolutions_controller.rb | 19 - .../review_comment_summaries_controller.rb | 75 - .../review_comment_threads_controller.rb | 42 - .../canvas_api/review_comments_controller.rb | 4 - .../review_thread_reads_controller.rb | 22 - .../canvas_api/share_links_controller.rb | 86 -- .../controllers/canvas_pairings_controller.rb | 74 - hub/app/controllers/concerns/.keep | 0 .../concerns/artifact_respondable.rb | 81 - .../controllers/concerns/authentication.rb | 72 - .../concerns/canvas_api_compatibility.rb | 21 - .../concerns/canvas_comment_responses.rb | 28 - .../concerns/fresh_authorization_required.rb | 60 - .../concerns/has_github_app_administration.rb | 85 -- .../concerns/has_learning_workspace.rb | 16 - .../concerns/has_repository_templates.rb | 18 - .../concerns/has_repository_workspace.rb | 124 -- .../concerns/has_review_comment_context.rb | 90 -- .../concerns/has_security_audit_page.rb | 47 - .../concerns/has_share_link_context.rb | 95 -- .../concerns/improvement_note_responses.rb | 21 - .../concerns/mention_candidates.rb | 23 - .../concerns/preview_controls_respondable.rb | 23 - .../concerns/preview_file_respondable.rb | 92 -- .../concerns/public_sharing_required.rb | 28 - .../concerns/review_comment_actions.rb | 73 - .../concerns/review_comment_mutations.rb | 9 - .../concerns/review_comment_pages.rb | 108 -- .../controllers/concerns/security_auditing.rb | 70 - .../concerns/security_rate_limiting.rb | 70 - .../concerns/trusted_request_source.rb | 11 - .../copilot_connections_controller.rb | 97 -- .../fresh_authorizations_controller.rb | 66 - .../github_app_credentials_controller.rb | 54 - .../github_app_setup_controller.rb | 68 - ...github_app_webhook_rotations_controller.rb | 58 - hub/app/controllers/github_apps_controller.rb | 63 - .../github_authentications_controller.rb | 133 -- .../controllers/github_webhooks_controller.rb | 37 - hub/app/controllers/home_controller.rb | 13 - .../hub_admin_enrollments_controller.rb | 37 - hub/app/controllers/hub_admins_controller.rb | 159 -- .../learning_decisions_controller.rb | 104 -- .../controllers/learning_runs_controller.rb | 33 - hub/app/controllers/learnings_controller.rb | 22 - .../local_authentications_controller.rb | 27 - .../mention_candidates_controller.rb | 4 - .../controllers/new_prototypes_controller.rb | 19 - .../controllers/notifications_controller.rb | 93 -- .../preview_authorizations_controller.rb | 55 - .../controllers/preview_files_controller.rb | 125 -- .../prototype_authors_controller.rb | 27 - .../prototype_management_controller.rb | 57 - .../prototype_registrations_controller.rb | 66 - hub/app/controllers/prototypes_controller.rb | 72 - .../controllers/replica_browses_controller.rb | 27 - .../controllers/repositories_controller.rb | 117 -- .../repository_security_audits_controller.rb | 19 - ...itory_workflow_installations_controller.rb | 67 - .../review_comment_reactions_controller.rb | 38 - .../review_comment_replies_controller.rb | 17 - .../review_comment_resolutions_controller.rb | 21 - .../controllers/review_comments_controller.rb | 4 - .../review_thread_reads_controller.rb | 32 - .../controllers/security_audits_controller.rb | 14 - .../session_revocations_controller.rb | 91 -- hub/app/controllers/sessions_controller.rb | 8 - hub/app/controllers/share_links_controller.rb | 134 -- ...share_preview_authorizations_controller.rb | 32 - .../controllers/shared_builds_controller.rb | 21 - .../shared_preview_files_controller.rb | 79 - .../sharing_settings_controller.rb | 86 -- hub/app/controllers/superglue_controller.rb | 4 - .../template_screenshots_controller.rb | 34 - hub/app/controllers/templates_controller.rb | 39 - hub/app/controllers/welcome_controller.rb | 16 - hub/app/helpers/application_helper.rb | 2 - hub/app/helpers/notifications_helper.rb | 11 - hub/app/helpers/preview_controls_helper.rb | 8 - .../build-visit-and-remote/index.test.ts | 40 - .../build-visit-and-remote/index.ts | 44 - .../management.test.tsx | 236 --- .../mount-application/index.test.tsx | 21 - .../application/mount-application/index.tsx | 26 - .../application/navigation-request/index.ts | 21 - .../application/notifications-view.test.tsx | 246 --- .../javascript/application/page-mapping.ts | 14 - .../prototype-notification-view.test.tsx | 197 --- .../repositories-show-view/index.test.tsx | 55 - .../repository-inbox-view.test.tsx | 91 -- .../application/sidebar-inbox-views.test.tsx | 253 ---- hub/app/javascript/application/store.ts | 19 - .../components/hub-empty-state/index.test.tsx | 1 - .../components/hub-empty-state/index.tsx | 8 +- .../components/hub-icon-button/index.test.tsx | 2 - .../components/prototype-card/index.tsx | 2 +- .../repository-navigation-item/index.test.tsx | 69 +- .../repository-navigation-item/index.tsx | 1 - .../components/template-card/index.test.tsx | 1 - .../components/template-card/index.tsx | 1 - .../javascript/entrypoints/application.tsx | 17 +- .../features/conversation-inbox/index.tsx | 2 - .../index.test.tsx | 4 - .../prototype-metadata-management/index.tsx | 1 - .../prototype-workspace-header/index.test.tsx | 37 +- .../prototype-workspace-header/index.tsx | 52 +- .../repository-navigation/index.test.tsx | 4 - .../javascript/hooks/use-page-data/index.ts | 6 - .../hooks/use-page-section/index.test.tsx | 145 -- .../hooks/use-page-section/index.ts | 34 - .../use-prototype-listing/index.test.tsx | 210 --- .../hooks/use-prototype-listing/index.ts | 179 --- .../hooks/use-prototype-review/index.test.tsx | 4 - .../hooks/use-prototype-review/index.ts | 8 - .../use-prototype-viewer-url/index.test.ts | 14 +- .../index.test.ts | 64 - .../use-review-comments-subscription/index.ts | 50 - .../use-review-comments-subscription/types.ts | 5 - .../pages/builds-index-page/index.test.tsx | 5 - .../pages/builds-index-page/index.tsx | 14 +- .../pages/builds-show-page/index.test.tsx | 4 - .../pages/builds-show-page/index.tsx | 7 +- .../fresh-authorization-page/view.test.tsx | 44 - .../index.test.tsx | 1 - .../prototype-management-page/index.test.tsx | 1 - .../pages/prototype-management-page/index.tsx | 1 - .../pages/prototypes-show-page/index.test.tsx | 55 +- .../replica-browse-page/adapter.test.tsx | 48 - .../pages/replica-browse-page/index.test.tsx | 452 ------ .../repositories-show-page/index.test.tsx | 7 - .../pages/repositories-show-page/index.tsx | 2 - .../pages/welcome-page/index.test.tsx | 4 - .../javascript/pages/welcome-page/index.tsx | 1 - .../storybook/component-usage/stories.tsx | 1 - .../javascript/storybook/overview/stories.tsx | 10 +- hub/app/javascript/superglue.d.ts | 11 - hub/app/javascript/test/environment.d.ts | 5 + hub/app/jobs/application_job.rb | 7 - hub/app/mailers/application_mailer.rb | 4 - hub/app/models/analytics.rb | 17 - .../models/analytics/custom_configuration.rb | 250 --- hub/app/models/analytics/custom_endpoint.rb | 112 -- hub/app/models/analytics/delivery.rb | 135 -- hub/app/models/analytics/destination.rb | 207 --- hub/app/models/analytics/event.rb | 93 -- hub/app/models/analytics/transport.rb | 138 -- hub/app/models/application_record.rb | 3 - hub/app/models/artifact_store.rb | 131 -- hub/app/models/artifact_store/azure.rb | 72 - hub/app/models/artifact_store/azure/client.rb | 221 --- .../artifact_store/azure/managed_identity.rb | 112 -- hub/app/models/artifact_store/local.rb | 234 --- hub/app/models/branch_banner_dismissal.rb | 34 - hub/app/models/build.rb | 353 ----- hub/app/models/build/commit_author.rb | 113 -- hub/app/models/build/local_package.rb | 184 --- hub/app/models/build/package_extraction.rb | 189 --- hub/app/models/build/package_import.rb | 153 -- hub/app/models/build/start_page_import.rb | 121 -- hub/app/models/build/templates.rb | 91 -- hub/app/models/build/workflow_run.rb | 96 -- hub/app/models/canvas_grant.rb | 135 -- hub/app/models/canvas_pairing.rb | 154 -- hub/app/models/comment_mention.rb | 36 - hub/app/models/concerns/.keep | 0 hub/app/models/copilot_connection.rb | 240 --- hub/app/models/copilot_connection_state.rb | 37 - hub/app/models/current.rb | 9 - hub/app/models/deadline.rb | 18 - hub/app/models/fresh_authorization.rb | 207 --- .../fresh_authorization/continuation.rb | 104 -- hub/app/models/github_app_configuration.rb | 186 --- .../administration.rb | 178 --- .../build_discovery.rb | 89 -- .../github_app_configuration/diagnostics.rb | 57 - .../github_app_configuration/installation.rb | 180 --- .../models/github_app_configuration/oauth.rb | 154 -- .../github_app_configuration/ownership.rb | 55 - .../github_app_configuration/recovery.rb | 36 - .../github_app_configuration/repositories.rb | 61 - .../github_app_configuration/rotator.rb | 48 - .../models/github_app_configuration/tokens.rb | 46 - hub/app/models/github_app_setup.rb | 178 --- hub/app/models/github_client.rb | 64 - hub/app/models/github_client/actions.rb | 66 - hub/app/models/github_client/apps.rb | 33 - hub/app/models/github_client/batch.rb | 27 - hub/app/models/github_client/installations.rb | 49 - hub/app/models/github_client/oauth.rb | 46 - hub/app/models/github_client/pull_requests.rb | 39 - hub/app/models/github_client/repositories.rb | 229 --- hub/app/models/github_client/transport.rb | 152 -- .../github_client/webhook_deliveries.rb | 92 -- hub/app/models/github_identity.rb | 41 - hub/app/models/github_webhook.rb | 328 ---- hub/app/models/hub_admin_enrollment.rb | 71 - hub/app/models/improvement_comment.rb | 253 ---- .../improvement_comment/message_contract.rb | 52 - .../improvement_comment/thread_contract.rb | 90 -- hub/app/models/improvement_comment_reply.rb | 58 - hub/app/models/learning.rb | 47 - hub/app/models/learning/collection.rb | 123 -- hub/app/models/learning/command.rb | 228 --- hub/app/models/learning/execution_lock.rb | 63 - hub/app/models/learning/overview.rb | 164 -- hub/app/models/learning/run.rb | 387 ----- hub/app/models/learning/store.rb | 125 -- hub/app/models/local_development.rb | 186 --- hub/app/models/notification.rb | 176 --- hub/app/models/notification_resource.rb | 35 - hub/app/models/preview_grant.rb | 98 -- hub/app/models/preview_origin.rb | 111 -- hub/app/models/prototype.rb | 269 ---- hub/app/models/prototype_introduction.rb | 34 - hub/app/models/public_share_response_body.rb | 25 - hub/app/models/repository.rb | 169 --- hub/app/models/repository/authorization.rb | 103 -- hub/app/models/repository/branch_presence.rb | 145 -- .../models/repository/build_discoveries.rb | 117 -- hub/app/models/repository/local_discovery.rb | 147 -- .../models/repository/prototype_candidates.rb | 238 --- .../models/repository/prototype_listing.rb | 117 -- .../repository/workflow_installation.rb | 296 ---- .../repository/workflow_run_discoveries.rb | 114 -- hub/app/models/review_comment.rb | 408 ----- .../models/review_comment/anchor_contract.rb | 302 ---- hub/app/models/review_comment/mentions.rb | 50 - hub/app/models/review_comment/thread.rb | 188 --- hub/app/models/review_comment_reaction.rb | 61 - hub/app/models/review_thread_read.rb | 79 - hub/app/models/security_audit_event.rb | 126 -- hub/app/models/security_rate_limit_cache.rb | 9 - hub/app/models/session.rb | 26 - hub/app/models/session_revocation.rb | 81 - hub/app/models/share_link.rb | 339 ----- .../models/share_link_creator_revocation.rb | 67 - hub/app/models/share_preview_grant.rb | 82 - hub/app/models/share_preview_origin.rb | 11 - hub/app/models/sharing_setting.rb | 73 - hub/app/models/user.rb | 215 --- hub/app/models/webhook_delivery.rb | 88 -- hub/app/models/webhook_recovery_checkpoint.rb | 147 -- .../webhook_recovery_checkpoint/scan.rb | 128 -- hub/app/models/webhook_redelivery.rb | 248 --- hub/app/views/analytics/show.json.props | 132 -- hub/app/views/analytics/show.tsx | 9 - hub/app/views/application/superglue.html.erb | 5 - hub/app/views/builds/index.json.props | 59 - hub/app/views/builds/index.tsx | 8 - hub/app/views/builds/show.json.props | 120 -- hub/app/views/builds/show.tsx | 8 - .../views/canvas_pairings/index.json.props | 22 - hub/app/views/canvas_pairings/index.tsx | 7 - hub/app/views/canvas_pairings/show.json.props | 20 - hub/app/views/canvas_pairings/show.tsx | 7 - .../views/copilot_connections/show.json.props | 42 - hub/app/views/copilot_connections/show.tsx | 7 - .../fresh_authorizations/show.json.props | 24 - hub/app/views/fresh_authorizations/show.tsx | 21 - .../github_app_credentials/edit.json.props | 20 - hub/app/views/github_app_credentials/edit.tsx | 8 - .../views/github_app_setup/install.json.props | 4 - hub/app/views/github_app_setup/install.tsx | 7 - .../github_app_setup/register.json.props | 8 - hub/app/views/github_app_setup/register.tsx | 7 - .../views/github_app_setup/show.json.props | 10 - hub/app/views/github_app_setup/show.tsx | 7 - .../show.json.props | 18 - .../github_app_webhook_rotations/show.tsx | 8 - .../github_apps/_configuration.json.props | 19 - .../github_apps/reset_confirmation.json.props | 17 - .../views/github_apps/reset_confirmation.tsx | 8 - hub/app/views/github_apps/show.json.props | 20 - hub/app/views/github_apps/show.tsx | 8 - hub/app/views/home/index.json.props | 16 - hub/app/views/home/index.tsx | 10 - .../hub_admin_enrollments/show.json.props | 8 - hub/app/views/hub_admin_enrollments/show.tsx | 7 - .../views/hub_admins/_navigation.json.props | 26 - hub/app/views/hub_admins/edit.json.props | 17 - hub/app/views/hub_admins/edit.tsx | 12 - hub/app/views/hub_admins/index.json.props | 41 - hub/app/views/hub_admins/index.tsx | 41 - hub/app/views/layouts/application.html.erb | 19 - hub/app/views/layouts/application.json.props | 17 - hub/app/views/layouts/mailer.html.erb | 13 - hub/app/views/layouts/mailer.text.erb | 1 - hub/app/views/learnings/show.json.props | 215 --- hub/app/views/learnings/show.tsx | 8 - hub/app/views/new_prototypes/show.json.props | 74 - hub/app/views/new_prototypes/show.tsx | 8 - hub/app/views/notifications/index.json.props | 5 - hub/app/views/notifications/index.tsx | 24 - .../prototype_management/show.json.props | 111 -- hub/app/views/prototype_management/show.tsx | 16 - hub/app/views/prototypes/show.json.props | 324 ---- hub/app/views/prototypes/show.tsx | 148 -- hub/app/views/pwa/manifest.json.erb | 22 - hub/app/views/pwa/service-worker.js | 26 - hub/app/views/replica_browses/show.json.props | 110 -- hub/app/views/replica_browses/show.tsx | 48 - hub/app/views/repositories/show.json.props | 213 --- hub/app/views/repositories/show.tsx | 20 - .../show.json.props | 35 - .../views/repository_security_audits/show.tsx | 8 - hub/app/views/security_audits/show.json.props | 1 - hub/app/views/security_audits/show.tsx | 8 - .../views/session_revocations/show.json.props | 32 - hub/app/views/session_revocations/show.tsx | 10 - hub/app/views/share_links/index.json.props | 90 -- hub/app/views/share_links/index.tsx | 19 - .../shared/_analytics_navigation.json.props | 16 - .../_hub_administration_navigation.json.props | 15 - .../_hub_administration_workspace.json.props | 24 - .../views/shared/_hub_workspace.json.props | 46 - .../shared/_notification_inbox.json.props | 26 - .../_notification_navigation.json.props | 12 - .../_repository_navigation_context.json.props | 22 - .../shared/_repository_templates.json.props | 31 - .../views/shared/_review_workspace.json.props | 110 -- .../views/shared/_security_audit.json.props | 49 - .../shared/_welcome_navigation.json.props | 4 - hub/app/views/shared_builds/show.json.props | 7 - hub/app/views/shared_builds/show.tsx | 7 - .../views/sharing_settings/show.json.props | 15 - hub/app/views/sharing_settings/show.tsx | 8 - hub/app/views/welcome/show.json.props | 30 - hub/app/views/welcome/show.tsx | 8 - hub/bin/bundler-audit | 6 - hub/bin/check-annotations | 5 - hub/bin/check-dependency-age | 9 - hub/bin/check-development-queue | 34 - hub/bin/ci | 6 - hub/bin/dev | 2 - hub/bin/devcontainer | 155 -- hub/bin/docker-entrypoint | 8 - hub/bin/jobs | 6 - hub/bin/rails | 4 - hub/bin/rake | 4 - hub/bin/replica-deployment-config | 31 - hub/bin/rubocop | 8 - hub/bin/setup | 38 - hub/bin/thrust | 5 - hub/config.ru | 6 - hub/config/application.rb | 91 -- hub/config/boot.rb | 4 - hub/config/build_action_releases | 2 - hub/config/bundler-audit.yml | 5 - hub/config/cable.yml | 17 - hub/config/cache.yml | 17 - hub/config/ci.rb | 24 - hub/config/credentials.yml.enc | 1 - hub/config/database.yml | 50 - hub/config/environment.rb | 5 - hub/config/environments/development.rb | 82 - hub/config/environments/production.rb | 91 -- hub/config/environments/test.rb | 50 - .../initializers/active_record_encryption.rb | 23 - hub/config/initializers/assets.rb | 7 - .../initializers/content_security_policy.rb | 10 - .../initializers/deployment_configuration.rb | 126 -- .../initializers/filter_parameter_logging.rb | 10 - hub/config/initializers/inflections.rb | 16 - hub/config/initializers/session_store.rb | 8 - hub/config/initializers/superglue.rb | 1 - hub/config/locales/analytics.en.yml | 81 - hub/config/locales/application.en.yml | 4 - hub/config/locales/authentication.en.yml | 9 - hub/config/locales/canvas_pairings.en.yml | 24 - hub/config/locales/copilot_connection.en.yml | 38 - hub/config/locales/fresh_authorization.en.yml | 14 - hub/config/locales/gallery.en.yml | 351 ----- hub/config/locales/github_app.en.yml | 56 - hub/config/locales/github_app_setup.en.yml | 20 - hub/config/locales/home.en.yml | 16 - .../hub_administration_workspace.en.yml | 7 - hub/config/locales/hub_admins.en.yml | 52 - hub/config/locales/learnings.en.yml | 115 -- hub/config/locales/local_development.en.yml | 5 - hub/config/locales/notifications.en.yml | 34 - hub/config/locales/prototype_prompts.en.yml | 3 - hub/config/locales/replica_browses.en.yml | 18 - hub/config/locales/security_audit.en.yml | 31 - .../locales/security_rate_limits.en.yml | 3 - hub/config/locales/session_revocation.en.yml | 21 - hub/config/locales/share_links.en.yml | 40 - hub/config/locales/sharing_settings.en.yml | 23 - hub/config/locales/templates.en.yml | 22 - hub/config/locales/welcome.en.yml | 19 - hub/config/puma.rb | 42 - hub/config/queue.yml | 18 - hub/config/recurring.yml | 131 -- hub/config/routes.rb | 168 --- hub/config/vite.json | 18 - hub/db/cable_schema.rb | 11 - hub/db/cache_schema.rb | 24 - ...221000_create_github_app_configurations.rb | 53 - ...k_rotation_to_github_app_configurations.rb | 6 - ...25000_use_bigint_for_github_identifiers.rb | 7 - ...0260911234000_rename_last_reconciled_at.rb | 8 - ...235500_create_identity_and_repositories.rb | 46 - ...12010000_create_build_discovery_records.rb | 74 - ...60912145000_add_workflow_runs_to_builds.rb | 29 - .../20260912190000_add_build_import_state.rb | 71 - ...0913030000_remove_build_source_evidence.rb | 26 - .../20260913193000_create_preview_grants.rb | 22 - ...60914030000_add_commit_author_to_builds.rb | 26 - ...31000_add_commit_author_check_to_builds.rb | 5 - ...000_add_commit_author_request_to_builds.rb | 5 - ...33000_strengthen_commit_author_identity.rb | 30 - .../20260914080000_create_review_comments.rb | 55 - ...14150000_add_threads_to_review_comments.rb | 103 -- ...0_add_reviewer_state_to_review_comments.rb | 93 -- .../20260915191125_create_share_links.rb | 57 - ...60915191128_create_share_preview_grants.rb | 22 - ...00_add_chronological_ordering_to_builds.rb | 24 - ...24120000_add_local_development_identity.rb | 28 - ...24121000_track_local_package_completion.rb | 16 - ...924121500_add_local_repository_identity.rb | 12 - ...260924122000_add_prototype_registration.rb | 19 - .../20260925000000_create_canvas_grants.rb | 16 - .../20260925000100_create_canvas_pairings.rb | 23 - ...0200_index_canvas_credential_expiration.rb | 8 - ...110000_add_renewable_canvas_connections.rb | 14 - .../20260928195500_create_analytics_events.rb | 40 - ...e_analytics_destinations_and_deliveries.rb | 45 - ...d_enqueue_claim_to_analytics_deliveries.rb | 5 - ...20260929073000_add_prototype_start_page.rb | 14 - ...3000_track_prototype_start_page_imports.rb | 17 - ...80000_add_custom_analytics_destinations.rb | 49 - ...090000_track_local_branch_head_activity.rb | 5 - ...0260930090001_mark_replica_build_owners.rb | 18 - ...1005193000_create_security_audit_events.rb | 34 - ...61005223000_create_fresh_authorizations.rb | 34 - ...005234000_add_github_app_administration.rb | 38 - ...61006000000_add_public_sharing_controls.rb | 26 - ...000100_create_webhook_recovery_metadata.rb | 63 - ...261006000200_create_session_revocations.rb | 15 - ...6150000_create_branch_banner_dismissals.rb | 10 - ...50100_bind_webhook_recovery_credentials.rb | 10 - ...6150200_index_webhook_recovery_identity.rb | 5 - ...recovery_coverage_and_retention_indexes.rb | 10 - ...head_observation_revision_to_prototypes.rb | 6 - ...08130000_create_prototype_introductions.rb | 10 - ...eate_comment_mentions_and_notifications.rb | 44 - ...261010000100_create_copilot_connections.rb | 20 - ...nection_to_fresh_authorization_purposes.rb | 14 - .../20261010000300_create_learning_runs.rb | 43 - ...01500_extend_conversation_notifications.rb | 20 - ...20261012190000_add_prototype_retirement.rb | 14 - ...000_add_prototype_comment_notifications.rb | 13 - ...090000_add_welcome_preferences_to_users.rb | 7 - ...learnings_consent_to_canvas_connections.rb | 10 - ...00100_remove_legacy_copilot_credentials.rb | 23 - ...000200_create_copilot_connection_states.rb | 15 - ...add_learning_removal_to_review_comments.rb | 8 - .../20261019000000_index_prototype_gallery.rb | 10 - ...61019140000_create_improvement_comments.rb | 71 - ...on_account_to_github_app_configurations.rb | 20 - ...pending_actions_to_fresh_authorizations.rb | 9 - hub/db/queue_schema.rb | 172 --- hub/db/schema.rb | 805 ---------- hub/db/seeds.rb | 1 - hub/db/seeds/local/example/.prototype | 3 - hub/db/seeds/local/example/replica.json | 26 - .../local/example/route-page.tsx.template | 5 - hub/db/seeds/setup.rb | 1 - .../seeds/test/repositories/github_empty.rb | 10 - .../test/repositories/github_prototype.rb | 19 - hub/db/seeds/test/setup.rb | 111 -- hub/deploy/compose.yml | 24 - hub/deploy/replica | 214 --- hub/docs/README.md | 23 +- hub/docs/analytics.md | 109 -- hub/docs/architecture.md | 87 +- hub/docs/artifact-storage.md | 88 -- ...entication-and-repository-authorization.md | 107 -- hub/docs/backups-and-restore.md | 30 - hub/docs/build-discovery.md | 95 -- hub/docs/coding-conventions.md | 80 +- hub/docs/copilot.md | 86 -- hub/docs/deployment.md | 447 ------ hub/docs/development.md | 67 +- hub/docs/fresh-authorization.md | 58 - hub/docs/gallery.md | 164 -- hub/docs/github-actions-builds.md | 130 -- hub/docs/github-app-setup.md | 151 -- hub/docs/learnings.md | 125 -- hub/docs/local-development.md | 32 - hub/docs/preview-serving.md | 157 -- hub/docs/prototype-canvas.md | 568 +------ hub/docs/prototype-controls.md | 4 +- hub/docs/review-collaboration.md | 163 -- hub/docs/security-audit.md | 126 -- hub/docs/security-rate-limits.md | 72 - hub/docs/security.md | 110 -- hub/docs/session-revocation.md | 41 - hub/docs/share-links.md | 185 --- hub/docs/storybook.md | 166 +- hub/docs/testing-and-operations.md | 158 +- hub/eslint.config.ts | 8 +- hub/index.html | 13 + hub/lib/analytics_outbound_hosts.rb | 26 - ...annotate_rb_composite_foreign_key_names.rb | 13 - hub/lib/azure_front_door_verification.rb | 24 - hub/lib/build_action_releases.rb | 14 - hub/lib/dependency_age_policy.rb | 20 - hub/lib/dependency_age_policy/audit.rb | 55 - .../dependency_loader.rb | 152 -- .../dependency_age_policy/registry_client.rb | 123 -- hub/lib/dependency_age_policy/result.rb | 42 - hub/lib/hub_content_security_policy.rb | 43 - hub/lib/hub_tracing.rb | 119 -- hub/lib/hub_tracing/batch_span_processor.rb | 64 - hub/lib/hub_tracing/data_filter.rb | 15 - hub/lib/hub_tracing/enabled.rb | 89 -- hub/lib/hub_tracing/exporter.rb | 144 -- hub/lib/hub_tracing/propagator.rb | 29 - hub/lib/preview_path_routing.rb | 51 - hub/lib/private_storage_root.rb | 33 - hub/lib/replica_deployment/configuration.rb | 74 - hub/lib/share_link_lifetime.rb | 13 - hub/lib/tasks/.keep | 0 hub/lib/tasks/github_app.rake | 72 - hub/lib/tasks/webhook_recovery.rake | 21 - hub/lib/welcome_video.rb | 78 - hub/log/.keep | 0 hub/package-lock.json | 1065 +++++++++++-- hub/package.json | 16 +- hub/public/400.html | 135 -- hub/public/404.html | 135 -- hub/public/406-unsupported-browser.html | 135 -- hub/public/422.html | 135 -- hub/public/500.html | 135 -- hub/script/build-template-story-fixtures.mjs | 4 +- hub/server/create-app/index.test.ts | 72 + hub/server/create-app/index.ts | 16 + hub/server/index.ts | 10 + hub/storage/.keep | 0 hub/test/bin/devcontainer_test.rb | 251 --- .../application_cable/connection_test.rb | 118 -- .../channels/review_comments_channel_test.rb | 211 --- .../review_comments_revocation_test.rb | 99 -- hub/test/controllers/.keep | 0 .../concerns/artifact_respondable_test.rb | 36 - .../security_admin_rate_limits_test.rb | 114 -- hub/test/fixtures/files/.keep | 0 .../fixtures/files/fake-replica-learning.mjs | 135 -- .../files/learning-blocked-labeler.mjs | 18 - hub/test/fixtures/files/learning-cli.mjs | 6 - .../learning-consolidator-without-support.mjs | 17 - hub/test/fixtures/files/learning-labeler.mjs | 36 - .../files/learning-merge-consolidator.mjs | 30 - .../files/learning-multirequest-labeler.mjs | 39 - hub/test/fixtures/files/learning.txt | 1 - .../fixtures/files/slow-replica-learning.mjs | 10 - hub/test/helpers/.keep | 0 hub/test/integration/.keep | 0 hub/test/integration/analytics_test.rb | 363 ----- hub/test/integration/authentication_test.rb | 207 --- .../canvas_improvement_note_threads_test.rb | 400 ----- .../canvas_learning_recall_test.rb | 204 --- hub/test/integration/canvas_pairings_test.rb | 576 ------- hub/test/integration/canvas_renewals_test.rb | 182 --- .../canvas_repository_comments_test.rb | 615 -------- .../integration/canvas_share_links_test.rb | 473 ------ .../integration/combined_security_test.rb | 162 -- .../integration/copilot_connections_test.rb | 233 --- .../integration/fresh_authorizations_test.rb | 652 -------- hub/test/integration/gallery_test.rb | 900 ----------- .../github_app_administration_test.rb | 311 ---- .../github_app_installation_atomicity_test.rb | 152 -- hub/test/integration/github_app_setup_test.rb | 118 -- hub/test/integration/github_webhooks_test.rb | 61 - hub/test/integration/home_test.rb | 60 - .../integration/hub_admin_enrollments_test.rb | 235 --- .../hub_administration_entrypoint_test.rb | 101 -- .../hub_administration_workspace_test.rb | 125 -- hub/test/integration/hub_admins_test.rb | 384 ----- hub/test/integration/hub_tracing_test.rb | 458 ------ .../integration/learning_execution_test.rb | 355 ----- hub/test/integration/learnings_test.rb | 328 ---- .../integration/local_development_test.rb | 1344 ----------------- hub/test/integration/notifications_test.rb | 368 ----- .../integration/oauth_rate_limits_test.rb | 121 -- hub/test/integration/previews_test.rb | 580 ------- ...prototype_introduction_concurrency_test.rb | 119 -- .../integration/prototype_listing_test.rb | 340 ----- .../prototype_registrations_test.rb | 1094 -------------- hub/test/integration/replica_browses_test.rb | 301 ---- hub/test/integration/repositories_test.rb | 163 -- .../repository_workflow_installations_test.rb | 317 ---- hub/test/integration/review_comments_test.rb | 668 -------- .../security_actions_audit_test.rb | 234 --- .../security_audit_throttles_test.rb | 190 --- hub/test/integration/security_audits_test.rb | 163 -- .../integration/security_rate_limits_test.rb | 213 --- .../session_revocation_callbacks_test.rb | 160 -- .../integration/session_revocations_test.rb | 280 ---- hub/test/integration/share_links_test.rb | 463 ------ hub/test/integration/shared_builds_test.rb | 460 ------ hub/test/integration/sharing_settings_test.rb | 149 -- .../integration/solid_cable_delivery_test.rb | 197 --- hub/test/integration/templates_test.rb | 397 ----- .../integration/webhook_rate_limits_test.rb | 82 - hub/test/integration/welcome_test.rb | 201 --- .../lib/azure_front_door_verification_test.rb | 59 - hub/test/lib/build_action_releases_test.rb | 17 - .../lib/copilot_credentials_migration_test.rb | 118 -- hub/test/lib/dependency_age_policy_test.rb | 312 ---- ...ithub_app_administration_migration_test.rb | 44 - ...app_installation_account_migration_test.rb | 32 - .../lib/hub_content_security_policy_test.rb | 72 - .../hub_tracing_batch_span_processor_test.rb | 161 -- hub/test/lib/hub_tracing_data_filter_test.rb | 81 - hub/test/lib/hub_tracing_test.rb | 595 -------- hub/test/lib/preview_path_routing_test.rb | 100 -- .../lib/replica_deployment/command_test.rb | 204 --- .../replica_deployment/configuration_test.rb | 95 -- .../generator_command_test.rb | 31 - hub/test/lib/review_prose_test.rb | 83 - hub/test/lib/share_link_lifetime_test.rb | 20 - hub/test/lib/tasks/github_app_test.rb | 133 -- .../lib/welcome_preferences_migration_test.rb | 36 - hub/test/lib/welcome_video_test.rb | 84 -- hub/test/mailers/.keep | 0 hub/test/models/.keep | 0 .../analytics_custom_configuration_test.rb | 138 -- .../models/analytics_custom_delivery_test.rb | 279 ---- .../models/analytics_custom_endpoint_test.rb | 80 - hub/test/models/analytics_delivery_test.rb | 276 ---- hub/test/models/analytics_test.rb | 169 --- hub/test/models/analytics_transport_test.rb | 111 -- .../artifact_store/azure/client_test.rb | 151 -- .../azure/managed_identity_test.rb | 117 -- hub/test/models/artifact_store/azure_test.rb | 98 -- hub/test/models/artifact_store/local_test.rb | 91 -- hub/test/models/artifact_store_test.rb | 28 - hub/test/models/build/commit_author_test.rb | 124 -- hub/test/models/build/local_package_test.rb | 45 - .../models/build/package_extraction_test.rb | 115 -- hub/test/models/build/package_import_test.rb | 343 ----- .../models/build/start_page_import_test.rb | 197 --- hub/test/models/build/templates_test.rb | 71 - hub/test/models/build/workflow_run_test.rb | 90 -- hub/test/models/build_test.rb | 130 -- hub/test/models/comment_mentions_test.rb | 158 -- .../models/conversation_notifications_test.rb | 274 ---- .../copilot_connection_concurrency_test.rb | 120 -- hub/test/models/copilot_connection_test.rb | 257 ---- .../fresh_authorization_concurrency_test.rb | 70 - hub/test/models/fresh_authorization_test.rb | 257 ---- ...hub_app_administration_concurrency_test.rb | 322 ---- .../administration_test.rb | 405 ----- .../build_discovery_test.rb | 272 ---- .../diagnostics_test.rb | 31 - .../installation_test.rb | 93 -- .../github_app_configuration/oauth_test.rb | 150 -- .../repositories_test.rb | 60 - .../github_app_configuration/rotator_test.rb | 65 - .../github_app_configuration/tokens_test.rb | 65 - .../transfer_recovery_test.rb | 237 --- .../models/github_app_configuration_test.rb | 42 - ...ithub_app_dual_barrier_concurrency_test.rb | 226 --- .../models/github_app_setup_model_test.rb | 125 -- .../github_app_transfer_discovery_test.rb | 250 --- hub/test/models/github_client_batch_test.rb | 62 - hub/test/models/github_client_test.rb | 405 ----- ...thub_webhook_signature_concurrency_test.rb | 94 -- hub/test/models/github_webhook_test.rb | 719 --------- hub/test/models/hub_admin_concurrency_test.rb | 85 -- hub/test/models/hub_admin_enrollment_test.rb | 54 - hub/test/models/improvement_comment_test.rb | 79 - hub/test/models/learning/command_test.rb | 239 --- .../models/learning/execution_lock_test.rb | 104 -- hub/test/models/learning/overview_test.rb | 244 --- hub/test/models/learning/run_test.rb | 1108 -------------- hub/test/models/learning/store_test.rb | 41 - hub/test/models/learning/test_helpers_test.rb | 126 -- hub/test/models/preview_grant_test.rb | 77 - hub/test/models/preview_origin_test.rb | 80 - hub/test/models/private_storage_root_test.rb | 113 -- .../models/prototype_introduction_test.rb | 36 - hub/test/models/prototype_test.rb | 298 ---- .../models/public_sharing_concurrency_test.rb | 402 ----- .../models/public_sharing_upgrade_test.rb | 32 - .../models/repository/authorization_test.rb | 128 -- .../models/repository/branch_presence_test.rb | 337 ----- .../repository/build_discoveries_test.rb | 272 ---- .../repository/prototype_candidates_test.rb | 245 --- .../repository/workflow_installation_test.rb | 421 ------ .../workflow_run_discoveries_test.rb | 272 ---- hub/test/models/repository_test.rb | 54 - hub/test/models/review_comment_test.rb | 522 ------- hub/test/models/security_audit_event_test.rb | 107 -- .../models/security_rate_limit_cache_test.rb | 61 - .../session_revocation_concurrency_test.rb | 313 ---- hub/test/models/session_revocation_test.rb | 197 --- .../share_link_creator_revocation_test.rb | 135 -- hub/test/models/share_link_startup_test.rb | 151 -- hub/test/models/share_link_test.rb | 340 ----- hub/test/models/share_preview_grant_test.rb | 67 - hub/test/models/sharing_setting_test.rb | 124 -- hub/test/models/user_hub_admin_test.rb | 178 --- hub/test/models/user_test.rb | 72 - hub/test/models/webhook_delivery_test.rb | 30 - .../webhook_recovery_concurrency_test.rb | 534 ------- .../models/webhook_recovery_retention_test.rb | 78 - hub/test/models/webhook_recovery_test.rb | 600 -------- hub/test/support/artifact_store_contract.rb | 138 -- .../authorization_continuation_helpers.rb | 27 - hub/test/support/github_request_stubs.rb | 157 -- hub/test/support/learning_test_helpers.rb | 138 -- hub/test/support/security_rate_limit_store.rb | 27 - .../support/share_link_lifetime_helpers.rb | 9 - hub/test/test_helper.rb | 18 - .../hub_administration_navigation_test.rb | 86 -- hub/tmp/.keep | 0 hub/tsconfig.json | 3 +- hub/tsconfig.server.json | 16 + hub/vendor/.keep | 0 hub/vite.config.ts | 10 +- hub/vitest.config.ts | 2 +- packages/learning/README.md | 12 +- scripts/.gitignore | 1 + scripts/.npmrc | 1 + scripts/check-ci-workflows.rb | 312 ---- scripts/check-ci-workflows.test.ts | 69 + scripts/check-ci-workflows.ts | 207 +++ scripts/check-dependency-age.test.ts | 175 +++ scripts/check-dependency-age.ts | 166 ++ scripts/package-lock.json | 31 + scripts/package.json | 16 + starter/AGENTS.md | 2 +- starter/README.md | 2 +- 809 files changed, 2183 insertions(+), 73429 deletions(-) delete mode 100644 .github/workflows/hub-release.yml delete mode 100644 hub/.annotaterb.yml delete mode 100644 hub/.devcontainer/Dockerfile delete mode 100644 hub/.devcontainer/cli.Dockerfile delete mode 100644 hub/.devcontainer/devcontainer-lock.json delete mode 100644 hub/.devcontainer/devcontainer.json delete mode 100644 hub/.dockerignore delete mode 100644 hub/.rubocop.yml delete mode 100644 hub/.ruby-version rename hub/{db/seeds/local/example => .storybook/template-sources}/field-notes.css (100%) rename hub/{db/seeds/local/example => .storybook/template-sources}/field-notes.tsx (100%) rename hub/{db/seeds/local/example => .storybook/template-sources}/signal-room.css (100%) rename hub/{db/seeds/local/example => .storybook/template-sources}/signal-room.tsx (100%) rename hub/{db/seeds/local/example => .storybook/template-sources}/welcome.css (100%) rename hub/{db/seeds/local/example => .storybook/template-sources}/welcome.tsx (100%) delete mode 100644 hub/Dockerfile delete mode 100644 hub/Gemfile delete mode 100644 hub/Gemfile.lock delete mode 100644 hub/Procfile.dev delete mode 100644 hub/Rakefile delete mode 100644 hub/app/assets/images/.keep delete mode 100644 hub/app/channels/application_cable/channel.rb delete mode 100644 hub/app/channels/application_cable/connection.rb delete mode 100644 hub/app/channels/review_comments_channel.rb delete mode 100644 hub/app/controllers/analytics_controller.rb delete mode 100644 hub/app/controllers/analytics_destinations_controller.rb delete mode 100644 hub/app/controllers/application_controller.rb delete mode 100644 hub/app/controllers/branch_banner_dismissals_controller.rb delete mode 100644 hub/app/controllers/build_screenshots_controller.rb delete mode 100644 hub/app/controllers/builds_controller.rb delete mode 100644 hub/app/controllers/canvas_api/base_controller.rb delete mode 100644 hub/app/controllers/canvas_api/cable_tickets_controller.rb delete mode 100644 hub/app/controllers/canvas_api/connections_controller.rb delete mode 100644 hub/app/controllers/canvas_api/improvement_notes_controller.rb delete mode 100644 hub/app/controllers/canvas_api/learning_recalls_controller.rb delete mode 100644 hub/app/controllers/canvas_api/mention_candidates_controller.rb delete mode 100644 hub/app/controllers/canvas_api/pairings_controller.rb delete mode 100644 hub/app/controllers/canvas_api/prototypes_controller.rb delete mode 100644 hub/app/controllers/canvas_api/renewals_controller.rb delete mode 100644 hub/app/controllers/canvas_api/repository_review_comments_controller.rb delete mode 100644 hub/app/controllers/canvas_api/review_comment_reactions_controller.rb delete mode 100644 hub/app/controllers/canvas_api/review_comment_replies_controller.rb delete mode 100644 hub/app/controllers/canvas_api/review_comment_resolutions_controller.rb delete mode 100644 hub/app/controllers/canvas_api/review_comment_summaries_controller.rb delete mode 100644 hub/app/controllers/canvas_api/review_comment_threads_controller.rb delete mode 100644 hub/app/controllers/canvas_api/review_comments_controller.rb delete mode 100644 hub/app/controllers/canvas_api/review_thread_reads_controller.rb delete mode 100644 hub/app/controllers/canvas_api/share_links_controller.rb delete mode 100644 hub/app/controllers/canvas_pairings_controller.rb delete mode 100644 hub/app/controllers/concerns/.keep delete mode 100644 hub/app/controllers/concerns/artifact_respondable.rb delete mode 100644 hub/app/controllers/concerns/authentication.rb delete mode 100644 hub/app/controllers/concerns/canvas_api_compatibility.rb delete mode 100644 hub/app/controllers/concerns/canvas_comment_responses.rb delete mode 100644 hub/app/controllers/concerns/fresh_authorization_required.rb delete mode 100644 hub/app/controllers/concerns/has_github_app_administration.rb delete mode 100644 hub/app/controllers/concerns/has_learning_workspace.rb delete mode 100644 hub/app/controllers/concerns/has_repository_templates.rb delete mode 100644 hub/app/controllers/concerns/has_repository_workspace.rb delete mode 100644 hub/app/controllers/concerns/has_review_comment_context.rb delete mode 100644 hub/app/controllers/concerns/has_security_audit_page.rb delete mode 100644 hub/app/controllers/concerns/has_share_link_context.rb delete mode 100644 hub/app/controllers/concerns/improvement_note_responses.rb delete mode 100644 hub/app/controllers/concerns/mention_candidates.rb delete mode 100644 hub/app/controllers/concerns/preview_controls_respondable.rb delete mode 100644 hub/app/controllers/concerns/preview_file_respondable.rb delete mode 100644 hub/app/controllers/concerns/public_sharing_required.rb delete mode 100644 hub/app/controllers/concerns/review_comment_actions.rb delete mode 100644 hub/app/controllers/concerns/review_comment_mutations.rb delete mode 100644 hub/app/controllers/concerns/review_comment_pages.rb delete mode 100644 hub/app/controllers/concerns/security_auditing.rb delete mode 100644 hub/app/controllers/concerns/security_rate_limiting.rb delete mode 100644 hub/app/controllers/concerns/trusted_request_source.rb delete mode 100644 hub/app/controllers/copilot_connections_controller.rb delete mode 100644 hub/app/controllers/fresh_authorizations_controller.rb delete mode 100644 hub/app/controllers/github_app_credentials_controller.rb delete mode 100644 hub/app/controllers/github_app_setup_controller.rb delete mode 100644 hub/app/controllers/github_app_webhook_rotations_controller.rb delete mode 100644 hub/app/controllers/github_apps_controller.rb delete mode 100644 hub/app/controllers/github_authentications_controller.rb delete mode 100644 hub/app/controllers/github_webhooks_controller.rb delete mode 100644 hub/app/controllers/home_controller.rb delete mode 100644 hub/app/controllers/hub_admin_enrollments_controller.rb delete mode 100644 hub/app/controllers/hub_admins_controller.rb delete mode 100644 hub/app/controllers/learning_decisions_controller.rb delete mode 100644 hub/app/controllers/learning_runs_controller.rb delete mode 100644 hub/app/controllers/learnings_controller.rb delete mode 100644 hub/app/controllers/local_authentications_controller.rb delete mode 100644 hub/app/controllers/mention_candidates_controller.rb delete mode 100644 hub/app/controllers/new_prototypes_controller.rb delete mode 100644 hub/app/controllers/notifications_controller.rb delete mode 100644 hub/app/controllers/preview_authorizations_controller.rb delete mode 100644 hub/app/controllers/preview_files_controller.rb delete mode 100644 hub/app/controllers/prototype_authors_controller.rb delete mode 100644 hub/app/controllers/prototype_management_controller.rb delete mode 100644 hub/app/controllers/prototype_registrations_controller.rb delete mode 100644 hub/app/controllers/prototypes_controller.rb delete mode 100644 hub/app/controllers/replica_browses_controller.rb delete mode 100644 hub/app/controllers/repositories_controller.rb delete mode 100644 hub/app/controllers/repository_security_audits_controller.rb delete mode 100644 hub/app/controllers/repository_workflow_installations_controller.rb delete mode 100644 hub/app/controllers/review_comment_reactions_controller.rb delete mode 100644 hub/app/controllers/review_comment_replies_controller.rb delete mode 100644 hub/app/controllers/review_comment_resolutions_controller.rb delete mode 100644 hub/app/controllers/review_comments_controller.rb delete mode 100644 hub/app/controllers/review_thread_reads_controller.rb delete mode 100644 hub/app/controllers/security_audits_controller.rb delete mode 100644 hub/app/controllers/session_revocations_controller.rb delete mode 100644 hub/app/controllers/sessions_controller.rb delete mode 100644 hub/app/controllers/share_links_controller.rb delete mode 100644 hub/app/controllers/share_preview_authorizations_controller.rb delete mode 100644 hub/app/controllers/shared_builds_controller.rb delete mode 100644 hub/app/controllers/shared_preview_files_controller.rb delete mode 100644 hub/app/controllers/sharing_settings_controller.rb delete mode 100644 hub/app/controllers/superglue_controller.rb delete mode 100644 hub/app/controllers/template_screenshots_controller.rb delete mode 100644 hub/app/controllers/templates_controller.rb delete mode 100644 hub/app/controllers/welcome_controller.rb delete mode 100644 hub/app/helpers/application_helper.rb delete mode 100644 hub/app/helpers/notifications_helper.rb delete mode 100644 hub/app/helpers/preview_controls_helper.rb delete mode 100644 hub/app/javascript/application/build-visit-and-remote/index.test.ts delete mode 100644 hub/app/javascript/application/build-visit-and-remote/index.ts delete mode 100644 hub/app/javascript/application/build-visit-and-remote/management.test.tsx delete mode 100644 hub/app/javascript/application/mount-application/index.test.tsx delete mode 100644 hub/app/javascript/application/mount-application/index.tsx delete mode 100644 hub/app/javascript/application/navigation-request/index.ts delete mode 100644 hub/app/javascript/application/notifications-view.test.tsx delete mode 100644 hub/app/javascript/application/page-mapping.ts delete mode 100644 hub/app/javascript/application/prototype-notification-view.test.tsx delete mode 100644 hub/app/javascript/application/repositories-show-view/index.test.tsx delete mode 100644 hub/app/javascript/application/repository-inbox-view.test.tsx delete mode 100644 hub/app/javascript/application/sidebar-inbox-views.test.tsx delete mode 100644 hub/app/javascript/application/store.ts delete mode 100644 hub/app/javascript/hooks/use-page-data/index.ts delete mode 100644 hub/app/javascript/hooks/use-page-section/index.test.tsx delete mode 100644 hub/app/javascript/hooks/use-page-section/index.ts delete mode 100644 hub/app/javascript/hooks/use-prototype-listing/index.test.tsx delete mode 100644 hub/app/javascript/hooks/use-prototype-listing/index.ts delete mode 100644 hub/app/javascript/hooks/use-review-comments-subscription/index.test.ts delete mode 100644 hub/app/javascript/hooks/use-review-comments-subscription/index.ts delete mode 100644 hub/app/javascript/hooks/use-review-comments-subscription/types.ts delete mode 100644 hub/app/javascript/pages/fresh-authorization-page/view.test.tsx delete mode 100644 hub/app/javascript/pages/replica-browse-page/adapter.test.tsx delete mode 100644 hub/app/javascript/superglue.d.ts create mode 100644 hub/app/javascript/test/environment.d.ts delete mode 100644 hub/app/jobs/application_job.rb delete mode 100644 hub/app/mailers/application_mailer.rb delete mode 100644 hub/app/models/analytics.rb delete mode 100644 hub/app/models/analytics/custom_configuration.rb delete mode 100644 hub/app/models/analytics/custom_endpoint.rb delete mode 100644 hub/app/models/analytics/delivery.rb delete mode 100644 hub/app/models/analytics/destination.rb delete mode 100644 hub/app/models/analytics/event.rb delete mode 100644 hub/app/models/analytics/transport.rb delete mode 100644 hub/app/models/application_record.rb delete mode 100644 hub/app/models/artifact_store.rb delete mode 100644 hub/app/models/artifact_store/azure.rb delete mode 100644 hub/app/models/artifact_store/azure/client.rb delete mode 100644 hub/app/models/artifact_store/azure/managed_identity.rb delete mode 100644 hub/app/models/artifact_store/local.rb delete mode 100644 hub/app/models/branch_banner_dismissal.rb delete mode 100644 hub/app/models/build.rb delete mode 100644 hub/app/models/build/commit_author.rb delete mode 100644 hub/app/models/build/local_package.rb delete mode 100644 hub/app/models/build/package_extraction.rb delete mode 100644 hub/app/models/build/package_import.rb delete mode 100644 hub/app/models/build/start_page_import.rb delete mode 100644 hub/app/models/build/templates.rb delete mode 100644 hub/app/models/build/workflow_run.rb delete mode 100644 hub/app/models/canvas_grant.rb delete mode 100644 hub/app/models/canvas_pairing.rb delete mode 100644 hub/app/models/comment_mention.rb delete mode 100644 hub/app/models/concerns/.keep delete mode 100644 hub/app/models/copilot_connection.rb delete mode 100644 hub/app/models/copilot_connection_state.rb delete mode 100644 hub/app/models/current.rb delete mode 100644 hub/app/models/deadline.rb delete mode 100644 hub/app/models/fresh_authorization.rb delete mode 100644 hub/app/models/fresh_authorization/continuation.rb delete mode 100644 hub/app/models/github_app_configuration.rb delete mode 100644 hub/app/models/github_app_configuration/administration.rb delete mode 100644 hub/app/models/github_app_configuration/build_discovery.rb delete mode 100644 hub/app/models/github_app_configuration/diagnostics.rb delete mode 100644 hub/app/models/github_app_configuration/installation.rb delete mode 100644 hub/app/models/github_app_configuration/oauth.rb delete mode 100644 hub/app/models/github_app_configuration/ownership.rb delete mode 100644 hub/app/models/github_app_configuration/recovery.rb delete mode 100644 hub/app/models/github_app_configuration/repositories.rb delete mode 100644 hub/app/models/github_app_configuration/rotator.rb delete mode 100644 hub/app/models/github_app_configuration/tokens.rb delete mode 100644 hub/app/models/github_app_setup.rb delete mode 100644 hub/app/models/github_client.rb delete mode 100644 hub/app/models/github_client/actions.rb delete mode 100644 hub/app/models/github_client/apps.rb delete mode 100644 hub/app/models/github_client/batch.rb delete mode 100644 hub/app/models/github_client/installations.rb delete mode 100644 hub/app/models/github_client/oauth.rb delete mode 100644 hub/app/models/github_client/pull_requests.rb delete mode 100644 hub/app/models/github_client/repositories.rb delete mode 100644 hub/app/models/github_client/transport.rb delete mode 100644 hub/app/models/github_client/webhook_deliveries.rb delete mode 100644 hub/app/models/github_identity.rb delete mode 100644 hub/app/models/github_webhook.rb delete mode 100644 hub/app/models/hub_admin_enrollment.rb delete mode 100644 hub/app/models/improvement_comment.rb delete mode 100644 hub/app/models/improvement_comment/message_contract.rb delete mode 100644 hub/app/models/improvement_comment/thread_contract.rb delete mode 100644 hub/app/models/improvement_comment_reply.rb delete mode 100644 hub/app/models/learning.rb delete mode 100644 hub/app/models/learning/collection.rb delete mode 100644 hub/app/models/learning/command.rb delete mode 100644 hub/app/models/learning/execution_lock.rb delete mode 100644 hub/app/models/learning/overview.rb delete mode 100644 hub/app/models/learning/run.rb delete mode 100644 hub/app/models/learning/store.rb delete mode 100644 hub/app/models/local_development.rb delete mode 100644 hub/app/models/notification.rb delete mode 100644 hub/app/models/notification_resource.rb delete mode 100644 hub/app/models/preview_grant.rb delete mode 100644 hub/app/models/preview_origin.rb delete mode 100644 hub/app/models/prototype.rb delete mode 100644 hub/app/models/prototype_introduction.rb delete mode 100644 hub/app/models/public_share_response_body.rb delete mode 100644 hub/app/models/repository.rb delete mode 100644 hub/app/models/repository/authorization.rb delete mode 100644 hub/app/models/repository/branch_presence.rb delete mode 100644 hub/app/models/repository/build_discoveries.rb delete mode 100644 hub/app/models/repository/local_discovery.rb delete mode 100644 hub/app/models/repository/prototype_candidates.rb delete mode 100644 hub/app/models/repository/prototype_listing.rb delete mode 100644 hub/app/models/repository/workflow_installation.rb delete mode 100644 hub/app/models/repository/workflow_run_discoveries.rb delete mode 100644 hub/app/models/review_comment.rb delete mode 100644 hub/app/models/review_comment/anchor_contract.rb delete mode 100644 hub/app/models/review_comment/mentions.rb delete mode 100644 hub/app/models/review_comment/thread.rb delete mode 100644 hub/app/models/review_comment_reaction.rb delete mode 100644 hub/app/models/review_thread_read.rb delete mode 100644 hub/app/models/security_audit_event.rb delete mode 100644 hub/app/models/security_rate_limit_cache.rb delete mode 100644 hub/app/models/session.rb delete mode 100644 hub/app/models/session_revocation.rb delete mode 100644 hub/app/models/share_link.rb delete mode 100644 hub/app/models/share_link_creator_revocation.rb delete mode 100644 hub/app/models/share_preview_grant.rb delete mode 100644 hub/app/models/share_preview_origin.rb delete mode 100644 hub/app/models/sharing_setting.rb delete mode 100644 hub/app/models/user.rb delete mode 100644 hub/app/models/webhook_delivery.rb delete mode 100644 hub/app/models/webhook_recovery_checkpoint.rb delete mode 100644 hub/app/models/webhook_recovery_checkpoint/scan.rb delete mode 100644 hub/app/models/webhook_redelivery.rb delete mode 100644 hub/app/views/analytics/show.json.props delete mode 100644 hub/app/views/analytics/show.tsx delete mode 100644 hub/app/views/application/superglue.html.erb delete mode 100644 hub/app/views/builds/index.json.props delete mode 100644 hub/app/views/builds/index.tsx delete mode 100644 hub/app/views/builds/show.json.props delete mode 100644 hub/app/views/builds/show.tsx delete mode 100644 hub/app/views/canvas_pairings/index.json.props delete mode 100644 hub/app/views/canvas_pairings/index.tsx delete mode 100644 hub/app/views/canvas_pairings/show.json.props delete mode 100644 hub/app/views/canvas_pairings/show.tsx delete mode 100644 hub/app/views/copilot_connections/show.json.props delete mode 100644 hub/app/views/copilot_connections/show.tsx delete mode 100644 hub/app/views/fresh_authorizations/show.json.props delete mode 100644 hub/app/views/fresh_authorizations/show.tsx delete mode 100644 hub/app/views/github_app_credentials/edit.json.props delete mode 100644 hub/app/views/github_app_credentials/edit.tsx delete mode 100644 hub/app/views/github_app_setup/install.json.props delete mode 100644 hub/app/views/github_app_setup/install.tsx delete mode 100644 hub/app/views/github_app_setup/register.json.props delete mode 100644 hub/app/views/github_app_setup/register.tsx delete mode 100644 hub/app/views/github_app_setup/show.json.props delete mode 100644 hub/app/views/github_app_setup/show.tsx delete mode 100644 hub/app/views/github_app_webhook_rotations/show.json.props delete mode 100644 hub/app/views/github_app_webhook_rotations/show.tsx delete mode 100644 hub/app/views/github_apps/_configuration.json.props delete mode 100644 hub/app/views/github_apps/reset_confirmation.json.props delete mode 100644 hub/app/views/github_apps/reset_confirmation.tsx delete mode 100644 hub/app/views/github_apps/show.json.props delete mode 100644 hub/app/views/github_apps/show.tsx delete mode 100644 hub/app/views/home/index.json.props delete mode 100644 hub/app/views/home/index.tsx delete mode 100644 hub/app/views/hub_admin_enrollments/show.json.props delete mode 100644 hub/app/views/hub_admin_enrollments/show.tsx delete mode 100644 hub/app/views/hub_admins/_navigation.json.props delete mode 100644 hub/app/views/hub_admins/edit.json.props delete mode 100644 hub/app/views/hub_admins/edit.tsx delete mode 100644 hub/app/views/hub_admins/index.json.props delete mode 100644 hub/app/views/hub_admins/index.tsx delete mode 100644 hub/app/views/layouts/application.html.erb delete mode 100644 hub/app/views/layouts/application.json.props delete mode 100644 hub/app/views/layouts/mailer.html.erb delete mode 100644 hub/app/views/layouts/mailer.text.erb delete mode 100644 hub/app/views/learnings/show.json.props delete mode 100644 hub/app/views/learnings/show.tsx delete mode 100644 hub/app/views/new_prototypes/show.json.props delete mode 100644 hub/app/views/new_prototypes/show.tsx delete mode 100644 hub/app/views/notifications/index.json.props delete mode 100644 hub/app/views/notifications/index.tsx delete mode 100644 hub/app/views/prototype_management/show.json.props delete mode 100644 hub/app/views/prototype_management/show.tsx delete mode 100644 hub/app/views/prototypes/show.json.props delete mode 100644 hub/app/views/prototypes/show.tsx delete mode 100644 hub/app/views/pwa/manifest.json.erb delete mode 100644 hub/app/views/pwa/service-worker.js delete mode 100644 hub/app/views/replica_browses/show.json.props delete mode 100644 hub/app/views/replica_browses/show.tsx delete mode 100644 hub/app/views/repositories/show.json.props delete mode 100644 hub/app/views/repositories/show.tsx delete mode 100644 hub/app/views/repository_security_audits/show.json.props delete mode 100644 hub/app/views/repository_security_audits/show.tsx delete mode 100644 hub/app/views/security_audits/show.json.props delete mode 100644 hub/app/views/security_audits/show.tsx delete mode 100644 hub/app/views/session_revocations/show.json.props delete mode 100644 hub/app/views/session_revocations/show.tsx delete mode 100644 hub/app/views/share_links/index.json.props delete mode 100644 hub/app/views/share_links/index.tsx delete mode 100644 hub/app/views/shared/_analytics_navigation.json.props delete mode 100644 hub/app/views/shared/_hub_administration_navigation.json.props delete mode 100644 hub/app/views/shared/_hub_administration_workspace.json.props delete mode 100644 hub/app/views/shared/_hub_workspace.json.props delete mode 100644 hub/app/views/shared/_notification_inbox.json.props delete mode 100644 hub/app/views/shared/_notification_navigation.json.props delete mode 100644 hub/app/views/shared/_repository_navigation_context.json.props delete mode 100644 hub/app/views/shared/_repository_templates.json.props delete mode 100644 hub/app/views/shared/_review_workspace.json.props delete mode 100644 hub/app/views/shared/_security_audit.json.props delete mode 100644 hub/app/views/shared/_welcome_navigation.json.props delete mode 100644 hub/app/views/shared_builds/show.json.props delete mode 100644 hub/app/views/shared_builds/show.tsx delete mode 100644 hub/app/views/sharing_settings/show.json.props delete mode 100644 hub/app/views/sharing_settings/show.tsx delete mode 100644 hub/app/views/welcome/show.json.props delete mode 100644 hub/app/views/welcome/show.tsx delete mode 100755 hub/bin/bundler-audit delete mode 100755 hub/bin/check-annotations delete mode 100755 hub/bin/check-dependency-age delete mode 100755 hub/bin/check-development-queue delete mode 100755 hub/bin/ci delete mode 100755 hub/bin/dev delete mode 100755 hub/bin/devcontainer delete mode 100755 hub/bin/docker-entrypoint delete mode 100755 hub/bin/jobs delete mode 100755 hub/bin/rails delete mode 100755 hub/bin/rake delete mode 100755 hub/bin/replica-deployment-config delete mode 100755 hub/bin/rubocop delete mode 100755 hub/bin/setup delete mode 100755 hub/bin/thrust delete mode 100644 hub/config.ru delete mode 100644 hub/config/application.rb delete mode 100644 hub/config/boot.rb delete mode 100644 hub/config/build_action_releases delete mode 100644 hub/config/bundler-audit.yml delete mode 100644 hub/config/cable.yml delete mode 100644 hub/config/cache.yml delete mode 100644 hub/config/ci.rb delete mode 100644 hub/config/credentials.yml.enc delete mode 100644 hub/config/database.yml delete mode 100644 hub/config/environment.rb delete mode 100644 hub/config/environments/development.rb delete mode 100644 hub/config/environments/production.rb delete mode 100644 hub/config/environments/test.rb delete mode 100644 hub/config/initializers/active_record_encryption.rb delete mode 100644 hub/config/initializers/assets.rb delete mode 100644 hub/config/initializers/content_security_policy.rb delete mode 100644 hub/config/initializers/deployment_configuration.rb delete mode 100644 hub/config/initializers/filter_parameter_logging.rb delete mode 100644 hub/config/initializers/inflections.rb delete mode 100644 hub/config/initializers/session_store.rb delete mode 100644 hub/config/initializers/superglue.rb delete mode 100644 hub/config/locales/analytics.en.yml delete mode 100644 hub/config/locales/application.en.yml delete mode 100644 hub/config/locales/authentication.en.yml delete mode 100644 hub/config/locales/canvas_pairings.en.yml delete mode 100644 hub/config/locales/copilot_connection.en.yml delete mode 100644 hub/config/locales/fresh_authorization.en.yml delete mode 100644 hub/config/locales/gallery.en.yml delete mode 100644 hub/config/locales/github_app.en.yml delete mode 100644 hub/config/locales/github_app_setup.en.yml delete mode 100644 hub/config/locales/home.en.yml delete mode 100644 hub/config/locales/hub_administration_workspace.en.yml delete mode 100644 hub/config/locales/hub_admins.en.yml delete mode 100644 hub/config/locales/learnings.en.yml delete mode 100644 hub/config/locales/local_development.en.yml delete mode 100644 hub/config/locales/notifications.en.yml delete mode 100644 hub/config/locales/prototype_prompts.en.yml delete mode 100644 hub/config/locales/replica_browses.en.yml delete mode 100644 hub/config/locales/security_audit.en.yml delete mode 100644 hub/config/locales/security_rate_limits.en.yml delete mode 100644 hub/config/locales/session_revocation.en.yml delete mode 100644 hub/config/locales/share_links.en.yml delete mode 100644 hub/config/locales/sharing_settings.en.yml delete mode 100644 hub/config/locales/templates.en.yml delete mode 100644 hub/config/locales/welcome.en.yml delete mode 100644 hub/config/puma.rb delete mode 100644 hub/config/queue.yml delete mode 100644 hub/config/recurring.yml delete mode 100644 hub/config/routes.rb delete mode 100644 hub/config/vite.json delete mode 100644 hub/db/cable_schema.rb delete mode 100644 hub/db/cache_schema.rb delete mode 100644 hub/db/migrate/20260911221000_create_github_app_configurations.rb delete mode 100644 hub/db/migrate/20260911221500_add_webhook_rotation_to_github_app_configurations.rb delete mode 100644 hub/db/migrate/20260911225000_use_bigint_for_github_identifiers.rb delete mode 100644 hub/db/migrate/20260911234000_rename_last_reconciled_at.rb delete mode 100644 hub/db/migrate/20260911235500_create_identity_and_repositories.rb delete mode 100644 hub/db/migrate/20260912010000_create_build_discovery_records.rb delete mode 100644 hub/db/migrate/20260912145000_add_workflow_runs_to_builds.rb delete mode 100644 hub/db/migrate/20260912190000_add_build_import_state.rb delete mode 100644 hub/db/migrate/20260913030000_remove_build_source_evidence.rb delete mode 100644 hub/db/migrate/20260913193000_create_preview_grants.rb delete mode 100644 hub/db/migrate/20260914030000_add_commit_author_to_builds.rb delete mode 100644 hub/db/migrate/20260914031000_add_commit_author_check_to_builds.rb delete mode 100644 hub/db/migrate/20260914032000_add_commit_author_request_to_builds.rb delete mode 100644 hub/db/migrate/20260914033000_strengthen_commit_author_identity.rb delete mode 100644 hub/db/migrate/20260914080000_create_review_comments.rb delete mode 100644 hub/db/migrate/20260914150000_add_threads_to_review_comments.rb delete mode 100644 hub/db/migrate/20260914165500_add_reviewer_state_to_review_comments.rb delete mode 100644 hub/db/migrate/20260915191125_create_share_links.rb delete mode 100644 hub/db/migrate/20260915191128_create_share_preview_grants.rb delete mode 100644 hub/db/migrate/20260917130000_add_chronological_ordering_to_builds.rb delete mode 100644 hub/db/migrate/20260924120000_add_local_development_identity.rb delete mode 100644 hub/db/migrate/20260924121000_track_local_package_completion.rb delete mode 100644 hub/db/migrate/20260924121500_add_local_repository_identity.rb delete mode 100644 hub/db/migrate/20260924122000_add_prototype_registration.rb delete mode 100644 hub/db/migrate/20260925000000_create_canvas_grants.rb delete mode 100644 hub/db/migrate/20260925000100_create_canvas_pairings.rb delete mode 100644 hub/db/migrate/20260925000200_index_canvas_credential_expiration.rb delete mode 100644 hub/db/migrate/20260928110000_add_renewable_canvas_connections.rb delete mode 100644 hub/db/migrate/20260928195500_create_analytics_events.rb delete mode 100644 hub/db/migrate/20260928200000_create_analytics_destinations_and_deliveries.rb delete mode 100644 hub/db/migrate/20260928200500_add_enqueue_claim_to_analytics_deliveries.rb delete mode 100644 hub/db/migrate/20260929073000_add_prototype_start_page.rb delete mode 100644 hub/db/migrate/20260929093000_track_prototype_start_page_imports.rb delete mode 100644 hub/db/migrate/20260929180000_add_custom_analytics_destinations.rb delete mode 100644 hub/db/migrate/20260930090000_track_local_branch_head_activity.rb delete mode 100644 hub/db/migrate/20260930090001_mark_replica_build_owners.rb delete mode 100644 hub/db/migrate/20261005193000_create_security_audit_events.rb delete mode 100644 hub/db/migrate/20261005223000_create_fresh_authorizations.rb delete mode 100644 hub/db/migrate/20261005234000_add_github_app_administration.rb delete mode 100644 hub/db/migrate/20261006000000_add_public_sharing_controls.rb delete mode 100644 hub/db/migrate/20261006000100_create_webhook_recovery_metadata.rb delete mode 100644 hub/db/migrate/20261006000200_create_session_revocations.rb delete mode 100644 hub/db/migrate/20261006150000_create_branch_banner_dismissals.rb delete mode 100644 hub/db/migrate/20261006150100_bind_webhook_recovery_credentials.rb delete mode 100644 hub/db/migrate/20261006150200_index_webhook_recovery_identity.rb delete mode 100644 hub/db/migrate/20261007000100_add_webhook_recovery_coverage_and_retention_indexes.rb delete mode 100644 hub/db/migrate/20261007000200_add_head_observation_revision_to_prototypes.rb delete mode 100644 hub/db/migrate/20261008130000_create_prototype_introductions.rb delete mode 100644 hub/db/migrate/20261009090000_create_comment_mentions_and_notifications.rb delete mode 100644 hub/db/migrate/20261010000100_create_copilot_connections.rb delete mode 100644 hub/db/migrate/20261010000200_add_copilot_connection_to_fresh_authorization_purposes.rb delete mode 100644 hub/db/migrate/20261010000300_create_learning_runs.rb delete mode 100644 hub/db/migrate/20261012101500_extend_conversation_notifications.rb delete mode 100644 hub/db/migrate/20261012190000_add_prototype_retirement.rb delete mode 100644 hub/db/migrate/20261013082000_add_prototype_comment_notifications.rb delete mode 100644 hub/db/migrate/20261013090000_add_welcome_preferences_to_users.rb delete mode 100644 hub/db/migrate/20261013120000_add_learnings_consent_to_canvas_connections.rb delete mode 100644 hub/db/migrate/20261015000100_remove_legacy_copilot_credentials.rb delete mode 100644 hub/db/migrate/20261015000200_create_copilot_connection_states.rb delete mode 100644 hub/db/migrate/20261015060000_add_learning_removal_to_review_comments.rb delete mode 100644 hub/db/migrate/20261019000000_index_prototype_gallery.rb delete mode 100644 hub/db/migrate/20261019140000_create_improvement_comments.rb delete mode 100644 hub/db/migrate/20261021195500_add_installation_account_to_github_app_configurations.rb delete mode 100644 hub/db/migrate/20261022160000_add_pending_actions_to_fresh_authorizations.rb delete mode 100644 hub/db/queue_schema.rb delete mode 100644 hub/db/schema.rb delete mode 100644 hub/db/seeds.rb delete mode 100644 hub/db/seeds/local/example/.prototype delete mode 100644 hub/db/seeds/local/example/replica.json delete mode 100644 hub/db/seeds/local/example/route-page.tsx.template delete mode 100644 hub/db/seeds/setup.rb delete mode 100644 hub/db/seeds/test/repositories/github_empty.rb delete mode 100644 hub/db/seeds/test/repositories/github_prototype.rb delete mode 100644 hub/db/seeds/test/setup.rb delete mode 100644 hub/deploy/compose.yml delete mode 100755 hub/deploy/replica delete mode 100644 hub/docs/analytics.md delete mode 100644 hub/docs/artifact-storage.md delete mode 100644 hub/docs/authentication-and-repository-authorization.md delete mode 100644 hub/docs/backups-and-restore.md delete mode 100644 hub/docs/build-discovery.md delete mode 100644 hub/docs/copilot.md delete mode 100644 hub/docs/deployment.md delete mode 100644 hub/docs/fresh-authorization.md delete mode 100644 hub/docs/gallery.md delete mode 100644 hub/docs/github-actions-builds.md delete mode 100644 hub/docs/github-app-setup.md delete mode 100644 hub/docs/learnings.md delete mode 100644 hub/docs/local-development.md delete mode 100644 hub/docs/preview-serving.md delete mode 100644 hub/docs/review-collaboration.md delete mode 100644 hub/docs/security-audit.md delete mode 100644 hub/docs/security-rate-limits.md delete mode 100644 hub/docs/security.md delete mode 100644 hub/docs/session-revocation.md delete mode 100644 hub/docs/share-links.md create mode 100644 hub/index.html delete mode 100644 hub/lib/analytics_outbound_hosts.rb delete mode 100644 hub/lib/annotate_rb_composite_foreign_key_names.rb delete mode 100644 hub/lib/azure_front_door_verification.rb delete mode 100644 hub/lib/build_action_releases.rb delete mode 100644 hub/lib/dependency_age_policy.rb delete mode 100644 hub/lib/dependency_age_policy/audit.rb delete mode 100644 hub/lib/dependency_age_policy/dependency_loader.rb delete mode 100644 hub/lib/dependency_age_policy/registry_client.rb delete mode 100644 hub/lib/dependency_age_policy/result.rb delete mode 100644 hub/lib/hub_content_security_policy.rb delete mode 100644 hub/lib/hub_tracing.rb delete mode 100644 hub/lib/hub_tracing/batch_span_processor.rb delete mode 100644 hub/lib/hub_tracing/data_filter.rb delete mode 100644 hub/lib/hub_tracing/enabled.rb delete mode 100644 hub/lib/hub_tracing/exporter.rb delete mode 100644 hub/lib/hub_tracing/propagator.rb delete mode 100644 hub/lib/preview_path_routing.rb delete mode 100644 hub/lib/private_storage_root.rb delete mode 100644 hub/lib/replica_deployment/configuration.rb delete mode 100644 hub/lib/share_link_lifetime.rb delete mode 100644 hub/lib/tasks/.keep delete mode 100644 hub/lib/tasks/github_app.rake delete mode 100644 hub/lib/tasks/webhook_recovery.rake delete mode 100644 hub/lib/welcome_video.rb delete mode 100644 hub/log/.keep delete mode 100644 hub/public/400.html delete mode 100644 hub/public/404.html delete mode 100644 hub/public/406-unsupported-browser.html delete mode 100644 hub/public/422.html delete mode 100644 hub/public/500.html create mode 100644 hub/server/create-app/index.test.ts create mode 100644 hub/server/create-app/index.ts create mode 100644 hub/server/index.ts delete mode 100644 hub/storage/.keep delete mode 100644 hub/test/bin/devcontainer_test.rb delete mode 100644 hub/test/channels/application_cable/connection_test.rb delete mode 100644 hub/test/channels/review_comments_channel_test.rb delete mode 100644 hub/test/channels/review_comments_revocation_test.rb delete mode 100644 hub/test/controllers/.keep delete mode 100644 hub/test/controllers/concerns/artifact_respondable_test.rb delete mode 100644 hub/test/controllers/security_admin_rate_limits_test.rb delete mode 100644 hub/test/fixtures/files/.keep delete mode 100644 hub/test/fixtures/files/fake-replica-learning.mjs delete mode 100644 hub/test/fixtures/files/learning-blocked-labeler.mjs delete mode 100644 hub/test/fixtures/files/learning-cli.mjs delete mode 100644 hub/test/fixtures/files/learning-consolidator-without-support.mjs delete mode 100644 hub/test/fixtures/files/learning-labeler.mjs delete mode 100644 hub/test/fixtures/files/learning-merge-consolidator.mjs delete mode 100644 hub/test/fixtures/files/learning-multirequest-labeler.mjs delete mode 100644 hub/test/fixtures/files/learning.txt delete mode 100644 hub/test/fixtures/files/slow-replica-learning.mjs delete mode 100644 hub/test/helpers/.keep delete mode 100644 hub/test/integration/.keep delete mode 100644 hub/test/integration/analytics_test.rb delete mode 100644 hub/test/integration/authentication_test.rb delete mode 100644 hub/test/integration/canvas_improvement_note_threads_test.rb delete mode 100644 hub/test/integration/canvas_learning_recall_test.rb delete mode 100644 hub/test/integration/canvas_pairings_test.rb delete mode 100644 hub/test/integration/canvas_renewals_test.rb delete mode 100644 hub/test/integration/canvas_repository_comments_test.rb delete mode 100644 hub/test/integration/canvas_share_links_test.rb delete mode 100644 hub/test/integration/combined_security_test.rb delete mode 100644 hub/test/integration/copilot_connections_test.rb delete mode 100644 hub/test/integration/fresh_authorizations_test.rb delete mode 100644 hub/test/integration/gallery_test.rb delete mode 100644 hub/test/integration/github_app_administration_test.rb delete mode 100644 hub/test/integration/github_app_installation_atomicity_test.rb delete mode 100644 hub/test/integration/github_app_setup_test.rb delete mode 100644 hub/test/integration/github_webhooks_test.rb delete mode 100644 hub/test/integration/home_test.rb delete mode 100644 hub/test/integration/hub_admin_enrollments_test.rb delete mode 100644 hub/test/integration/hub_administration_entrypoint_test.rb delete mode 100644 hub/test/integration/hub_administration_workspace_test.rb delete mode 100644 hub/test/integration/hub_admins_test.rb delete mode 100644 hub/test/integration/hub_tracing_test.rb delete mode 100644 hub/test/integration/learning_execution_test.rb delete mode 100644 hub/test/integration/learnings_test.rb delete mode 100644 hub/test/integration/local_development_test.rb delete mode 100644 hub/test/integration/notifications_test.rb delete mode 100644 hub/test/integration/oauth_rate_limits_test.rb delete mode 100644 hub/test/integration/previews_test.rb delete mode 100644 hub/test/integration/prototype_introduction_concurrency_test.rb delete mode 100644 hub/test/integration/prototype_listing_test.rb delete mode 100644 hub/test/integration/prototype_registrations_test.rb delete mode 100644 hub/test/integration/replica_browses_test.rb delete mode 100644 hub/test/integration/repositories_test.rb delete mode 100644 hub/test/integration/repository_workflow_installations_test.rb delete mode 100644 hub/test/integration/review_comments_test.rb delete mode 100644 hub/test/integration/security_actions_audit_test.rb delete mode 100644 hub/test/integration/security_audit_throttles_test.rb delete mode 100644 hub/test/integration/security_audits_test.rb delete mode 100644 hub/test/integration/security_rate_limits_test.rb delete mode 100644 hub/test/integration/session_revocation_callbacks_test.rb delete mode 100644 hub/test/integration/session_revocations_test.rb delete mode 100644 hub/test/integration/share_links_test.rb delete mode 100644 hub/test/integration/shared_builds_test.rb delete mode 100644 hub/test/integration/sharing_settings_test.rb delete mode 100644 hub/test/integration/solid_cable_delivery_test.rb delete mode 100644 hub/test/integration/templates_test.rb delete mode 100644 hub/test/integration/webhook_rate_limits_test.rb delete mode 100644 hub/test/integration/welcome_test.rb delete mode 100644 hub/test/lib/azure_front_door_verification_test.rb delete mode 100644 hub/test/lib/build_action_releases_test.rb delete mode 100644 hub/test/lib/copilot_credentials_migration_test.rb delete mode 100644 hub/test/lib/dependency_age_policy_test.rb delete mode 100644 hub/test/lib/github_app_administration_migration_test.rb delete mode 100644 hub/test/lib/github_app_installation_account_migration_test.rb delete mode 100644 hub/test/lib/hub_content_security_policy_test.rb delete mode 100644 hub/test/lib/hub_tracing_batch_span_processor_test.rb delete mode 100644 hub/test/lib/hub_tracing_data_filter_test.rb delete mode 100644 hub/test/lib/hub_tracing_test.rb delete mode 100644 hub/test/lib/preview_path_routing_test.rb delete mode 100644 hub/test/lib/replica_deployment/command_test.rb delete mode 100644 hub/test/lib/replica_deployment/configuration_test.rb delete mode 100644 hub/test/lib/replica_deployment/generator_command_test.rb delete mode 100644 hub/test/lib/review_prose_test.rb delete mode 100644 hub/test/lib/share_link_lifetime_test.rb delete mode 100644 hub/test/lib/tasks/github_app_test.rb delete mode 100644 hub/test/lib/welcome_preferences_migration_test.rb delete mode 100644 hub/test/lib/welcome_video_test.rb delete mode 100644 hub/test/mailers/.keep delete mode 100644 hub/test/models/.keep delete mode 100644 hub/test/models/analytics_custom_configuration_test.rb delete mode 100644 hub/test/models/analytics_custom_delivery_test.rb delete mode 100644 hub/test/models/analytics_custom_endpoint_test.rb delete mode 100644 hub/test/models/analytics_delivery_test.rb delete mode 100644 hub/test/models/analytics_test.rb delete mode 100644 hub/test/models/analytics_transport_test.rb delete mode 100644 hub/test/models/artifact_store/azure/client_test.rb delete mode 100644 hub/test/models/artifact_store/azure/managed_identity_test.rb delete mode 100644 hub/test/models/artifact_store/azure_test.rb delete mode 100644 hub/test/models/artifact_store/local_test.rb delete mode 100644 hub/test/models/artifact_store_test.rb delete mode 100644 hub/test/models/build/commit_author_test.rb delete mode 100644 hub/test/models/build/local_package_test.rb delete mode 100644 hub/test/models/build/package_extraction_test.rb delete mode 100644 hub/test/models/build/package_import_test.rb delete mode 100644 hub/test/models/build/start_page_import_test.rb delete mode 100644 hub/test/models/build/templates_test.rb delete mode 100644 hub/test/models/build/workflow_run_test.rb delete mode 100644 hub/test/models/build_test.rb delete mode 100644 hub/test/models/comment_mentions_test.rb delete mode 100644 hub/test/models/conversation_notifications_test.rb delete mode 100644 hub/test/models/copilot_connection_concurrency_test.rb delete mode 100644 hub/test/models/copilot_connection_test.rb delete mode 100644 hub/test/models/fresh_authorization_concurrency_test.rb delete mode 100644 hub/test/models/fresh_authorization_test.rb delete mode 100644 hub/test/models/github_app_administration_concurrency_test.rb delete mode 100644 hub/test/models/github_app_configuration/administration_test.rb delete mode 100644 hub/test/models/github_app_configuration/build_discovery_test.rb delete mode 100644 hub/test/models/github_app_configuration/diagnostics_test.rb delete mode 100644 hub/test/models/github_app_configuration/installation_test.rb delete mode 100644 hub/test/models/github_app_configuration/oauth_test.rb delete mode 100644 hub/test/models/github_app_configuration/repositories_test.rb delete mode 100644 hub/test/models/github_app_configuration/rotator_test.rb delete mode 100644 hub/test/models/github_app_configuration/tokens_test.rb delete mode 100644 hub/test/models/github_app_configuration/transfer_recovery_test.rb delete mode 100644 hub/test/models/github_app_configuration_test.rb delete mode 100644 hub/test/models/github_app_dual_barrier_concurrency_test.rb delete mode 100644 hub/test/models/github_app_setup_model_test.rb delete mode 100644 hub/test/models/github_app_transfer_discovery_test.rb delete mode 100644 hub/test/models/github_client_batch_test.rb delete mode 100644 hub/test/models/github_client_test.rb delete mode 100644 hub/test/models/github_webhook_signature_concurrency_test.rb delete mode 100644 hub/test/models/github_webhook_test.rb delete mode 100644 hub/test/models/hub_admin_concurrency_test.rb delete mode 100644 hub/test/models/hub_admin_enrollment_test.rb delete mode 100644 hub/test/models/improvement_comment_test.rb delete mode 100644 hub/test/models/learning/command_test.rb delete mode 100644 hub/test/models/learning/execution_lock_test.rb delete mode 100644 hub/test/models/learning/overview_test.rb delete mode 100644 hub/test/models/learning/run_test.rb delete mode 100644 hub/test/models/learning/store_test.rb delete mode 100644 hub/test/models/learning/test_helpers_test.rb delete mode 100644 hub/test/models/preview_grant_test.rb delete mode 100644 hub/test/models/preview_origin_test.rb delete mode 100644 hub/test/models/private_storage_root_test.rb delete mode 100644 hub/test/models/prototype_introduction_test.rb delete mode 100644 hub/test/models/prototype_test.rb delete mode 100644 hub/test/models/public_sharing_concurrency_test.rb delete mode 100644 hub/test/models/public_sharing_upgrade_test.rb delete mode 100644 hub/test/models/repository/authorization_test.rb delete mode 100644 hub/test/models/repository/branch_presence_test.rb delete mode 100644 hub/test/models/repository/build_discoveries_test.rb delete mode 100644 hub/test/models/repository/prototype_candidates_test.rb delete mode 100644 hub/test/models/repository/workflow_installation_test.rb delete mode 100644 hub/test/models/repository/workflow_run_discoveries_test.rb delete mode 100644 hub/test/models/repository_test.rb delete mode 100644 hub/test/models/review_comment_test.rb delete mode 100644 hub/test/models/security_audit_event_test.rb delete mode 100644 hub/test/models/security_rate_limit_cache_test.rb delete mode 100644 hub/test/models/session_revocation_concurrency_test.rb delete mode 100644 hub/test/models/session_revocation_test.rb delete mode 100644 hub/test/models/share_link_creator_revocation_test.rb delete mode 100644 hub/test/models/share_link_startup_test.rb delete mode 100644 hub/test/models/share_link_test.rb delete mode 100644 hub/test/models/share_preview_grant_test.rb delete mode 100644 hub/test/models/sharing_setting_test.rb delete mode 100644 hub/test/models/user_hub_admin_test.rb delete mode 100644 hub/test/models/user_test.rb delete mode 100644 hub/test/models/webhook_delivery_test.rb delete mode 100644 hub/test/models/webhook_recovery_concurrency_test.rb delete mode 100644 hub/test/models/webhook_recovery_retention_test.rb delete mode 100644 hub/test/models/webhook_recovery_test.rb delete mode 100644 hub/test/support/artifact_store_contract.rb delete mode 100644 hub/test/support/authorization_continuation_helpers.rb delete mode 100644 hub/test/support/github_request_stubs.rb delete mode 100644 hub/test/support/learning_test_helpers.rb delete mode 100644 hub/test/support/security_rate_limit_store.rb delete mode 100644 hub/test/support/share_link_lifetime_helpers.rb delete mode 100644 hub/test/test_helper.rb delete mode 100644 hub/test/views/hub_administration_navigation_test.rb delete mode 100644 hub/tmp/.keep create mode 100644 hub/tsconfig.server.json delete mode 100644 hub/vendor/.keep create mode 100644 scripts/.gitignore create mode 100644 scripts/.npmrc delete mode 100644 scripts/check-ci-workflows.rb create mode 100644 scripts/check-ci-workflows.test.ts create mode 100644 scripts/check-ci-workflows.ts create mode 100644 scripts/check-dependency-age.test.ts create mode 100644 scripts/check-dependency-age.ts create mode 100644 scripts/package-lock.json create mode 100644 scripts/package.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d7391538..2c865c80 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,16 +1,11 @@ version: 2 updates: - - package-ecosystem: bundler - directory: "/hub" - schedule: - interval: weekly - cooldown: - default-days: 7 - package-ecosystem: npm directories: - "/hub" - "/actions/build" - "/starter" + - "/scripts" schedule: interval: weekly cooldown: diff --git a/.github/extensions/replica-prototype/README.md b/.github/extensions/replica-prototype/README.md index 8a9d00a2..26992e2b 100644 --- a/.github/extensions/replica-prototype/README.md +++ b/.github/extensions/replica-prototype/README.md @@ -2,6 +2,8 @@ The Replica prototype canvas puts a local prototype in the Copilot app alongside repository status and a Publish control. Open it from **+ → Canvas** to find running HTTP servers in this checkout, then select **Connect** beside the one you want. When none is running, **Start dev server** is the primary action beside **Scan again**. Opening a canvas never executes a repository script; starting requires your click and does not send a chat request. +Local preview and Controls work without Hub. The [new Hub](../../../hub/README.md) cannot publish prototypes or accept comments yet. The extension keeps its Hub connection features for compatible existing services, but the new Hub does not support them or provide sign-in, storage, or deployment setup. + ## Local prototype controls **Controls** in the canvas toolbar reads `/controls.replica.json` from the preview and binds its eleven supported field types to ordinary app behavior. Existing sets, flat groups and conditions render in the shared panel. Controls work independently of Hub and comments; dismissal keeps the same iframe and route. @@ -27,7 +29,7 @@ copilot plugin install replica-prototype@replica Use `github/replica#main` to select `main` explicitly. -To pin an immutable release, replace `` with an approved `hub-v*` tag containing the catalog: +To keep using one release, replace `` with an approved existing tag containing the catalog. The new Hub has no release workflow: ```sh copilot plugin marketplace add 'github/replica#' @@ -76,7 +78,7 @@ New improvements remain local until their captured source matches a successful p ## Learnings -When this repository has a Hub connection and a Hub administrator connected Copilot, each prompt you submit gets the [learnings](../../../hub/docs/learnings.md) your repository's administrators accepted in Hub. The extension never sends your prompt to Hub. Copilot treats the learnings as guidance from your team, not as commands. Its first successful answer replaces earlier learning context, including after a restart or session resume; an empty answer explicitly clears it. Later unchanged answers add nothing. When learnings change, or Learnings is turned off, the next successful answer replaces the earlier guidance. It adds nothing when Hub's answer is not in the form that `replica-learning` writes. It never delays a prompt by more than three seconds and never fails it. Without a Hub connection it adds nothing and never asks you to connect. If you connected before the approval page mentioned learnings, disconnect and connect again to get them. +When connected to a compatible external Hub service with Copilot configured, each prompt gets the accepted [learnings](../../../packages/learning/README.md) that service returns. The extension never sends your prompt to Hub. Copilot treats the learnings as guidance from your team, not as commands. Its first successful answer replaces earlier learning context, including after a restart or session resume; an empty answer explicitly clears it. Later unchanged answers add nothing. When learnings change, or Learnings is turned off, the next successful answer replaces the earlier guidance. It adds nothing when Hub's answer is not in the form that `replica-learning` writes. It never delays a prompt by more than three seconds and never fails it. Without a Hub connection it adds nothing and never asks you to connect. If you connected before the approval page mentioned learnings, disconnect and connect again to get them. Copilot CLI runs extensions only while its extensions feature is on. In Copilot CLI 1.0.96 that is an experimental feature: start Copilot CLI with `--experimental`, or run `/experimental on`. In prompt mode (`copilot -p`), it loads a repository's project copy only when `GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS=true`. @@ -248,7 +250,7 @@ Teams can configure their Hub by merging `.github/replica.json` into the prototy This is separate from `.github/github-app.yml`; no startup script or app-specific configuration key is required. With `gh` available and authenticated for the origin repository, opening the canvas discovers its configured Hub and offers **Connect to the hub** without URL entry. Branch edits and repository hints cannot override that configuration. Discovery uses a five-minute cache; the **Reset to repository Hub URL** icon beside the editable URL checks again immediately and fills the draft. **Save Hub** is still required to switch. Invalid or unavailable configuration is shown separately from missing configuration. -The same file may contain a `templates` array for [Hub's Templates gallery](https://github.com/github/replica/blob/main/hub/docs/gallery.md). Discovery validates only `hub.origin`, not the full Hub configuration schema. Other fields, including `version`, the `templates` catalog, and extra Hub settings, are ignored and preserved. Discovery has no configuration size limit, but requires a complete, nontruncated JSON blob. Template routes never select a Hub. +The same file may contain a `templates` array retained for compatible Hub services. The [new Hub](../../../hub/README.md) has no gallery. Discovery validates only `hub.origin`, not the full Hub configuration schema. Other fields, including `version`, the `templates` catalog, and extra Hub settings, are ignored and preserved. Discovery has no configuration size limit, but requires a complete, nontruncated JSON blob. Template routes never select a Hub. A missing file, missing or null `hub.origin`, or blank URL falls back to manual Hub URL entry when no saved selection or explicit environment origin exists. A configured URL must be a valid origin without credentials, paths, query strings, or fragments; remote origins require HTTPS. Malformed JSON and invalid configured URLs remain explicit errors, with manual URL entry still available. @@ -262,7 +264,7 @@ Seven-day access tokens renew automatically while repository access remains vali The canvas follows branch changes. If Hub has not discovered the branch, the repository remains connected and comments can be saved locally; push the branch and wait for discovery to use its published Builds and reviews. Once Hub imports a successful Build of the current checkout, the canvas automatically registers eligible prototypes using the branch name as the initial title. Existing metadata is preserved. Use **Edit metadata** in **Hub** to change the title or description afterward. Registration requires write access and Hub's branch eligibility checks; errors appear in the shared critical banner and status polling retries automatically. Updated Hub APIs supply a `registrationReason` code explaining missing write access, excluded branches, missing remote heads, or branch authorship requirements. Unknown codes and older Hubs retain a generic error; independently verified permission and remote-ref facts can supply a specific explanation. Installing the extension does not deploy the updated Hub API. -For a Hub started in [local development mode](https://github.com/github/replica/blob/main/hub/docs/local-development.md), open Copilot in Hub's `.local/prototypes/example` checkout and pass `"repository": "local/example"` when opening the canvas. Local repository hints only apply to matching checkouts under `.local/prototypes/` with a loopback Hub origin; they cannot redirect a remote checkout to another Hub repository. Start that example's dev server on a port reachable from the canvas before opening it. +Open the canvas in an independent starter checkout for local preview. The [new Hub](../../../hub/README.md) does not create example repositories or support publishing and review. Existing local-repository hints apply only to matching checkouts under `.local/prototypes/` with a compatible Hub running on the same computer; they cannot redirect a remote checkout to another Hub repository. Selecting **Publish** checks for uncommitted changes and, when present without a saved commit preference, opens a dialog offering **Commit changes & publish** or **Publish committed version only**. Dismiss it with the close icon or Escape. The dialog lists the changed paths. Local agent skills under `.github/skills/`, `.agents/skills/`, or `.claude/skills/` do not count as prototype changes and stay uncommitted. Copilot selects task-related changes only when committing is chosen, and publishes the current commit when none are found. **Always commit changes before publishing for this prototype** remembers this permission locally for the current repository and branch, not other prototypes; later Publish clicks on that branch skip the choice and request task-related commits. After the first registered, published Build, change it through the immediately saved toggle under **Settings** at the bottom of **Edit details**. The committed-only option excludes local edits. Clean checkouts need no choice. See the [canonical publishing boundary](../../../hub/docs/prototype-canvas.md#publishing-boundary) for preference scope, failures, and target safeguards. @@ -280,7 +282,7 @@ Copilot uses the existing push workflow, not manual Actions dispatch or another **Add a comment** targets an element or highlighted area. Comments on unpublished changes stay on the author's device until a matching Build is published; comments on a clean checkout of the published Build save directly to Hub. See [local preview comments](../../../hub/docs/prototype-canvas.md#local-preview-comments) for requirements, storage and recovery. -The header's comment count opens a sidebar with Hub's search, filters, unread state and pin visibility controls. Select a marker or row to open its conversation beside the target. Published conversations support replies, edits, deletion, reactions and resolution. Hub sends live updates, with periodic refresh when unavailable. The **Hub** panel remains focused on connection, metadata and publication. Hub credentials never reach the local preview. +The header's comment count opens a sidebar with Hub's search, filters, unread state and pin visibility controls. Select a marker or row to open its conversation beside the target. Published conversations support replies, edits, deletion, reactions and resolution when connected to a compatible Hub. This branch removes the old live-update connection; the sidebar keeps its existing periodic refresh. The new Hub API does not yet support reviews. The **Hub** panel remains focused on connection, metadata and publication. Hub credentials never reach the local preview. Open an already-running unauthenticated loopback prototype in this canvas. The extension's per-panel reverse proxy supplies its bundled bridge to ordinary Vite dev/preview and static HTML servers. Multiple prototypes, including servers in the same checkout, have independent proxy ports and lifecycles. Closing a panel stops only its proxy. diff --git a/.github/extensions/replica-prototype/create-hub-client/index.mjs b/.github/extensions/replica-prototype/create-hub-client/index.mjs index 0fff12bc..9f34eafe 100644 --- a/.github/extensions/replica-prototype/create-hub-client/index.mjs +++ b/.github/extensions/replica-prototype/create-hub-client/index.mjs @@ -744,52 +744,5 @@ export function createHubClient(context, options = {}) { throw error } }, - async subscribe(onChange, onStop) { - const status = await this.status() - if (status.state !== "connected") throw new Error("Connect to a discovered Hub prototype.") - const { cableUrl, protocol } = await this.request("/api/canvas/cable_ticket", { - method: "POST", - }) - const expected = new URL(origin.origin.replace(/^http/, "ws")) - if ( - typeof cableUrl !== "string" || - new URL(cableUrl).origin !== expected.origin || - typeof protocol !== "string" || - !protocol.startsWith("replica-canvas.") - ) { - throw new Error("Hub returned an invalid review connection.") - } - const socket = new WebSocket(cableUrl, ["actioncable-v1-json", protocol]) - const identifier = JSON.stringify({ - channel: "ReviewCommentsChannel", - repositoryId: status.prototype.repositoryId, - prototypeId: status.prototype.id, - }) - socket.addEventListener("open", () => - socket.send(JSON.stringify({ command: "subscribe", identifier })), - ) - socket.addEventListener("message", (event) => { - try { - const message = JSON.parse(String(event.data)) - if (message == null) throw new Error("Hub returned an invalid review message.") - const data = readHubObject(message) - if ( - data?.identifier === identifier && - readHubObject(data.message)?.type === "review_comments_changed" - ) - onChange() - if (data?.type === "reject_subscription" && data.identifier === identifier) { - onStop(new Error("Hub rejected the review subscription.")) - socket.close() - } - } catch (error) { - onStop(readHubError(error)) - socket.close() - } - }) - socket.addEventListener("error", () => onStop(new Error("Hub review connection failed."))) - socket.addEventListener("close", () => onStop(new Error("Hub review connection closed."))) - return socket - }, } } diff --git a/.github/extensions/replica-prototype/create-hub-client/index.mts b/.github/extensions/replica-prototype/create-hub-client/index.mts index 0ba7c545..d1c0ff9d 100644 --- a/.github/extensions/replica-prototype/create-hub-client/index.mts +++ b/.github/extensions/replica-prototype/create-hub-client/index.mts @@ -876,52 +876,5 @@ export function createHubClient(context: HubContext, options: HubClientOptions = throw error } }, - async subscribe(onChange: () => void, onStop: (error: Error) => void) { - const status = await this.status() - if (status.state !== "connected") throw new Error("Connect to a discovered Hub prototype.") - const { cableUrl, protocol } = await this.request("/api/canvas/cable_ticket", { - method: "POST", - }) - const expected = new URL(origin.origin.replace(/^http/, "ws")) - if ( - typeof cableUrl !== "string" || - new URL(cableUrl).origin !== expected.origin || - typeof protocol !== "string" || - !protocol.startsWith("replica-canvas.") - ) { - throw new Error("Hub returned an invalid review connection.") - } - const socket = new WebSocket(cableUrl, ["actioncable-v1-json", protocol]) - const identifier = JSON.stringify({ - channel: "ReviewCommentsChannel", - repositoryId: status.prototype.repositoryId, - prototypeId: status.prototype.id, - }) - socket.addEventListener("open", () => - socket.send(JSON.stringify({ command: "subscribe", identifier })), - ) - socket.addEventListener("message", (event) => { - try { - const message: unknown = JSON.parse(String(event.data)) - if (message == null) throw new Error("Hub returned an invalid review message.") - const data = readHubObject(message) - if ( - data?.identifier === identifier && - readHubObject(data.message)?.type === "review_comments_changed" - ) - onChange() - if (data?.type === "reject_subscription" && data.identifier === identifier) { - onStop(new Error("Hub rejected the review subscription.")) - socket.close() - } - } catch (error) { - onStop(readHubError(error)) - socket.close() - } - }) - socket.addEventListener("error", () => onStop(new Error("Hub review connection failed."))) - socket.addEventListener("close", () => onStop(new Error("Hub review connection closed."))) - return socket - }, } } diff --git a/.github/extensions/replica-prototype/create-hub-client/index.test.mjs b/.github/extensions/replica-prototype/create-hub-client/index.test.mjs index 0ed6da8d..4bb67362 100644 --- a/.github/extensions/replica-prototype/create-hub-client/index.test.mjs +++ b/.github/extensions/replica-prototype/create-hub-client/index.test.mjs @@ -356,11 +356,6 @@ esac repositoryId: 5, branch: new URL(request.url, "http://localhost").searchParams.get("branch"), }) - } else if (request.url === "/api/canvas/cable_ticket") { - json({ - cableUrl: `ws://127.0.0.1:${server.address().port}/cable`, - protocol: "replica-canvas.signed-ticket", - }) } else if (request.url === "/api/canvas/connection" && request.method === "DELETE") { response.writeHead(204) response.end() @@ -368,36 +363,6 @@ esac json({ error: "Unexpected request." }, 404) } }) - server.on("upgrade", (request, socket) => { - assert.equal(request.headers.origin, undefined) - assert.match(request.headers["sec-websocket-protocol"], /replica-canvas\.signed-ticket/) - const accept = createHash("sha1") - .update(`${request.headers["sec-websocket-key"]}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) - .digest("base64") - socket.write( - `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\nSec-WebSocket-Protocol: actioncable-v1-json\r\n\r\n`, - ) - socket.once("data", () => { - const identifier = JSON.stringify({ - channel: "ReviewCommentsChannel", - repositoryId: 5, - prototypeId: 4, - }) - const payload = Buffer.from( - JSON.stringify({ - identifier, - message: { type: "review_comments_changed" }, - }), - ) - socket.write( - Buffer.concat([ - Buffer.from([0x81, 126, payload.length >> 8, payload.length & 255]), - payload, - ]), - ) - setTimeout(() => socket.end(), 100) - }) - }) await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) try { await writeFile( @@ -461,30 +426,6 @@ esac assert.equal((await client.status()).state, "disconnected") assert.equal(calls.filter(({ auth }) => auth === "Bearer hub-secret").length, 3) assert.ok(!calls[0].auth) - - await client.pair() - await client.poll() - await new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error("No review invalidation arrived.")), - 2_000, - ) - void client - .subscribe( - () => { - clearTimeout(timeout) - resolve() - }, - (error) => { - clearTimeout(timeout) - reject(error) - }, - ) - .catch((error) => { - clearTimeout(timeout) - reject(error) - }) - }) } finally { process.env.PATH = originalPath delete process.env.TEST_BRANCH diff --git a/.github/extensions/replica-prototype/start-canvas-server/index.mjs b/.github/extensions/replica-prototype/start-canvas-server/index.mjs index bcb73a52..6e3da4cc 100644 --- a/.github/extensions/replica-prototype/start-canvas-server/index.mjs +++ b/.github/extensions/replica-prototype/start-canvas-server/index.mjs @@ -315,7 +315,6 @@ export async function startCanvasServer(initialContext, session, options = {}) { }, } } - const streams = new Set() const shareLinks = createShareLinks(hubClient, options.openBrowser || openBrowser) const token = randomBytes(24).toString("base64url") const documentToken = randomBytes(32).toString("base64url") @@ -338,7 +337,6 @@ export async function startCanvasServer(initialContext, session, options = {}) { updateMetadata, registerForPublish, selectHubOrigin, - streams, options, hubStatus, refreshDiscovery, @@ -428,7 +426,6 @@ export async function startCanvasServer(initialContext, session, options = {}) { improvementNotes, close: async () => { options.agentCommands?.close() - for (const stream of streams) stream.end() try { await previewUpdate } finally { @@ -452,7 +449,6 @@ async function routeRequest( updateMetadata, registerForPublish, selectHubOrigin, - streams, options, hubStatus, refreshDiscovery, @@ -485,6 +481,9 @@ async function routeRequest( return } requireToken(request, token) + if (requestUrl.pathname === "/api/hub/events" && request.method === "GET") { + throw httpError(501, "Live review updates have not been built for the new Hub.") + } if (requestUrl.pathname === "/api/improvement-notes/capture" && request.method === "POST") { sendJson(response, 200, await improvementNotes.capture()) return @@ -780,34 +779,6 @@ async function routeRequest( ) return } - if (requestUrl.pathname === "/api/hub/events" && request.method === "GET") { - const socket = await hub.subscribe( - () => { - if (!response.writableEnded) response.write("event: comments\ndata: {}\n\n") - }, - (error) => { - if (response.writableEnded) return - response.write(`event: error\ndata: ${JSON.stringify(error.message)}\n\n`) - response.end() - }, - ) - response.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-store", - "X-Content-Type-Options": "nosniff", - }) - response.write(": connected\n\n") - streams.add(response) - const heartbeat = setInterval(() => { - if (!response.writableEnded) response.write(": keepalive\n\n") - }, 25_000) - response.on("close", () => { - streams.delete(response) - clearInterval(heartbeat) - socket.close() - }) - return - } const commentRoute = /^\/api\/hub\/comments\/(\d+)(?:\/(replies|resolution|read|reactions)(?:\/(%2B1|\+1|-1|laugh|hooray|confused|heart|rocket|eyes))?)?$/i.exec( requestUrl.pathname, diff --git a/.github/extensions/replica-prototype/start-canvas-server/index.mts b/.github/extensions/replica-prototype/start-canvas-server/index.mts index 3e363640..9c73741d 100644 --- a/.github/extensions/replica-prototype/start-canvas-server/index.mts +++ b/.github/extensions/replica-prototype/start-canvas-server/index.mts @@ -375,7 +375,6 @@ export async function startCanvasServer( }, } } - const streams = new Set() const shareLinks = createShareLinks(hubClient, options.openBrowser || openBrowser) const token = randomBytes(24).toString("base64url") const documentToken = randomBytes(32).toString("base64url") @@ -398,7 +397,6 @@ export async function startCanvasServer( updateMetadata, registerForPublish, selectHubOrigin, - streams, options, hubStatus, refreshDiscovery, @@ -492,7 +490,6 @@ export async function startCanvasServer( improvementNotes, close: async () => { options.agentCommands?.close() - for (const stream of streams) stream.end() try { await previewUpdate } finally { @@ -517,7 +514,6 @@ async function routeRequest( updateMetadata: UpdateMetadata, registerForPublish: RegisterForPublish, selectHubOrigin: HubConnection["selectHubOrigin"], - streams: Set, options: CanvasServerOptions, hubStatus: HubStatusReader, refreshDiscovery: HubConnection["refreshDiscovery"], @@ -552,6 +548,9 @@ async function routeRequest( } requireToken(request, token) + if (requestUrl.pathname === "/api/hub/events" && request.method === "GET") { + throw httpError(501, "Live review updates have not been built for the new Hub.") + } if (requestUrl.pathname === "/api/improvement-notes/capture" && request.method === "POST") { sendJson(response, 200, await improvementNotes.capture()) return @@ -847,34 +846,6 @@ async function routeRequest( ) return } - if (requestUrl.pathname === "/api/hub/events" && request.method === "GET") { - const socket = await hub.subscribe( - () => { - if (!response.writableEnded) response.write("event: comments\ndata: {}\n\n") - }, - (error) => { - if (response.writableEnded) return - response.write(`event: error\ndata: ${JSON.stringify(error.message)}\n\n`) - response.end() - }, - ) - response.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-store", - "X-Content-Type-Options": "nosniff", - }) - response.write(": connected\n\n") - streams.add(response) - const heartbeat = setInterval(() => { - if (!response.writableEnded) response.write(": keepalive\n\n") - }, 25_000) - response.on("close", () => { - streams.delete(response) - clearInterval(heartbeat) - socket.close() - }) - return - } const commentRoute = /^\/api\/hub\/comments\/(\d+)(?:\/(replies|resolution|read|reactions)(?:\/(%2B1|\+1|-1|laugh|hooray|confused|heart|rocket|eyes))?)?$/i.exec( requestUrl.pathname, diff --git a/.github/extensions/replica-prototype/start-canvas-server/index.test.mjs b/.github/extensions/replica-prototype/start-canvas-server/index.test.mjs index 4eb93eff..83889cae 100644 --- a/.github/extensions/replica-prototype/start-canvas-server/index.test.mjs +++ b/.github/extensions/replica-prototype/start-canvas-server/index.test.mjs @@ -22,6 +22,25 @@ function publishData(prompt) { } describe("startCanvasServer()", () => { + it("reports that live review updates are unavailable", async () => { + await withContext(async (context) => { + const canvas = await startCanvasServer(context, { send() {} }) + try { + const html = await (await fetch(canvas.url)).text() + const { token } = JSON.parse(html.match(/window\.__REPLICA_CANVAS__ = ([^;]+);/)[1]) + const response = await fetch(new URL("/api/hub/events", canvas.url), { + headers: { "X-Replica-Canvas-Token": token }, + }) + assert.equal(response.status, 501) + assert.deepEqual(await response.json(), { + error: "Live review updates have not been built for the new Hub.", + }) + } finally { + await canvas.close() + } + }) + }) + it("rejects malformed HTTP action results without consuming the pending command", async () => { await withContext(async (context) => { const commands = agentCommands() @@ -37,7 +56,10 @@ describe("startCanvasServer()", () => { try { const html = await (await fetch(canvas.url)).text() const { token } = JSON.parse(html.match(/window\.__REPLICA_CANVAS__ = ([^;]+);/)[1]) - const headers = { "X-Replica-Canvas-Token": token, "X-Replica-Renderer-Id": "renderer-1" } + const headers = { + "X-Replica-Canvas-Token": token, + "X-Replica-Renderer-Id": "renderer-1", + } const pending = commands.invoke("reload") pending.catch(() => {}) const next = await (await fetch(new URL("/api/agent/next", canvas.url), { headers })).json() @@ -928,7 +950,12 @@ exit 1 }), ) } else if (request.url === "/api/canvas/connection") { - response.end(JSON.stringify({ repository: "github/replica", viewerLogin: "octocat" })) + response.end( + JSON.stringify({ + repository: "github/replica", + viewerLogin: "octocat", + }), + ) } else { response.writeHead(404) response.end(JSON.stringify({ error: "Hub has not discovered this branch." })) diff --git a/.github/workflows/ci-changes.yml b/.github/workflows/ci-changes.yml index 62e6e55b..0f6a7935 100644 --- a/.github/workflows/ci-changes.yml +++ b/.github/workflows/ci-changes.yml @@ -27,11 +27,10 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 - - uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0 - with: - working-directory: hub + - run: npm ci --prefix scripts --ignore-scripts --engine-strict - run: node --test scripts/select-ci-suites.test.mjs - - run: ruby scripts/check-ci-workflows.rb + - run: npm test --prefix scripts + - run: node scripts/check-ci-workflows.ts - id: select run: node scripts/select-ci-suites.mjs env: diff --git a/.github/workflows/dependency-age.yml b/.github/workflows/dependency-age.yml index 4e072893..c5aec224 100644 --- a/.github/workflows/dependency-age.yml +++ b/.github/workflows/dependency-age.yml @@ -12,13 +12,12 @@ jobs: audit: name: Dependency age runs-on: ubuntu-latest - defaults: - run: - working-directory: hub steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - bundler-cache: true - working-directory: hub - - run: DEPENDENCY_AGE_ROOT=.. DEPENDENCY_AGE_EXCLUDED_PATHS=starter bin/check-dependency-age + node-version: 24 + - run: npm ci --prefix scripts --ignore-scripts --engine-strict + - run: node scripts/check-dependency-age.ts + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/hub-ci.yml b/.github/workflows/hub-ci.yml index 8bd35321..7222b2b9 100644 --- a/.github/workflows/hub-ci.yml +++ b/.github/workflows/hub-ci.yml @@ -16,114 +16,8 @@ jobs: changes: uses: ./.github/workflows/ci-changes.yml - rails_test: - name: Rails tests${{ matrix.node == 26 && ' (Node 26)' || '' }} - needs: changes - if: ${{ needs.changes.outputs.hub == 'true' }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node: [24, 26] - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0 - with: - bundler-cache: true - working-directory: hub - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: ${{ matrix.node }} - cache: npm - cache-dependency-path: | - hub/package-lock.json - actions/build/package-lock.json - - run: npm ci --engine-strict - - run: npm ci --ignore-scripts --omit=dev --engine-strict - working-directory: actions/build - - run: ../actions/build/node_modules/.bin/playwright install --with-deps chromium - - run: bundle exec vite build - env: - RAILS_ENV: test - - run: bin/rails db:prepare - env: - RAILS_ENV: test - - run: bin/rails test - env: - RAILS_ENV: test - - development_queue: - name: Development queue - needs: changes - if: ${{ needs.changes.outputs.hub == 'true' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0 - with: - bundler-cache: true - working-directory: hub - - run: bin/rails db:prepare - - run: bin/check-development-queue - - ruby_lint: - name: Ruby lint - needs: changes - if: ${{ needs.changes.outputs.hub == 'true' }} - runs-on: ubuntu-latest - env: - RUBOCOP_CACHE_ROOT: tmp/rubocop - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0 - with: - bundler-cache: true - working-directory: hub - - run: bin/rubocop -f github - - zeitwerk: - name: Zeitwerk - needs: changes - if: ${{ needs.changes.outputs.hub == 'true' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0 - with: - bundler-cache: true - working-directory: hub - - run: bin/rails db:prepare - - run: bin/rails zeitwerk:check - - annotations: - name: Annotations - needs: changes - if: ${{ needs.changes.outputs.hub == 'true' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0 - with: - bundler-cache: true - working-directory: hub - - run: bin/rails db:prepare - - run: bin/check-annotations - - bundler_audit: - name: Bundler audit - needs: changes - if: ${{ needs.changes.outputs.hub == 'true' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0 - with: - bundler-cache: true - working-directory: hub - - run: bin/bundler-audit check --update - - typecheck: - name: TypeScript types${{ matrix.node == 26 && ' (Node 26)' || '' }} + check: + name: Hub ${{ matrix.command }}${{ matrix.node == 26 && ' (Node 26)' || '' }} needs: changes if: ${{ needs.changes.outputs.hub == 'true' }} runs-on: ubuntu-latest @@ -131,6 +25,7 @@ jobs: fail-fast: false matrix: node: [24, 26] + command: [lint, typecheck, test, build, storybook:build] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 @@ -139,63 +34,9 @@ jobs: cache: npm cache-dependency-path: hub/package-lock.json - run: npm ci --engine-strict - - run: npm run typecheck + - run: npm run ${{ matrix.command }} - frontend_lint: - name: Frontend lint${{ matrix.node == 26 && ' (Node 26)' || '' }} - needs: changes - if: ${{ needs.changes.outputs.hub == 'true' }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node: [24, 26] - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: ${{ matrix.node }} - cache: npm - cache-dependency-path: hub/package-lock.json - - run: npm ci --engine-strict - - run: npm run lint - - frontend_test_shard: - name: Frontend tests shard ${{ matrix.shard }}/4${{ matrix.node == 26 && ' (Node 26)' || '' }} - needs: changes - if: ${{ always() }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node: [24, 26] - shard: [1, 2, 3, 4] - steps: - - name: Require valid suite selection - working-directory: . - env: - SELECTION_RESULT: ${{ needs.changes.result }} - SELECTED: ${{ needs.changes.outputs.hub }} - run: | - test "$SELECTION_RESULT" = success - case "$SELECTED" in - true|false) ;; - *) echo "Invalid Hub suite selection" >&2; exit 1 ;; - esac - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - if: ${{ needs.changes.outputs.hub == 'true' }} - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - if: ${{ needs.changes.outputs.hub == 'true' }} - with: - node-version: ${{ matrix.node }} - cache: npm - cache-dependency-path: hub/package-lock.json - - run: npm ci --engine-strict - if: ${{ needs.changes.outputs.hub == 'true' }} - - run: npm test -- --shard=${{ matrix.shard }}/4 - if: ${{ needs.changes.outputs.hub == 'true' }} - - frontend_integration_test: + integration: name: Controls and extension tests${{ matrix.node == 26 && ' (Node 26)' || '' }} needs: changes if: ${{ needs.changes.outputs.hub == 'true' }} @@ -210,7 +51,9 @@ jobs: with: node-version: ${{ matrix.node }} cache: npm - cache-dependency-path: hub/package-lock.json + cache-dependency-path: | + hub/package-lock.json + actions/build/package-lock.json - run: npm ci --engine-strict - run: npm ci --ignore-scripts --omit=dev --engine-strict working-directory: actions/build @@ -221,146 +64,27 @@ jobs: REPLICA_CHROMIUM_PATH="$(node -p 'require("../actions/build/node_modules/playwright").chromium.executablePath()')" export REPLICA_CHROMIUM_PATH npm run extension:test - - frontend_test: - name: Frontend tests - needs: [changes, frontend_test_shard, frontend_integration_test] - if: ${{ always() }} - runs-on: ubuntu-latest - steps: - - name: Require all frontend suites to pass - working-directory: . - env: - SELECTION_RESULT: ${{ needs.changes.result }} - SELECTED: ${{ needs.changes.outputs.hub }} - SHARD_RESULT: ${{ needs.frontend_test_shard.result }} - INTEGRATION_RESULT: ${{ needs.frontend_integration_test.result }} - run: | - test "$SELECTION_RESULT" = success - case "$SELECTED" in - true) test "$SHARD_RESULT" = success && test "$INTEGRATION_RESULT" = success ;; - false) test "$SHARD_RESULT" = success && test "$INTEGRATION_RESULT" = skipped ;; - *) echo "Invalid Hub suite selection" >&2; exit 1 ;; - esac - - frontend_build: - name: Frontend build${{ matrix.node == 26 && ' (Node 26)' || '' }} - needs: changes - if: ${{ needs.changes.outputs.hub == 'true' }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node: [24, 26] - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: ${{ matrix.node }} - cache: npm - cache-dependency-path: hub/package-lock.json - - run: npm ci --engine-strict - - run: npm run build - run: npm run controls:assets:check + - run: npm run controls:schema:check - run: npm run extension:assets:check - storybook_build: - name: Storybook build${{ matrix.node == 26 && ' (Node 26)' || '' }} - needs: changes - if: ${{ needs.changes.outputs.hub == 'true' }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node: [24, 26] - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: ${{ matrix.node }} - cache: npm - cache-dependency-path: hub/package-lock.json - - run: npm ci --engine-strict - - run: npm run storybook:build - - production_database: - name: Production database - needs: changes - if: ${{ needs.changes.outputs.hub == 'true' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0 - with: - bundler-cache: true - working-directory: hub - - run: bin/rails db:prepare - env: - RAILS_ENV: production - SECRET_KEY_BASE_DUMMY: "1" - - production_image_platform: - name: Production image (${{ matrix.architecture }}) - needs: changes - if: ${{ needs.changes.outputs.hub == 'true' }} - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - architecture: amd64 - runner: ubuntu-latest - - architecture: arm64 - runner: ubuntu-24.04-arm - env: - IMAGE_PLATFORM: linux/${{ matrix.architecture }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - - run: docker buildx build --platform "$IMAGE_PLATFORM" --build-context learning=../packages/learning --tag replica-hub:ci --load . - - name: Check build-time secret cleanup - run: docker run --platform "$IMAGE_PLATFORM" --rm --entrypoint /bin/sh replica-hub:ci -c 'test ! -e /rails/tmp/local_secret.txt' - - name: Check deployment configuration - run: | - umask 077 - docker run --platform "$IMAGE_PLATFORM" --rm replica-hub:ci \ - bin/replica-deployment-config generate \ - --host hub.example.com \ - --format dotenv > deploy/.env - docker compose --file deploy/compose.yml --project-directory deploy config --quiet - - name: Check production startup - timeout-minutes: 3 - run: | - docker run --platform "$IMAGE_PLATFORM" --detach --name replica-hub-ci --network none --env-file deploy/.env replica-hub:ci - docker exec replica-hub-ci curl \ - --fail --silent --show-error \ - --retry 60 --retry-connrefused --retry-delay 2 --retry-max-time 120 --max-time 5 \ - http://localhost/up - - name: Clean up production startup check - if: ${{ always() }} - run: | - if docker container inspect replica-hub-ci >/dev/null 2>&1; then - docker logs replica-hub-ci - docker rm --force replica-hub-ci - fi - rm -f deploy/.env - - production_image: - name: Production image - needs: [changes, production_image_platform] + result: + name: Frontend tests + needs: [changes, check, integration] if: ${{ always() }} runs-on: ubuntu-latest steps: - - name: Require both production images to pass + - name: Require the selected Hub suite to pass working-directory: . env: SELECTION_RESULT: ${{ needs.changes.result }} SELECTED: ${{ needs.changes.outputs.hub }} - IMAGE_RESULT: ${{ needs.production_image_platform.result }} + CHECK_RESULT: ${{ needs.check.result }} + INTEGRATION_RESULT: ${{ needs.integration.result }} run: | test "$SELECTION_RESULT" = success case "$SELECTED" in - true) test "$IMAGE_RESULT" = success ;; - false) test "$IMAGE_RESULT" = skipped ;; + true) test "$CHECK_RESULT" = success && test "$INTEGRATION_RESULT" = success ;; + false) test "$CHECK_RESULT" = skipped && test "$INTEGRATION_RESULT" = skipped ;; *) echo "Invalid Hub suite selection" >&2; exit 1 ;; esac diff --git a/.github/workflows/hub-release.yml b/.github/workflows/hub-release.yml deleted file mode 100644 index 36f666c3..00000000 --- a/.github/workflows/hub-release.yml +++ /dev/null @@ -1,180 +0,0 @@ -name: Hub release - -on: - release: - types: [published] - -permissions: - contents: write - packages: write - -jobs: - prepare: - name: Prepare release metadata - if: startsWith(github.event.release.tag_name, 'hub-v') - runs-on: ubuntu-latest - outputs: - version: ${{ steps.release.outputs.version }} - stable: ${{ steps.release.outputs.stable }} - revision: ${{ steps.release.outputs.revision }} - tags: ${{ steps.metadata.outputs.tags }} - labels: ${{ steps.metadata.outputs.labels }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.release.tag_name }} - - - name: Read release version - id: release - env: - RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - run: | - identifier='(0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)' - pattern="^hub-v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-${identifier}(\\.${identifier})*)?$" - if [[ ! "$RELEASE_TAG" =~ $pattern ]]; then - echo "Hub release tag must use semantic versioning, such as hub-v1.2.3." >&2 - exit 1 - fi - - version=${RELEASE_TAG#hub-v} - stable=true - tag_prerelease=false - if [[ "$version" == *-* ]]; then - stable=false - tag_prerelease=true - fi - if [[ "$RELEASE_PRERELEASE" != "$tag_prerelease" ]]; then - echo "The tag and GitHub prerelease setting must agree." >&2 - exit 1 - fi - - { - echo "stable=$stable" - echo "version=$version" - echo "revision=$(git rev-parse HEAD)" - } >> "$GITHUB_OUTPUT" - - - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - id: metadata - with: - images: ghcr.io/github/replica-hub - flavor: latest=false - tags: | - type=semver,pattern={{version}},value=${{ steps.release.outputs.version }} - type=semver,pattern={{major}}.{{minor}},value=${{ steps.release.outputs.version }},enable=${{ steps.release.outputs.stable == 'true' }} - type=semver,pattern={{major}},value=${{ steps.release.outputs.version }},enable=${{ steps.release.outputs.stable == 'true' }} - type=raw,value=latest,enable=${{ steps.release.outputs.stable == 'true' }} - - build: - name: Build release image (${{ matrix.architecture }}) - needs: prepare - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - architecture: amd64 - runner: ubuntu-latest - - architecture: arm64 - runner: ubuntu-24.04-arm - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.prepare.outputs.revision }} - - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 - id: build - with: - context: hub - build-contexts: learning=packages/learning - platforms: linux/${{ matrix.architecture }} - outputs: type=image,name=ghcr.io/github/replica-hub,push-by-digest=true,name-canonical=true,push=true - labels: ${{ needs.prepare.outputs.labels }} - build-args: | - REPLICA_IMAGE_REVISION=${{ needs.prepare.outputs.revision }} - REPLICA_IMAGE_VERSION=${{ needs.prepare.outputs.version }} - - name: Export image digest - env: - IMAGE_DIGEST: ${{ steps.build.outputs.digest }} - run: | - if [[ ! "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]; then - echo "Build did not return a SHA-256 image digest" >&2 - exit 1 - fi - mkdir -p "$RUNNER_TEMP/digests" - touch "$RUNNER_TEMP/digests/${IMAGE_DIGEST#sha256:}" - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: image-digest-${{ matrix.architecture }} - path: ${{ runner.temp }}/digests/* - if-no-files-found: error - retention-days: 1 - - publish: - name: Publish container and deployment bundle - needs: [prepare, build] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.prepare.outputs.revision }} - - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - path: ${{ runner.temp }}/digests - pattern: image-digest-* - - name: Publish multiarchitecture image - env: - IMAGE_TAGS: ${{ needs.prepare.outputs.tags }} - run: | - tag_args=() - while IFS= read -r tag; do - if [[ -z "$tag" ]]; then - echo "Release metadata contains an empty image tag" >&2 - exit 1 - fi - tag_args+=(--tag "$tag") - done <<< "$IMAGE_TAGS" - - refs=() - for architecture in amd64 arm64; do - files=("$RUNNER_TEMP/digests/image-digest-$architecture/"*) - if [[ "${#files[@]}" != 1 || ! -f "${files[0]}" ]]; then - echo "Expected exactly one image digest for $architecture" >&2 - exit 1 - fi - digest=$(basename "${files[0]}") - if [[ ! "$digest" =~ ^[a-f0-9]{64}$ ]]; then - echo "Invalid image digest for $architecture" >&2 - exit 1 - fi - refs+=("ghcr.io/github/replica-hub@sha256:$digest") - done - docker buildx imagetools create "${tag_args[@]}" "${refs[@]}" - - name: Package deployment bundle - run: | - mkdir -p hub/tmp/release - tar -czf hub/tmp/release/replica-deploy.tar.gz -C hub/deploy compose.yml replica - cd hub/tmp/release - sha256sum replica-deploy.tar.gz >replica-deploy.tar.gz.sha256 - - - name: Upload deployment bundle - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - run: | - gh release upload "$RELEASE_TAG" \ - hub/tmp/release/replica-deploy.tar.gz \ - hub/tmp/release/replica-deploy.tar.gz.sha256 \ - --clobber diff --git a/AGENTS.md b/AGENTS.md index e5ed151e..123a80a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Working on Replica -Replica contains the self-hosted Hub, a copyable prototype starter, and the shared prototype build action. +Replica contains Hub, a copyable prototype starter, the Copilot canvas extension, and the shared prototype build action. Hub is being rebuilt; local prototyping works, but hosted publishing and review are not available yet. ## Start here @@ -11,7 +11,7 @@ Read [Contributing](CONTRIBUTING.md) and the [shared documentation index](docs/R - [Testing and review](docs/testing-and-review.md) explains how to choose checks and review a change. - Use the [test-writing skill](.github/skills/write-tests/SKILL.md) when adding or changing automated tests. -Keep shared rules in root guides and project-specific rules in the affected project. Do not apply Hub's Rails, Primer, or devcontainer requirements to the starter, build action, or shared packages. The starter's copied instructions must remain usable without this monorepo. +Keep shared rules in root guides and project-specific rules in the affected project. Do not apply Hub-specific requirements to the starter, build action, or shared packages. The starter's copied instructions must remain usable without this monorepo. ## Working rules @@ -29,12 +29,12 @@ Follow the [commit and PR conventions](CONTRIBUTING.md#commits-and-pull-requests ## Project boundaries -- `hub/` owns deployment, repository discovery, Build storage, previews, and review collaboration. Shared canvas and Controls source follows its normal `app/javascript/` layers. Read `hub/AGENTS.md` before changing it. +- `hub/` starts a TypeScript Express API and React + Vite frontend. Hosting and review features are not implemented. Database, sign-in, and deployment choices are not decided. Shared canvas and Controls source stays under `hub/app/javascript/`. Read `hub/AGENTS.md` and [Hub architecture](hub/docs/architecture.md) before changing it. - `starter/` is the single source for the Vite + React app, `.agents/skills/`, and `.agents/context-templates/`. Product- and design-system-specific guidance belongs in consuming repositories. - `actions/build/` owns the artifact contract between prototype repositories and Hub. - `packages/learning/` owns the learning contracts, the memory provider and consolidator interfaces, the reference providers and consolidators, the conformance suite, and the `replica-learning` command. It is private repository source with no release yet. Read its `README.md` and [ADR 0001](docs/adr/0001-learning-extension-architecture.md) before changing it. -Hub releases use `hub-v*` tags. The starter follows `main` and has no separate release process. Start a new agent session after copying the starter so its local skills are discovered. +The new Hub has no release or deployment workflow. The starter follows `main` and has no separate release process. Start a new agent session after copying the starter so its local skills are discovered. For requests to create a prototype repository or set up an existing repository for prototyping, read [prototype repository setup](docs/prototype-repository-setup.md) before acting. It owns target selection, starter installation, the fresh-session handoff, and the path from local preview to optional guidance, a team baseline, or Hub. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b08fed54..3e4cb460 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,36 +25,19 @@ For improvements, explain the problem you want to solve and who would benefit. D ## Set up the development environment -Each Node.js project's `engines.node` declares its supported versions. The existing Hub, Starter, build-action, and Learning CI checks run on both supported Node majors. The devcontainer, production image, and composite build action retain their pinned runtime defaults; choosing another supported major locally does not change them. +Each Node.js project's `engines.node` declares its supported versions. Hub, Starter, build-action, and Learning CI checks run on both supported Node majors. The composite build action retains its pinned runtime default. ### Replica Hub -Replica Hub uses a devcontainer so contributors run the same Ruby, Node, SQLite, GitHub CLI, and Chromium versions. - -1. Install Docker or another Docker-compatible container runtime. -2. Fork and clone the repository. -3. Start the devcontainer: - - ```sh - cd hub - bin/devcontainer up - ``` - -4. Start Rails, Vite, and Solid Queue: - - ```sh - bin/devcontainer dev - ``` - -5. Open . - -Run Ruby and Node commands inside the container: +Hub has a TypeScript Express API and React + Vite frontend. Hosting and review features are not built yet. The database, sign-in, and deployment choices are still open. Use npm and a Node.js version matching `engines.node` in [hub/package.json](hub/package.json): ```sh -bin/devcontainer exec COMMAND +cd hub +npm ci +npm run dev ``` -See [Hub development](hub/docs/development.md) for the full local workflow. Local GitHub integration requires a private development GitHub App and test repositories. Never install that App on production repositories. +This starts the frontend. In a second terminal, run `cd hub && npm run dev:api` to start the API. See [Hub development](hub/docs/development.md) for their addresses and commands. No container or GitHub App setup is required. ### Replica Starter @@ -106,7 +89,7 @@ For agent-assisted rendered checks, interaction tests, screenshots and reviews, Read the [shared coding conventions](docs/coding-conventions.md) and existing documentation for the area you are changing before editing code. - For Hub changes, follow the existing system boundaries in [Hub architecture](hub/docs/architecture.md). -- For Hub changes, follow the Rails, TypeScript, interface, and test rules in [Hub coding conventions](hub/docs/coding-conventions.md). +- For Hub changes, follow the TypeScript, interface, and test rules in [Hub coding conventions](hub/docs/coding-conventions.md). - For Hub and canvas UI changes, follow the [Storybook coverage and screenshot requirements](hub/docs/storybook.md#ui-changes-and-pull-request-evidence). - Follow the [shared documentation conventions](docs/documentation-conventions.md) for prose, labels, errors, and code comments. @@ -115,21 +98,37 @@ Read the [shared coding conventions](docs/coding-conventions.md) and existing do Follow the [shared testing and review guide](docs/testing-and-review.md). For Hub code changes, run a focused test while developing, then run the complete Hub suite when the change is stable. Run this command from `hub/`: ```sh -bin/devcontainer exec bin/ci +npm ci +npm run lint +npm run typecheck +npm test +npm run build +npm run storybook:build ``` Useful focused commands include: | Goal | Command | | --------------- | ----------------------------------------------- | -| Rails test | `bin/devcontainer exec bin/rails test PATH` | -| TypeScript test | `bin/devcontainer exec npm test -- PATH` | -| Type check | `bin/devcontainer exec npm run typecheck` | -| Storybook build | `bin/devcontainer exec npm run storybook:build` | +| TypeScript test | `npm test -- PATH` | +| Type check | `npm run typecheck` | +| Storybook build | `npm run storybook:build` | | Prose review | `bin/review-prose` | From the repository root, run `hub/bin/review-prose` to include changed prose across all projects. Hub and build-action dependency updates must satisfy the [shared dependency policy](docs/testing-and-review.md#update-dependencies). See [Hub testing and operations](hub/docs/testing-and-operations.md) for the complete Hub check and review workflow. +Repository workflow and dependency checks have their own small npm project: + +```sh +npm ci --prefix scripts --ignore-scripts --engine-strict +npm test --prefix scripts +node --test scripts/select-ci-suites.test.mjs +node scripts/check-ci-workflows.ts +node scripts/check-dependency-age.ts +``` + +The dependency-age command needs network access to npm and GitHub. `GH_TOKEN` is optional locally and avoids GitHub's unauthenticated API rate limit. + ## Commits and pull requests Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages and PR titles: `type(optional-scope): short description`. For example, `fix(hub): retain prototype history` or `docs: clarify contribution rules`. Choose a type that reflects the change, such as `feat`, `fix`, `docs`, `refactor`, or `test`. These are conventions for contributors and agents; CI does not enforce PR titles. diff --git a/README.md b/README.md index 47fdfe3e..b4e35e79 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Designers create and approve the guidance. People across design, product, and en Use prototypes to test assumptions and work through questions together. Your team still decides what should ship and how to build it. -Replica is MIT-licensed. You can prototype locally without a server or set up Replica Hub for hosting and commenting. +Replica is MIT-licensed. You can prototype locally without a server. Hub is being rebuilt and does not yet provide hosted publishing, comments, or deployment. ## Try Replica @@ -26,14 +26,14 @@ Replica has four parts: | --- | --- | | **Starter** | Creates a local prototype project with agent skills and a working app you can adapt. | | **Copilot canvas extension** | Opens your local prototype alongside your Copilot conversation. | -| **Replica Hub (optional)** | Hosts published prototypes so your team can try them and leave comments. | -| **GitHub build action** | Builds prototypes for publishing to Hub. It is not needed for local prototyping. | +| **Replica Hub (in development)** | Starts an Express API and React + Vite frontend. Hosting and review features are not available yet. | +| **GitHub build action** | Builds prototype files for upload. It is not needed for local prototyping, and the new Hub cannot receive those files yet. | -**Ready to make prototypes?** Follow [Get started with Replica](docs/getting-started.md) to install the GitHub Copilot app, add your team's prototyping repository, install the canvas, and create your first prototype. It also covers connecting to Hub, publishing, sharing and commenting, and adding controls. +**Ready to make prototypes?** Follow [Get started with Replica](docs/getting-started.md) to install the GitHub Copilot app, add your team's prototyping repository, install the canvas, and create your first local prototype with controls. **Creating a prototyping repository?** Point your agent at [prototype repository setup](docs/prototype-repository-setup.md). It covers new and existing repositories, getting the starter running locally, and continuing with product guidance or a shared team baseline. Local prototyping needs no Hub. -**Setting up Replica for your organization?** Follow [environment setup](docs/prototype-environment.md) to add shared guidance and starting pages, deploy Hub, and connect publishing and team feedback. Your organization deploys and runs Hub on its own infrastructure. GitHub doesn't host it. +**Setting up Replica for your organization?** Follow [environment setup](docs/prototype-environment.md) to add shared guidance and starting pages. Hosted publishing and review are not available yet. See [Hub architecture](hub/docs/architecture.md) for what is included and what is not. **How did setup go?** [Share your feedback](https://survey.alchemer.com/s3/9055055/Replica-feedback). It helps us make Replica easier to start. @@ -42,15 +42,13 @@ Replica has four parts: - **App and sign-in:** the documented canvas workflow uses the GitHub Copilot app and requires GitHub sign-in. - **Model access:** use GitHub-hosted models with a Copilot plan, or [connect your own model provider](https://docs.github.com/en/copilot/how-tos/github-copilot-app/use-byok-models) without a Copilot plan. - **Your own provider:** this option is in public preview and subject to change. The provider's costs and terms apply. -- **Viewing and review:** viewing a finished prototype or reviewing it in Hub does not require a Copilot subscription. +- **Local preview:** the starter and canvas work without a Hub connection. ## Data and privacy -Your organization operates its Hub and controls its configuration. Hub records publishing and review activity in its own database and retains those activity events for 90 days. Exporting them to Segment, Mixpanel, or a custom HTTPS endpoint requires configuration by a repository administrator. Replica does not automatically send those activity records to GitHub. +Keep credentials and real customer data out of prototype repositories. GitHub services and your selected AI provider have their own data handling and terms. -These activity events do not track offline work, local editing, or interactions inside prototype previews. GitHub services and your selected AI provider have their own data handling and terms. Using a self-hosted Hub does not mean that no data leaves your organization. - -See [Hub analytics](hub/docs/analytics.md), [security](hub/docs/security.md), and [backups and retention](hub/docs/backups-and-restore.md) for details. +The new Hub does not yet store prototype data, sign people in, or record activity. There is no deployment setup. See [Hub status](hub/README.md). ## Support and contributions diff --git a/docs/README.md b/docs/README.md index 449de56f..418f7b2a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,11 +4,11 @@ These guides apply across the Replica monorepo. Start with [Contributing](../CON ## Prototype guides -[Get started with Replica](getting-started.md) walks new users through installing Copilot, adding their team's repository and canvas, creating and publishing a prototype, sharing feedback, and adding controls. +[Get started with Replica](getting-started.md) walks new users through installing Copilot, adding their team's repository and canvas, creating a local prototype, and adding controls. [Set up a prototype repository](prototype-repository-setup.md) guides agents through new or existing repository targets, starter installation, the fresh-session handoff, and a verified local preview before optional product guidance or team setup. -[Set up and configure your prototype environment](prototype-environment.md) walks environment maintainers through creating the shared repository, adding design-system guidance and templates, deploying Hub with Docker, connecting builds, and inviting the team. +[Set up and configure your prototype environment](prototype-environment.md) walks maintainers through creating the shared repository, adding design-system guidance and starting pages, and inviting the team. Hub publishing and review are not available yet. ## Shared conventions @@ -20,7 +20,7 @@ Commits and pull requests follow the [contribution conventions](../CONTRIBUTING. ## Project guidance -- [Hub documentation](../hub/docs/README.md) covers the Rails application, deployment, previews, and review collaboration. +- [Hub documentation](../hub/docs/README.md) covers starting and checking the TypeScript API and frontend, and what is not built yet. - [Starter instructions](../starter/AGENTS.md) and [setup guide](../starter/SETUP.md) cover the copyable prototype app and skills. - [Build action setup](../CONTRIBUTING.md#replica-build-action) covers the shared action. - [Controls documentation](../hub/docs/prototype-controls.md) covers the shared engine and definition schema owned by Hub. diff --git a/docs/adr/0001-learning-extension-architecture.md b/docs/adr/0001-learning-extension-architecture.md index 51c3781b..0f7657e3 100644 --- a/docs/adr/0001-learning-extension-architecture.md +++ b/docs/adr/0001-learning-extension-architecture.md @@ -2,6 +2,8 @@ Status: proposed on October 6, 2026, and revised on October 10, 2026 to follow the requirements the Replica maintainers agreed on. Needs review by the Replica maintainers. +The learning package, providers, CLI, and demos still work. Hub descriptions below record an earlier proposal, not what the [new Hub](../../hub/README.md) can do today. It does not collect comments, run learning jobs, store learning data, sign people in, or connect Copilot. Adding those features requires separate product and architecture decisions. + ## Context Replica should get better from the judgment people give it, and teams adopting the open-source release must be able to replace each part. The plan has three stages: explicit memory first, then experiences beyond screens, then optional model training. This decision covers the first stage: canonical learning contracts, a default SQLite memory provider, and a loop in which Hub suggests learnings from review comments and people decide which ones agents get, with no model training. @@ -94,9 +96,9 @@ Each import records whether every list page and reply page was present. The impo ### Integration with Hub -Hub hosts the loop and runs the `replica-learning` command for every step, so the store's rules stay the only rules. Hub keeps one store per repository in its own storage, outside any checkout. A background job imports the repository's review comments from Hub's database as one complete collection, removes the text of comments deleted on Hub, asks Copilot to label each new or edited comment, and consolidates the labels into proposals. When someone deletes a comment, another job removes its text by its Hub comment ID, which imports nothing, so it works without an active grant and adds no other comments. Repository administrators accept, edit, or reject proposals on a Learnings page. The Replica prototype plugin adds accepted learnings to the prompts a person submits in that repository. [Learnings](../../hub/docs/learnings.md) describes the product. +The earlier proposal had Hub run `replica-learning` for every step, so the package's rules applied throughout. It kept one store per repository outside any checkout. A background job imported review comments, removed deleted text, labeled new or edited comments, and proposed learnings. Administrators reviewed proposals, and the plugin read accepted learnings. The [new Hub](../../hub/README.md) does not run these steps. -Hub asks Copilot through the [Copilot SDK](https://github.com/github/copilot-sdk), with a token that the person who runs Hub provides and whose only permission is Copilot requests, as [Copilot](../../hub/docs/copilot.md) describes. The session has no tools, refuses every permission request, and never sees who wrote a comment. Copilot chooses the model unless the store's configuration names one, and each run records the usage Copilot reports. +The package can label through the [Copilot SDK](https://github.com/github/copilot-sdk). Its session has no tools, refuses every permission request, and never sees who wrote a comment. Copilot chooses the model unless the store's configuration names one, and each run records the usage Copilot reports. The new Hub does not set up or authorize this connection. The command still runs in a checkout, with data under `.replica/learning/`, for contributors and for other hosts. This complements #230: its discovery output can become labels, and its judge can become a consolidator. diff --git a/docs/documentation-conventions.md b/docs/documentation-conventions.md index 34d6822b..fc1a95f7 100644 --- a/docs/documentation-conventions.md +++ b/docs/documentation-conventions.md @@ -51,11 +51,7 @@ Use the accepted product name consistently. Remove rejected terminology and plan ## Guide shape -Use only the sections that help the reader, usually in this order: - -```text -Purpose -> current status -> contract -> flow -> rules -> recovery -> operations -``` +Use only the sections that help the reader. Usually start with the purpose and current status, then explain the contract, flow, rules, recovery, and operations. Document only current behavior. Use one main guide for each boundary. Overview and security docs should summarize and link instead of copying its details. Update the main guide when its rules change. @@ -68,7 +64,7 @@ Use: - A list for independent rules. - Prose only when the connection between ideas matters. -Keep diagrams moving in one direction and label their start and result. A diagram should make sense without translating implementation jargon. Do not add a diagram when one sentence is clearer. +Prefer Mermaid over ASCII diagrams when a diagram helps explain a flow or relationship. Keep diagrams moving in one direction and label their start and result. A diagram should make sense without translating implementation jargon. Do not add a diagram when one sentence is clearer. Product guides explain what people can do and see. Leave internal fields and thresholds in code or tests unless a contributor or operator needs them. Never include secrets, tokens, private keys, or real private repository data. diff --git a/docs/getting-started.md b/docs/getting-started.md index 650cb9fd..22954abf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,30 +1,28 @@ # Get started with Replica -Create an interactive prototype with GitHub Copilot, publish it to Replica Hub, and collect feedback from your team. This guide helps you join your organization's prototyping environment and create your first shared prototype. +Create an interactive local prototype with GitHub Copilot and your team's design guidance. This guide helps you join a shared prototype repository, open its local canvas, and add controls. -You can create and try prototypes locally without Hub. To create a repository rather than join an existing one, follow [prototype repository setup](prototype-repository-setup.md). Publishing and team comments need a Hub hosted by your organization, a connected GitHub repository, and the repository's Replica build workflow. Ask your team for its Hub URL and repository, or follow [environment setup](prototype-environment.md) if you are setting up shared hosting for the first time. +Hub is being rebuilt. Hosted publishing, sharing, comments, and deployment are not available yet. The starter and local canvas do not require Hub. To create a repository rather than join an existing one, follow [prototype repository setup](prototype-repository-setup.md). ## 1. Download the GitHub Copilot app Download the [GitHub Copilot app](https://github.com/features/ai/github-app), install it, and follow its sign-in instructions. -You also need [Git](https://git-scm.com/), [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm), and [Node.js](https://nodejs.org/). Install the Node.js version required by your team's prototyping repository; npm comes with the Node.js installation, so you do not need to install it separately. +You also need [Git](https://git-scm.com/), [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm), and [Node.js](https://nodejs.org/). Install the Node.js version required by your team's prototyping repository; npm comes with Node.js. -The canvas preview workflow below supports macOS and Linux. Server discovery requires `lsof` on the machine running the canvas extension; install it with your system's package manager if it is missing. **Start dev server** is supported only on macOS and Linux. +The canvas preview workflow supports macOS and Linux. Server discovery requires `lsof` on the machine running the canvas extension; install it with your system's package manager if it is missing. **Start dev server** is supported only on macOS and Linux. -Install [GitHub CLI](https://cli.github.com/) for the canvas's branch and publishing checks. In a terminal, check your GitHub sign-in: +Install [GitHub CLI](https://cli.github.com/) for the canvas's branch checks. Check your GitHub sign-in: ```sh gh auth status --hostname github.com ``` -If you are not signed in, run `gh auth login --hostname github.com` and follow the prompts. Use an account with access to the prototype repository. Copilot sign-in and Hub browser approval do not replace this GitHub CLI sign-in. +If you are not signed in, run `gh auth login --hostname github.com` and follow the prompts. Use an account with access to the prototype repository. Copilot sign-in does not replace this GitHub CLI sign-in. ## 2. Set up your prototyping repository -Your organization should have a shared prototyping environment: a GitHub repository with your product's components and design guidance, connected to your team's Replica Hub. A designer, design-system maintainer, or engineer may already have set this up. - -Ask your team for the prototype repository's GitHub URL, the Hub URL, and access. You need repository write access to publish prototypes. +Ask your team for the prototype repository's GitHub URL and access. A designer, design-system maintainer, or engineer may already have prepared the shared components and guidance. Add the repository to the Copilot app: @@ -35,35 +33,30 @@ Add the repository to the Copilot app: If the repository is missing or access is denied, ask its maintainer to check your GitHub access. See [connecting a repository in Copilot](https://docs.github.com/en/copilot/get-started/quickstart-copilot-app#connecting-a-repository-or-folder) for app setup help. -**Setting up the environment for your organization?** Follow [Set up and configure your prototype environment](prototype-environment.md) instead. It covers creating the repository, adding design-system guidance, deploying Hub with Docker, and connecting builds before inviting teammates. +**Setting up the environment for your organization?** Follow [environment setup](prototype-environment.md) instead. It covers creating the repository, adding guidance and starting pages, and inviting teammates. ## 3. Install and enable the canvas -The Replica prototype canvas shows your local preview beside the Copilot conversation and adds publishing, sharing, commenting, and prototype controls. +The Replica prototype canvas shows your local preview beside the Copilot conversation and provides prototype controls. -Ask your agent to install the plugin and provide the following commands to the agent: +Ask your agent to install the plugin and provide these commands: ```sh copilot plugin marketplace add github/replica copilot plugin install replica-prototype@replica ``` -You can also run this manually in a terminal with Copilot CLI available. - -Repositories using the current Replica starter setup script automatically run `npm install -g @github/copilot` if `copilot` is missing, then install the Replica plugin. This needs npm and a writable global installation directory. Setup does not use sudo or change your shell configuration. Dependency installation with `npm ci` still runs if CLI or plugin setup fails; this does not mean the canvas is ready. Check the app's setup output for the failure reason and recovery steps. If automatic installation fails, or your repository does not use that script, follow the [Copilot CLI installation instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli), then rerun `node scripts/setup-replica-plugin.mjs` in repositories that have it. +You can also run them manually in a terminal with Copilot CLI available. -In the Copilot app sidebar, open **Customize**, then **Installed**. Find the `replica-prototype` plugin and enable it. Also find its `replica-prototype` canvas and enable that extension; both the plugin and extension must be enabled. +Repositories using the current starter setup script automatically run `npm install -g @github/copilot` if `copilot` is missing, then install the plugin. This needs npm and a writable global installation directory. Setup does not use sudo or change your shell configuration. Dependency installation with `npm ci` still runs if CLI or plugin setup fails; this does not mean the canvas is ready. Check the app's setup output for the failure reason and recovery steps. If automatic installation fails, follow the [Copilot CLI installation instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli), then rerun `node scripts/setup-replica-plugin.mjs` in repositories that have it. -Start a fresh session in the Copilot app, then select **+ → Canvas → Replica prototype**. +In the Copilot app sidebar, open **Customize**, then **Installed**. Find the `replica-prototype` plugin and enable it. Also enable its `replica-prototype` canvas; both must be enabled. -If the canvas is missing, check that installation completed and that you started a new session after installing it. +Start a fresh session in the Copilot app, then select **+ → Canvas → Replica prototype**. If the canvas is missing, check installation and start a new session. ## 4. Create a prototype -Start a new prototype by clicking **+** button next to your prototype repository. Each prototpye should get it's own session. -Describe the change you want and let copilot work for a couple minutes. - -### Example prompt: Create a searchable project list +Start a separate session for each prototype. Describe the change you want: ```text Help a teammate find a project in a list. Use our approved design-system @@ -71,67 +64,16 @@ guidance and sample data. Add search, an empty result message, and a way to clear the search. Keep this separate from our live product. ``` -The replica canvas should open automatically and show your prototype. If it lists running servers, choose **Connect** beside your prototype's server. -If none is running, choose **Start dev server**. If startup fails, ask Copilot to explain the error before changing anything. - -## 5. Connect to the Hub - -Now it is time to connect to the hub to publish and share your prototype. - -In the Replica canvas, open **Edit details** and select **Connect to the Hub**. Enter your team's Hub URL if prompted, then continue to browser approval. - -Sign in with the GitHub account that has access to the prototype repository. Check the Hub address, repository, and approval code. -Approve only when the browser shows the same code as the canvas. - -Return to the canvas after approval. Connecting does not push or publish anything, and you can keep working locally by default. - -If the connection fails, check the Hub URL and repository access with your team. An expired or revoked connection needs browser approval again. See [canvas connection guidance](../hub/docs/prototype-canvas.md#pairing-and-review) for more detail. - -Administrators can follow [Hub and repository setup](prototype-environment.md#4-deploy-the-hub). - -## 6. Publish your prototype - -Publishing sends your prototype branch to GitHub, it gets build and added to the **Prototype** page in your organisations' Hub. Local changes are not visible to reviewers until you publish them. - -To publihs your prototype, choose **Publish** in the canvas. Copilot will publish your work. - -Wait until the canvas shows **Published**. A completed push or successful GitHub workflow is not enough: the Hub must also import the successful Build for your current version. - -Use **Edit details** to give the prototype a useful name and short description. After making more changes, choose **Publish changes** to share the new version. +Open the canvas and choose **Connect** beside your prototype's server. If none is running, choose **Start dev server**. If startup fails, ask Copilot to explain the error before changing anything. -If publication fails, read the displayed error and open **View workflow run** when available. Ask Copilot to investigate that failure before retrying. If the build workflow is missing, ask your repository maintainer to follow [workflow installation](../hub/docs/github-actions-builds.md#install-the-repository-workflow). +Verify that the local prototype renders and responds to the intended interactions. The canvas may display Hub publishing controls, but the new Hub does not support them yet. See [Hub status](../hub/README.md). -## 7. Share and comment +## 5. Add controls -Choose **Share prototype**, the link icon beside the publishing button. The **Internal prototype link** is useful to share your prototype with your teammates. - -Internal links require GitHub sign-in and repository read access. They let teammates try the published prototype and add comments. - -To leave feedback, open the prototype in Hub, choose **Add a comment**, and select an element or highlight a region. Use the comment-count button to find conversations, reply, and resolve or reopen them. Conversations remain available across published versions. - -You can also read and reply to coments in the connected canvas. Starting an anchored comment there requires the local checkout's commit to match the published Build. - -You can even ask your agent to review the comments people left on your prototype. - -### Prompt 2: Review feedback before changing the prototype - -```text -Read the unresolved comments for this prototype. Summarize the feedback and suggest the next change. -Do not reply to or resolve comments yet. -``` - -For people without repository access, **Public share links** may be available. Public sharing is off by default and requires a Hub administrator to enable it and permission to manage links. Anyone with an active public link can view its selected Build without signing in, the link does not include team review conversations or let anonymous visitors comment. - -Check the prototype's content before creating a public link from the **Share prototype** page. Enter a label, choose an expiration, and select **Create share link**. Copy the resulting URL and delete the link when it is no longer needed. Each public link stays on its selected Build: publishing a newer version does not update that link. See [share-link guidance](../hub/docs/share-links.md) for permissions and limits. - -## 8. Add controls - -Controls let you change prototype scenarios without asking Copilot to edit code each time. For example, switch between empty and populated states or change the number of items. They live in Replica's panel, separate from the controls someone would use in your product. +Controls let you change prototype scenarios without asking Copilot to edit code each time. They live in Replica's panel, separate from controls someone would use in your product. Run `/prototype-controls` in your prototype session, or describe the variations you need: -### Prompt 3: Add scenario controls - ```text Add a control to switch this project list between empty and populated states, and a slider to change the item count. @@ -139,8 +81,6 @@ Wire them to the rendered sample data. Keep these controls in Replica's panel rather than adding settings to the product UI. ``` -After Copilot adds the definitions, reload the preview page and open **Controls**, the sliders icon in the canvas toolbar. Change each setting and check that the prototype responds. **Reset all controls** restores the defaults. - -Controls work locally without the Hub. Publish the changes to make them available in Hub previews too. To share a particular scenario, select **Include current control settings** on the canvas sharing page before copying the internal or public link. Publish any new control definitions first, a link cannot add controls missing from its Build. +After Copilot adds the definitions, reload the preview and open **Controls**, the sliders icon in the canvas toolbar. Change each setting and check that the prototype responds. **Reset all controls** restores the defaults. -The [starter Controls guide](../starter/README.md#prototype-controls) covers authoring and recovery. If a control is disabled or reports an error, ask Copilot to investigate the displayed reason rather than assuming the change took effect. +Controls work locally without Hub. The [starter Controls guide](../starter/README.md#prototype-controls) covers authoring and recovery. If a control is disabled or reports an error, ask Copilot to investigate the displayed reason rather than assuming the change took effect. diff --git a/docs/prototype-environment.md b/docs/prototype-environment.md index 4cdde0d3..da17fa72 100644 --- a/docs/prototype-environment.md +++ b/docs/prototype-environment.md @@ -1,32 +1,24 @@ # Set up and configure your prototype environment -Give your organization a shared place to create prototypes with its own components and design guidance, publish them, and collect feedback in Replica Hub. +Give your organization a shared repository for prototypes with its own components and design guidance. -This guide is for the people setting up and maintaining that environment, not everyone creating a prototype. It covers the starter repository, design-system guidance, Docker deployment, GitHub connection, and handoff to your team. If your organization already has an environment, follow [Get started with Replica](getting-started.md) to join it instead. +This guide covers the starter repository, design-system guidance, starting pages, and handoff to your team. Hub is being rebuilt. Deployment, repository connections, publishing, hosted previews, and team comments are not available yet. See [Hub status](../hub/README.md) and [architecture](../hub/docs/architecture.md). -For a local prototype repository without hosted publishing or comments, use [prototype repository setup](prototype-repository-setup.md). It does not require the server, domain, Docker, or GitHub App described below. - -The linked technical guides own deployment configuration, access rules, and recovery procedures. Keep them open as you work through the steps below. +For a local prototype without a shared team baseline, use [prototype repository setup](prototype-repository-setup.md). ## Who should be involved -A designer or design-system maintainer leads the product guidance and starting pages. An engineer helps connect packages and get builds working. Your organization's infrastructure and GitHub administrators deploy the hub and connect repositories. - -You will need a GitHub organization, permission to create a prototype repository and install a private GitHub App, and a Linux server with Docker and the Docker Compose plugin. You also need a dedicated domain, DNS access, and a hosting service or reverse proxy that can provide HTTPS for Hub and its wildcard preview addresses. - -The person preparing the prototype repository needs the [GitHub Copilot app](https://github.com/features/ai/github-app), [Git](https://git-scm.com/), [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm), and [Node.js](https://nodejs.org/). Use a Node.js version matching `engines.node` in [starter/package.json](../starter/package.json). npm comes with the Node.js installation, so you do not need to install it separately. Reuse an existing compatible installation, or arrange an approved installation with an engineer. The starter does not install these tools. +A designer or design-system maintainer leads the guidance and starting pages. An engineer helps connect packages and get the local app working. A repository maintainer grants the team access. -Keep the two environments separate: the prototype repository runs locally on authors' computers, while the Hub container runs on your organization's server. Authors joining later do not need their own Hub or Docker installation. +The person preparing the repository needs the [GitHub Copilot app](https://github.com/features/ai/github-app), [Git](https://git-scm.com/), [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm), and [Node.js](https://nodejs.org/). Use a Node.js version matching `engines.node` in [starter/package.json](../starter/package.json). Reuse an existing compatible installation, or arrange an approved installation with an engineer. The starter does not install these tools. Authors using the canvas preview workflow need macOS or Linux, with `lsof` available on the machine running the extension for server discovery. Install `lsof` with the system's package manager if needed. The canvas's **Start dev server** action supports only macOS and Linux. -Install [GitHub CLI](https://cli.github.com/) on the author's computer for canvas branch and publishing checks. Run `gh auth status --hostname github.com` to check authentication. If needed, run `gh auth login --hostname github.com` and sign in with an account that can access the prototype repository. Copilot sign-in and Hub approval do not replace this step. - ## 1. Create the team's prototype repository Follow steps 1 through 3 of [prototype repository setup](prototype-repository-setup.md) to create an independent starter, open a fresh Copilot session, and verify its local preview. Then return here to prepare the team's guidance and starting pages. -Use a GitHub repository in your organization for this shared environment. Give prototype authors write access and reviewers read access. Keep production credentials and real customer data out of it. Continue below to prepare guidance and starting pages before inviting the team. +Use a GitHub repository in your organization for the shared baseline. Give authors the access they need to contribute. Keep production credentials and real customer data out of it. ## 2. Add your product and design-system guidance @@ -42,96 +34,22 @@ The [starter guide](../starter/README.md) explains the included app and skills. Add a small set of representative pages with sample data, such as a list, a detail page, and an empty state. Choose pages that help teammates explore common product questions. -The hub always offers **Default**, which opens the repository's root page. To offer additional templates, declare their names, descriptions, and routes in `.github/replica.json`. See [template configuration](../hub/docs/github-actions-builds.md#uploaded-artifact) for the format and limits, and [templates in the hub](../hub/docs/gallery.md#templates) for how people use them. - -Review the pages and guidance together, then follow [share the baseline on GitHub](prototype-repository-setup.md#share-the-baseline-on-github) to create or use the intended organization repository, connect `origin`, and commit and push the reviewed baseline when authorized. For an existing shared repository, merge through its contribution process. Verify the actual default branch contains the baseline before continuing to Hub setup. New prototypes start from that shared version without changing it. - -## 4. Deploy the hub - -Your organization hosts Hub; GitHub does not host it for you. Use the published Hub image and Docker Compose bundle rather than the contributor devcontainer. The deployment runs one Hub container with its databases and Build files in a persistent Docker volume. - -Reserve an entire dedicated domain for the hub and its preview subdomains. A subdomain on a domain shared with unrelated applications is not enough to isolate prototype code from those applications. - -### Download and initialize the container deployment - -On your Linux server, follow [Download the deployment bundle](../hub/docs/deployment.md#download-the-deployment-bundle) to select an explicit stable `hub-v*` release, download its public bundle and checksum, verify the archive, and extract it into a new deployment directory. Do not use `releases/latest` or skip checksum verification. The public release assets do not require GitHub authentication. - -From the extracted deployment directory, run: - -```sh -./replica init -``` - -Enter the public hostname you chose for Hub. Initialization downloads the configured container image, creates a private `.env` file with deployment secrets, and prints the setup code for the first GitHub App connection. Keep that code available for step 5. - -Keep `.env` outside Git and preserve it for the lifetime of the deployment. Do not rerun initialization on an existing deployment: new encryption keys cannot read credentials saved with the old keys. - -### Configure DNS and HTTPS - -For a Hub at `hub.example.com`, create DNS records for `hub.example.com` and `*.preview.hub.example.com` pointing to the server. Configure HTTPS for both addresses and forward both to the Hub service, which listens on `127.0.0.1:3000` by default. A custom proxy must preserve the original host and HTTPS scheme. - -Follow [DNS and HTTPS setup](../hub/docs/deployment.md#configure-dns-and-https) and [trusted proxy configuration](../hub/docs/deployment.md#client-ip-addresses-and-trusted-proxies) for your hosting platform. Verify client IP reporting and prevent direct access around your public proxy before relying on request limits or audit addresses. - -### Start Hub and protect its data +Review the pages and guidance together, then follow [share the baseline on GitHub](prototype-repository-setup.md#share-the-baseline-on-github) to create or use the intended organization repository, connect `origin`, and commit and push the reviewed baseline when authorized. For an existing shared repository, merge through its contribution process. Verify the actual default branch contains the baseline before inviting the team. -From the same deployment directory, run: +## 4. Connect the local prototype canvas -```sh -./replica up -./replica status -``` +Install the Replica prototype plugin through Copilot CLI using the [canvas installation instructions](../.github/extensions/replica-prototype/README.md#install). Keep only one copy of the canvas enabled in a session. -The start command waits for the health check and prints the setup address. Open your Hub's HTTPS `/setup` page, then continue with step 5. If startup fails, use `./replica logs` and the [deployment guide](../hub/docs/deployment.md) to investigate before retrying. +Start a fresh prototype session, open the canvas, and connect its local preview. A Hub connection is not needed. The extension may still display Hub connection or publishing controls; the new Hub does not support them yet. -Arrange consistent backups of the databases and Build files, and a separate protected backup of `.env` and encryption secrets. Follow [Backups and restore](../hub/docs/backups-and-restore.md) before inviting the wider team. A running container is not a backup. +## 5. Try the local flow before inviting the team -## 5. Connect the repository and enable builds +Ask one prototype author to follow [Get started with Replica](getting-started.md), beginning with adding your shared repository to the Copilot app. They should be able to start a session, make a change on a new branch, connect the local canvas, and use prototype controls. -Open `/setup` on your deployed Hub, enter the setup code printed during initialization, and follow [GitHub App setup](../hub/docs/github-app-setup.md). Create the private App under your GitHub organization and install it on the prototype repository. A private App owned by a personal account allows only that person to sign in. Keep the App private and check the intended review audience before connecting a public repository. +Give your team the repository's GitHub URL, required local tools and setup commands, any approved canvas version, and the getting started guide. Name the people who maintain the guidance and starting pages. Teammates should reuse this environment rather than repeat the starter installation. -Enroll the person administering the hub through `/operator/enrollment`, following the [operator enrollment instructions](../hub/docs/security-audit.md#enroll-an-operator). Creating the App does not grant that role automatically. - -Sign in to the hub with a GitHub account that has write access to the prototype repository. Open that repository and choose **Open installation pull request**. Review and merge the proposed `.github/workflows/replica.yml` workflow. See [repository workflow installation](../hub/docs/github-actions-builds.md#install-the-repository-workflow). - -Confirm that the default branch builds successfully and the hub imports the result. The **Templates** catalog below the **Create** form should show the starting pages you prepared. If a build fails, follow its error and workflow output; a working local preview does not prove the published build will work. - -## 6. Connect the Replica prototype canvas - -Set your team's Hub URL in `.github/replica.json` in the prototype repository so authors do not have to enter it manually: - -```json -{ - "version": 1, - "hub": { - "origin": "https://hub.example.com" - } -} -``` - -Replace the example with your deployed Hub's HTTPS origin: the scheme and hostname, with a port if needed, but no path, query, or fragment. If the file already contains templates or other settings, add `hub.origin` without replacing those settings. - -Commit the configuration and merge it into the repository's actual default branch. The canvas discovers the URL from that branch, not from unmerged prototype changes. Discovery requires GitHub CLI authenticated with access to the repository. - -This configures the Hub address, not authorization. Each author must still choose **Connect to the hub** and approve the connection in their browser. An existing saved Hub selection takes precedence over repository configuration; changing the file does not silently switch an author's Hub. See the [canvas guide](../.github/extensions/replica-prototype/README.md) for discovery and switching details. - -Install the Replica prototype plugin through Copilot CLI using the [canvas installation instructions](../.github/extensions/replica-prototype/README.md#install). Choose the repository's default branch or an approved release tag. - -Keep only one copy of the canvas enabled in a session. - -Open the canvas in a prototype session and connect its local preview. Choose **Connect to the hub**, enter your organization's hub URL if needed, and approve the connection in your browser after checking that the codes match. See [canvas connection and approval](../hub/docs/prototype-canvas.md#pairing-and-review). - -## 7. Try the whole flow before inviting the team - -Ask one prototype author to follow [Get started with Replica](getting-started.md), beginning with adding your shared repository to the Copilot app. They should be able to start a session, make a change on a new branch, connect the canvas, publish, and copy an internal link. Ask a reviewer to sign in, open that link, and leave a comment. Also check that the author can read that feedback in the canvas. - -Use internal links for this first trial. Public sharing is off by default and needs a separate decision about who may see prototypes. Follow [Share links](../hub/docs/share-links.md) before enabling it. - -Once that flow works, give your team the repository's GitHub URL, Hub URL, required local tools and setup commands, any approved canvas release, and the [getting started guide](getting-started.md). Name the people who maintain the guidance and templates and the person who operates Hub so teammates know where to ask for help. Teammates should reuse this environment, not repeat the starter installation or deploy another Hub. - -## 8. Maintain the shared environment +## 6. Maintain the shared environment Keep product guidance and starting pages current on the repository's default branch. Review and approve guidance changes before asking authors to use them. -On the Hub server, use `./replica status` to check health and `./replica logs` to investigate failures. Use `./replica down` to stop the service without deleting data. Updates are deliberate: follow [deployment operations](../hub/docs/deployment.md#operate-the-deployment) before running `./replica update`, and retain the existing secrets and persistent storage. - -Keep public sharing off unless your organization decides to enable it. Review access, link expiration, backups, and recovery ownership as part of operating the environment, not as tasks each prototype author must configure. +Do not configure Hub hosting, sign-in, or storage from old release instructions. Those choices are not decided for the new Hub. diff --git a/docs/prototype-repository-setup.md b/docs/prototype-repository-setup.md index 6c4045d2..d83d5f73 100644 --- a/docs/prototype-repository-setup.md +++ b/docs/prototype-repository-setup.md @@ -1,6 +1,6 @@ # Set up a prototype repository -Use this guide when someone points an agent at `github/replica` and asks it to set up a prototyping repository. The first milestone is an independent starter app with a verified local preview. Product guidance, reference components, a shared team baseline, and Hub publishing are separate milestones. +Use this guide when someone points an agent at `github/replica` and asks it to set up a prototyping repository. The first milestone is an independent starter app with a verified local preview. Product guidance, reference components, and a shared team baseline are separate milestones. Publishing and comments are not available in the [new Hub](../hub/README.md) yet. This guide uses the existing installer and starter workflows. It does not change `/setup-skills`, which drafts the full shared context library on plain setup. @@ -67,7 +67,7 @@ This milestone checks the existing starter page without building product UI. Rep | Product or design-system setup | Run the new repository's `/setup-skills` with the supplied sources. Reuse prior answers. Follow its source intake, installation approval, draft review, and separate activation steps. | | Reference-based starting UI | Complete the applicable guidance setup, then use the local `create-prototype` skill to build the requested shell or page. Establish whether components come from an installable package, permitted source reuse, or newly authored components. Verify the rendered result against the authorized reference. | | Shared team baseline | Complete the applicable guidance setup, [prepare useful starting pages](prototype-environment.md#3-prepare-useful-starting-pages), and review them together. Then [share the baseline on GitHub](#share-the-baseline-on-github). | -| Hosted publishing and comments | First [share the reviewed baseline on GitHub and verify it on the actual default branch](#share-the-baseline-on-github), then follow [deploy the hub](prototype-environment.md#4-deploy-the-hub) and the remaining environment setup steps, or connect to an existing team Hub. | +| Hosted publishing and comments | Explain that the new Hub cannot publish prototypes or accept comments yet, and has no deployment workflow. Do not infer setup steps from old releases. Local previews and a shared repository remain available. | Guidance drafts are not active guidance. Installed tokens or packages are not proof that reference components are usable or that a shell resembles the reference. Keep those outcomes distinct. @@ -75,7 +75,7 @@ Guidance drafts are not active guidance. Installed tokens or packages are not pr After reviewing the starter files, guidance, and starting pages, use the intended empty GitHub repository or create one with the agreed owner and visibility. Connect it as a remote named `origin`, make the initial commit, and push the intended default branch only when authorized. For an existing shared repository, follow its contribution process to merge the reviewed changes instead of replacing its remote or history. Ask an engineer for help with these Git steps if needed. -Verify the repository's actual default branch contains the intended baseline before calling it ready for teammates or continuing to Hub repository connection. A local preview is not evidence of a published Hub Build. +Verify the repository's actual default branch contains the intended baseline before calling it ready for teammates. A local preview is not evidence of a published Hub Build. ## 5. Report what is ready diff --git a/docs/testing-and-review.md b/docs/testing-and-review.md index 504fe329..6b8fa514 100644 --- a/docs/testing-and-review.md +++ b/docs/testing-and-review.md @@ -2,7 +2,7 @@ Run the checks for the behavior you changed. For example, a shared Controls change needs checks for Hub previews and the Copilot plugin. Include screenshot capture when its adapter changes. A documentation-only change needs a prose and link review rather than application tests. -Use [Contributing](../CONTRIBUTING.md#set-up-the-development-environment) to find each project's environment and commands. Hub Ruby and Node commands run inside its devcontainer; that is not a requirement for every project in the monorepo. +Use [Contributing](../CONTRIBUTING.md#set-up-the-development-environment) to find each project's environment and commands. Hub runs directly on Node.js with npm; no container or external services are needed. ## Choose the relevant checks @@ -50,29 +50,29 @@ The [shared selection workflow](../.github/workflows/ci-changes.yml) calls [the | --- | --- | | Hub code and configuration | Hub | | Shared Controls runtime, schema, fixtures, and capture adapter under `hub/app/javascript/utils/prototype-controls/` | Hub and build action | -| `actions/build/` | Hub and build action; Hub uses the action for local packaging and browser checks | +| `actions/build/` | Hub and build action; Controls and extension integration use its pinned browser | | Copilot extension and plugin catalog | Hub | -| `packages/learning/` | Hub; its image, lint, and learning integration consume the package | -| `starter/` | Hub; local setup copies the starter and depends on its router, metadata, dependencies, and setup files; the Controls runtime suite also checks its shared reference. Starter CI still runs separately | +| `packages/learning/` | Hub; its shared lint and retained integration consume the package | +| `starter/` | Hub; retained Storybook fixtures and Controls checks consume starter files. Starter CI still runs separately | | Existing installer scripts | Neither; Starter CI covers them | | Root contributor documents, shared and Hub guides, skills, and documentation assets | Neither | | Workflow changes, selector changes, and unclassified paths | Both | -The workflows preserve the existing required check names; skipping an entire workflow with a path filter could leave those checks pending. Static jobs use job-level conditions. The Vitest shard jobs always expand their Node and shard matrix and validate selection, but skip checkout, setup, and tests when Hub is unaffected. The required `Frontend tests`, `Production image`, and `Replica action tests` result jobs accept omitted work only after successful selection explicitly excluded it. The frontend and build-action result jobs require every selected Node version to pass. The production-image matrix checks AMD64 and ARM64 independently without cancelling the other architecture on failure. Selection failures, invalid outputs, and failures in selected suites fail those result checks. Keep required-check configuration unchanged. +The `Frontend tests` and `Replica action tests` result jobs always run, so skipping an entire workflow cannot leave these checks pending. They accept skipped work only when the selector succeeded and said it was unnecessary. Hub's result covers lint, types, Vitest, the frontend/API build, Storybook, and Controls/extension checks on every supported Node major. A failed selector, invalid output, or failed selected check makes the result fail. The new Hub has no production-image, database, or release jobs; branch protection must not require removed jobs. -When adding a shared dependency or a new project, update the selector and its behavioral tests together. Unclassified paths deliberately run both suites until their dependencies are known. Run `node --test scripts/select-ci-suites.test.mjs` and `ruby scripts/check-ci-workflows.rb` from the repository root. The latter reads the actual workflow gates and checks their success, failure, cancellation, and missing-output behavior; both commands run before CI selects suites. Check changed workflows with `actionlint` when available. Actual job skipping and check reporting still need verification on GitHub after publishing. +When adding a shared dependency or a new project, update the selector and its behavioral tests together. Unknown paths deliberately run both suites until their dependencies are known. Install tooling with `npm ci --prefix scripts --ignore-scripts --engine-strict`, then run `node --test scripts/select-ci-suites.test.mjs`, `npm test --prefix scripts`, and `node scripts/check-ci-workflows.ts` from the repository root. The workflow checker reads the result-check scripts and tests them with success, failure, cancellation, and missing outputs. These checks run before CI selects suites. Check changed workflows with `actionlint` when available. Actual job skipping and check reporting still need verification on GitHub after publishing. -Existing Node-dependent jobs in Hub, Starter, build-action, and Learning CI use a Node 24 and 26 matrix rather than a separate compatibility workflow. Each check keeps its existing name on Node 24 and adds a `(Node 26)` suffix on Node 26. A failure does not cancel the other version. npm installation enforces dependency engines, and `scripts/check-ci-workflows.rb` checks the supported ranges, matching lockfile metadata, and matrix wiring. These checks track the supported majors, not their exact minimum patch releases. +Node-dependent jobs in Hub, Starter, build-action, and Learning CI use a Node 24 and 26 matrix rather than a separate compatibility workflow. Each check adds a `(Node 26)` suffix on Node 26. A failure does not cancel the other version. npm installation enforces dependency engines, and `scripts/check-ci-workflows.ts` checks the supported ranges, matching lockfile metadata, and matrix wiring. These checks track the supported majors, not their exact minimum patch releases. -Ruby-only jobs, CI selection, and container builds retain their configured runtimes. The build-action suite tests its JavaScript and browser capture on both Node versions, but runs the composite-action smoke test only on Node 24: the composite action pins that runtime itself and uploads a single named artifact. Container and composite-action runtime defaults are unchanged. +CI selection and dependency-age tooling run on Node 24. The build-action suite tests its JavaScript and browser capture on both Node versions, but runs the composite-action smoke test only on Node 24: the composite action pins that runtime itself and uploads a single named artifact. ## Update dependencies -Hub and the shared build action wait seven days before resolving newly published dependencies. npm enforces the delay through each project's committed `.npmrc`. Hub's RubyGems source in `Gemfile` applies the delay to Bundler. Dependabot applies the same cooldown to npm, Bundler, and GitHub Actions updates across the repository. The starter manages its dependency resolution independently. +Hub, the shared build action, and repository tooling wait seven days before resolving newly published dependencies. npm enforces the delay through each project's committed `.npmrc`. Dependabot applies the same cooldown to npm and GitHub Actions updates across the repository. The starter manages its dependency resolution independently. -Inside the Hub devcontainer, `bin/check-dependency-age` verifies the Hub npm and Bundler lockfiles. The repository-level [Dependency age workflow](../.github/workflows/dependency-age.yml) runs the same checker across Hub, the shared build action, and external GitHub Action commits. These checks cover existing lockfiles that bypass the package manager's resolver delay and fail when publication metadata is unavailable. +From the repository root, `node scripts/check-dependency-age.ts` verifies every npm lockfile except Starter and installed dependencies, including Hub, the build action, Learning, and repository tooling. The unconditional [Dependency age workflow](../.github/workflows/dependency-age.yml) runs the same checker. It also requires external GitHub Actions in workflows and action definitions to use full commit SHAs and verifies their commit times. These checks cover existing lockfiles that bypass the resolver delay and fail when publication metadata is unavailable. npm metadata comes from its registry; Action commit metadata comes from GitHub's API. Set `GH_TOKEN` locally if needed for API rate limits; CI supplies its read-only token. -CI does not run `npm audit`: a newly published security fix could otherwise make the audit fail while the age check rejects the fixed version. Dependabot and Bundler Audit check dependencies for known vulnerabilities. Organization-managed CodeQL scans application code on GitHub. +CI does not run `npm audit`: a newly published security fix could otherwise make the audit fail while the age check rejects the fixed version. Dependabot checks dependencies for known vulnerabilities. Organization-managed CodeQL scans application code on GitHub. When a dependency is too new, select an older compatible version or wait until the seven-day window ends. See [Hub testing and operations](../hub/docs/testing-and-operations.md#update-dependencies) for Hub-specific tooling. diff --git a/hub/.annotaterb.yml b/hub/.annotaterb.yml deleted file mode 100644 index be06ed36..00000000 --- a/hub/.annotaterb.yml +++ /dev/null @@ -1,8 +0,0 @@ -classified_sort: true -exclude_fixtures: true -exclude_tests: true -position_in_class: before_doc -require: ./lib/annotate_rb_composite_foreign_key_names -show_check_constraints: true -show_foreign_keys: true -show_indexes: true diff --git a/hub/.devcontainer/Dockerfile b/hub/.devcontainer/Dockerfile deleted file mode 100644 index 37471daf..00000000 --- a/hub/.devcontainer/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -FROM ruby:4.0.5-bookworm - -ARG BUNDLER_VERSION=4.0.13 -ARG USER_GID=1000 -ARG USER_UID=1000 -ARG USERNAME=vscode - -RUN apt-get update -qq \ - && apt-get install --no-install-recommends -y build-essential chromium curl git libyaml-dev pkg-config sqlite3 sudo \ - && gem install bundler --version ${BUNDLER_VERSION} --no-document \ - && groupadd --gid ${USER_GID} ${USERNAME} \ - && useradd --uid ${USER_UID} --gid ${USER_GID} --create-home --shell /bin/bash ${USERNAME} \ - && echo "${USERNAME} ALL=(root) NOPASSWD:ALL" > /etc/sudoers.d/${USERNAME} \ - && chmod 0440 /etc/sudoers.d/${USERNAME} \ - && rm -rf /var/lib/apt/lists/* - -ENV BUNDLE_PATH=/usr/local/bundle - -RUN mkdir -p ${BUNDLE_PATH} && chown -R ${USERNAME}:${USERNAME} ${BUNDLE_PATH} - -USER ${USERNAME} diff --git a/hub/.devcontainer/cli.Dockerfile b/hub/.devcontainer/cli.Dockerfile deleted file mode 100644 index 5c961fbe..00000000 --- a/hub/.devcontainer/cli.Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM docker:29-cli AS docker -FROM node:24-bookworm-slim - -COPY --from=docker /usr/local/bin/docker /usr/local/bin/docker -COPY --from=docker /usr/local/libexec/docker /usr/local/libexec/docker - -ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ - -RUN npm install --global @devcontainers/cli@0.89.0 - -ENTRYPOINT ["devcontainer"] diff --git a/hub/.devcontainer/devcontainer-lock.json b/hub/.devcontainer/devcontainer-lock.json deleted file mode 100644 index 3f866d1d..00000000 --- a/hub/.devcontainer/devcontainer-lock.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "features": { - "ghcr.io/devcontainers/features/github-cli:1": { - "version": "1.1.2", - "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:7c409bf6316ffd85f04fd92fba85a744282639e603417dd4796409866bdb6805", - "integrity": "sha256:7c409bf6316ffd85f04fd92fba85a744282639e603417dd4796409866bdb6805" - }, - "ghcr.io/devcontainers/features/node:1": { - "version": "1.7.1", - "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", - "integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6" - } - } -} diff --git a/hub/.devcontainer/devcontainer.json b/hub/.devcontainer/devcontainer.json deleted file mode 100644 index 1c2fde68..00000000 --- a/hub/.devcontainer/devcontainer.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "Replica Hub", - "build": { - "dockerfile": "Dockerfile", - "context": ".." - }, - "workspaceMount": "source=${localWorkspaceFolder}/..,target=/workspaces,type=bind", - "workspaceFolder": "/workspaces/hub", - "features": { - "ghcr.io/devcontainers/features/github-cli:1": {}, - "ghcr.io/devcontainers/features/node:1": { - "version": "24", - "pnpmVersion": "none" - } - }, - "forwardPorts": [19500, 3036, 6006], - "mounts": [ - "source=replica-hub-bundle,target=/usr/local/bundle,type=volume", - "source=replica-hub-node-modules,target=${containerWorkspaceFolder}/node_modules,type=volume" - ], - "onCreateCommand": "sudo chown -R vscode:vscode /usr/local/bundle node_modules", - "portsAttributes": { - "19500": { "label": "Replica Hub" }, - "3036": { "label": "Vite HMR" }, - "6006": { "label": "Storybook" } - }, - "postCreateCommand": "bin/setup --skip-server", - "runArgs": [ - "--publish", - "127.0.0.1:19500:19500", - "--publish", - "127.0.0.1:3036:3036", - "--publish", - "127.0.0.1:6006:6006" - ], - "remoteUser": "vscode" -} diff --git a/hub/.dockerignore b/hub/.dockerignore deleted file mode 100644 index f13ad339..00000000 --- a/hub/.dockerignore +++ /dev/null @@ -1,44 +0,0 @@ -# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. - -# Ignore git directory. -/.git/ -/.gitignore - -# Ignore bundler config. -/.bundle - -# Ignore all environment files. -/.env* - -# Ignore all default key files. -/config/master.key -/config/credentials/*.key - -# Ignore all logfiles and tempfiles. -/log/* -/tmp/* -!/log/.keep -!/tmp/.keep - -# Ignore pidfiles, but keep the directory. -/tmp/pids/* -!/tmp/pids/.keep - -# Ignore storage (uploaded files in development and any SQLite databases). -/storage/* -!/storage/.keep -/tmp/storage/* -!/tmp/storage/.keep - -# Ignore assets. -/node_modules/ -/app/assets/builds/* -!/app/assets/builds/.keep -/public/assets - -# Ignore Kamal files. -/config/deploy*.yml -/.kamal - -# Ignore development files -/.devcontainer diff --git a/hub/.env.example b/hub/.env.example index 80e5ecb6..4024e6fa 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -1,22 +1,2 @@ -REPLICA_HUB_ORIGIN=http://replica.localhost:19500 -REPLICA_PREVIEW_ORIGIN_TEMPLATE=http://b{build_id}.preview.replica.localhost:19500 -REPLICA_SHARE_PREVIEW_ORIGIN_TEMPLATE=http://s{build_id}.preview.replica.localhost:19500 -# Hub uses the reviewed Action pin in config/build_action_releases by default. -# Override only when using a different reviewed Action repository. -# REPLICA_BUILD_ACTION=owner/repo/actions/build@REVIEWED_COMMIT -REPLICA_ARTIFACT_STORE=local -REPLICA_ARTIFACT_ROOT=storage/artifacts -# Azure deployments use these instead of REPLICA_ARTIFACT_ROOT. -# REPLICA_ARTIFACT_STORE=azure -# REPLICA_AZURE_STORAGE_ACCOUNT= -# REPLICA_AZURE_STORAGE_CONTAINER= -# REPLICA_AZURE_MANAGED_IDENTITY_CLIENT_ID= -# Learnings asks Copilot with this token: a fine-grained personal access token whose only -# permission is Copilot requests. Hub never stores it. See docs/copilot.md. -# REPLICA_COPILOT_TOKEN= -# Use a non-empty secret. A long random value is recommended for public deployments. -REPLICA_BOOTSTRAP_SECRET= -# Production deployments should set independent random values for these keys. -REPLICA_DATABASE_ENCRYPTION_PRIMARY_KEY= -REPLICA_DATABASE_ENCRYPTION_DETERMINISTIC_KEY= -REPLICA_DATABASE_ENCRYPTION_KEY_DERIVATION_SALT= +# Optional API port. The Vite development proxy expects the default. +PORT=3001 diff --git a/hub/.gitignore b/hub/.gitignore index ed6810b4..3874176e 100644 --- a/hub/.gitignore +++ b/hub/.gitignore @@ -1,26 +1,11 @@ -/.bundle /.env* !/.env.example /.local /.npm /.storybook/instances /.storybook/settings.json -/.kamal/secrets -/config/master.key -/config/credentials/*.key +/.storybook/template-fixture-build /coverage -/deploy/.env -/log/* -!/log/.keep +/dist /node_modules -/public/assets -/public/vite* -/storage/* -!/storage/.keep /storybook-static -/tmp/* -!/tmp/.keep -/tmp/pids/* -!/tmp/pids/.keep -/tmp/storage/* -!/tmp/storage/.keep diff --git a/hub/.prettierignore b/hub/.prettierignore index 551b7a15..0790484a 100644 --- a/hub/.prettierignore +++ b/hub/.prettierignore @@ -5,9 +5,8 @@ log .storybook/settings.json node_modules public -storage +dist storybook-static -tmp **/*.md app/javascript/features/install-replica-controls-bridge/bridge.js app/javascript/utils/prototype-controls/browser.js diff --git a/hub/.rubocop.yml b/hub/.rubocop.yml deleted file mode 100644 index f9d86d4a..00000000 --- a/hub/.rubocop.yml +++ /dev/null @@ -1,8 +0,0 @@ -# Omakase Ruby styling for Rails -inherit_gem: { rubocop-rails-omakase: rubocop.yml } - -# Overwrite or add rules to create your own house style -# -# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` -# Layout/SpaceInsideArrayLiteralBrackets: -# Enabled: false diff --git a/hub/.ruby-version b/hub/.ruby-version deleted file mode 100644 index ea897ac5..00000000 --- a/hub/.ruby-version +++ /dev/null @@ -1 +0,0 @@ -ruby-4.0.5 diff --git a/hub/.storybook/analyze-usage/index.test.ts b/hub/.storybook/analyze-usage/index.test.ts index 27cadb48..64e36674 100644 --- a/hub/.storybook/analyze-usage/index.test.ts +++ b/hub/.storybook/analyze-usage/index.test.ts @@ -23,7 +23,7 @@ async function writeSources(root: string, sources: Record) { "@pages/*": ["./app/javascript/pages/*"], }, }, - include: ["app/javascript/**/*", "app/views/**/*.tsx"], + include: ["app/javascript/**/*"], }), ...sources, } @@ -120,7 +120,7 @@ export const Example = { render: () => } "app/javascript/pages/__tests__/example.tsx": render, "app/javascript/application/storybook/example.tsx": render, "app/javascript/features/test-support/render-prototype-canvas-hub-panel/index.tsx": render, - "app/views/examples/show.test.tsx": render, + "app/javascript/application/example/index.test.tsx": render, "app/javascript/pages/contest/example.tsx": render, }) @@ -138,7 +138,7 @@ export const Example = { render: () => } } }) - it("follows JSX consumer edges through cycles to pages, canvas applications, and Rails views", async () => { + it("follows JSX consumer edges through cycles to pages and applications", async () => { const root = await mkdtemp(join(tmpdir(), "replica-analyze-usage-")) try { await writeSources(root, { @@ -161,7 +161,7 @@ export const Example = { render: () => } 'import Second from "@features/second"', "export default function Canvas() { return }", ].join("\n"), - "app/views/examples/show.tsx": [ + "app/javascript/application/example/index.tsx": [ 'import ExamplePage from "@pages/example"', "export default function ExampleView() { return }", ].join("\n"), @@ -176,13 +176,13 @@ export const Example = { render: () => } expect(result.components["app/javascript/components/widget/index.tsx"]).toEqual({ consumers: [{ path: "app/javascript/features/first/index.tsx", lines: [3] }], surfaces: [ + "app/javascript/application/example/index.tsx", "app/javascript/application/prototype-canvas/index.tsx", "app/javascript/pages/example/index.tsx", - "app/views/examples/show.tsx", ], }) expect(result.components["app/javascript/pages/example/index.tsx"]?.surfaces).toEqual([ - "app/views/examples/show.tsx", + "app/javascript/application/example/index.tsx", ]) } finally { await rm(root, { recursive: true, force: true }) diff --git a/hub/.storybook/analyze-usage/index.ts b/hub/.storybook/analyze-usage/index.ts index 4e576752..445cc83f 100644 --- a/hub/.storybook/analyze-usage/index.ts +++ b/hub/.storybook/analyze-usage/index.ts @@ -26,7 +26,7 @@ export function analyzeUsage(root: string): UsageIndex { } function isProduction(path: string) { return ( - /^(app\/javascript\/|app\/views\/)/.test(path) && + /^app\/javascript\//.test(path) && !/(^|\/)(storybook|test-support|test|tests|__tests__|fixtures|__fixtures__)(\/|$)|(^|\/)stories\.[^.]+$|\.(test|spec|stories)\.[^.]+$/.test( path, ) @@ -120,7 +120,7 @@ export function analyzeUsage(root: string): UsageIndex { const consumer = pending.pop()! if (visited.has(consumer)) continue visited.add(consumer) - if (/^app\/javascript\/(pages|application)\/|^app\/views\//.test(consumer)) { + if (/^app\/javascript\/(pages|application)\//.test(consumer)) { usage.surfaces.push(consumer) } pending.push(...(index.components[consumer]?.consumers.map((item) => item.path) ?? [])) diff --git a/hub/.storybook/main.test.ts b/hub/.storybook/main.test.ts index ef29eea4..c93ca290 100644 --- a/hub/.storybook/main.test.ts +++ b/hub/.storybook/main.test.ts @@ -59,10 +59,7 @@ describe("Storybook viteFinal()", () => { it("keeps standalone asset paths without removing unrelated Vite plugins", () => { const viteConfig: UserConfig = { base: "/vite/", - plugins: [ - [{ name: "vite-plugin-ruby" }, { name: "vite-plugin-ruby:assets-manifest" }], - { name: "vite:react-babel" }, - ], + plugins: [[{ name: "example:assets" }], { name: "vite:react-babel" }], } const finalConfig = config.viteFinal(viteConfig) const plugins = finalConfig.plugins as unknown as (Plugin | Plugin[])[] @@ -70,7 +67,6 @@ describe("Storybook viteFinal()", () => { expect(finalConfig.base).toBe("./") expect(pluginNames).toContain("vite:react-babel") - expect(pluginNames).not.toContain("vite-plugin-ruby") - expect(pluginNames).not.toContain("vite-plugin-ruby:assets-manifest") + expect(pluginNames).toContain("example:assets") }) }) diff --git a/hub/.storybook/main.ts b/hub/.storybook/main.ts index cf1af1da..0d2643fe 100644 --- a/hub/.storybook/main.ts +++ b/hub/.storybook/main.ts @@ -1,7 +1,7 @@ import { basename, extname, resolve } from "node:path" import type { StorybookConfig } from "@storybook/react-vite" -import { mergeConfig, type Plugin, type PluginOption } from "vite" +import { mergeConfig, type Plugin } from "vite" import { viteAliases } from "../vite-aliases.ts" import { usagePlugin } from "./usage-plugin/index.ts" @@ -38,61 +38,48 @@ const config = { sidebarOnboardingChecklist: false, }, viteFinal(config) { - // RubyPlugin overrides Storybook's relative asset base when Vite resolves plugins. - const plugins = config.plugins?.filter((plugin) => !containsRubyPlugin(plugin)) + return mergeConfig(config, { + base: "./", + plugins: [ + usagePlugin(resolve(import.meta.dirname, "..")), + controlsValidatorPlugin(), + { + name: "storybook:rolldown-entry-names", + enforce: "post", + config(config, { command }) { + const rolldownOptions = config.build?.rolldownOptions + const input = rolldownOptions?.input - return mergeConfig( - { ...config, plugins }, - { - base: "./", - plugins: [ - usagePlugin(resolve(import.meta.dirname, "..")), - controlsValidatorPlugin(), - { - name: "storybook:rolldown-entry-names", - enforce: "post", - config(config, { command }) { - const rolldownOptions = config.build?.rolldownOptions - const input = rolldownOptions?.input + if (command !== "build" || !rolldownOptions || !input) return - if (command !== "build" || !rolldownOptions || !input) return + // Rolldown cannot derive [name] from Storybook's path-based names. + let inputPaths: string[] - // Rolldown cannot derive [name] from Storybook's path-based names. - let inputPaths: string[] + if (typeof input === "string") { + inputPaths = [input] + } else if (Array.isArray(input)) { + inputPaths = input + } else { + inputPaths = Object.values(input) + } - if (typeof input === "string") { - inputPaths = [input] - } else if (Array.isArray(input)) { - inputPaths = input - } else { - inputPaths = Object.values(input) - } - - rolldownOptions.input = Object.fromEntries( - inputPaths.map((inputPath) => [basename(inputPath, extname(inputPath)), inputPath]), - ) - }, - } satisfies Plugin, - ], - resolve: { - dedupe: ["react", "react-dom"], - alias: { - ...viteAliases, - [controlsValidatorModule]: controlsValidatorPath, + rolldownOptions.input = Object.fromEntries( + inputPaths.map((inputPath) => [basename(inputPath, extname(inputPath)), inputPath]), + ) }, + } satisfies Plugin, + ], + resolve: { + dedupe: ["react", "react-dom"], + alias: { + ...viteAliases, + [controlsValidatorModule]: controlsValidatorPath, }, - optimizeDeps: { include: [controlsValidatorModule] }, - server: { allowedHosts: true }, }, - ) + optimizeDeps: { include: [controlsValidatorModule] }, + server: { allowedHosts: true }, + }) }, } satisfies StorybookConfig -function containsRubyPlugin(plugin: PluginOption): boolean { - if (Array.isArray(plugin)) return plugin.some(containsRubyPlugin) - return ( - !!plugin && typeof plugin === "object" && "name" in plugin && plugin.name === "vite-plugin-ruby" - ) -} - export default config diff --git a/hub/db/seeds/local/example/field-notes.css b/hub/.storybook/template-sources/field-notes.css similarity index 100% rename from hub/db/seeds/local/example/field-notes.css rename to hub/.storybook/template-sources/field-notes.css diff --git a/hub/db/seeds/local/example/field-notes.tsx b/hub/.storybook/template-sources/field-notes.tsx similarity index 100% rename from hub/db/seeds/local/example/field-notes.tsx rename to hub/.storybook/template-sources/field-notes.tsx diff --git a/hub/db/seeds/local/example/signal-room.css b/hub/.storybook/template-sources/signal-room.css similarity index 100% rename from hub/db/seeds/local/example/signal-room.css rename to hub/.storybook/template-sources/signal-room.css diff --git a/hub/db/seeds/local/example/signal-room.tsx b/hub/.storybook/template-sources/signal-room.tsx similarity index 100% rename from hub/db/seeds/local/example/signal-room.tsx rename to hub/.storybook/template-sources/signal-room.tsx diff --git a/hub/db/seeds/local/example/welcome.css b/hub/.storybook/template-sources/welcome.css similarity index 100% rename from hub/db/seeds/local/example/welcome.css rename to hub/.storybook/template-sources/welcome.css diff --git a/hub/db/seeds/local/example/welcome.tsx b/hub/.storybook/template-sources/welcome.tsx similarity index 100% rename from hub/db/seeds/local/example/welcome.tsx rename to hub/.storybook/template-sources/welcome.tsx diff --git a/hub/.storybook/usage-plugin/index.test.ts b/hub/.storybook/usage-plugin/index.test.ts index 9a1942a2..61916ae1 100644 --- a/hub/.storybook/usage-plugin/index.test.ts +++ b/hub/.storybook/usage-plugin/index.test.ts @@ -51,7 +51,7 @@ describe("usagePlugin()", () => { watcher.emit("add", resolve(root, "app/javascript/components/widget/index.tsx")) vi.advanceTimersByTime(60) watcher.emit("change", resolve(root, "app/javascript/components/widget/stories.tsx")) - watcher.emit("unlink", resolve(root, "app/views/example.tsx")) + watcher.emit("unlink", resolve(root, "app/javascript/application/example/index.tsx")) watcher.emit("change", resolve(root, "tsconfig.json")) vi.advanceTimersByTime(99) expect(analyzeUsage).toHaveBeenCalledTimes(1) @@ -122,7 +122,7 @@ describe("usagePlugin()", () => { await load() const events = [ ["add", "app/javascript/components/widget/index.ts"], - ["unlink", "app/views/example.tsx"], + ["unlink", "app/javascript/application/example/index.tsx"], ["change", "app/javascript/components/widget/stories.tsx"], ["change", "app/javascript/types.d.ts"], ["change", "tsconfig.json"], diff --git a/hub/.storybook/usage-plugin/index.ts b/hub/.storybook/usage-plugin/index.ts index ac110bee..561ce622 100644 --- a/hub/.storybook/usage-plugin/index.ts +++ b/hub/.storybook/usage-plugin/index.ts @@ -34,7 +34,7 @@ export function usagePlugin(root: string): Plugin { const isConfig = /(^|\/)tsconfig(?:\.[^/]+)?\.json$/.test(path) const isStory = /(^|\/)stories\.tsx$/.test(path) const isSource = - /^(app\/javascript\/|app\/views\/).+\.tsx?$/.test(path) && + /^app\/javascript\/.+\.tsx?$/.test(path) && !/(^|\/)(storybook|test|tests|__tests__|fixtures|__fixtures__|test-support)(\/|$)|\.(test|spec|stories)\.[^.]+$/.test( path, ) diff --git a/hub/AGENTS.md b/hub/AGENTS.md index a7c0a015..a5c7db23 100644 --- a/hub/AGENTS.md +++ b/hub/AGENTS.md @@ -1,21 +1,15 @@ # Working on Replica Hub -Replica Hub is a self-hosted Rails application where teams review prototype branches. GitHub owns source code and repository access. Hub stores Builds and review data. Prototype code runs on isolated preview origins. +Hub is being rebuilt in TypeScript. The app starts locally, but its product features are not built yet. Read the [root instructions](../AGENTS.md), [shared guides](../docs/README.md), and [Hub documentation](docs/README.md) before editing. -## Start here +- [Architecture](docs/architecture.md) explains the chosen tools, what works, and what has not been built. +- [Code architecture conventions](docs/backend-architecture.md) define repositories, integrations, utilities, domains, and allowed imports. +- [Coding conventions](docs/coding-conventions.md) explain frontend layers, formatting, and interface rules. +- [Development](docs/development.md) and [testing](docs/testing-and-operations.md) own local and CI commands. +- [Storybook](docs/storybook.md#ui-changes-and-pull-request-evidence) owns presentation coverage, cleanup, and PR screenshots. -Read the [root instructions](../AGENTS.md) and [shared guides](../docs/README.md), then the [Hub documentation index](docs/README.md) and guides for the work: +Run Node commands directly from `hub/`. Agree on the design before adding a database, sign-in, deployment, or product API routes. -- [Development](docs/development.md) for the devcontainer and local runtime. -- [Coding conventions](docs/coding-conventions.md) for Rails, TypeScript, and interface rules. -- [Shared documentation conventions](../docs/documentation-conventions.md) for discussions, product decisions, prose, and handoffs. -- [Testing and operations](docs/testing-and-operations.md) for review and verification. -- [Architecture](docs/architecture.md) and the affected product guide for system boundaries and current behavior. -- [Storybook](docs/storybook.md#ui-changes-and-pull-request-evidence) for UI changes, story coverage and cleanup, and PR screenshots. +Keep the top-level `HubThemeProvider` around Hub and Storybook content. It synchronizes the resolved light/dark mode to `` so portals inherit Brand and `--hub-*` tokens. Do not add a fixed light theme to an overlay. -## Working rules - -- Extend the existing architecture instead of creating a parallel source, Build, preview, or review flow. -- Use the devcontainer for Ruby and Node commands. -- Keep the top-level `HubThemeProvider` around Hub and Storybook content. It synchronizes the resolved light/dark mode to `` so portals into `document.body` inherit Brand and `--hub-*` tokens. React context alone does not carry CSS inheritance across a portal; do not add a fixed light theme to an overlay. -- Follow the [root Git workflow](../AGENTS.md#git-and-publishing) for checkout checks and publishing steps. +Preserve the shared canvas and Controls source paths under `app/javascript/`; the prototype extension and build action consume them independently of the Hub API. diff --git a/hub/Dockerfile b/hub/Dockerfile deleted file mode 100644 index aa9bbe3c..00000000 --- a/hub/Dockerfile +++ /dev/null @@ -1,111 +0,0 @@ -# syntax=docker/dockerfile:1 -# check=error=true - -# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: -# docker build --build-context learning=../packages/learning -t replica_hub . -# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name replica_hub replica_hub - -# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html - -# Keep RUBY_VERSION, .ruby-version, and the matching image index digest in sync. -ARG RUBY_VERSION=4.0.5 -FROM docker.io/library/ruby:$RUBY_VERSION-slim@sha256:42ad5227a4b2758f32563b34b5967e1055e75c80243eef04e6d2572887d1dec4 AS base - -ARG BUNDLER_VERSION=4.0.13 - -# Rails app lives here -WORKDIR /rails - -# Install base packages -RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y curl libjemalloc2 sqlite3 && \ - gem install bundler --version ${BUNDLER_VERSION} --no-document && \ - ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ - rm -rf /var/lib/apt/lists /var/cache/apt/archives - -# Set production environment variables and enable jemalloc for reduced memory usage and latency. -ENV RAILS_ENV="production" \ - BUNDLE_DEPLOYMENT="1" \ - BUNDLE_PATH="/usr/local/bundle" \ - BUNDLE_WITHOUT="development:test" \ - LD_PRELOAD="/usr/local/lib/libjemalloc.so" - -FROM docker.io/library/node:24-bookworm-slim@sha256:a5f4366e834695d5f363b920c104953a535d3a9864d1bdf74566a0ebe01daee5 AS node - -# Throw-away build stage to reduce size of final image -FROM base AS build -WORKDIR /rails/hub - -# Install packages needed to build gems -RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \ - rm -rf /var/lib/apt/lists /var/cache/apt/archives - -# Install application gems -COPY vendor/* ./vendor/ -COPY Gemfile Gemfile.lock ./ - -RUN bundle install && \ - rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ - # -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 - bundle exec bootsnap precompile -j 1 --gemfile - -# Copy application code -COPY . . - -# replica-learning asks Copilot to suggest learnings. Hub runs it with Node.js at runtime. -COPY --from=learning package.json package-lock.json /opt/replica/learning/ -COPY --from=learning bin /opt/replica/learning/bin/ -COPY --from=learning src /opt/replica/learning/src/ - -COPY --from=node /usr/local/bin/node /usr/local/bin/node -COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules -RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ - ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \ - npm ci && \ - npm ci --prefix /opt/replica/learning --omit=dev --ignore-scripts - -# Precompile bootsnap code for faster boot times. -# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 -RUN bundle exec bootsnap precompile -j 1 app/ lib/ - -# Precompiling assets for production without requiring secret RAILS_MASTER_KEY -RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile && \ - rm -f tmp/local_secret.txt && \ - rm -rf node_modules - -# Final stage for app image -FROM base - -ARG REPLICA_BUILD_ACTION -ARG REPLICA_IMAGE_REVISION -ARG REPLICA_IMAGE_VERSION - -ENV REPLICA_BUILD_ACTION="${REPLICA_BUILD_ACTION}" - -LABEL org.opencontainers.image.source="https://github.com/github/replica" \ - org.opencontainers.image.title="Replica Hub" \ - org.opencontainers.image.description="Self-hosted review and collaboration for interactive prototypes" \ - org.opencontainers.image.revision="${REPLICA_IMAGE_REVISION}" \ - org.opencontainers.image.version="${REPLICA_IMAGE_VERSION}" - -# Node.js and replica-learning stay owned by root, so the app cannot change the code it runs. -COPY --from=build /opt/replica/learning /opt/replica/learning -ENV REPLICA_LEARNING_CLI="/opt/replica/learning/bin/replica-learning.mjs" - -# Run and own only the runtime files as a non-root user for security -RUN groupadd --system --gid 1000 rails && \ - useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash -USER 1000:1000 - -# Copy built artifacts: gems, application -COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" -COPY --chown=rails:rails --from=build /rails/hub /rails -COPY --from=node /usr/local/bin/node /usr/local/bin/node - -# Entrypoint prepares the database. -ENTRYPOINT ["/rails/bin/docker-entrypoint"] - -# Start server via Thruster by default, this can be overwritten at runtime -EXPOSE 80 -CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/hub/Gemfile b/hub/Gemfile deleted file mode 100644 index 18d3fce9..00000000 --- a/hub/Gemfile +++ /dev/null @@ -1,44 +0,0 @@ -source "https://rubygems.org", cooldown: 7 -git_source(:github) { |repo| "https://github.com/#{repo}.git" } - -gem "active_job-performs", "1.0.2" -gem "active_record-associated_object", "1.0.0" -gem "bootsnap", require: false -gem "json", "~> 3.1" -gem "jwt", "~> 3.3" -gem "oaken", "1.0.0" -gem "opentelemetry-exporter-otlp", require: false -gem "opentelemetry-instrumentation-action_pack", require: false -gem "opentelemetry-instrumentation-active_job", require: false -gem "opentelemetry-instrumentation-net_http", require: false -gem "opentelemetry-sdk", require: false -gem "propshaft" -gem "puma", ">= 5.0" -gem "rails", "~> 8.1.4" -gem "rexml", "~> 3.4" -gem "rubyzip", "~> 3.7" -gem "solid_cable" -gem "solid_cache" -gem "solid_queue" -gem "sqlite3", ">= 2.1" -gem "superglue", "1.1.1" -gem "thruster", require: false -gem "tzinfo-data", platforms: %i[windows jruby] -gem "vite_rails", "3.11.1" - -group :development, :test do - gem "bundler-audit", require: false - gem "debug", platforms: %i[mri windows], require: "debug/prelude" - gem "rubocop-rails-omakase", require: false -end - -group :test do - gem "webmock", "~> 3.34" -end - -group :development do - gem "annotaterb", "4.25.0", require: false - gem "chusaku", "1.5.2", require: false - gem "foreman", "0.90.0", require: false - gem "web-console" -end diff --git a/hub/Gemfile.lock b/hub/Gemfile.lock deleted file mode 100644 index a2683ffd..00000000 --- a/hub/Gemfile.lock +++ /dev/null @@ -1,627 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - action_text-trix (2.1.19) - railties - actioncable (8.1.4) - actionpack (= 8.1.4) - activesupport (= 8.1.4) - nio4r (~> 2.0) - websocket-driver (>= 0.6.1) - zeitwerk (~> 2.6) - actionmailbox (8.1.4) - actionpack (= 8.1.4) - activejob (= 8.1.4) - activerecord (= 8.1.4) - activestorage (= 8.1.4) - activesupport (= 8.1.4) - mail (>= 2.8.0) - actionmailer (8.1.4) - actionpack (= 8.1.4) - actionview (= 8.1.4) - activejob (= 8.1.4) - activesupport (= 8.1.4) - mail (>= 2.8.0) - rails-dom-testing (~> 2.2) - actionpack (8.1.4) - actionview (= 8.1.4) - activesupport (= 8.1.4) - nokogiri (>= 1.8.5) - rack (>= 2.2.4) - rack-session (>= 1.0.1) - rack-test (>= 0.6.3) - rails-dom-testing (~> 2.2) - rails-html-sanitizer (~> 1.6) - useragent (~> 0.16) - actiontext (8.1.4) - action_text-trix (~> 2.1.15) - actionpack (= 8.1.4) - activerecord (= 8.1.4) - activestorage (= 8.1.4) - activesupport (= 8.1.4) - globalid (>= 0.6.0) - nokogiri (>= 1.8.5) - actionview (8.1.4) - activesupport (= 8.1.4) - builder (~> 3.1) - erubi (~> 1.11) - rails-dom-testing (~> 2.2) - rails-html-sanitizer (~> 1.6) - active_job-performs (1.0.2) - activejob (>= 6.1) - active_record-associated_object (1.0.0) - activerecord (>= 6.1) - activejob (8.1.4) - activesupport (= 8.1.4) - globalid (>= 0.3.6) - activemodel (8.1.4) - activesupport (= 8.1.4) - activerecord (8.1.4) - activemodel (= 8.1.4) - activesupport (= 8.1.4) - timeout (>= 0.4.0) - activestorage (8.1.4) - actionpack (= 8.1.4) - activejob (= 8.1.4) - activerecord (= 8.1.4) - activesupport (= 8.1.4) - marcel (~> 1.0) - activesupport (8.1.4) - base64 - bigdecimal - concurrent-ruby (~> 1.0, >= 1.3.1) - connection_pool (>= 2.2.5) - drb - i18n (>= 1.6, < 2) - json - logger (>= 1.4.2) - minitest (>= 5.1) - securerandom (>= 0.3) - tzinfo (~> 2.0, >= 2.0.5) - uri (>= 0.13.1) - addressable (2.9.0) - public_suffix (>= 2.0.2, < 8.0) - annotaterb (4.25.0) - activerecord (>= 6.0.0) - activesupport (>= 6.0.0) - ast (2.4.3) - base64 (0.3.0) - bigdecimal (4.1.3) - bindex (0.8.1) - bootsnap (1.26.0) - msgpack (~> 1.5) - builder (3.3.0) - bundler-audit (0.9.3) - bundler (>= 1.2.0) - thor (~> 1.0) - chusaku (1.5.2) - railties (>= 3.0) - concurrent-ruby (1.3.8) - connection_pool (3.0.2) - crack (1.0.1) - bigdecimal - rexml - crass (1.0.7) - date (3.5.1) - debug (1.11.1) - irb (~> 1.10) - reline (>= 0.3.8) - drb (2.2.3) - dry-cli (1.4.1) - erb (6.0.7) - erubi (1.13.1) - et-orbi (1.4.2) - tzinfo - foreman (0.90.0) - thor (~> 1.4) - form_props (0.2.2) - actionview (>= 7.0, < 9.0) - activesupport (>= 7.0, < 9.0) - props_template (>= 0.30.0) - fugit (1.13.0) - et-orbi (~> 1.4) - raabro (~> 1.4) - globalid (1.4.0) - activesupport (>= 6.1) - google-protobuf (4.36.2) - bigdecimal - rake (~> 13.3) - google-protobuf (4.36.2-aarch64-linux-gnu) - bigdecimal - rake (~> 13.3) - google-protobuf (4.36.2-aarch64-linux-musl) - bigdecimal - rake (~> 13.3) - google-protobuf (4.36.2-arm64-darwin) - bigdecimal - rake (~> 13.3) - google-protobuf (4.36.2-x86_64-linux-gnu) - bigdecimal - rake (~> 13.3) - google-protobuf (4.36.2-x86_64-linux-musl) - bigdecimal - rake (~> 13.3) - googleapis-common-protos-types (1.23.0) - google-protobuf (~> 4.26) - hashdiff (1.2.1) - i18n (1.15.2) - concurrent-ruby (~> 1.0) - io-console (0.9.2) - irb (1.18.0) - pp (>= 0.6.0) - prism (>= 1.3.0) - rdoc (>= 4.0.0) - reline (>= 0.4.2) - json (3.1.0) - jwt (3.3.0) - base64 - language_server-protocol (3.17.0.6) - lint_roller (1.1.0) - logger (1.7.0) - loofah (2.25.2) - crass (~> 1.0.2) - nokogiri (>= 1.12.0) - mail (2.9.1) - logger - mini_mime (>= 0.1.1) - net-imap - net-pop - net-smtp - marcel (1.2.1) - mini_mime (1.1.5) - minitest (6.0.6) - drb (~> 2.0) - prism (~> 1.5) - msgpack (1.8.5) - mutex_m (0.3.0) - net-imap (0.6.7) - date - net-protocol - net-pop (0.1.2) - net-protocol - net-protocol (0.3.0) - timeout - net-smtp (0.5.1) - net-protocol - nio4r (2.7.5) - nokogiri (1.19.4-aarch64-linux-gnu) - racc (~> 1.4) - nokogiri (1.19.4-aarch64-linux-musl) - racc (~> 1.4) - nokogiri (1.19.4-arm-linux-gnu) - racc (~> 1.4) - nokogiri (1.19.4-arm-linux-musl) - racc (~> 1.4) - nokogiri (1.19.4-arm64-darwin) - racc (~> 1.4) - nokogiri (1.19.4-x86_64-linux-gnu) - racc (~> 1.4) - nokogiri (1.19.4-x86_64-linux-musl) - racc (~> 1.4) - oaken (1.0.0) - oj (3.17.6) - bigdecimal (>= 3.0) - ostruct (>= 0.2) - opentelemetry-api (1.11.1) - logger - opentelemetry-common (0.25.2) - opentelemetry-api (~> 1.0) - opentelemetry-exporter-otlp (0.37.0) - google-protobuf (>= 3.18) - googleapis-common-protos-types (~> 1.3) - opentelemetry-api (~> 1.1) - opentelemetry-common (~> 0.20) - opentelemetry-exporter-otlp-common (~> 0.2) - opentelemetry-sdk (~> 1.10) - opentelemetry-semantic_conventions - opentelemetry-exporter-otlp-common (0.3.0) - google-protobuf (>= 3.18) - googleapis-common-protos-types (~> 1.3) - opentelemetry-api (~> 1.1) - opentelemetry-common (~> 0.20) - opentelemetry-instrumentation-action_pack (0.18.1) - opentelemetry-instrumentation-rack (~> 0.29) - opentelemetry-instrumentation-active_job (0.13.0) - opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-base (0.27.0) - opentelemetry-api (~> 1.7) - opentelemetry-common (~> 0.21) - opentelemetry-logs-api (~> 0.6) - opentelemetry-metrics-api (~> 0.9) - opentelemetry-registry (~> 0.1) - opentelemetry-instrumentation-net_http (0.29.2) - opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-rack (0.31.2) - opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-logs-api (0.6.0) - opentelemetry-api (~> 1.0) - opentelemetry-metrics-api (0.9.0) - opentelemetry-api (~> 1.0) - opentelemetry-registry (0.6.3) - opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.13.1) - logger - opentelemetry-api (~> 1.1) - opentelemetry-common (~> 0.20) - opentelemetry-registry (~> 0.2) - opentelemetry-semantic_conventions - opentelemetry-semantic_conventions (1.44.0) - opentelemetry-api (~> 1.0) - ostruct (0.6.3) - parallel (2.2.0) - parser (3.3.12.0) - ast (~> 2.4.1) - racc - pp (0.6.4) - prettyprint - prettyprint (0.2.0) - prism (1.9.0) - props_template (0.38.0) - actionview (>= 7.0, < 9.0) - activesupport (>= 7.0, < 9.0) - oj (~> 3.9) - propshaft (1.3.2) - actionpack (>= 7.0.0) - activesupport (>= 7.0.0) - rack - public_suffix (7.0.5) - puma (8.0.2) - nio4r (~> 2.0) - raabro (1.5.0) - racc (1.8.1) - rack (3.2.7) - rack-proxy (2.0.0) - rack (>= 2.0, < 4) - rack-session (2.1.2) - base64 (>= 0.1.0) - rack (>= 3.0.0) - rack-test (2.2.0) - rack (>= 1.3) - rackup (2.3.1) - rack (>= 3) - rails (8.1.4) - actioncable (= 8.1.4) - actionmailbox (= 8.1.4) - actionmailer (= 8.1.4) - actionpack (= 8.1.4) - actiontext (= 8.1.4) - actionview (= 8.1.4) - activejob (= 8.1.4) - activemodel (= 8.1.4) - activerecord (= 8.1.4) - activestorage (= 8.1.4) - activesupport (= 8.1.4) - bundler (>= 1.15.0) - railties (= 8.1.4) - rails-dom-testing (2.3.0) - activesupport (>= 5.0.0) - minitest - nokogiri (>= 1.6) - rails-html-sanitizer (1.7.1) - loofah (~> 2.25, >= 2.25.2) - nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (8.1.4) - actionpack (= 8.1.4) - activesupport (= 8.1.4) - irb (~> 1.13) - rackup (>= 1.0.0) - rake (>= 12.2) - thor (~> 1.0, >= 1.2.2) - tsort (>= 0.2) - zeitwerk (~> 2.6) - rainbow (3.1.1) - rake (13.4.2) - rbs (4.2.0) - logger - prism (>= 1.6.0) - tsort - rdoc (8.0.0) - erb - prism (>= 1.6.0) - rbs (>= 4.0.0) - tsort - regexp_parser (2.12.0) - reline (0.7.0) - io-console (~> 0.5) - rexml (3.4.4) - rubocop (1.91.0) - json (>= 2.3) - language_server-protocol (~> 3.17.0.2) - lint_roller (~> 1.1.0) - parallel (>= 1.10) - parser (>= 3.3.0.2) - rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.49.0, < 2.0) - ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.50.0) - parser (>= 3.3.7.2) - prism (~> 1.7) - rubocop-performance (1.27.0) - lint_roller (~> 1.1) - rubocop (>= 1.89.0, < 2.0) - rubocop-ast (>= 1.47.1, < 2.0) - rubocop-rails (2.37.0) - activesupport (>= 4.2.0) - lint_roller (~> 1.1) - rack (>= 1.1) - rubocop (>= 1.89.0, < 2.0) - rubocop-ast (>= 1.44.0, < 2.0) - rubocop-rails-omakase (1.1.0) - rubocop (>= 1.72) - rubocop-performance (>= 1.24) - rubocop-rails (>= 2.30) - ruby-progressbar (1.13.0) - rubyzip (3.7.0) - securerandom (0.4.1) - solid_cable (4.1.0) - actioncable (>= 7.2) - activejob (>= 7.2) - activerecord (>= 7.2) - railties (>= 7.2) - solid_cache (1.0.10) - activejob (>= 7.2) - activerecord (>= 7.2) - railties (>= 7.2) - solid_queue (1.7.0) - activejob (>= 7.1) - activerecord (>= 7.1) - concurrent-ruby (>= 1.3.1) - fugit (~> 1.11) - railties (>= 7.1) - thor (>= 1.3.1) - sqlite3 (2.10.0-aarch64-linux-gnu) - sqlite3 (2.10.0-aarch64-linux-musl) - sqlite3 (2.10.0-arm-linux-gnu) - sqlite3 (2.10.0-arm-linux-musl) - sqlite3 (2.10.0-arm64-darwin) - sqlite3 (2.10.0-x86_64-linux-gnu) - sqlite3 (2.10.0-x86_64-linux-musl) - superglue (1.1.1) - actionpack (>= 7.0, < 9.0) - form_props (~> 0.2) - props_template (~> 0.37) - thor (1.5.0) - thruster (0.1.27) - thruster (0.1.27-aarch64-linux) - thruster (0.1.27-arm64-darwin) - thruster (0.1.27-x86_64-linux) - timeout (0.6.1) - tsort (0.2.0) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - unicode-display_width (3.2.0) - unicode-emoji (~> 4.1) - unicode-emoji (4.2.0) - uri (1.1.1) - useragent (0.16.11) - vite_rails (3.11.1) - railties (>= 5.1, < 9) - vite_ruby (~> 3.0, >= 3.2.2) - vite_ruby (3.10.5) - dry-cli (>= 0.7, < 2) - logger (~> 1.6) - mutex_m - rack-proxy (>= 0.6.1) - zeitwerk (~> 2.2) - web-console (4.3.0) - actionview (>= 8.0.0) - bindex (>= 0.4.0) - railties (>= 8.0.0) - webmock (3.34.0) - addressable (>= 2.8.0) - crack (>= 0.3.2) - hashdiff (>= 0.4.0, < 2.0.0) - websocket-driver (0.8.2) - base64 - websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.5) - zeitwerk (2.8.3) - -PLATFORMS - aarch64-linux - aarch64-linux-gnu - aarch64-linux-musl - arm-linux-gnu - arm-linux-musl - arm64-darwin-23 - x86_64-linux - x86_64-linux-gnu - x86_64-linux-musl - -DEPENDENCIES - active_job-performs (= 1.0.2) - active_record-associated_object (= 1.0.0) - annotaterb (= 4.25.0) - bootsnap - bundler-audit - chusaku (= 1.5.2) - debug - foreman (= 0.90.0) - json (~> 3.1) - jwt (~> 3.3) - oaken (= 1.0.0) - opentelemetry-exporter-otlp - opentelemetry-instrumentation-action_pack - opentelemetry-instrumentation-active_job - opentelemetry-instrumentation-net_http - opentelemetry-sdk - propshaft - puma (>= 5.0) - rails (~> 8.1.4) - rexml (~> 3.4) - rubocop-rails-omakase - rubyzip (~> 3.7) - solid_cable - solid_cache - solid_queue - sqlite3 (>= 2.1) - superglue (= 1.1.1) - thruster - tzinfo-data - vite_rails (= 3.11.1) - web-console - webmock (~> 3.34) - -CHECKSUMS - action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 - actioncable (8.1.4) sha256=cfcd72f8c861e01ccdc96d772ad8c5ad6ed0ea7c571aef81a794b6027eaa7f11 - actionmailbox (8.1.4) sha256=9c4525e577ebb671ce558f3ea78b8a634f64e56a0e6e8d105b28b873793a06eb - actionmailer (8.1.4) sha256=f82d95d6762ae46ea38f221d3363639266af4f8e56d50e2bd367558674baf093 - actionpack (8.1.4) sha256=245493cc51be195a7645e6fc184880fa7e5b32ff1a08e053ee31f58c76e30ea4 - actiontext (8.1.4) sha256=33ee99bea7da6af911fc7d98b17013a8e1ec62aa01b20b20e9a5366f40dbf644 - actionview (8.1.4) sha256=eff1efd07c4d969abad288322b423bcb67fdbab5e324c91ca37352b80f226076 - active_job-performs (1.0.2) sha256=3843cb62dbb4e96f46df0f639b9d39454d058bf22b33c6737014e92a89dbfe1d - active_record-associated_object (1.0.0) sha256=d350fb0c14622c1e3138016233e181bbc72112a24a13f9b0e43bde7a7ee14d5d - activejob (8.1.4) sha256=4ed5b2ed8d4cc5c8f5f09becfef8346f202a4884eee0c8c73cae5ac378824099 - activemodel (8.1.4) sha256=9a0906c127bd118aa5f06157db12b8f750da83dccee72db2f658547a0ae7a0e8 - activerecord (8.1.4) sha256=3b4957ea7f246c9231bd7faa909312eac03a9101aaf87977b6c52733ff00b04f - activestorage (8.1.4) sha256=f4956f3ec9ee63e5e0b332b57c877cb627f82b56f4cc902279310d2aa285c7a8 - activesupport (8.1.4) sha256=891dd3fe43c4fc8391934b237b1189bb1f661c932a84a840043db9d46a4e80df - addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af - annotaterb (4.25.0) sha256=1c7a4c43d6afe3eb460731289ea8c831e9d864ce2f57897b5e1c749c1dd4ed89 - ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 - base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b - bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66 - bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e - bootsnap (1.26.0) sha256=ca96237015e6cd74a02963d5821cf00ac5ea134653b323e8cd6d702a7718bf1b - builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f - bundler (4.0.13) sha256=19f08be7f27022cf0b89f27da0b044ae075e8270a9ef44ad248a932614e1ca3b - bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 - chusaku (1.5.2) sha256=e9853addb285032164ee7837defa2085ab1c21191a2282fa895b8d14600cf671 - concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 - connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a - crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e - crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 - date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 - debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 - drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 - dry-cli (1.4.1) sha256=b8015bb76c708aa8705a36faf694973e75eeeffca39b89c8e172dc6f66a7d874 - erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 - erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 - et-orbi (1.4.2) sha256=bb555dae668419cb24caa2a293a170e58be6d4df1e017c51f5030bdc133cd20c - foreman (0.90.0) sha256=ff675e2d47b607ac58714a6d4ac3e1ee8f06f41d8db084531c31961e2c3f117c - form_props (0.2.2) sha256=a46f8f7fd216b26ed6d6416d22334344a531188aee0e854aebc81761ee280cad - fugit (1.13.0) sha256=a4f093fce740da52f216740a5041e2a594ea763cdb89e8b2754ca4399634ab18 - globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 - google-protobuf (4.36.2) sha256=2f8daff50ae14aad36ab0ccdcf284065fa71d726c444efe114e6db82ffe62494 - google-protobuf (4.36.2-aarch64-linux-gnu) sha256=c3279ec6eaa59f6ed6276eda2a33cffb4306471183ab79a332e698b8564f5169 - google-protobuf (4.36.2-aarch64-linux-musl) sha256=0772c9dde1e6dce9a6b44d1d35c28c29adee99d9bd6aff5e21840daeb22161ce - google-protobuf (4.36.2-arm64-darwin) sha256=f70d810e6bbab9d8ad9c1d5fd685a21de4c10ad3ad32efb9455e17e5b1286917 - google-protobuf (4.36.2-x86_64-linux-gnu) sha256=d25a820873e423f1d209ae8c672a4a28b019e2429debd0901d26670a0b4e14db - google-protobuf (4.36.2-x86_64-linux-musl) sha256=0fa17686c896da96a60a705e56ddc7d4ff56413f52787c655bd4c9fb33ffe14f - googleapis-common-protos-types (1.23.0) sha256=992e740a523794d9fc5f29a504465d8fc737aaa16c930fe7228e3346860faf0a - hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 - i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 - io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 - irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 - json (3.1.0) sha256=3ce7ccf8fde58960177a3a756c31f292a4c1e1dedee884547096a5b0604e3bd8 - jwt (3.3.0) sha256=44cc34fbc341c148233cf66641522b4bce0dfb1ce55e8073f05c56f44f4ee546 - language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 - lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 - logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 - loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 - mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 - marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f - mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef - minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 - msgpack (1.8.5) sha256=97419eaa7b35dfe3e462c0e6c1a3dfdcbfc8756605c09e46e4c6daef127517ba - mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 - net-imap (0.6.7) sha256=b5c9573be975d856de252ee851871724da66aa2d449b2482d5690bd12bc23660 - net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 - net-protocol (0.3.0) sha256=ba310c3d4f1cad46bb1ab20336b06669b1ff8f7c568d9cb9342b32a718547472 - net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 - nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 - nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f - nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af - nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca - nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb - nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527 - nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a - nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b - oaken (1.0.0) sha256=e89249bc4f6cd3ab9b5e3abdc06c377fa772e6bcb3005cc09ff044bb8d3f1dc2 - oj (3.17.6) sha256=f511257e01a12030c3ceb8023cd2e96fca924ea9c741f57ea366816f1fd8e68c - opentelemetry-api (1.11.1) sha256=d07f9987ea6240b3fd5ed5dd7450f93c58e403e0553e804069c23ae18ba61ff7 - opentelemetry-common (0.25.2) sha256=c035d44987789356fe8feeeb870708b44fe7564ffc032dec7200c39da92a911d - opentelemetry-exporter-otlp (0.37.0) sha256=edc8bda657dce5f4d527ac735f75d44043bb5924de6df0e3f01260108aa76136 - opentelemetry-exporter-otlp-common (0.3.0) sha256=855d10d788a7272dfe5b14efa229507619abbef29085ef4a8acfd2c0d3af6171 - opentelemetry-instrumentation-action_pack (0.18.1) sha256=0b827d703baa2951458e36f2bdfa4a2cdb17a0130cfb0e1b6fe5d67fadc85c2d - opentelemetry-instrumentation-active_job (0.13.0) sha256=42da156924bbabdaca6268be1e46963a04829bd0b2b82fc6e368597a3525cde2 - opentelemetry-instrumentation-base (0.27.0) sha256=e55edb82cd0b018e82e746be61fa36cb05b7b68b2fa37f0e4a96b923ad3d7735 - opentelemetry-instrumentation-net_http (0.29.2) sha256=4256105f058404eb0bd43e4a987e48f9e48fcade06b40a388092c380b2e946eb - opentelemetry-instrumentation-rack (0.31.2) sha256=4a183066110455711a0c77754e3f46652f646221d7704d968a48d155b47f8309 - opentelemetry-logs-api (0.6.0) sha256=b889ac61d90326bffadb83bf7278d246d1086e2349c9052aeb072ed375ce73d0 - opentelemetry-metrics-api (0.9.0) sha256=e11a12055113e11421313f71c758e5e10e4662e9e5c3b91786218a692e81206d - opentelemetry-registry (0.6.3) sha256=568fa8eaea94394f29f4fce3f0339ab10c3bf6d6c8beed6b3cfee6fd37fcc608 - opentelemetry-sdk (1.13.1) sha256=91c9cc315ac7ef30243549eeff8b27cd0d39bd02bdf9c9ae481ab96a1cec6754 - opentelemetry-semantic_conventions (1.44.0) sha256=b09b8793422f13b01c08f7a6f29b5f511f47f37bcdb57c0513465a0464168513 - ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 - parallel (2.2.0) sha256=e1059c5fd7b649558a0aec38a769f06a42942bdb40503d005a59c352fe011cd8 - parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 - pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 - prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 - prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 - props_template (0.38.0) sha256=0c01b763e96bf282fa05cf15d304efe0a1842175861a16a29e699d946ed3fdbf - propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e - public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 - puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb - raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a - racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f - rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 - rack-proxy (2.0.0) sha256=4f1d435d82afe93bc916d1226df8be307c1b808551f0ecdb56e0b668fd5756e6 - rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 - rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 - rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 - rails (8.1.4) sha256=5f3ecdf9a2a3b79b2e31b8fee3b64a00f7284d76e5631f87d0e58e665911a33b - rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d - rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2 - railties (8.1.4) sha256=b5e7a8e8984e33114d68af62227971960aae1e6c02bd7c9d826ffad97fff583c - rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a - rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 - rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 - rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 - regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb - reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d - rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 - rubocop (1.91.0) sha256=9c82b7bf391c5d7e3798c5b9996e22a1fe3bd7468e351dfdeb96140c058296d0 - rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db - rubocop-performance (1.27.0) sha256=eeeb1374d062a368ee1c787b70eb0b0cc4b184cb1f8565f424760946146d61ce - rubocop-rails (2.37.0) sha256=6e1645add5060e0328f8ddda0d820f55697c591394398bf14bb9dccb62f14b7e - rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d - ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 - rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121 - securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - solid_cable (4.1.0) sha256=828e3d509207b82b4f05f4e9c85f696994710679b52d511aa7f97ebd212d3226 - solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 - solid_queue (1.7.0) sha256=6566b70b801d1c317c81bba7bcdd5677c019afac584a30374b4164002ca356d3 - sqlite3 (2.10.0-aarch64-linux-gnu) sha256=dafeea94182af2eb53a10797be359fa6ec4270532bd45255d1c2beb69f2eb743 - sqlite3 (2.10.0-aarch64-linux-musl) sha256=ec4ffedded0bd8579629e14d0647cfee9092c0af0009c05d6404d90857b1cb8b - sqlite3 (2.10.0-arm-linux-gnu) sha256=bcf2e165f27fd28f46ba34eeba3503d557ea7f76f9dd40434ce719f2ba0d5cda - sqlite3 (2.10.0-arm-linux-musl) sha256=338510e64876c7d02d91733eeaee384806c58b3835d8a688d1bfdbf7e468c497 - sqlite3 (2.10.0-arm64-darwin) sha256=f619f33adfd01a1827e8764a3da92b289c1a99d93707d021a242dccba6909c4e - sqlite3 (2.10.0-x86_64-linux-gnu) sha256=295f48b240ba663efa2e3b6f38cbc0f19f5c8a2823a71fda3bf15531be5dd0e8 - sqlite3 (2.10.0-x86_64-linux-musl) sha256=876dd86a15c7828075ff7612e3d0ab0eb2eba0d8485b13fd4000e8a335f86bc5 - superglue (1.1.1) sha256=b600a4c5d2855474b504d7e8b1fb5b8fdd9da528a0470672cae1daa4532ceb31 - thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 - thruster (0.1.27) sha256=5fd3855192bb4e8d13db69ae5f70d39f2dfd88fe0394fa372104996edf0bb599 - thruster (0.1.27-aarch64-linux) sha256=c50789f2dcd4dfa072c4719f99c38dea5d47ebb60ad0cc1fd1996b2266ecfd22 - thruster (0.1.27-arm64-darwin) sha256=ece0ff0f2ec195008030fbca97067e5e867f4e1f66501fa8b8a3ec3f829bf2bf - thruster (0.1.27-x86_64-linux) sha256=7e0e6381011401810b047ff25506e0e38755b2430499ff5586f1a110086981c6 - timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb - tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f - tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b - unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 - unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f - uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 - useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 - vite_rails (3.11.1) sha256=61fa4a7c9248fc28f22a05e0760810bf79f645b776c721d888a815c5d21dd338 - vite_ruby (3.10.5) sha256=e9ee92be1cb31c0b6360b02182cfe09d3ac2b7fc278db7870e3f32ae64dde49e - web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 - webmock (3.34.0) sha256=72eed29dc8c00148d592acaedaaa6b869e2e397de57dfc8602b84aa834f46b33 - websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 - websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 - zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 - -BUNDLED WITH - 4.0.13 diff --git a/hub/Procfile.dev b/hub/Procfile.dev deleted file mode 100644 index f65d6113..00000000 --- a/hub/Procfile.dev +++ /dev/null @@ -1,3 +0,0 @@ -vite: exec ./node_modules/.bin/vite dev -web: exec bin/rails server -b 0.0.0.0 -p ${REPLICA_HUB_PORT:-19500} -jobs: exec bin/jobs diff --git a/hub/README.md b/hub/README.md index 309de134..e9d66af0 100644 --- a/hub/README.md +++ b/hub/README.md @@ -1,69 +1,9 @@ # Replica Hub -Replica Hub is a self-hosted project that gives teams a shared place to review and collaborate on interactive prototypes. Teams can view and interact with each prototype in the browser, comment on specific elements or highlighted regions, reply, react, and see new activity from other reviewers. Prototype authors can also create public share links for people who do not use Hub. +Hub is being rebuilt on `build/hub/new` using TypeScript, Express, React, Vite, and Vitest. This branch lets contributors start the new app locally. It is not ready to replace the existing Hub. -Teams create and maintain the GitHub repositories that hold their prototypes. Teams use non-default branches for prototypes. To connect those repositories, a team uses the setup flow in its self-hosted Replica Hub instance to create a private GitHub App, then installs that App on the prototype repositories it wants Hub to use. +The API has one route: `GET /api/health`. The frontend displays the existing Replica logo and supports light and dark themes. Existing components, features, and pages are kept in Storybook, but they are not connected to the new API. The old backend, sign-in, saved data, live updates, background jobs, and deployment setup have been removed without replacements. -Repository writers can ask Hub to open a pull request that adds the required GitHub Actions workflow. That workflow calls the shared build action from the Replica repository, which builds each prototype branch and captures screenshots. It uploads a GitHub Actions artifact containing the built `dist` files and screenshots for Hub to store. +Use [Development](docs/development.md) to start the API and frontend. Review [Architecture](docs/architecture.md), [code architecture conventions](docs/backend-architecture.md), and [coding conventions](docs/coding-conventions.md) before adding behavior. -```text -prototype branch - -> repository workflow - -> shared action from Replica - -> GitHub Actions artifact (`dist` + screenshots) - -> self-hosted Replica Hub - | - +-- isolated preview - | - +-- team conversations - +-- public share links -``` - -GitHub stores the source code and decides repository access. The private App lets Hub discover prototype branches, follow their GitHub Actions runs, and check each reviewer's access. Hub keeps successful Build output and review conversations so the team can return to the same review later. - -## Deployment - -Replica Hub publishes a public container image at `ghcr.io/github/replica-hub`. The recommended deployment uses the release bundle to run that image with Docker Compose. - -The example below selects `hub-v1.0.0`, the stable Hub release published on October 15, 2026. Its public release assets do not require GitHub authentication. See [Deploy Replica Hub](docs/deployment.md#download-the-deployment-bundle) for the complete setup requirements. - -On a Linux server with `curl`, `tar`, and `sha256sum`, download and verify the bundle before extracting it: - -```sh -HUB_RELEASE=hub-v1.0.0 -HUB_RELEASE_URL="https://github.com/github/replica/releases/download/${HUB_RELEASE}" -mkdir replica-hub && - cd replica-hub && - curl -fsSL --output replica-deploy.tar.gz \ - "${HUB_RELEASE_URL}/replica-deploy.tar.gz" && - curl -fsSL --output replica-deploy.tar.gz.sha256 \ - "${HUB_RELEASE_URL}/replica-deploy.tar.gz.sha256" && - sha256sum -c replica-deploy.tar.gz.sha256 && - tar -xzf replica-deploy.tar.gz -``` - -The `&&` chain stops on a failed download or checksum mismatch, before extraction. Do not continue to setup after an error. Hub releases use `hub-v*` tags; a canvas release can become the repository's latest release, so do not use `releases/latest` for this bundle. - -## Development - -```sh -cd hub -bin/devcontainer up -bin/devcontainer dev -``` - -Open . Run commands through the container: - -```sh -bin/devcontainer exec bin/ci -``` - -Run Storybook as the interface workbench: - -```sh -bin/devcontainer storybook -``` - -Open . - -See the [documentation index](docs/README.md) for setup, architecture, and contributor guidance. First-time deployments also need a bootstrap secret; see [GitHub App setup](docs/github-app-setup.md). +The prototype extension and build action still use the shared canvas and Controls code at its existing paths. They remain separate projects. Their Hub connections will need new API routes before they can work with this branch. diff --git a/hub/Rakefile b/hub/Rakefile deleted file mode 100644 index 3bd8ab29..00000000 --- a/hub/Rakefile +++ /dev/null @@ -1,11 +0,0 @@ -# Add your own tasks in files placed in lib/tasks ending in .rake, -# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. - -require_relative "config/application" - -if Rails.env.local? - require "chusaku" - Chusaku.load_tasks -end - -Rails.application.load_tasks diff --git a/hub/app/assets/images/.keep b/hub/app/assets/images/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/hub/app/channels/application_cable/channel.rb b/hub/app/channels/application_cable/channel.rb deleted file mode 100644 index d6726972..00000000 --- a/hub/app/channels/application_cable/channel.rb +++ /dev/null @@ -1,4 +0,0 @@ -module ApplicationCable - class Channel < ActionCable::Channel::Base - end -end diff --git a/hub/app/channels/application_cable/connection.rb b/hub/app/channels/application_cable/connection.rb deleted file mode 100644 index a0ca50e6..00000000 --- a/hub/app/channels/application_cable/connection.rb +++ /dev/null @@ -1,71 +0,0 @@ -module ApplicationCable - class Connection < ActionCable::Connection::Base - identified_by :current_user, :credential_identity - attr_reader :canvas_grant_id, :browser_session_id - - # Looks up the signed-in user from the current Hub session. - def connect - ticket = request.headers["Sec-WebSocket-Protocol"].to_s.split(",").map(&:strip) - .find { _1.start_with?("replica-canvas.") } - return connect_canvas(ticket) if ticket - - browser_session = Session.includes(:user).find_by \ - id: request.session[:authenticated_session_id] - self.current_user = browser_session&.user if browser_session&.user&.local? == LocalDevelopment.enabled? - reject_unauthorized_connection if current_user.nil? - @browser_session_id = browser_session.id - self.credential_identity = browser_session.to_gid_param - reject_unauthorized_connection if !credential_active? - end - - # Sees committed revocation instead of reusing an earlier request's result. - def credential_active? - ApplicationRecord.uncached do - if canvas_grant_id - CanvasGrant.authenticated.where(id: canvas_grant_id, user_id: current_user.id).exists? - else - Session.where(id: browser_session_id, user_id: current_user.id).exists? - end - end - end - - private - - # Node has no browser Origin; only an unexpired signed canvas ticket may omit it. - def allow_request_origin? - return super if request.headers["Origin"].present? - - ticket = request.headers["Sec-WebSocket-Protocol"].to_s.split(",").map(&:strip) - .find { _1.start_with?("replica-canvas.") } - if ticket - signed = Base64.urlsafe_decode64(ticket.delete_prefix("replica-canvas.")) - id = Rails.application.message_verifier("canvas-cable").verified \ - signed, - purpose: "canvas-cable" - return true if id.is_a?(Integer) && CanvasGrant.authenticated.where(id:).exists? - end - super - rescue ArgumentError - super - end - - # The one-use API credential is never sent in a WebSocket URL. - def connect_canvas(protocol) - signed = Base64.urlsafe_decode64(protocol.delete_prefix("replica-canvas.")) - id = Rails.application.message_verifier("canvas-cable").verified \ - signed, - purpose: "canvas-cable" - grant = CanvasGrant.authenticated.where(id:).includes(:user, :repository).first - permission = grant&.repository&.authorization&.permission_for(grant.user, fresh: true) - allowed = grant && grant.repository.authorization.sufficient?(permission, minimum: :read) - reject_unauthorized_connection if !allowed - - self.current_user = grant.user - @canvas_grant_id = grant.id - self.credential_identity = grant.to_gid_param - reject_unauthorized_connection if !credential_active? - rescue ArgumentError, GithubClient::Error - reject_unauthorized_connection - end - end -end diff --git a/hub/app/channels/review_comments_channel.rb b/hub/app/channels/review_comments_channel.rb deleted file mode 100644 index 2433c4c2..00000000 --- a/hub/app/channels/review_comments_channel.rb +++ /dev/null @@ -1,62 +0,0 @@ -# Announces that one authorized Prototype's review data changed. -class ReviewCommentsChannel < ApplicationCable::Channel - periodically :verify_connection_access, every: 1.minute - - # Checks repository access before streaming change notices for one Prototype. - def subscribed - return reject if !connection.credential_active? - - repository = Repository.find_by_route_id! \ - positive_identifier(params[:repositoryId] || params[:repositoryGithubId]) - return reject if connection.canvas_grant_id && - CanvasGrant.authenticated.where(id: connection.canvas_grant_id, repository:).none? - - configuration = GithubAppConfiguration.current - return reject if !repository.authorization.authorized?(current_user, configuration:) - return reject if !connection.credential_active? - - prototype = repository.reviewable_prototypes.find(positive_identifier(params[:prototypeId])) - stream_for prototype, method(:transmit_review_notice), coder: ActiveSupport::JSON - rescue ActiveRecord::RecordNotFound, GithubClient::Error - reject - end - - class << self - # Tells authorized subscribers to reload review data through the HTTP API. - def broadcast_invalidation(prototype) - broadcast_to prototype, { type: "review_comments_changed" } - end - end - - private - - # A notice queued before revocation must not reach an ended connection. - def transmit_review_notice(message) - if connection.credential_active? - transmit message - else - connection.close - end - end - - # Live traffic never extends a canvas connection or substitutes for fresh access. - def verify_connection_access - return connection.close if !connection.credential_active? - return if !connection.canvas_grant_id - - grant = CanvasGrant.authenticated.find_by(id: connection.canvas_grant_id) - return if grant && grant.repository.authorization.authorized?(current_user, fresh: true) && - connection.credential_active? - - connection.close - rescue GithubClient::Error - connection.close - end - - # Accepts only positive integer identifiers from the subscription request. - def positive_identifier(value) - raise ActiveRecord::RecordNotFound if !value.is_a?(Integer) || !value.positive? - - value - end -end diff --git a/hub/app/controllers/analytics_controller.rb b/hub/app/controllers/analytics_controller.rb deleted file mode 100644 index 3a9e9642..00000000 --- a/hub/app/controllers/analytics_controller.rb +++ /dev/null @@ -1,20 +0,0 @@ -# Shows only the selected repository's administrator-facing delivery settings. -class AnalyticsController < SuperglueController - include HasRepositoryWorkspace - - rescue_from GithubClient::Error, with: -> { head :service_unavailable } - - # @route GET /repositories/:repository_github_id/analytics (repository_analytics) - def show - load_repository_workspace(fresh: true) - return if performed? - return head :not_found if !@repository.authorization.sufficient?(@repository_permission, minimum: :admin) - - @destinations = @repository.analytics_destinations.order(:provider).to_a - latest_failed_ids = Analytics::Delivery.where( - repository: @repository, destination_id: @destinations.map(&:id), state: "failed" - ).group(:destination_id).maximum(:id).values - @failure_codes = Analytics::Delivery.where(id: latest_failed_ids) - .pluck(:destination_id, :failure_code).to_h - end -end diff --git a/hub/app/controllers/analytics_destinations_controller.rb b/hub/app/controllers/analytics_destinations_controller.rb deleted file mode 100644 index 4c848101..00000000 --- a/hub/app/controllers/analytics_destinations_controller.rb +++ /dev/null @@ -1,128 +0,0 @@ -# Requires a fresh repository-admin permission for every analytics mutation. -class AnalyticsDestinationsController < ApplicationController - include FreshAuthorizationRequired - before_action :authorize_repository - before_action :authorize_sensitive_action - before_action -> { enforce_security_rate_limit(:analytics_destinations) } - rescue_from GithubClient::Error, with: -> { head :service_unavailable } - rescue_from ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique, ActionController::ParameterMissing, - with: :invalid_configuration - rescue_from Analytics::CustomConfiguration::Invalid, Analytics::CustomEndpoint::Invalid, - with: :invalid_custom_configuration - - # @route POST /repositories/:repository_github_id/analytics/destinations (repository_analytics_destinations) - def create - @destination = @repository.analytics_destinations.new - configure_destination - end - - # @route PATCH /repositories/:repository_github_id/analytics/destinations/:id (repository_analytics_destination) - # @route PUT /repositories/:repository_github_id/analytics/destinations/:id (repository_analytics_destination) - def update - @destination = @repository.analytics_destinations.find(params[:id]) - if params[:operation].in?(%w[pause resume]) - @destination.update!(enabled: params[:operation] == "resume") - redirect_to repository_analytics_path(@repository.route_id), status: :see_other - else - configure_destination - end - end - - # @route DELETE /repositories/:repository_github_id/analytics/destinations/:id (repository_analytics_destination) - def destroy - @repository.analytics_destinations.find(params[:id]).destroy! - redirect_to \ - repository_analytics_path(@repository.route_id), - notice: t("analytics.disconnected"), - status: :see_other - end - - private - - # GitHub repository administration remains necessary in addition to fresh identity. - def authorize_sensitive_action - # The isolated development demo has no GitHub identity to verify. - return if LocalDevelopment.enabled? && @repository.local? && Current.user.local? - - require_fresh_authorization \ - purpose: "analytics_destination", return_to: repository_analytics_path(@repository.route_id) - end - - def security_audit_action - operation = { "create" => "create", "destroy" => "delete", "update" => "update" }[action_name] - if action_name == "update" && params[:operation].in?(%w[pause resume]) - operation = params[:operation] - end - "analytics_destination.#{operation}" - end - - # Direct URLs cannot bypass repository selection or current GitHub permission. - def authorize_repository - @repository = Repository.find_by_route_id!(params[:repository_github_id]) - allowed = @repository.authorization.authorized?(Current.user, minimum: :admin, fresh: true) - head :not_found if !allowed - end - - # Blank rotation fields keep existing secrets without ever returning them to the client. - def configure_destination - input = params.require(:destination).permit \ - :provider, :region, :history, :name, :endpoint, :template, :headers, - :success_pointer, :success_value, :keep_pending, - event_types: [], - credentials: %i[write_key service_account_username service_account_secret project_id], - secrets: %i[name value] - if @destination.new_record? && !input[:history].in?(%w[new retained]) - return invalid_configuration - end - - provider = input[:provider] || @destination.provider - attributes = { provider:, event_types: input.fetch(:event_types, []).reject(&:blank?) } - if provider == "custom" - secrets = input.fetch(:secrets, []) - secrets = secrets.values if secrets.is_a?(ActionController::Parameters) - custom_input = input.permit(:endpoint, :template, :headers, :success_pointer, :success_value).to_h - custom_input["secrets"] = secrets.map(&:to_h) - previous = if @destination.persisted? - Analytics::CustomConfiguration.new(@destination.custom_config, @destination.credentials) - end - Analytics::CustomConfiguration.from_input! \ - custom_input, previous:, existing_secrets: @destination.credentials || {} - Analytics::CustomEndpoint.new(custom_input.fetch("endpoint")).public_ip! - attributes.merge!(name: input[:name], custom_input:) - else - attributes.merge!(region: input[:region], credentials: input.fetch(:credentials, {}).to_h) - end - include_retained = @destination.new_record? && input[:history] == "retained" - @destination.configure! \ - attributes, include_retained:, keep_pending: provider == "custom" && input[:keep_pending] == "1" - redirect_to \ - repository_analytics_path(@repository.route_id), - notice: t("analytics.configured_notice"), - status: :see_other - end - - # Reports validation without echoing submitted provider credentials. - def invalid_configuration - record_security_audit(outcome: "failed") - custom_input = params[:destination].is_a?(ActionController::Parameters) && - params[:destination][:provider] == "custom" - key = if @destination&.provider == "custom" || custom_input - "analytics.invalid_custom_configuration" - else - "analytics.invalid_configuration" - end - redirect_to \ - repository_analytics_path(@repository.route_id), - alert: t(key), - status: :see_other - end - - # Keeps URL settings, response data, and submitted credentials out of errors. - def invalid_custom_configuration - record_security_audit(outcome: "failed") - redirect_to \ - repository_analytics_path(@repository.route_id), - alert: t("analytics.invalid_custom_configuration"), - status: :see_other - end -end diff --git a/hub/app/controllers/application_controller.rb b/hub/app/controllers/application_controller.rb deleted file mode 100644 index e336a298..00000000 --- a/hub/app/controllers/application_controller.rb +++ /dev/null @@ -1,10 +0,0 @@ -class ApplicationController < ActionController::Base - include Authentication - include SecurityAuditing - include SecurityRateLimiting - include HasRepositoryWorkspace - before_action :limit_hub_admin_mutation - - # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. - allow_browser versions: :modern -end diff --git a/hub/app/controllers/branch_banner_dismissals_controller.rb b/hub/app/controllers/branch_banner_dismissals_controller.rb deleted file mode 100644 index 73e5589c..00000000 --- a/hub/app/controllers/branch_banner_dismissals_controller.rb +++ /dev/null @@ -1,30 +0,0 @@ -# Saves personal registration-banner preferences without changing the branch. -class BranchBannerDismissalsController < ApplicationController - rescue_from GithubClient::Error, with: :repository_unavailable - - # @route POST /repositories/:repository_github_id/prototypes/:prototype_id/branch_banner_dismissal (repository_prototype_branch_banner_dismissal) - def create - repository = Repository.find_by_route_id!(params[:repository_github_id]) - authorized = repository.authorization.authorized? \ - Current.user, - minimum: :write, - fresh: true - return head :not_found if !authorized - - prototype = repository.reviewable_prototypes.find(params[:prototype_id]) - BranchBannerDismissal.record!(user: Current.user, prototype:) - - redirect_to repository_path(repository.route_id), status: :see_other - rescue LocalDevelopment::Error => error - response.status = 422 - response.content_type = "text/plain; charset=utf-8" - self.response_body = error.message - end - - private - - # Fails closed when GitHub cannot confirm current repository access. - def repository_unavailable - head :service_unavailable - end -end diff --git a/hub/app/controllers/build_screenshots_controller.rb b/hub/app/controllers/build_screenshots_controller.rb deleted file mode 100644 index 252d45a7..00000000 --- a/hub/app/controllers/build_screenshots_controller.rb +++ /dev/null @@ -1,30 +0,0 @@ -# Serves retained screenshots after GitHub confirms repository access. -class BuildScreenshotsController < ApplicationController - include ArtifactRespondable - - rescue_from GithubClient::Error, with: :repository_unavailable - - # @route GET /repositories/:repository_github_id/prototypes/:prototype_id/builds/:build_number/screenshots/:size (repository_prototype_build_screenshot) - def show - repository = Repository.find_by_route_id!(params[:repository_github_id]) - return head :not_found if !repository.authorization.authorized?(Current.user) - - prototype = repository.reviewable_prototypes.find(params[:prototype_id]) - build = prototype.builds.where(state: "successful").find_by!(number: params[:build_number]) - size = params[:size] - return head :bad_request if params[:theme].present? && !%w[light dark].include?(params[:theme]) - - send_artifact \ - build.screenshot_path(size, theme: params[:theme]), - fallback: params[:theme] == "dark" ? build.screenshot_path(size) : nil, - content_type: "image/png", - filename: "build-#{build.number}-#{size}.png" - end - - private - - # Returns unavailable when GitHub cannot confirm repository access. - def repository_unavailable - head :service_unavailable - end -end diff --git a/hub/app/controllers/builds_controller.rb b/hub/app/controllers/builds_controller.rb deleted file mode 100644 index a78d248c..00000000 --- a/hub/app/controllers/builds_controller.rb +++ /dev/null @@ -1,126 +0,0 @@ -# Shows one Build after GitHub confirms access to its repository. -class BuildsController < SuperglueController - include HasRepositoryWorkspace - - BUILDS_PER_PAGE = 25 - - rescue_from GithubClient::Error, with: :repository_unavailable - - # @route GET /repositories/:repository_github_id/prototypes/:prototype_id/builds (repository_prototype_builds) - def index - load_prototype - return if performed? - - @history_build = history_build - before = params[:before] - before_at = params[:before_at] - return head :not_found if before_at && !before - if before && !before_at - return redirect_legacy_cursor(before) - end - return head :not_found if before && (!valid_build_number?(before) || !valid_time?(before_at)) - - builds = @prototype.builds.chronological - if before - builds = builds.chronologically_before(time: Time.iso8601(before_at), number: before.to_i) - end - page_builds = builds.limit(BUILDS_PER_PAGE + 1).to_a - return head :not_found if before && page_builds.empty? - - @builds = page_builds.first(BUILDS_PER_PAGE) - if before - @newest_page_path = repository_prototype_builds_path \ - @repository.route_id, - @prototype, - **history_parameters - end - if page_builds.size > BUILDS_PER_PAGE - @next_page_path = repository_prototype_builds_path \ - @repository.route_id, - @prototype, - **history_parameters, - before: @builds.last.number, - before_at: @builds.last.chronological_at.iso8601(6) - end - prepare_review_header - end - - # @route GET /repositories/:repository_github_id/prototypes/:prototype_id/builds/:number (repository_prototype_build) - def show - load_prototype - return if performed? - - @build = @prototype.builds.find_by!(number: params[:number]) - prepare_review_header - if @build.state == "successful" - @selected_build = @build - else - @selected_build = nil - @latest_build = @build - end - end - - private - - # Loads one Prototype through a currently authorized repository. - def load_prototype - load_repository_workspace - return if performed? - - @prototype = @repository.reviewable_prototypes.find(params[:prototype_id]) - end - - def prepare_review_header - @latest_build = @prototype.current_build || @prototype.builds.chronological.first - @selected_build = @history_build || - @prototype.builds.where(state: "successful").chronological.first - @build_options = @prototype.builds.chronological.to_a - @can_manage_share_links = @prototype.share_links_manageable_by? \ - Current.user, - repository_permission: @repository_permission - end - - # Returns unavailable when GitHub cannot confirm repository access. - def repository_unavailable - head :service_unavailable - end - - def valid_build_number?(value) - value.is_a?(String) && value.match?(/\A[1-9]\d*\z/) - end - - def valid_time?(value) - return false if !value.is_a?(String) - - Time.iso8601(value) - true - rescue ArgumentError - false - end - - def history_build - number = params[:build] - return if number.nil? || (number.is_a?(ActionController::Parameters) && number.empty?) - - raise ActiveRecord::RecordNotFound if !valid_build_number?(number) - - @prototype.builds.where(state: "successful").find_by!(number: number.to_i) - end - - def history_parameters - @history_build ? { build: @history_build.number } : {} - end - - # Adds the captured timestamp required by chronological pagination. - def redirect_legacy_cursor(number) - return head :not_found if !valid_build_number?(number) - - build = @prototype.builds.find_by!(number: number.to_i) - redirect_to repository_prototype_builds_path \ - @repository.route_id, - @prototype, - **history_parameters, - before: build.number, - before_at: build.chronological_at.iso8601(6) - end -end diff --git a/hub/app/controllers/canvas_api/base_controller.rb b/hub/app/controllers/canvas_api/base_controller.rb deleted file mode 100644 index 69541a56..00000000 --- a/hub/app/controllers/canvas_api/base_controller.rb +++ /dev/null @@ -1,65 +0,0 @@ -# Authenticates extension requests independently of Hub's browser session. -class CanvasApi::BaseController < ActionController::API - include CanvasApiCompatibility - include SecurityAuditing - include SecurityRateLimiting - before_action :authenticate_canvas - before_action :authorize_repository - before_action :verify_live_connection - before_action :record_canvas_activity - before_action { response.headers["Cache-Control"] = "private, no-store" } - - rescue_from GithubClient::Error, with: :repository_unavailable - rescue_from CanvasGrant::Expired, with: -> { head :unauthorized } - - private - - # Keeps the bearer secret in the extension's Node process, not browser cookies. - def authenticate_canvas - header = request.headers["Authorization"].to_s - secret = header.delete_prefix("Bearer ") if header.start_with?("Bearer ") - Current.canvas_grant = CanvasGrant.authenticate(secret) - head :unauthorized if Current.canvas_grant.nil? - end - - # Caches read permission briefly; writes still recheck GitHub immediately. - def authorize_repository - return if performed? - - @repository = Current.canvas_grant.repository - if params[:repository_github_id].present? && - params[:repository_github_id].to_s != @repository.route_id.to_s - return head :not_found - end - - @repository_permission = @repository.authorization.permission_for(Current.user, fresh: fresh_permission?) - head :forbidden if !@repository.authorization.sufficient?(@repository_permission, minimum: :read) - end - - # Writes recheck GitHub immediately; reads may use a recently confirmed permission. - def fresh_permission? - !request.get? - end - - def require_writer - head :forbidden if !@repository.authorization.sufficient?(@repository_permission, minimum: :write) - end - - # A repository check may have waited on GitHub while the deployment ended sessions. - def verify_live_connection - return if performed? - - head :unauthorized if CanvasGrant.authenticated.where(id: Current.canvas_grant.id).none? - end - - # Polls, cable tickets, and token renewals never send this trusted-client assertion. - def record_canvas_activity - return if performed? || request.headers["X-Replica-Canvas-Activity"] != "interactive" - - Current.canvas_grant.record_activity! - end - - def repository_unavailable - head :service_unavailable - end -end diff --git a/hub/app/controllers/canvas_api/cable_tickets_controller.rb b/hub/app/controllers/canvas_api/cable_tickets_controller.rb deleted file mode 100644 index 7277b768..00000000 --- a/hub/app/controllers/canvas_api/cable_tickets_controller.rb +++ /dev/null @@ -1,14 +0,0 @@ -# Exchanges an authenticated API request for a short-lived WebSocket handshake. -class CanvasApi::CableTicketsController < CanvasApi::BaseController - # @route POST /api/canvas/cable_ticket (canvas_api_cable_ticket) - def create - signed = Rails.application.message_verifier("canvas-cable").generate \ - Current.canvas_grant.id, - purpose: "canvas-cable", - expires_in: 30.seconds - ticket = Base64.urlsafe_encode64(signed, padding: false) - origin = Rails.configuration.x.hub_origin.delete_suffix("/") - cable_url = "#{origin.sub(/\Ahttp/, 'ws')}/cable" - render json: { cableUrl: cable_url, protocol: "replica-canvas.#{ticket}" } - end -end diff --git a/hub/app/controllers/canvas_api/connections_controller.rb b/hub/app/controllers/canvas_api/connections_controller.rb deleted file mode 100644 index 36124eb1..00000000 --- a/hub/app/controllers/canvas_api/connections_controller.rb +++ /dev/null @@ -1,33 +0,0 @@ -# Revokes the grant used by this extension without relying on a browser session. -class CanvasApi::ConnectionsController < CanvasApi::BaseController - skip_before_action :authorize_repository, only: :destroy - - # @route GET /api/canvas/connection (canvas_api_connection) - def show - render json: { - repository: @repository.name_with_owner, - repositoryId: @repository.route_id, - canPublish: @repository.authorization.sufficient?(@repository_permission, minimum: :write), - viewerId: Current.user.id, - viewerLogin: Current.user.login - } - end - - # @route PATCH /api/canvas/connection (canvas_api_connection) - def update - Current.canvas_grant.record_activity! - head :no_content - end - - # @route DELETE /api/canvas/connection (canvas_api_connection) - def destroy - Current.canvas_grant.revoke! - head :no_content - end - - private - - def security_audit_action - { "destroy" => "canvas_connection.revoke", "update" => "canvas_connection.activity" }[action_name] - end -end diff --git a/hub/app/controllers/canvas_api/improvement_notes_controller.rb b/hub/app/controllers/canvas_api/improvement_notes_controller.rb deleted file mode 100644 index b2ccf5c4..00000000 --- a/hub/app/controllers/canvas_api/improvement_notes_controller.rb +++ /dev/null @@ -1,49 +0,0 @@ -class CanvasApi::ImprovementNotesController < CanvasApi::BaseController - include HasReviewCommentContext - include ReviewCommentMutations - include ImprovementNoteResponses - - prepend_before_action -> { reject_large_request(maximum: 1_600_000) }, only: :create - before_action :require_writer, only: :create - rescue_from ActiveRecord::RecordInvalid, with: :invalid_note - rescue_from ImprovementComment::Conflict, with: ->(error) { render json: { error: error.message }, status: :conflict } - - # @route GET /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/improvement_notes (canvas_api_improvement_notes) - def index - notes = @prototype.improvement_notes.where(user: Current.user) - .includes(:build, :user, replies: { request_links: :request }).order(created_at: :desc, id: :desc) - render json: { notes: notes.map { improvement_note_json(_1) } } - end - - # @route POST /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/improvement_notes (canvas_api_improvement_notes) - def create - revision = params[:revision] - previous_revision = params[:previous_revision] - return head :bad_request unless revision.is_a?(Integer) && revision.positive? - return head :bad_request unless previous_revision.is_a?(Integer) && previous_revision >= 0 - return head :bad_request unless [ true, false ].include?(params[:deleted]) - return head :bad_request unless params[:anchor].is_a?(ActionController::Parameters) - return head :bad_request unless params[:thread].is_a?(ActionController::Parameters) - return head :bad_request if params.key?(:note_thread) - return head :bad_request if %i[local_id body status].any? { !params[_1].nil? && !params[_1].is_a?(String) } - if params.key?(:build_number) && (!params[:build_number].is_a?(Integer) || !params[:build_number].positive?) - return head :bad_request - end - - attributes = params.permit(:local_id, :revision, :previous_revision, :build_number, :body, :status, :deleted).to_h.symbolize_keys - attributes[:anchor] = params[:anchor].to_unsafe_h - attributes[:thread] = params[:thread].to_unsafe_h - note = ImprovementComment.synchronize!(prototype: @prototype, user: Current.user, attributes:) - render json: improvement_note_json(note), status: :ok - end - - private - - def load_prototype - @prototype = @repository.reviewable_prototypes.find(params[:prototype_id]) - end - - def invalid_note(error) - render json: { errors: error.record.errors.full_messages }, status: :unprocessable_entity - end -end diff --git a/hub/app/controllers/canvas_api/learning_recalls_controller.rb b/hub/app/controllers/canvas_api/learning_recalls_controller.rb deleted file mode 100644 index 5d1ba7f4..00000000 --- a/hub/app/controllers/canvas_api/learning_recalls_controller.rb +++ /dev/null @@ -1,41 +0,0 @@ -# Gives an agent the learnings its repository's administrators accepted. -# -# The Replica plugin asks with each prompt a person submits. It never sends the prompt, so recall -# orders the learnings by how many prototypes and reviewers raised them, then by when they were -# accepted. This answers from the cached read permission and returns empty text when there is -# nothing to give. It names each learning it gives by ID and content digest, so the plugin can tell -# when they change. The store records each recall as a use by the person the agent works for. -class CanvasApi::LearningRecallsController < CanvasApi::BaseController - # The plugin stops waiting after three seconds. Ending a recall can take Learning::Command's - # STOP_TIME more, so Hub ends a slower one, such as one waiting for a busy store, early enough to - # answer within those three seconds. - TIMEOUT = 1.5.seconds - - # Each recall starts a replica-learning process, so Hub limits how often one connection can ask. - before_action -> { enforce_security_rate_limit(:learning_recalls, identity: Current.canvas_grant.id) } - - # @route POST /api/canvas/learnings/recall (canvas_api_learnings_recall) - def create - # A connection approved before the approval page mentioned learnings never agreed to them. - return render json: { code: "learnings_consent_required" }, status: :forbidden if Current.canvas_grant.learnings_consented_at.nil? - - # The code tells the plugin that no learnings apply, unlike a 404 from a Hub without this route. - return render json: { code: "learnings_off" }, status: :not_found if !Learning.available_for?(@repository) - - store = Learning::Store.new(@repository) - return render json: { text: "", memories: [] } if !store.exist? - - result = store.call("recall", "--actor", Learning.person_for(Current.user), timeout: TIMEOUT) - memories = result.fetch("memories").map { { id: _1.fetch("id"), digest: _1.fetch("contentDigest") } } - render json: { text: memories.empty? ? "" : result.fetch("text"), memories: } - rescue Learning::Error, KeyError, TypeError, NoMethodError - head :service_unavailable - end - - private - - # Recall only reads, so a recently confirmed read permission is enough, as for GET requests. - def fresh_permission? - false - end -end diff --git a/hub/app/controllers/canvas_api/mention_candidates_controller.rb b/hub/app/controllers/canvas_api/mention_candidates_controller.rb deleted file mode 100644 index da544c3d..00000000 --- a/hub/app/controllers/canvas_api/mention_candidates_controller.rb +++ /dev/null @@ -1,4 +0,0 @@ -# Provides suggestions within the canvas grant's authorized repository. -class CanvasApi::MentionCandidatesController < CanvasApi::BaseController - include MentionCandidates -end diff --git a/hub/app/controllers/canvas_api/pairings_controller.rb b/hub/app/controllers/canvas_api/pairings_controller.rb deleted file mode 100644 index 96c6005b..00000000 --- a/hub/app/controllers/canvas_api/pairings_controller.rb +++ /dev/null @@ -1,58 +0,0 @@ -# Issues short-lived device proofs without exposing browser cookies to the extension. -class CanvasApi::PairingsController < ActionController::API - include CanvasApiCompatibility - include SecurityAuditing - rate_limit to: 10, within: 1.minute, only: :create, name: "create", - by: -> { trusted_request_ip }, with: -> { head :too_many_requests } - rate_limit to: 60, within: 1.minute, only: :show, name: "show", - by: -> { trusted_request_ip }, with: -> { head :too_many_requests } - - before_action { response.headers["Cache-Control"] = "no-store" } - rescue_from GithubClient::Error, with: -> { head :service_unavailable } - - # @route POST /api/canvas/pairings (canvas_api_pairings) - def create - name = params.require(:repository) - return head :bad_request if !name.is_a?(String) || - !name.match?(%r{\A[^/\s]+/[^/\s]+\z}) - - repository = @repository = Repository.active.where("LOWER(name_with_owner) = ?", name.downcase).first - return head :not_found if repository.nil? - - pairing, device_code, refresh_token = CanvasPairing.start!(repository, renewable: params[:renewable] == true) - render \ - json: { - deviceCode: device_code, - refreshToken: refresh_token, - userCode: pairing.user_code, - verificationUrl: "#{Rails.configuration.x.hub_origin.delete_suffix('/')}#{canvas_pairing_path(pairing.user_code)}", - expiresAt: pairing.expires_at.iso8601, - intervalSeconds: 3 - }, - status: :created - end - - # @route POST /api/canvas/pairings/token (canvas_api_pairing_token) - def show - status, credentials, pairing = CanvasPairing.consume(params.require(:device_code)) - if status == "expired" - record_security_audit \ - outcome: "denied", - repository: pairing&.repository, - actor: nil, - context: pairing ? { canvas_pairing_id: pairing.id } : {} - end - return render json: { status: } if credentials.nil? - - render json: credentials.fetch(:grant).credentials(credentials.fetch(:secret)).merge(status:) - rescue ActiveRecord::RecordNotFound, SessionRevocation::Superseded, GithubAppConfiguration::Superseded - record_security_audit(outcome: "denied", actor: nil) - render json: { status: "expired" } - end - - private - - def security_audit_action - action_name == "create" ? "canvas_pairing.create" : "canvas_pairing.consume" - end -end diff --git a/hub/app/controllers/canvas_api/prototypes_controller.rb b/hub/app/controllers/canvas_api/prototypes_controller.rb deleted file mode 100644 index 61972f9d..00000000 --- a/hub/app/controllers/canvas_api/prototypes_controller.rb +++ /dev/null @@ -1,131 +0,0 @@ -# Returns authoritative Build metadata and updates registration for the paired repository. -class CanvasApi::PrototypesController < CanvasApi::BaseController - before_action :require_registration_writer, only: :update - rescue_from ActiveRecord::RecordInvalid, with: :invalid_metadata - rescue_from LocalDevelopment::Error, with: :repository_unavailable - - # @route GET /api/canvas/prototype (canvas_api_prototype) - def show - prototype = current_prototype - return branch_not_found if prototype.nil? - - render json: prototype_json(prototype) - end - - # @route PATCH /api/canvas/prototype (canvas_api_prototype) - # @route PUT /api/canvas/prototype (canvas_api_prototype) - def update - prototype = current_prototype - return branch_not_found if prototype.nil? - if !prototype.registered? - reason = @repository.prototype_candidates.registration_ineligibility_reason \ - prototype, Current.user, permission: @repository_permission, fresh: true - if reason - return render json: { error: "This branch is not eligible for registration in Hub.", registrationReason: reason }, - status: :not_found - end - end - - if params[:automatic] == true - unless prototype.register_automatically!(commit_sha: params[:commit_sha], author: Current.user) - return render json: { error: "Hub has not imported this checkout's successful Build yet.", - registrationReason: "successful_build_required" }, - status: :conflict - end - else - metadata = params.permit(:name, :description, :start_page) - prototype.register!(**metadata.to_h.symbolize_keys, author: Current.user) - end - render json: prototype_json(prototype) - end - - private - - # Distinguishes registration permission from a loss of repository read access. - def require_registration_writer - return if @repository.authorization.sufficient?(@repository_permission, minimum: :write) - - render json: { error: "Registration in Hub requires repository write access.", - registrationReason: "write_access_required" }, status: :forbidden - end - - # Excluded branches are not evidence that a remote branch was deleted. - def branch_not_found - branch = params[:branch] - excluded = !@repository.prototype_branch?(branch) || - @repository.prototypes.active.where(branch_name: branch, replica: true).exists? - payload = { error: "Hub has not discovered this branch." } - payload[:registrationReason] = "branch_not_reviewable" if excluded - render json: payload, status: :not_found - end - - def current_prototype - branch = params.require(:branch) - raise ActionController::BadRequest if !branch.is_a?(String) || branch.blank? || - branch.length > 255 - - @repository.reviewable_prototypes.active.find_by(branch_name: branch) - end - - def prototype_json(prototype) - reason = registration_reason(prototype) - successful = prototype.builds.where(state: "successful") - published = successful.chronological.first - current = prototype.current_build - can_manage_share_links = prototype.share_links_manageable_by? \ - Current.user, - repository_permission: @repository_permission - { - id: prototype.id, - repositoryId: @repository.route_id, - repository: @repository.name_with_owner, - branch: prototype.branch_name, - name: prototype.name, - description: prototype.description, - startPage: prototype.start_page, - startPageSet: prototype.start_page_set_at.present?, - startPageImportError: prototype.start_page_import_error, - viewerLogin: Current.user.login, - viewerId: Current.user.id, - registered: prototype.registered?, - canEdit: @repository.authorization.sufficient?(@repository_permission, minimum: :write), - canManageShareLinks: can_manage_share_links, - publicSharingEnabled: SharingSetting.enabled?, - shareLinkExpirationOptions: ShareLink.expiration_options.keys, - canRegister: reason.nil?, - registrationReason: reason, - published: prototype.registered? && published.present?, - buildState: current&.state, - currentBuild: current && { commitSha: current.commit_sha, state: current.state }, - versionCount: successful.count, - build: published && { - number: published.number, - commitSha: published.commit_sha, - url: "#{Rails.configuration.x.hub_origin.delete_suffix('/')}#{repository_prototype_path(@repository.route_id, prototype, build: published.number)}", - publishedAt: published.finished_at.iso8601, - author: { - login: published.commit_author_login, - name: published.commit_author_name, - avatarUrl: published.commit_author_avatar_url - } - } - } - end - - # Avoids a branch-list request on every display-only status poll. - def registration_reason(prototype) - return if prototype.registered? - - Rails.cache.fetch( - [ "canvas-registration-reason", @repository.id, prototype.id, Current.user.id, @repository_permission ], - expires_in: 1.minute - ) do - @repository.prototype_candidates.registration_ineligibility_reason \ - prototype, Current.user, permission: @repository_permission - end - end - - def invalid_metadata(error) - render json: { errors: error.record.errors.full_messages }, status: :unprocessable_entity - end -end diff --git a/hub/app/controllers/canvas_api/renewals_controller.rb b/hub/app/controllers/canvas_api/renewals_controller.rb deleted file mode 100644 index a95f23db..00000000 --- a/hub/app/controllers/canvas_api/renewals_controller.rb +++ /dev/null @@ -1,48 +0,0 @@ -# Exchanges the private refresh secret only after fresh repository authorization. -class CanvasApi::RenewalsController < ActionController::API - include CanvasApiCompatibility - include SecurityAuditing - before_action { response.headers["Cache-Control"] = "no-store" } - rate_limit to: 60, within: 1.minute, by: -> { trusted_request_ip }, with: -> { head :too_many_requests } - rescue_from GithubClient::Error, with: -> { head :service_unavailable } - rescue_from CanvasGrant::Expired, ActiveRecord::RecordNotFound, with: -> { head :unauthorized } - rescue_from SessionRevocation::Superseded, GithubAppConfiguration::Superseded, with: -> { head :unauthorized } - - # @route POST /api/canvas/connection/renewal (canvas_api_connection_renewal) - def create - grant = CanvasGrant.for_refresh(params.require(:refresh_token)) - return head :unauthorized if grant.nil? - @repository = grant.repository - @security_audit_actor = grant.user - generation = SessionRevocation.current.generation - configuration = GithubAppConfiguration.current - snapshot = configuration&.credential_snapshot || { configuration_id: nil, credential_revision: nil } - return head :forbidden if !grant.repository.authorization.authorized?(grant.user, fresh: true, configuration:) - - credentials = SessionRevocation.with_generation!(generation) do - if LocalDevelopment.enabled? - grant.renew! - else - GithubAppConfiguration.with_credentials!(**snapshot) { grant.renew! } - end - end - render json: credentials - end - - # @route DELETE /api/canvas/connection/renewal (canvas_api_connection_renewal) - def destroy - grant = CanvasGrant.for_refresh(params.require(:refresh_token)) - if grant.nil? - record_security_audit(outcome: "denied", actor: nil) - return head :no_content - end - grant&.revoke! - head :no_content - end - - private - - def security_audit_action - action_name == "create" ? "canvas_connection.renew" : "canvas_connection.revoke" - end -end diff --git a/hub/app/controllers/canvas_api/repository_review_comments_controller.rb b/hub/app/controllers/canvas_api/repository_review_comments_controller.rb deleted file mode 100644 index 05a0ed4b..00000000 --- a/hub/app/controllers/canvas_api/repository_review_comments_controller.rb +++ /dev/null @@ -1,19 +0,0 @@ -class CanvasApi::RepositoryReviewCommentsController < CanvasApi::BaseController - include CanvasCommentResponses - - # @route GET /api/canvas/repositories/:repository_github_id/review_comments (canvas_api_repository_review_comments) - def index - filter = improvement_notes_filter - comments = canvas_comments_for(@repository.reviewable_prototypes, filter:) - prepare_viewer_thread_reads(comments) - render json: { - comments: comments.map do |comment| - prototype = comment.prototype - canvas_comment_json(comment).merge \ - prototype: { id: prototype.id, name: prototype.name, branch: prototype.branch_name } - end, - nextCursor: nil, - hasMore: false - } - end -end diff --git a/hub/app/controllers/canvas_api/review_comment_reactions_controller.rb b/hub/app/controllers/canvas_api/review_comment_reactions_controller.rb deleted file mode 100644 index 6123d7a7..00000000 --- a/hub/app/controllers/canvas_api/review_comment_reactions_controller.rb +++ /dev/null @@ -1,35 +0,0 @@ -# Uses the same thread reaction rules for paired canvas reviewers. -class CanvasApi::ReviewCommentReactionsController < CanvasApi::BaseController - include HasReviewCommentContext - include ReviewCommentMutations - - prepend_before_action :reject_large_request - - # @route POST /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:review_comment_id/reactions (canvas_api_review_comment_reactions) - def create - attributes = reaction_attributes - comment = @prototype.review_comments.not_deleted.find(params[:review_comment_id]) - comment.thread.add_reaction! \ - user: Current.user, - content: attributes[:content], - activity_version: positive_activity_version(attributes[:activity_version]) - render_review_comment_change comment, status: :created - end - - # @route DELETE /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:review_comment_id/reactions/:content (canvas_api_review_comment_reaction) - def destroy - attributes = reaction_attributes - comment = @prototype.review_comments.find(params[:review_comment_id]) - comment.thread.remove_reaction! \ - user: Current.user, - content: params[:content], - activity_version: positive_activity_version(attributes[:activity_version]) - render_review_comment_change comment - end - - private - - def reaction_attributes - params.require(:review_comment_reaction).permit(:content, :activity_version) - end -end diff --git a/hub/app/controllers/canvas_api/review_comment_replies_controller.rb b/hub/app/controllers/canvas_api/review_comment_replies_controller.rb deleted file mode 100644 index a591f2d5..00000000 --- a/hub/app/controllers/canvas_api/review_comment_replies_controller.rb +++ /dev/null @@ -1,14 +0,0 @@ -# Adds replies to existing Hub conversations, never a new local anchor. -class CanvasApi::ReviewCommentRepliesController < CanvasApi::BaseController - include HasReviewCommentContext - include ReviewCommentMutations - - prepend_before_action :reject_large_request - - # @route POST /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:review_comment_id/replies (canvas_api_review_comment_replies) - def create - root = @prototype.review_comments.roots.find(params[:review_comment_id]) - reply = root.thread.create_reply! user: Current.user, body: comment_body - render_review_comment_change reply, status: :created - end -end diff --git a/hub/app/controllers/canvas_api/review_comment_resolutions_controller.rb b/hub/app/controllers/canvas_api/review_comment_resolutions_controller.rb deleted file mode 100644 index 07ec207e..00000000 --- a/hub/app/controllers/canvas_api/review_comment_resolutions_controller.rb +++ /dev/null @@ -1,19 +0,0 @@ -# Changes existing thread resolution through Hub's canonical operation. -class CanvasApi::ReviewCommentResolutionsController < CanvasApi::BaseController - include HasReviewCommentContext - include ReviewCommentMutations - - # @route POST /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:review_comment_id/resolution (canvas_api_review_comment_resolution) - def create - root = @prototype.review_comments.roots.find(params[:review_comment_id]) - root.thread.resolve_by!(Current.user) - render_review_comment_change root - end - - # @route DELETE /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:review_comment_id/resolution (canvas_api_review_comment_resolution) - def destroy - root = @prototype.review_comments.roots.find(params[:review_comment_id]) - root.thread.reopen_by!(Current.user) - render_review_comment_change root - end -end diff --git a/hub/app/controllers/canvas_api/review_comment_summaries_controller.rb b/hub/app/controllers/canvas_api/review_comment_summaries_controller.rb deleted file mode 100644 index 208a2c9c..00000000 --- a/hub/app/controllers/canvas_api/review_comment_summaries_controller.rb +++ /dev/null @@ -1,75 +0,0 @@ -class CanvasApi::ReviewCommentSummariesController < CanvasApi::BaseController - include ReviewCommentPages - - RESPONSE_MAXIMUM_BYTES = 60 * 1024 - - rescue_from ReviewComment::ResponseTooLarge, with: -> { head :content_too_large } - - # @route GET /api/canvas/repositories/:repository_github_id/review_comment_summaries (canvas_api_repository_review_comment_summaries) - def index - filters = summary_filters - page = ReviewComment.summary_page_for_repository \ - @repository, - viewer: Current.user, - cursor: pagination_cursor, - page_size: page_size, - filters: do |candidate| - summary_page_json(candidate).to_json.bytesize <= RESPONSE_MAXIMUM_BYTES - end - render json: summary_page_json(page) - end - - private - - def summary_filters - resolution = params[:resolution] || "all" - raise ReviewComment::InvalidPagination if !%w[all unresolved resolved].include?(resolution) - - dates = %i[from to].to_h do |key| - value = params[key] - if value - raise ReviewComment::InvalidPagination if !value.is_a?(String) || - !value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})\z/) - - Date.iso8601(value[0, 10]) - value = Time.iso8601(value) - end - [ key, value ] - end - raise ReviewComment::InvalidPagination if dates[:from] && dates[:to] && dates[:from] >= dates[:to] - - dates.merge(resolution:) - rescue ArgumentError - raise ReviewComment::InvalidPagination - end - - def summary_page_json(page) - @reply_counts ||= ReviewComment.not_deleted.where(root_id: page.records.map(&:id)).group(:root_id).count - { - comments: page.records.map do |comment| - prototype = comment.prototype - anchor = comment.anchor - { - id: comment.id, - body: comment.body, - authorLogin: comment.user.login, - createdAt: comment.created_at.iso8601(6), - editedAt: comment.edited_at&.iso8601(6), - prototype: { id: prototype.id, name: prototype.name, branch: prototype.branch_name }, - buildNumber: comment.build.number, - buildPath: repository_prototype_path(@repository.route_id, prototype, build: comment.build.number), - resolvedAt: comment.resolved_at&.iso8601(6), - replyCount: @reply_counts.fetch(comment.id, 0), - targetContext: anchor.slice("kind", "route", "accessibleName", "tagName", "selectedText").merge( - anchor["kind"] == "area" ? { - "elements" => anchor.fetch("elements").map { _1.slice("accessibleName", "tagName", "role", "textExcerpt") } - } : {} - ), - screenshotReference: nil - } - end, - nextCursor: page.next_cursor, - hasMore: page.has_more - } - end -end diff --git a/hub/app/controllers/canvas_api/review_comment_threads_controller.rb b/hub/app/controllers/canvas_api/review_comment_threads_controller.rb deleted file mode 100644 index 91b6b0e7..00000000 --- a/hub/app/controllers/canvas_api/review_comment_threads_controller.rb +++ /dev/null @@ -1,42 +0,0 @@ -class CanvasApi::ReviewCommentThreadsController < CanvasApi::BaseController - include ReviewCommentPages - - RESPONSE_MAXIMUM_BYTES = 60 * 1024 - - rescue_from ReviewComment::ResponseTooLarge, with: -> { head :content_too_large } - rescue_from ReviewComment::ThreadChanged, with: -> { head :conflict } - - # @route GET /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:id/thread (canvas_api_review_comment_thread) - def show - @prototype = @repository.reviewable_prototypes.find(params[:prototype_id]) - @root = @prototype.review_comments.roots.includes(:build, :user, :resolved_by, :reactions).find(params[:id]) - @activity_version = @root.activity_version - @reply_count = @root.replies.not_deleted.count - prepare_viewer_thread_reads([ @root ]) - page = ReviewComment.reply_page_for \ - @root, - viewer: Current.user, - cursor: pagination_cursor, - page_size: page_size do |candidate| - thread_page_json(candidate).to_json.bytesize <= RESPONSE_MAXIMUM_BYTES - end - payload = thread_page_json(page) - raise ReviewComment::ThreadChanged if @root.reload.activity_version != @activity_version - - render json: payload - end - - private - - def thread_page_json(page) - { - root: review_comment_json(@root).merge( - prototype: { id: @prototype.id, name: @prototype.name, branch: @prototype.branch_name } - ), - replies: page.records.map { review_comment_json(_1) }, - replyCount: @reply_count, - nextCursor: page.next_cursor, - hasMore: page.has_more - } - end -end diff --git a/hub/app/controllers/canvas_api/review_comments_controller.rb b/hub/app/controllers/canvas_api/review_comments_controller.rb deleted file mode 100644 index 68038b2a..00000000 --- a/hub/app/controllers/canvas_api/review_comments_controller.rb +++ /dev/null @@ -1,4 +0,0 @@ -# Lists and changes review comments through a repository-scoped canvas grant. -class CanvasApi::ReviewCommentsController < CanvasApi::BaseController - include ReviewCommentActions -end diff --git a/hub/app/controllers/canvas_api/review_thread_reads_controller.rb b/hub/app/controllers/canvas_api/review_thread_reads_controller.rb deleted file mode 100644 index 33bb1792..00000000 --- a/hub/app/controllers/canvas_api/review_thread_reads_controller.rb +++ /dev/null @@ -1,22 +0,0 @@ -# Tracks the paired user's seen version of an existing Hub thread. -class CanvasApi::ReviewThreadReadsController < CanvasApi::BaseController - include HasReviewCommentContext - - prepend_before_action :reject_large_request - - # @route POST /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:review_comment_id/read (canvas_api_review_comment_read) - def create - root = @prototype.review_comments.roots.find(params[:review_comment_id]) - value = params.require(:review_thread_read).permit(:activity_version)[:activity_version] - root.thread.record_read_by! Current.user, activity_version: positive_activity_version(value) - render_review_comment_change root, broadcast: false - end - - # Clears only the paired reviewer's read state. - # @route DELETE /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:review_comment_id/read (canvas_api_review_comment_read) - def destroy - root = @prototype.review_comments.roots.find(params[:review_comment_id]) - root.thread.record_unread_by! Current.user - render_review_comment_change root, broadcast: false - end -end diff --git a/hub/app/controllers/canvas_api/share_links_controller.rb b/hub/app/controllers/canvas_api/share_links_controller.rb deleted file mode 100644 index 9caeaa4f..00000000 --- a/hub/app/controllers/canvas_api/share_links_controller.rb +++ /dev/null @@ -1,86 +0,0 @@ -# Creates public links through a repository-scoped canvas grant. -class CanvasApi::ShareLinksController < CanvasApi::BaseController - include HasShareLinkContext - - before_action :load_share_link_prototype - before_action :require_public_sharing - - rescue_from ShareLink::InvalidExpiration, ActiveRecord::RecordInvalid, with: :invalid_share_link - rescue_from ActiveRecord::RecordNotFound, with: :share_link_target_not_found - rescue_from ShareLink::InvalidPagination, with: :invalid_pagination - rescue_from SharingSetting::Disabled, SharingSetting::Changed, with: :public_sharing_disabled - rescue_from ShareLinkCreatorRevocation::Changed, with: :creator_access_changed - - # @route GET /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/share_links (canvas_api_share_links) - def index - page = ShareLink.page_for(@prototype, cursor: params[:cursor]) - render json: { - shareLinks: page.records.map do |link| - share_link_json(link).merge( - createdAt: link.created_at.iso8601, - createdBy: { login: link.created_by.login, name: link.created_by.name } - ) - end, - hasMore: page.has_more, - nextCursor: page.next_cursor - } - end - - # @route POST /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/share_links (canvas_api_share_links) - def create - share_link = create_share_link - return if performed? - - render \ - json: share_link_json(share_link), - status: :created - end - - # @route DELETE /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/share_links/:id (canvas_api_share_link) - def destroy - link = @prototype.share_links.find_by(id: params[:id]) - unless link - render \ - json: { errors: [ "The share link was not found. Refresh links to see the active list." ] }, - status: :not_found - return - end - - link.destroy! - head :no_content - end - - private - - def require_public_sharing - public_sharing_disabled if !SharingSetting.enabled? - end - - def security_audit_action - { "create" => "share_link.create", "destroy" => "share_link.delete" }[action_name] - end - - def share_link_json(link) - { - id: link.id, - url: "#{Rails.configuration.x.hub_origin.delete_suffix('/')}#{shared_build_path(link.public_id)}", - buildNumber: link.build.number, - label: link.label, - expiresAt: link.effective_expires_at&.iso8601 - } - end - - def invalid_pagination - render json: { errors: [ "The share-link page cursor is invalid." ] }, status: :bad_request - end - - def invalid_share_link(error) - record_security_audit(outcome: "failed") - render json: { errors: share_link_errors(error) }, status: :unprocessable_entity - end - - def share_link_target_not_found - render json: { errors: [ "The requested Prototype or successful Build was not found." ] }, - status: :not_found - end -end diff --git a/hub/app/controllers/canvas_pairings_controller.rb b/hub/app/controllers/canvas_pairings_controller.rb deleted file mode 100644 index c6dbd4d4..00000000 --- a/hub/app/controllers/canvas_pairings_controller.rb +++ /dev/null @@ -1,74 +0,0 @@ -# Lets a signed-in Hub user approve or revoke a repository-scoped canvas connection. -class CanvasPairingsController < SuperglueController - rescue_from GithubClient::Error, with: :repository_unavailable - - # @route GET /canvas/pair/:user_code (canvas_pairing) - def show - @pairing = pending_pairing - return head :gone if @pairing.nil? - head :not_found if !@pairing.repository.authorization.authorized?(Current.user, fresh: true) - end - - # @route POST /canvas/pair/:user_code (approve_canvas_pairing) - def approve - pairing = pending_pairing - return head :gone if pairing.nil? - generation = SessionRevocation.current.generation - configuration = GithubAppConfiguration.current - snapshot = configuration&.credential_snapshot || { configuration_id: nil, credential_revision: nil } - return head :not_found if !pairing.repository.authorization.authorized?(Current.user, fresh: true, configuration:) - - SessionRevocation.with_generation!(generation) do - if LocalDevelopment.enabled? - pairing.approve! \ - Current.user, - browser_session: Current.session, - learnings_consent_version: params[:learnings_consent_version] - else - GithubAppConfiguration.with_credentials!(**snapshot) do - pairing.approve! \ - Current.user, - browser_session: Current.session, - learnings_consent_version: params[:learnings_consent_version] - end - end - end - redirect_to canvas_connections_path, notice: I18n.t( - "canvas_pairings.show.approved", repository: pairing.repository.name_with_owner - ), - status: :see_other - rescue ActiveRecord::RecordNotFound, SessionRevocation::Superseded, GithubAppConfiguration::Superseded - head :gone - end - - # @route GET /canvas/connections (canvas_connections) - def index - @grants = CanvasGrant.current.where(user: Current.user) - .includes(:repository).order(created_at: :desc) - end - - # @route DELETE /canvas/connections/:id (canvas_connection) - def destroy - CanvasGrant.where(user: Current.user).find(params[:id]).revoke! - redirect_to canvas_connections_path, notice: "Canvas connection revoked.", status: :see_other - end - - private - - def security_audit_action - { "approve" => "canvas_pairing.approve", "destroy" => "canvas_connection.revoke" }[action_name] - end - - def pending_pairing - CanvasPairing.includes(:repository).find_by(user_code: params[:user_code]) - &.then do |pairing| - @repository = pairing.repository - pairing if pairing.expires_at > Time.current && - pairing.approved_at.nil? && pairing.consumed_at.nil? - end - end - - def repository_unavailable - head :service_unavailable - end -end diff --git a/hub/app/controllers/concerns/.keep b/hub/app/controllers/concerns/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/hub/app/controllers/concerns/artifact_respondable.rb b/hub/app/controllers/concerns/artifact_respondable.rb deleted file mode 100644 index 5fa76ee0..00000000 --- a/hub/app/controllers/concerns/artifact_respondable.rb +++ /dev/null @@ -1,81 +0,0 @@ -# Streams a temporary response file and deletes it afterward. -class ArtifactResponseBody - # Keeps the file open until Rack finishes sending the response. - def initialize(file) - @file = file - end - - # Sends the file in fixed-size chunks. - def each - while (chunk = @file.read(64.kilobytes)) - break if chunk.empty? - - yield chunk - end - ensure - close - end - - # Deletes the temporary response file. - def close - @file.close! - end -end - -# Sends one stored Build file without exposing its storage service. -module ArtifactRespondable - private - - # Copies one stored file for the response and adds optional content at the end. - def send_artifact( - name, - content_type:, - filename: nil, - missing: :not_found, - fallback: nil, - resource_policy: "same-origin", - append: nil - ) - store = ArtifactStore.current - file = Tempfile.new([ "replica-artifact-response-", ".bin" ], binmode: true) - store.read(name, destination: file) - file.write(append) if append - file.rewind - - response.headers["Cache-Control"] = "private, no-store" - response.headers["X-Content-Type-Options"] = "nosniff" - response.headers["Cross-Origin-Resource-Policy"] = resource_policy if resource_policy - response.headers["Content-Type"] = content_type - response.headers["Content-Disposition"] = if filename - ActionDispatch::Http::ContentDisposition.format(disposition: "inline", filename:) - else - "inline" - end - self.response_body = ArtifactResponseBody.new(file) - rescue ArtifactStore::Missing - file&.close! - if fallback - return send_artifact \ - fallback, - content_type:, - filename:, - missing:, - resource_policy:, - append: - end - raise if missing == :raise - - head :not_found - rescue ArtifactStore::InvalidName - file&.close! - raise if missing == :raise - - head :not_found - rescue ArtifactStore::TemporaryError - file&.close! - head :service_unavailable - rescue StandardError - file&.close! - raise - end -end diff --git a/hub/app/controllers/concerns/authentication.rb b/hub/app/controllers/concerns/authentication.rb deleted file mode 100644 index 031ce34d..00000000 --- a/hub/app/controllers/concerns/authentication.rb +++ /dev/null @@ -1,72 +0,0 @@ -module Authentication - extend ActiveSupport::Concern - - included do - before_action :require_authentication - helper_method :authenticated? - end - - class_methods do - # Allows named public actions to render for signed-out visitors. - def allow_unauthenticated_access(**options) - skip_before_action :require_authentication, **options - end - end - - private - - # Restores the database session referenced by the encrypted cookie. - def resume_session - browser_session = Session.includes(:user).find_by(id: session[:authenticated_session_id]) - allowed = browser_session && browser_session.user.local? == LocalDevelopment.enabled? - Current.session = allowed ? browser_session : nil - end - - # Redirects anonymous browser requests before protected data is loaded. - def require_authentication - resume_session - if Current.user.nil? - session[:authentication_return_to] = request.path if request.path.match?(%r{\A/canvas/pair/[0-9A-F]{10}\z}) - redirect_to root_path - end - response.headers["Cache-Control"] = "private, no-store" if Current.user - end - - # Reports whether the current browser session identifies a user. - def authenticated? - Current.user.present? - end - - # Rotates the encrypted cookie and records a revocable browser session. - def start_new_session_for(user, fresh_authorized_at: nil, - generation: SessionRevocation.current.generation, configuration_id: nil, credential_revision: nil) - browser_session = SessionRevocation.with_generation!(generation) do - if LocalDevelopment.enabled? && configuration_id.nil? && credential_revision.nil? - create_browser_session_for(user, fresh_authorized_at:) - else - GithubAppConfiguration.with_credentials!(configuration_id:, credential_revision:) do - create_browser_session_for(user, fresh_authorized_at:) - end - end - end - - session[:authenticated_session_id] = browser_session.id - Current.session = browser_session - end - - # Issuance happens inside both independent primary-database barriers. - def create_browser_session_for(user, fresh_authorized_at:) - reset_session - user.sessions.create! \ - ip_address: request.remote_ip, - user_agent: request.user_agent, - fresh_authorized_at: - end - - # Revokes the current database session and clears its cookie. - def terminate_session - resume_session - Current.session&.destroy! - reset_session - end -end diff --git a/hub/app/controllers/concerns/canvas_api_compatibility.rb b/hub/app/controllers/concerns/canvas_api_compatibility.rb deleted file mode 100644 index 1f14c6fd..00000000 --- a/hub/app/controllers/concerns/canvas_api_compatibility.rb +++ /dev/null @@ -1,21 +0,0 @@ -# Lets compatible plugin and Hub releases communicate without matching release numbers. -module CanvasApiCompatibility - extend ActiveSupport::Concern - - included do - before_action :check_canvas_interface - end - - private - - def check_canvas_interface - response.headers["X-Replica-Canvas-Version"] = "1" - version = request.headers["X-Replica-Canvas-Version"] - return if version.blank? || version == "1" - - render json: { - code: "incompatible_canvas_interface", - error: "This plugin and Hub do not support the same Canvas interface. Update to compatible releases." - }, status: :conflict - end -end diff --git a/hub/app/controllers/concerns/canvas_comment_responses.rb b/hub/app/controllers/concerns/canvas_comment_responses.rb deleted file mode 100644 index b86ac2c8..00000000 --- a/hub/app/controllers/concerns/canvas_comment_responses.rb +++ /dev/null @@ -1,28 +0,0 @@ -# Combines explicitly requested improvements and reviews at the canvas feed boundary. -module CanvasCommentResponses - extend ActiveSupport::Concern - - include ReviewCommentPages - include ImprovementNoteResponses - - private - - def canvas_comments_for(prototypes, filter: "exclude") - comments = [] - unless filter == "only" - comments.concat \ - ReviewComment.where(prototype: prototypes) - .includes(:prototype, :build, :user, :resolved_by, :reactions, comment_mentions: :github_identity).to_a - end - unless filter == "exclude" - comments.concat \ - ImprovementComment.roots.where(prototype: prototypes) - .includes(:prototype, :build, :user, replies: { request_links: :request }).to_a - end - comments.sort_by { [ _1.created_at, _1.id, _1.class.name ] }.reverse - end - - def canvas_comment_json(comment) - comment.is_a?(ImprovementComment) ? improvement_note_json(comment) : review_comment_json(comment) - end -end diff --git a/hub/app/controllers/concerns/fresh_authorization_required.rb b/hub/app/controllers/concerns/fresh_authorization_required.rb deleted file mode 100644 index 8314c30b..00000000 --- a/hub/app/controllers/concerns/fresh_authorization_required.rb +++ /dev/null @@ -1,60 +0,0 @@ -# Resumes confirmed browser actions through their original permission and mutation paths. -module FreshAuthorizationRequired - extend ActiveSupport::Concern - - included do - before_action :restore_authorized_action - rescue_from FreshAuthorization::Continuation::Invalid, with: :continuation_unavailable - end - - private - - # Supplies the same explicit challenge contract to browser and canvas consumers. - def require_fresh_authorization(purpose:, return_to: root_path) - credential = Current.canvas_grant || Current.session - return true if FreshAuthorization.fresh?(credential) - - record_security_audit(outcome: "denied") if security_audit_action - pending_action = FreshAuthorization::Continuation.capture(request, params, purpose:) if !Current.canvas_grant - challenge = FreshAuthorization.start! \ - credential:, purpose:, return_to: Current.canvas_grant ? root_path : return_to, pending_action: pending_action - url = "#{Rails.configuration.x.hub_origin.delete_suffix('/')}#{fresh_authorization_path(challenge.public_id)}" - if Current.canvas_grant - render \ - json: { - code: "fresh_authorization_required", - errors: [ I18n.t("fresh_authorization.required") ], - authorization: { url:, expiresAt: challenge.expires_at.iso8601 } - }, - status: :forbidden - else - redirect_to fresh_authorization_path(challenge.public_id), status: :see_other - end - false - rescue FreshAuthorization::Invalid - if Current.canvas_grant - render json: { errors: [ I18n.t("fresh_authorization.failure") ] }, status: :unauthorized - else - redirect_to root_path, alert: I18n.t("fresh_authorization.failure"), status: :see_other - end - false - end - - # An opaque identifier selects server-saved fields, never replacement client input. - def restore_authorized_action - return if !params.key?(:authorization_continuation_id) - - raise FreshAuthorization::Continuation::Invalid if !params[:authorization_continuation_id].is_a?(String) - - challenge = FreshAuthorization.find_by(public_id: params[:authorization_continuation_id]) - raise FreshAuthorization::Continuation::Invalid if challenge.nil? - - @continued_action_parameters = challenge.continuation.claim!(request, browser_session: Current.session) - self.params = ActionController::Parameters.new(request.path_parameters).merge(@continued_action_parameters) - end - - def continuation_unavailable - record_security_audit(outcome: "denied") if security_audit_action - redirect_to root_path, alert: I18n.t("fresh_authorization.continuation_failed"), status: :see_other - end -end diff --git a/hub/app/controllers/concerns/has_github_app_administration.rb b/hub/app/controllers/concerns/has_github_app_administration.rb deleted file mode 100644 index a0435c14..00000000 --- a/hub/app/controllers/concerns/has_github_app_administration.rb +++ /dev/null @@ -1,85 +0,0 @@ -# Shares App form/error mechanics; each controller owns its normal administrator guard. -module HasGithubAppAdministration - extend ActiveSupport::Concern - include FreshAuthorizationRequired - - included do - rescue_from GithubAppConfiguration::Administration::Denied, with: :administration_denied - rescue_from FreshAuthorization::Invalid, with: :authorization_expired - rescue_from GithubAppConfiguration::Administration::InvalidCredentials, - GithubAppConfiguration::Rotator::IdentityMismatch, with: :invalid_credentials - rescue_from GithubClient::Error, with: :github_failed - rescue_from ActiveRecord::RecordInvalid, with: :save_failed - rescue_from ActiveRecord::RecordNotFound, ActiveRecord::StaleObjectError, - GithubAppConfiguration::Superseded, with: :configuration_changed - end - - private - - # Only server-resolved App IDs belong in request audit context. - def load_configuration - @configuration = GithubAppConfiguration.current - if @configuration - Current.security_audit_context = Current.security_audit_context.merge( - github_app_configuration_id: @configuration.id - ) - elsif controller_path != "github_apps" || action_name != "show" - raise GithubAppConfiguration::Administration::Denied, :not_configured - end - end - - # A stale confirmation cannot target a new singleton configuration. - def confirm_change - valid = params[:confirmed] == "1" && params[:configuration_id].is_a?(String) && - params[:configuration_id].match?(/\A[1-9]\d{0,17}\z/) && - params[:configuration_id].to_i == @configuration.id && - params[:expected_version].is_a?(String) && - params[:expected_version].match?(/\A(?:0|[1-9]\d{0,17})\z/) - raise GithubAppConfiguration::Administration::Denied, :confirmation_required if !valid - - @expected_version = params[:expected_version].to_i - end - - # Denial is independent of repository access, including for a removed administrator. - def deny_admin_access - record_security_audit(outcome: "denied") - @access_denied = true - @users = [] - flash.now[:alert] = t("github_app.errors.not_authorized") - render "hub_admins/index", status: :forbidden - end - - def administration_denied(error) - return deny_admin_access if error.reason == :not_authorized - - record_security_audit(outcome: "denied") - redirect_to github_app_path, alert: t("github_app.errors.#{error.reason}"), status: :see_other - end - - def authorization_expired - record_security_audit(outcome: "denied") - redirect_to github_app_path, alert: t("fresh_authorization.required"), status: :see_other - end - - def configuration_changed - administration_denied(GithubAppConfiguration::Administration::Denied.new(:state_changed)) - end - - def invalid_credentials - record_security_audit(outcome: "failed") - redirect_to edit_github_app_credentials_path, - alert: t("github_app.errors.invalid_credentials"), status: :see_other - end - - def github_failed - record_security_audit(outcome: "failed") - redirect_to github_app_path, alert: t("github_app.errors.github_failed"), status: :see_other - end - - def save_failed(error) - raise error if error.record.is_a?(SecurityAuditEvent) - - record_security_audit(outcome: "failed") - redirect_to github_app_path, alert: t("github_app.errors.save_failed"), status: :see_other - end -end diff --git a/hub/app/controllers/concerns/has_learning_workspace.rb b/hub/app/controllers/concerns/has_learning_workspace.rb deleted file mode 100644 index f8fff1ba..00000000 --- a/hub/app/controllers/concerns/has_learning_workspace.rb +++ /dev/null @@ -1,16 +0,0 @@ -# Loads a repository whose learnings its administrators can see and decide on. -module HasLearningWorkspace - extend ActiveSupport::Concern - include HasRepositoryWorkspace - - private - - # Responds 404 unless Learnings is on and the user currently administers the repository. - def load_learning_workspace - load_repository_workspace(fresh: true) - return if performed? - return head :not_found if !Learning.available_for?(@repository) - - head :not_found if !@repository.authorization.sufficient?(@repository_permission, minimum: :admin) - end -end diff --git a/hub/app/controllers/concerns/has_repository_templates.rb b/hub/app/controllers/concerns/has_repository_templates.rb deleted file mode 100644 index cafbf965..00000000 --- a/hub/app/controllers/concerns/has_repository_templates.rb +++ /dev/null @@ -1,18 +0,0 @@ -module HasRepositoryTemplates - extend ActiveSupport::Concern - - include HasRepositoryWorkspace - - private - - # Captures one authorized Build for template metadata, screenshots, and routes. - def load_repository_templates - load_repository_workspace - return if performed? - - owner = @repository.replica_build_owner - @latest_build = owner&.current_build || owner&.builds&.chronological&.first - @selected_build = owner&.builds&.where(state: "successful")&.chronological&.first - @templates = @selected_build ? @selected_build.templates.entries : [] - end -end diff --git a/hub/app/controllers/concerns/has_repository_workspace.rb b/hub/app/controllers/concerns/has_repository_workspace.rb deleted file mode 100644 index 10f67ec8..00000000 --- a/hub/app/controllers/concerns/has_repository_workspace.rb +++ /dev/null @@ -1,124 +0,0 @@ -module HasRepositoryWorkspace - extend ActiveSupport::Concern - - REPOSITORY_SELECTOR_CACHE_TTL = 5.minutes - - included do - helper_method :hub_repository_workspace - end - - private - - # Loads one readable repository and the repository choices for the workspace. - def load_repository_workspace(fresh: false, repository_choices: true) - github_id = params[:github_id] || params[:repository_github_id] - @repository = Repository.find_by_route_id!(github_id) - configuration = @github_configuration = GithubAppConfiguration.current - @repository_permission = @repository.authorization.permission_for(Current.user, configuration:, fresh:) - if !@repository.authorization.sufficient?(@repository_permission, minimum: :read) - return head :not_found - end - - @repositories = repository_choices ? authorized_repositories(configuration, including: @repository) : [ @repository ] - end - - # Returns active repositories from a cached list of current GitHub permissions. - def authorized_repositories(configuration, including: nil, cache: Rails.cache) - repositories = workspace_repositories - cache_key = [ - "repository-selector", - Current.user.github_id || Current.user.id, - configuration&.cache_key_with_version, - repositories.cache_key_with_version - ] - route_ids = cache.read(cache_key) - if route_ids.nil? - entries = repositories.to_a - payloads = {} - if !LocalDevelopment.enabled? && configuration&.installation_state == "active" - uncached = entries.reject do |repository| - repository == including || repository.authorization.cached_permission(Current.user, cache:).present? - end - if uncached.any? - payloads = begin - configuration.github_client.repositories.permissions \ - uncached, - Current.user, - token: configuration.tokens.installation - rescue GithubClient::Error => error - uncached.to_h { |repository| [ repository.github_id, error ] } - end - end - end - route_ids = entries.filter_map do |repository| - permission = if repository == including - "read" - else - repository.authorization.permission_for \ - Current.user, - configuration:, - cache:, - payload: payloads[repository.github_id] - end - if repository.authorization.sufficient?(permission, minimum: :read) - repository.route_id - end - rescue GithubClient::Error => error - @repository_navigation_unavailable = true - Rails.logger.warn("Repository navigation permission check failed: #{error.class}") - nil - end - if !@repository_navigation_unavailable - cache.write(cache_key, route_ids, expires_in: REPOSITORY_SELECTOR_CACHE_TTL) - end - end - - authorized = if LocalDevelopment.enabled? - repositories.where(id: route_ids).to_a - else - repositories.where(github_id: route_ids).to_a - end - authorized << including if including && authorized.exclude?(including) - authorized.sort_by(&:name_with_owner) - end - - # Optional navigation must not turn repository availability into a Hub access rule. - def hub_repository_workspace - return @hub_repository_workspace if defined?(@hub_repository_workspace) - - @hub_repository_workspace = { repository: nil, repositories: [], permission: nil, error: nil } - configuration = GithubAppConfiguration.current - repositories = authorized_repositories(configuration) - if @repository_navigation_unavailable - @hub_repository_workspace[:error] = I18n.t("hub_administration_workspace.repositories_unavailable") - end - requested_id = params[:repository_github_id].presence - repository = requested_id ? repositories.find { _1.route_id.to_s == requested_id.to_s } : repositories.first - if repository.nil? - if requested_id - @hub_repository_workspace[:error] = I18n.t("hub_administration_workspace.repository_unavailable") - end - return @hub_repository_workspace - end - - permission = repository.authorization.permission_for(Current.user, configuration:) - if !repository.authorization.sufficient?(permission, minimum: :read) - @hub_repository_workspace[:error] = I18n.t("hub_administration_workspace.repository_unavailable") - return @hub_repository_workspace - end - - @hub_repository_workspace.merge!(repository:, repositories:, permission:) - rescue GithubClient::Error => error - Rails.logger.warn("Repository navigation permission check failed: #{error.class}") - @hub_repository_workspace[:error] = I18n.t("hub_administration_workspace.repositories_unavailable") - @hub_repository_workspace - end - - # Keeps optional navigation and audit choices within the normal repository mode. - def workspace_repositories - repositories = Repository.active - repositories = LocalDevelopment.enabled? ? repositories.where(github_id: nil) : - repositories.where(local_path: nil) - repositories.order(:name_with_owner) - end -end diff --git a/hub/app/controllers/concerns/has_review_comment_context.rb b/hub/app/controllers/concerns/has_review_comment_context.rb deleted file mode 100644 index 247afc8c..00000000 --- a/hub/app/controllers/concerns/has_review_comment_context.rb +++ /dev/null @@ -1,90 +0,0 @@ -# Shares request limits and repository access across review comment controllers. -# It also builds their JSON responses. -module HasReviewCommentContext - extend ActiveSupport::Concern - include ReviewCommentPages - - included do - before_action :load_prototype - - rescue_from GithubClient::Error, with: :repository_unavailable - rescue_from ActiveRecord::RecordInvalid, with: :invalid_comment - rescue_from ReviewComment::TooManyMentions, with: :too_many_mentions - end - - private - - # Rejects a comment request larger than the allowed byte limit. - def reject_large_request(maximum: ReviewComment::REQUEST_MAXIMUM_BYTES) - content_length = request.get_header("CONTENT_LENGTH") - if content_length.present? - return head :content_too_large if content_length.to_i > maximum - - return - end - - body = request.body.read(maximum + 1) - return head :content_too_large if body.bytesize > maximum - - request.set_header("rack.input", StringIO.new(body)) - request.set_header("CONTENT_LENGTH", body.bytesize.to_s) - end - - # Loads one Prototype only after GitHub confirms repository access. - def load_prototype - @repository = Repository.find_by_route_id!(params[:repository_github_id]) - configuration = GithubAppConfiguration.current - return head :not_found if !@repository.authorization.authorized?(Current.user, configuration:) - - @prototype = @repository.reviewable_prototypes.find(params[:prototype_id]) - end - - # Returns the only field accepted when changing or replying to a comment. - def comment_body - reject_note_thread_parameters! - params.require(:review_comment).permit(:body)[:body] - end - - # Reserves local work history for the dedicated improvement-note API. - def reject_note_thread_parameters! - fields = %w[note_thread thread] - nested = params[:review_comment] - if fields.any? { params.key?(_1) || (nested.is_a?(ActionController::Parameters) && nested.key?(_1)) } - raise ActionController::BadRequest, "Improvement threads require the improvement notes API." - end - end - - # Returns changed records and tells subscribed browsers to reload when requested. - def render_review_comment_change(comment, status: :ok, broadcast: true) - comments = comment.reply? ? [ comment, comment.root ] : [ comment ] - comments.each { _1.reactions.load } - prepare_viewer_thread_reads(comments) - render \ - json: { comments: comments.map { review_comment_json(_1) } }, - status: status - ReviewCommentsChannel.broadcast_invalidation(@prototype) if broadcast - end - - # Requires a positive activity version supplied as a JSON number. - def positive_activity_version(value) - raise ActionController::BadRequest if !value.is_a?(Integer) || !value.positive? - - value - end - - # Returns unavailable when GitHub cannot confirm repository access. - def repository_unavailable - head :service_unavailable - end - - # Returns model validation messages without saving partial thread changes. - def invalid_comment(error) - render \ - json: { errors: error.record.errors.full_messages }, - status: :unprocessable_entity - end - - def too_many_mentions(error) - render json: { errors: [ error.message ] }, status: :unprocessable_entity - end -end diff --git a/hub/app/controllers/concerns/has_security_audit_page.rb b/hub/app/controllers/concerns/has_security_audit_page.rb deleted file mode 100644 index a269a782..00000000 --- a/hub/app/controllers/concerns/has_security_audit_page.rb +++ /dev/null @@ -1,47 +0,0 @@ -# Shares bounded pagination without sharing authorization between audit scopes. -module HasSecurityAuditPage - extend ActiveSupport::Concern - - private - - # Choices require their own permission; readable repository navigation is not proof. - def load_audit_scopes - @audit_scopes = [] - if User.where(id: Current.user.id, hub_admin: true).where.not(github_id: nil).exists? - @audit_scopes << { id: "hub", label: I18n.t("security_audit.hub_scope"), path: security_audit_path } - end - configuration = GithubAppConfiguration.current - workspace_repositories.each do |repository| - permission = if repository == @repository - @repository_permission - else - repository.authorization.permission_for(Current.user, configuration:, fresh: true) - end - if repository.authorization.sufficient?(permission, minimum: :admin) - @audit_scopes << { - id: repository.route_id.to_s, - label: repository.name_with_owner, - path: repository_security_audit_path(repository.route_id) - } - end - rescue GithubClient::Error => error - Rails.logger.warn("Audit scope permission check failed: #{error.class}") - @audit_scopes_unavailable = true - end - end - - def load_audit_events(relation, path:) - if params[:before].present? - raise ActiveRecord::RecordNotFound if !params[:before].to_s.match?(/\A[1-9]\d{0,17}\z/) - - relation = relation.where(id: ...params[:before].to_i) - end - records = relation.order(id: :desc).limit(51).to_a - @events = records.first(50) - @next_path = "#{path}?#{ { before: @events.last.id }.to_query }" if records.length > 50 - end - - def security_audit_action - "security_audit.view" - end -end diff --git a/hub/app/controllers/concerns/has_share_link_context.rb b/hub/app/controllers/concerns/has_share_link_context.rb deleted file mode 100644 index 6ef66e21..00000000 --- a/hub/app/controllers/concerns/has_share_link_context.rb +++ /dev/null @@ -1,95 +0,0 @@ -# Shares link permissions and creation without combining browser and canvas authentication. -module HasShareLinkContext - extend ActiveSupport::Concern - include FreshAuthorizationRequired - - included do - prepend_before_action :capture_sharing_revision - end - - private - - def capture_sharing_revision - @sharing_revision = SharingSetting.revision - end - - def load_share_link_prototype - return head :not_found if @repository.local? - - @prototype = @repository.reviewable_prototypes.find(params[:prototype_id]) - return if @prototype.share_links_manageable_by? \ - Current.user, - repository_permission: @repository_permission - - head :not_found - end - - def creator_access_changed - record_security_audit(outcome: "denied") - if Current.canvas_grant - render json: { - code: "creator_access_changed", errors: [ I18n.t("share_links.access_changed") ] - }, status: :forbidden - else - redirect_to \ - repository_prototype_share_links_path(@repository.route_id, @prototype), - alert: I18n.t("share_links.access_changed"), - status: :see_other - end - end - - def public_sharing_disabled(error = nil) - record_security_audit(outcome: "denied") if security_audit_action - changed = error.is_a?(SharingSetting::Changed) - code = changed ? "public_sharing_changed" : "public_sharing_disabled" - message = I18n.t(changed ? "share_links.policy_changed" : "share_links.disabled") - if Current.canvas_grant - render json: { code:, errors: [ message ] }, status: :forbidden - else - redirect_to \ - repository_prototype_share_links_path(@repository.route_id, @prototype), - alert: message, - status: :see_other - end - end - - def create_share_link - raise SharingSetting::Disabled if !SharingSetting.enabled? - - attributes = share_link_params - ShareLink.expiration_duration(attributes[:expiration]) - build = @prototype.builds.where(state: "successful").find_by! \ - number: positive_build_number(attributes[:build_number]) - if attributes[:expiration] == "never" - return_to = Current.canvas_grant ? root_path : management_path - return if !require_fresh_authorization( - purpose: "never_expiring_share", - return_to: - ) - end - - @prototype.share_links.create_public! \ - prototype: @prototype, - build:, - created_by: Current.user, - label: attributes[:label], - expiration: attributes[:expiration], - authorization_proof: @repository.authorization.share_link_creation_proof, - sharing_revision: @sharing_revision - end - - def share_link_params - params.require(:share_link).permit(:build_number, :label, :expiration) - end - - def positive_build_number(value) - string = value.to_s - raise ActiveRecord::RecordNotFound if !string.match?(/\A[1-9]\d*\z/) - - string.to_i - end - - def share_link_errors(error) - error.respond_to?(:record) ? error.record.errors.full_messages : [ error.message ] - end -end diff --git a/hub/app/controllers/concerns/improvement_note_responses.rb b/hub/app/controllers/concerns/improvement_note_responses.rb deleted file mode 100644 index 000bcd2a..00000000 --- a/hub/app/controllers/concerns/improvement_note_responses.rb +++ /dev/null @@ -1,21 +0,0 @@ -# Serializes improvement notes without depending on review comment responses. -module ImprovementNoteResponses - private - - # Renders one root and its relational conversation. - def improvement_note_json(note) - can_write = note.editable_by?(Current.user) && - @repository.authorization.sufficient?(@repository_permission, minimum: :write) - { - id: note.id, kind: "improvement_note", localId: note.local_id, - revision: note.note_revision, status: note.note_status, - buildNumber: note.build.number, - body: note.body, anchor: note.anchor, - thread: note.note_thread, - deletedAt: note.deleted_at&.iso8601(6), - createdAt: note.created_at.iso8601(6), updatedAt: note.updated_at.iso8601(6), - author: { login: note.user.login, name: note.user.name, avatarUrl: note.user.avatar_url }, - viewerCanEdit: can_write, viewerCanDelete: can_write - } - end -end diff --git a/hub/app/controllers/concerns/mention_candidates.rb b/hub/app/controllers/concerns/mention_candidates.rb deleted file mode 100644 index ad04d85c..00000000 --- a/hub/app/controllers/concerns/mention_candidates.rb +++ /dev/null @@ -1,23 +0,0 @@ -# Shares suggestions without sharing browser and bearer-token authentication. -module MentionCandidates - extend ActiveSupport::Concern - - included do - include HasReviewCommentContext - before_action { response.headers["Cache-Control"] = "private, no-store" } - end - - def index - return render json: { candidates: [] } if @repository.local? - - configuration = GithubAppConfiguration.current - candidates = Rails.cache.fetch \ - [ "mention-candidates", @repository.cache_key_with_version ], - expires_in: 1.minute do - configuration.github_client.repositories.mention_candidates \ - @repository, - token: configuration.tokens.installation - end - render json: { candidates: candidates } - end -end diff --git a/hub/app/controllers/concerns/preview_controls_respondable.rb b/hub/app/controllers/concerns/preview_controls_respondable.rb deleted file mode 100644 index 4fcf90a4..00000000 --- a/hub/app/controllers/concerns/preview_controls_respondable.rb +++ /dev/null @@ -1,23 +0,0 @@ -# Serves controls transport without review commands or viewer information. -module PreviewControlsRespondable - extend ActiveSupport::Concern - - CONTROLS_TAG = "\n\n" - CONTROLS_ORIGIN_PLACEHOLDER = /(["'`])__REPLICA_CONTROLS_PARENT_ORIGIN__\1/ - - # Serves the injected Controls bridge after existing preview authorization. - # @route GET /__replica/preview/controls - # @route GET /__replica/share/controls - def controls - source = File.binread(Rails.root.join("app/javascript/features/install-replica-controls-bridge/bridge.js")) - raise "Controls bridge origin placeholder is missing" if !source.match?(CONTROLS_ORIGIN_PLACEHOLDER) - - source = source.sub(CONTROLS_ORIGIN_PLACEHOLDER) do - Rails.configuration.x.hub_origin.delete_suffix("/").to_json - end - response.headers["Cache-Control"] = "private, no-store" - response.headers["X-Content-Type-Options"] = "nosniff" - response.headers["Cross-Origin-Resource-Policy"] = "same-origin" - render body: source, content_type: "text/javascript; charset=utf-8" - end -end diff --git a/hub/app/controllers/concerns/preview_file_respondable.rb b/hub/app/controllers/concerns/preview_file_respondable.rb deleted file mode 100644 index 00e3838d..00000000 --- a/hub/app/controllers/concerns/preview_file_respondable.rb +++ /dev/null @@ -1,92 +0,0 @@ -# Serves retained Build files with shared browser routing and response rules. -module PreviewFileRespondable - extend ActiveSupport::Concern - - CONTENT_TYPES_WITH_CHARSET = { - ".css" => "text/css; charset=utf-8", - ".html" => "text/html; charset=utf-8", - ".js" => "text/javascript; charset=utf-8", - ".json" => "application/json; charset=utf-8", - ".map" => "application/json; charset=utf-8", - ".txt" => "text/plain; charset=utf-8" - }.freeze - - included do - include ArtifactRespondable - # Build scripts must load as ordinary browser scripts, not only XHR. - skip_after_action :verify_same_origin_request - before_action :set_preview_headers - end - - private - - # Serves one Build file or its HTML entry point for a browser route. - def serve_preview_file(build, html_append: nil) - path = requested_file - content_type = content_type_for(path) - append = html_append if html_append && content_type.start_with?("text/html") - send_artifact \ - "#{build.artifact_prefix}/dist/#{path}", - content_type:, - missing: :raise, - resource_policy: document_request? ? nil : "same-origin", - append: - rescue ArtifactStore::Missing, ArtifactStore::InvalidName => error - return head :not_found if !document_request? - return head :not_found if error.is_a?(ArtifactStore::InvalidName) && !safe_document_route?(path) - - send_artifact \ - "#{build.artifact_prefix}/dist/index.html", - content_type: "text/html; charset=utf-8", - resource_policy: nil, - append: html_append - end - - # Allows local browser routes without making invalid storage names readable. - def safe_document_route?(path) - PreviewGrant.validate_return_path(request.get_header(PreviewPathRouting::ORIGINAL_PATH)) - path.valid_encoding? && !path.start_with?("/") && !path.match?(/[[:cntrl:]\\]/) && - !path.split("/").intersect?([ ".", "..", ArtifactStore::INTERNAL_SEGMENT ]) - rescue ArgumentError - false - end - - # Applies the framing and privacy rules shared by preview responses. - def set_preview_headers - response.headers.delete("X-Frame-Options") - response.headers["Content-Security-Policy"] = - "frame-ancestors #{Rails.configuration.x.hub_origin.delete_suffix("/")}; worker-src blob:" - response.headers["Referrer-Policy"] = "no-referrer" - end - - # Returns the positive Build ID encoded in the request host. - def preview_build_id - request.get_header(PreviewPathRouting::BUILD_ID) - end - - # Returns the requested relative Build path, with root mapped to index.html. - def requested_file - encoded_path = request.get_header(PreviewPathRouting::ORIGINAL_PATH) - URI::DEFAULT_PARSER.unescape(encoded_path).delete_prefix("/").presence || "index.html" - end - - # Reports whether a missing path is a browser document navigation. - def document_request? - destination = request.headers["Sec-Fetch-Dest"] - mode = request.headers["Sec-Fetch-Mode"] - return true if mode == "navigate" - return %w[document iframe].include?(destination) if destination.present? - - request.accepts.first == Mime[:html] - end - - # Returns a safe response type based on the retained file extension. - def content_type_for(path) - return "application/octet-stream" if !path.valid_encoding? - - extension = File.extname(path).downcase - CONTENT_TYPES_WITH_CHARSET.fetch(extension) do - Rack::Mime.mime_type(extension, "application/octet-stream") - end - end -end diff --git a/hub/app/controllers/concerns/public_sharing_required.rb b/hub/app/controllers/concerns/public_sharing_required.rb deleted file mode 100644 index 8ace317d..00000000 --- a/hub/app/controllers/concerns/public_sharing_required.rb +++ /dev/null @@ -1,28 +0,0 @@ -# Checks again after rendering or storage reads, which may overlap disabling. -module PublicSharingRequired - extend ActiveSupport::Concern - - included do - around_action :check_public_response - end - - private - - def check_public_response - yield - return if @share_link.nil? || response.status >= 400 - - ShareLink.with_available_access(@share_link.id) do |available| - if available - self.response_body = PublicShareResponseBody.new(response_body, @share_link.id) - else - response_body.close if response_body.respond_to?(:close) - self.response_body = [] - response.status = :not_found - response.headers.delete("Location") - response.headers["Cache-Control"] = "private, no-store" - cookies.delete(share_preview_cookie_name) if respond_to?(:share_preview_cookie_name, true) - end - end - end -end diff --git a/hub/app/controllers/concerns/review_comment_actions.rb b/hub/app/controllers/concerns/review_comment_actions.rb deleted file mode 100644 index 12310734..00000000 --- a/hub/app/controllers/concerns/review_comment_actions.rb +++ /dev/null @@ -1,73 +0,0 @@ -# Shares comment actions while each controller retains its own authentication. -module ReviewCommentActions - extend ActiveSupport::Concern - - include HasReviewCommentContext - include ReviewCommentMutations - include CanvasCommentResponses - - included do - prepend_before_action :reject_large_request, only: %i[create update] - end - - # @route GET /repositories/:repository_github_id/prototypes/:prototype_id/review_comments (repository_prototype_review_comments) - # @route GET /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/review_comments (canvas_api_review_comments) - def index - filter = is_a?(CanvasApi::BaseController) ? improvement_notes_filter : "exclude" - comments = canvas_comments_for(@prototype, filter:) - prepare_viewer_thread_reads(comments) - render json: { comments: comments.map { canvas_comment_json(_1) }, nextCursor: nil, hasMore: false } - end - - # @route POST /repositories/:repository_github_id/prototypes/:prototype_id/review_comments (repository_prototype_review_comments) - # @route POST /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/review_comments (canvas_api_review_comments) - def create - attributes = review_comment_params - build = @prototype.builds.where(state: "successful").find_by! \ - number: positive_build_number(attributes[:build_number]) - comment = ReviewComment.create_with_mentions! \ - prototype: @prototype, - build:, - user: Current.user, - body: attributes[:body], - anchor: attributes[:anchor] - - render_review_comment_change comment, status: :created - end - - # @route PATCH /repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:id (repository_prototype_review_comment) - # @route PUT /repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:id (repository_prototype_review_comment) - # @route PATCH /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:id (canvas_api_review_comment) - # @route PUT /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:id (canvas_api_review_comment) - def update - comment = @prototype.review_comments.find(params[:id]) - return head :forbidden if !comment.editable_by?(Current.user) - - comment.thread.edit_body! comment_body, actor: Current.user - render_review_comment_change comment - end - - # @route DELETE /repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:id (repository_prototype_review_comment) - # @route DELETE /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:id (canvas_api_review_comment) - def destroy - comment = @prototype.review_comments.find(params[:id]) - return head :forbidden if comment.user_id != Current.user.id - - comment.thread.delete_body! actor: Current.user - render_review_comment_change comment - end - - private - - def review_comment_params - reject_note_thread_parameters! - params.require(:review_comment).permit(:build_number, :body, anchor: {}) - end - - def positive_build_number(value) - string = value.to_s - raise ActiveRecord::RecordNotFound if !string.match?(/\A[1-9]\d*\z/) - - string.to_i - end -end diff --git a/hub/app/controllers/concerns/review_comment_mutations.rb b/hub/app/controllers/concerns/review_comment_mutations.rb deleted file mode 100644 index fb286e90..00000000 --- a/hub/app/controllers/concerns/review_comment_mutations.rb +++ /dev/null @@ -1,9 +0,0 @@ -# Comment writes share one user budget; reading and marking read do not spend it. -module ReviewCommentMutations - extend ActiveSupport::Concern - - included do - before_action -> { enforce_security_rate_limit(:comments) }, - if: -> { action_name.in?(%w[create update destroy]) } - end -end diff --git a/hub/app/controllers/concerns/review_comment_pages.rb b/hub/app/controllers/concerns/review_comment_pages.rb deleted file mode 100644 index c010d852..00000000 --- a/hub/app/controllers/concerns/review_comment_pages.rb +++ /dev/null @@ -1,108 +0,0 @@ -# Shares pagination and serialization without selecting an authentication or prototype context. -module ReviewCommentPages - extend ActiveSupport::Concern - - included do - rescue_from ReviewComment::InvalidPagination, with: :invalid_pagination - rescue_from ReviewComment::ResponseTooLarge, with: -> { head :content_too_large } - end - - private - - def improvement_notes_filter - value = params[:improvement_notes] || "exclude" - raise ReviewComment::InvalidPagination unless %w[exclude include only].include?(value) - - value - end - - def pagination_cursor - cursor = params[:cursor] - raise ReviewComment::InvalidPagination if cursor.present? && !cursor.is_a?(String) - - cursor - end - - def page_size - value = params[:pageSize] - return ReviewComment::DEFAULT_PAGE_SIZE if value.nil? - raise ReviewComment::InvalidPagination if !value.is_a?(String) || !value.match?(/\A[1-9]\d*\z/) - - value = value.to_i - raise ReviewComment::InvalidPagination if value > ReviewComment::MAXIMUM_PAGE_SIZE - - value - end - - def invalid_pagination - head :bad_request - end - - def review_comment_json(comment) - payload = { - id: comment.id, - kind: comment.root_comment? ? "root" : "reply", - body: comment.body, - mentions: comment.comment_mentions.map { { login: _1.login, githubId: _1.github_identity.github_id } }, - createdAt: comment.created_at.iso8601(6), - updatedAt: comment.updated_at.iso8601(6), - editedAt: comment.edited_at&.iso8601(6), - deletedAt: comment.deleted_at&.iso8601(6), - author: author_json(comment.user), - viewerCanEdit: comment.editable_by?(Current.user), - viewerCanDelete: comment.editable_by?(Current.user), - reactions: reaction_json(comment) - } - return payload.merge(rootId: comment.root_id) if comment.reply? - - viewer_read = viewer_thread_read(comment) - build_path = repository_prototype_path \ - @repository.route_id, - @prototype || comment.prototype, - build: comment.build.number - payload.merge \ - buildNumber: comment.build.number, - buildPath: build_path, - anchor: comment.anchor, - resolvedAt: comment.resolved_at&.iso8601(6), - resolvedBy: comment.resolved_by ? author_json(comment.resolved_by) : nil, - activityVersion: comment.activity_version, - viewerLastSeenActivityVersion: viewer_read&.last_seen_activity_version - end - - def prepare_viewer_thread_reads(comments) - root_ids = comments.filter_map { _1.id if _1.is_a?(ReviewComment) && _1.root_comment? } - @viewer_thread_reads = ReviewThreadRead.where \ - root_id: root_ids, - user: Current.user - @viewer_thread_reads = @viewer_thread_reads.index_by(&:root_id) - end - - def viewer_thread_read(root) - return @viewer_thread_reads[root.id] if defined?(@viewer_thread_reads) - - root.thread_reads.find_by(user: Current.user) - end - - def reaction_json(comment) - reactions = comment.reactions.group_by(&:content) - ReviewCommentReaction::CONTENTS.filter_map do |content| - matching = reactions[content] - next if matching.blank? - - { - content:, - count: matching.length, - viewerHasReacted: matching.any? { _1.user_id == Current.user.id } - } - end - end - - def author_json(user) - { - login: user.login, - name: user.name, - avatarUrl: user.avatar_url - } - end -end diff --git a/hub/app/controllers/concerns/security_auditing.rb b/hub/app/controllers/concerns/security_auditing.rb deleted file mode 100644 index 3a53a49b..00000000 --- a/hub/app/controllers/concerns/security_auditing.rb +++ /dev/null @@ -1,70 +0,0 @@ -# Captures trusted request context and decisions without serializing request data. -module SecurityAuditing - extend ActiveSupport::Concern - include TrustedRequestSource - - included do - around_action :audit_security_request, prepend: true - end - - private - - # Keeps failures visible even when authentication halts a sensitive request. - def audit_security_request - Current.security_audit_context = { - ip_address: security_audit_ip, - source: self.class.module_parent == CanvasApi ? "canvas" : "browser" - }.merge(SecurityAuditEvent.device_context(request.user_agent)) - yield - return if response.status == 429 || security_audit_action.nil? || @security_audit_recorded - - outcome = if response.status.in?([ 401, 403, 404, 410 ]) || - (response.redirect? && Current.user.nil? && response.location.in?([ root_path, root_url ])) - "denied" - elsif response.status >= 400 - "failed" - end - record_security_audit(outcome:) if outcome - rescue ActiveRecord::RecordNotFound, - ActionController::InvalidAuthenticityToken, HubAdminEnrollment::InvalidProof, - GithubAppSetup::InvalidState, GithubAppConfiguration::Installation::OwnerMismatch, - CanvasGrant::Expired => error - record_security_audit(outcome: "denied") if security_audit_action && !@security_audit_recorded - raise error - rescue GithubClient::Error, GithubAppSetup::ExchangeFailed, ActionController::ParameterMissing => error - record_security_audit(outcome: "failed") if security_audit_action && !@security_audit_recorded - raise error - rescue ActiveRecord::RecordInvalid => error - raise if error.record.is_a?(SecurityAuditEvent) - - record_security_audit(outcome: "failed") if security_audit_action && !@security_audit_recorded - raise error - rescue ActiveRecord::RecordNotUnique => error - record_security_audit(outcome: "failed") if security_audit_action && !@security_audit_recorded - raise error - end - - # Each sensitive controller names its own operation instead of deriving it from input. - def security_audit_action - nil - end - - # Forwarded addresses are meaningful only when the socket peer is a trusted proxy. - def security_audit_ip - trusted_request_ip - end - - # Successful local state changes call this inside their transaction. - def record_security_audit(outcome:, repository: @repository, - actor: @security_audit_actor || Current.user, context: {}) - return if response.status == 429 || @security_audit_recorded - - SecurityAuditEvent.record! \ - action: security_audit_action, - outcome:, - repository:, - actor:, - context: (Current.security_audit_context || {}).merge(context) - @security_audit_recorded = true - end -end diff --git a/hub/app/controllers/concerns/security_rate_limiting.rb b/hub/app/controllers/concerns/security_rate_limiting.rb deleted file mode 100644 index 27cc6e82..00000000 --- a/hub/app/controllers/concerns/security_rate_limiting.rb +++ /dev/null @@ -1,70 +0,0 @@ -# Shares each feature's Rails counter across controllers and credentials. -module SecurityRateLimiting - extend ActiveSupport::Concern - include TrustedRequestSource - - BUDGETS = { - oauth_initiation: [ 60, 5.minutes ], - oauth_callback: [ 60, 5.minutes ], - comments: [ 60, 1.minute ], - analytics_destinations: [ 5, 1.minute ], - workflow_installation: [ 5, 10.minutes ], - hub_admin: [ 10, 1.minute ], - webhooks: [ 600, 1.minute ], - learning_recalls: [ 30, 1.minute ] - }.freeze - - # Server-owned action names keep independently developed admin pages compatible. - # Their ordinary permission and fresh-authorization checks still run afterward. - ADMIN_MUTATIONS = { - "hub_admins" => %w[create update], - "sharing_settings" => %w[update], - "session_revocations" => %w[create], - "github_apps" => %w[destroy], - "github_app_credentials" => %w[update], - "github_app_webhook_rotations" => %w[create destroy], - "copilot_connections" => %w[create destroy] - }.freeze - - private - - # Called after authentication; ineligible actors still reach the normal denial. - def limit_hub_admin_mutation - return if !ADMIN_MUTATIONS.fetch(controller_path, []).include?(action_name) - return if Current.user.nil? || - !User.where(id: Current.user.id, hub_admin: true).where.not(github_id: nil).exists? - - enforce_security_rate_limit(:hub_admin) - end - - # Rails owns atomic counting and expiry; a rejection never continues the action. - def enforce_security_rate_limit(feature, identity: Current.user&.id) - maximum, window = BUDGETS.fetch(feature) - raise ArgumentError, "A rate-limit identity is required" if identity.blank? - - rate_limiting \ - to: maximum, - within: window, - by: -> { identity }, - with: -> { render_security_rate_limit(window) }, - store: Rails.cache, - name: feature, - scope: "security" - !performed? - end - - # A full window is conservative retry guidance even near a counter's expiry. - def render_security_rate_limit(window) - seconds = window.to_i - response.headers["Retry-After"] = seconds.to_s - response.headers["Cache-Control"] = "private, no-store" - message = I18n.t("security_rate_limits.exceeded", seconds:) - if request.format.json? || self.class.module_parent == CanvasApi || - is_a?(ActionController::API) - render json: { code: "rate_limited", errors: [ message ], retryAfter: seconds }, - status: :too_many_requests - else - render plain: message, status: :too_many_requests - end - end -end diff --git a/hub/app/controllers/concerns/trusted_request_source.rb b/hub/app/controllers/concerns/trusted_request_source.rb deleted file mode 100644 index b5c7d572..00000000 --- a/hub/app/controllers/concerns/trusted_request_source.rb +++ /dev/null @@ -1,11 +0,0 @@ -# Uses forwarded client addresses only behind the configured proxy boundary. -module TrustedRequestSource - private - - def trusted_request_ip - proxies = Rails.application.config.action_dispatch.trusted_proxies || - ActionDispatch::RemoteIp::TRUSTED_PROXIES - peer = request.remote_addr - proxies.any? { _1 === peer } ? request.remote_ip : peer - end -end diff --git a/hub/app/controllers/copilot_connections_controller.rb b/hub/app/controllers/copilot_connections_controller.rb deleted file mode 100644 index dfbbb1c0..00000000 --- a/hub/app/controllers/copilot_connections_controller.rb +++ /dev/null @@ -1,97 +0,0 @@ -# Lets Hub administrators connect Copilot with the operator's token, see who connected it, and -# disconnect it. Both changes need fresh GitHub proof, like Hub's other administrator settings. -# Hub keeps no Copilot credential; see CopilotConnection. -class CopilotConnectionsController < SuperglueController - include FreshAuthorizationRequired - - before_action :require_github_hub - before_action :authorize_admin - before_action :confirm_change, only: %i[create destroy] - before_action :authorize_connect, only: :create - before_action :authorize_disconnect, only: :destroy - - rescue_from CopilotConnection::ChangeDenied, with: :change_denied - rescue_from FreshAuthorization::Invalid, with: :authorization_expired - - # @route GET /copilot (copilot_connection) - def show - @connection = CopilotConnection.current - record_security_audit(outcome: "succeeded") - end - - # @route POST /copilot (copilot_connection) - def create - CopilotConnection.connect!(actor: Current.user, browser_session: Current.session) - @security_audit_recorded = true - redirect_to copilot_connection_path, notice: t("copilot_connection.connected"), status: :see_other - rescue CopilotConnection::Refused, Learning::Error => error - reason, outcome = case error - when CopilotConnection::Refused then [ :refused, "denied" ] - else [ error.code == "not_installed" ? :not_installed : :unavailable, "failed" ] - end - record_security_audit(outcome:) - redirect_to copilot_connection_path, alert: t("copilot_connection.errors.#{reason}"), status: :see_other - end - - # @route DELETE /copilot (copilot_connection) - def destroy - CopilotConnection.disconnect!(actor: Current.user, browser_session: Current.session) - @security_audit_recorded = true - redirect_to copilot_connection_path, notice: t("copilot_connection.disconnected"), status: :see_other - end - - private - - # Local development has no Copilot token, so it uses the developer's own Copilot sign-in instead. - def require_github_hub - head :not_found if LocalDevelopment.enabled? - end - - # Check the saved role before showing the connection or starting a GitHub check. - def authorize_admin - return if User.where(id: Current.user.id, hub_admin: true).where.not(github_id: nil).exists? - - record_security_audit(outcome: "denied") - @access_denied = true - flash.now[:alert] = t("copilot_connection.errors.not_authorized") - render :show, status: :forbidden - end - - def confirm_change - return if params[:confirmed] == "1" - - change_denied(CopilotConnection::ChangeDenied.new(:confirmation_required)) - end - - def authorize_connect - require_fresh_authorization purpose: :copilot_connection, return_to: copilot_connection_path - end - - def authorize_disconnect - require_fresh_authorization purpose: :copilot_disconnection, return_to: copilot_connection_path - end - - def security_audit_action - { "show" => "copilot_connection.view", "create" => "copilot_connection.connect", - "destroy" => "copilot_connection.disconnect" }.fetch(action_name, nil) - end - - def change_denied(error) - record_security_audit(outcome: "denied", actor: User.find_by(id: Current.user.id)) - if error.reason == :not_authorized - @access_denied = true - flash.now[:alert] = t("copilot_connection.errors.not_authorized") - render :show, status: :forbidden - else - redirect_to \ - copilot_connection_path, - alert: t("copilot_connection.errors.#{error.reason}"), - status: :see_other - end - end - - def authorization_expired - record_security_audit(outcome: "denied") - redirect_to copilot_connection_path, alert: t("fresh_authorization.required"), status: :see_other - end -end diff --git a/hub/app/controllers/fresh_authorizations_controller.rb b/hub/app/controllers/fresh_authorizations_controller.rb deleted file mode 100644 index b98a71ac..00000000 --- a/hub/app/controllers/fresh_authorizations_controller.rb +++ /dev/null @@ -1,66 +0,0 @@ -# Verifies identity, then returns confirmed actions to their CSRF-protected routes. -class FreshAuthorizationsController < SuperglueController - allow_unauthenticated_access - before_action :resume_session - before_action -> { enforce_security_rate_limit(:oauth_initiation, identity: trusted_request_ip) }, only: :create - before_action :load_challenge - before_action { response.headers["Cache-Control"] = "private, no-store" } - - rescue_from FreshAuthorization::Invalid, with: :authorization_failed - rescue_from ActiveRecord::RecordNotFound, with: :authorization_failed - - # @route GET /authorization/:id (fresh_authorization) - def show - @continuing = @challenge.continuation.available_to?(Current.session) - raise FreshAuthorization::Invalid if !@continuing && !@challenge.available_to?(Current.session) - end - - # @route POST /authorization/:id (fresh_authorization) - def create - configuration = GithubAppConfiguration.current - raise FreshAuthorization::Invalid if configuration.nil? || LocalDevelopment.enabled? - - pending = {} - url = configuration.oauth.authorize_url(pending) - @challenge.bind_oauth!(pending.fetch(:github_oauth_state), browser_session: Current.session) - pending[:fresh_authorization_id] = @challenge.id - session.delete(:hub_admin_enrollment_proof) - session.update(pending) - response.location = url - head :see_other - end - - # @route DELETE /authorization/:id (fresh_authorization) - def destroy - @challenge.cancel!(browser_session: Current.session) - discard_challenge_flow - redirect_to @challenge.return_to, alert: t("fresh_authorization.cancelled"), status: :see_other - end - - private - - def load_challenge - @challenge = FreshAuthorization.find_by!(public_id: params[:id]) - end - - def authorization_failed - if action_name != "show" - if @challenge - @challenge.audit!("denied") - else - SecurityAuditEvent.record!(action: "fresh_authorization.verify", outcome: "denied") - end - end - redirect_to root_path, alert: t("fresh_authorization.failure"), status: :see_other - end - - # Cancellation clears only its own bundle, even if the App has been removed. - def discard_challenge_flow - return if session[:fresh_authorization_id] != @challenge.id - - %i[fresh_authorization_id github_oauth_state github_oauth_verifier github_oauth_started_at - github_oauth_generation github_oauth_configuration_id github_oauth_credential_revision].each do |key| - session.delete(key) - end - end -end diff --git a/hub/app/controllers/github_app_credentials_controller.rb b/hub/app/controllers/github_app_credentials_controller.rb deleted file mode 100644 index d3c5eb31..00000000 --- a/hub/app/controllers/github_app_credentials_controller.rb +++ /dev/null @@ -1,54 +0,0 @@ -# Accepts replacement credentials without exporting saved secrets. -class GithubAppCredentialsController < SuperglueController - include HasGithubAppAdministration - - before_action :authorize_admin - before_action :load_configuration - before_action :confirm_change, only: :update - before_action :authorize_replacement, only: :update - - # @route GET /github_app/credentials (edit_github_app_credentials) - def edit - record_security_audit(outcome: "succeeded") - end - - # @route PATCH /github_app/credentials (github_app_credentials) - def update - private_key = if @continued_action_parameters - @continued_action_parameters.fetch("private_key") - else - upload = params[:private_key] - limit = GithubAppConfiguration::Administration::PRIVATE_KEY_LIMIT - if !upload.is_a?(ActionDispatch::Http::UploadedFile) || upload.size > limit - raise GithubAppConfiguration::Administration::InvalidCredentials, "Invalid replacement credentials" - end - - upload.read(limit + 1) - end - - @configuration.administration.replace_credentials! \ - actor: Current.user, browser_session: Current.session, expected_version: @expected_version, - client_secret: params[:client_secret], private_key: private_key - @security_audit_recorded = true - redirect_to github_app_path, notice: t("github_app.credentials.saved"), status: :see_other - end - - private - - # Recheck the persisted role, not a cached user or App-owner identity. - def authorize_admin - return if User.where(id: Current.user.id, hub_admin: true).where.not(github_id: nil).exists? && - !LocalDevelopment.enabled? - - deny_admin_access - end - - def authorize_replacement - require_fresh_authorization \ - purpose: :github_app_credentials, return_to: edit_github_app_credentials_path - end - - def security_audit_action - action_name == "update" ? "github_app.rotate_credentials" : "github_app.view" - end -end diff --git a/hub/app/controllers/github_app_setup_controller.rb b/hub/app/controllers/github_app_setup_controller.rb deleted file mode 100644 index 8dab14a2..00000000 --- a/hub/app/controllers/github_app_setup_controller.rb +++ /dev/null @@ -1,68 +0,0 @@ -# Provides the one-time, bootstrap-secret-protected GitHub App setup flow. -class GithubAppSetupController < SuperglueController - allow_unauthenticated_access - before_action :disable_for_local_mode - before_action :resume_session - - rescue_from GithubAppSetup::Error, with: :setup_not_found - rescue_from GithubClient::Error, with: :setup_not_found - rescue_from GithubAppConfiguration::Installation::OwnerMismatch, with: :setup_not_found - rescue_from GithubAppConfiguration::Superseded, ActiveRecord::StaleObjectError, with: :setup_not_found - - # @route GET /setup (setup) - def show - if setup.authorized? - @registration = setup.registration - render :register - end - end - - # @route POST /setup (setup) - def create - setup.authorize!(params[:bootstrap_secret], organization: params[:organization]) - redirect_to setup_path - end - - # @route GET /setup/callback (callback_setup) - def callback - configuration = setup.exchange!(code: params[:code], state: params[:state]) - redirect_to \ - "#{Rails.configuration.x.github_url}/apps/#{configuration.app_slug}/installations/new", - allow_other_host: true - end - - # @route GET /setup/install (install_setup) - def install - return setup_not_found if !setup.installation_authorized? - - configuration = GithubAppConfiguration.current - return setup_not_found if configuration.nil? - configuration.installation.install!(params.require(:installation_id)) - setup.finish_installation! - end - - private - - def security_audit_action - { - "create" => "github_app_setup.authorize", - "callback" => "github_app_setup.exchange", - "install" => "github_app_setup.install" - }[action_name] - end - - # Local mode never provisions a GitHub App. - def disable_for_local_mode - head :not_found if LocalDevelopment.enabled? - end - - def setup - @setup ||= GithubAppSetup.new \ - session:, - bootstrap_secret: ENV.fetch("REPLICA_BOOTSTRAP_SECRET", "") - end - - def setup_not_found(_error = nil) - head :not_found - end -end diff --git a/hub/app/controllers/github_app_webhook_rotations_controller.rb b/hub/app/controllers/github_app_webhook_rotations_controller.rb deleted file mode 100644 index d149cee0..00000000 --- a/hub/app/controllers/github_app_webhook_rotations_controller.rb +++ /dev/null @@ -1,58 +0,0 @@ -# Separates a retriable server-side webhook update from ending old-secret acceptance. -class GithubAppWebhookRotationsController < SuperglueController - include HasGithubAppAdministration - - before_action :authorize_admin - before_action :load_configuration - before_action :confirm_change, only: %i[create destroy] - before_action :authorize_rotation, only: %i[create destroy] - - # @route GET /github_app/webhook_rotation (github_app_webhook_rotation) - def show - record_security_audit(outcome: "succeeded") - end - - # @route POST /github_app/webhook_rotation (github_app_webhook_rotation) - def create - @configuration.administration.rotate_webhook! \ - actor: Current.user, browser_session: Current.session, - expected_version: @expected_version, operation: params[:operation] - @security_audit_recorded = true - redirect_to github_app_webhook_rotation_path, - notice: t("github_app.webhook.confirmed"), status: :see_other - end - - # @route DELETE /github_app/webhook_rotation (github_app_webhook_rotation) - def destroy - @configuration.administration.finish_webhook! \ - actor: Current.user, browser_session: Current.session, expected_version: @expected_version - @security_audit_recorded = true - redirect_to github_app_webhook_rotation_path, - notice: t("github_app.webhook.finished"), status: :see_other - end - - private - - # Use the same live normal administrator role as the existing management page. - def authorize_admin - return if User.where(id: Current.user.id, hub_admin: true).where.not(github_id: nil).exists? && - !LocalDevelopment.enabled? - - deny_admin_access - end - - def authorize_rotation - if action_name == "create" && !params[:operation].in?(%w[start retry]) - raise GithubAppConfiguration::Administration::Denied, :confirmation_required - end - purpose = action_name == "destroy" ? :github_app_finish_webhook : :github_app_webhook - require_fresh_authorization \ - purpose:, return_to: github_app_webhook_rotation_path - end - - def security_audit_action - return "github_app.view" if action_name == "show" - - action_name == "destroy" ? "github_app.finish_webhook_rotation" : "github_app.rotate_webhook_secret" - end -end diff --git a/hub/app/controllers/github_apps_controller.rb b/hub/app/controllers/github_apps_controller.rb deleted file mode 100644 index 8cfc051e..00000000 --- a/hub/app/controllers/github_apps_controller.rb +++ /dev/null @@ -1,63 +0,0 @@ -# Offers deployment App status and deliberate reset, never repository-content access. -class GithubAppsController < SuperglueController - include HasGithubAppAdministration - - before_action :authorize_admin - before_action :load_configuration - before_action :confirm_change, only: :destroy - before_action :authorize_reset, only: :destroy - rescue_from GithubAppConfiguration::Administration::ResetDisconnectFailed, with: :reset_disconnect_failed - - # @route GET /github_app (github_app) - def show - record_security_audit(outcome: "succeeded") - end - - # @route GET /github_app/reset (reset_github_app) - def reset_confirmation - @bootstrap_ready = ENV.fetch("REPLICA_BOOTSTRAP_SECRET", "").present? - record_security_audit(outcome: "succeeded") - end - - # @route DELETE /github_app (github_app) - def destroy - @configuration.administration.reset! \ - actor: Current.user, browser_session: Current.session, - expected_version: @expected_version, confirmation: params[:confirmation] - reset_completed("complete") - end - - private - - # Match the existing normal administrator boundary before loading App data. - def authorize_admin - return if User.where(id: Current.user.id, hub_admin: true).where.not(github_id: nil).exists? && - !LocalDevelopment.enabled? - - deny_admin_access - end - - def authorize_reset - raise GithubAppConfiguration::Administration::Denied, :confirmation_required if - params[:confirmation] != GithubAppConfiguration::Administration::RESET_PHRASE - - require_fresh_authorization \ - purpose: :github_app_reset, return_to: reset_github_app_path - end - - def security_audit_action - action_name == "destroy" ? "github_app.reset" : "github_app.view" - end - - def reset_disconnect_failed - reset_completed("disconnect_failed") - end - - # A notification failure cannot undo committed database credential revocation. - def reset_completed(message) - @security_audit_recorded = true - reset_session - Current.session = nil - redirect_to root_path, alert: t("github_app.reset.#{message}"), status: :see_other - end -end diff --git a/hub/app/controllers/github_authentications_controller.rb b/hub/app/controllers/github_authentications_controller.rb deleted file mode 100644 index b42ac398..00000000 --- a/hub/app/controllers/github_authentications_controller.rb +++ /dev/null @@ -1,133 +0,0 @@ -# Starts GitHub OAuth and turns an admitted identity into a browser session. -class GithubAuthenticationsController < ApplicationController - allow_unauthenticated_access - before_action -> { enforce_security_rate_limit(:oauth_initiation, identity: trusted_request_ip) }, only: :create - before_action -> { enforce_security_rate_limit(:oauth_callback, identity: trusted_request_ip) }, only: :callback - - # @route POST /auth/github (github_authentication) - def create - return head :not_found if LocalDevelopment.enabled? - - configuration = GithubAppConfiguration.current - return authentication_failed if configuration.nil? - - pending = {} - url = configuration.oauth.authorize_url(pending) - session.delete(:hub_admin_enrollment_proof) - session.delete(:fresh_authorization_id) - session.update(pending) - response.location = url - head :found - end - - # @route GET /auth/github/callback (github_authentication_callback) - def callback - return head :not_found if LocalDevelopment.enabled? - - return fresh_authorization_callback if session[:fresh_authorization_id].present? - - configuration = GithubAppConfiguration.current - return authentication_failed if configuration.nil? - - enrollment = session[:hub_admin_enrollment_proof].present? - generation = session[:github_oauth_generation] - snapshot = configuration.oauth.credential_snapshot!(session) - user = configuration.oauth.authenticate!(session, code: params[:code], state: params[:state]) - return_to = authentication_return_to - start_new_session_for(user, fresh_authorized_at: Time.current, generation:, **snapshot) - show_welcome = user.consume_welcome_sign_in! - redirect_to return_to || (show_welcome ? welcome_path : repositories_path) - rescue SessionRevocation::Superseded - if enrollment && user.nil? - SecurityAuditEvent.record!(action: "hub_admin.enroll", outcome: "denied", actor: nil) - end - reset_session - redirect_to root_path, alert: t("authentication.sessions_ended"), status: :see_other - rescue User::RepositoryAccessDenied - access_denied - rescue User::AdmissionDenied, - GithubAppConfiguration::Oauth::InvalidState, - GithubAppConfiguration::Superseded, - HubAdminEnrollment::InvalidProof, - GithubClient::Forbidden - if enrollment - SecurityAuditEvent.record!(action: "hub_admin.enroll", outcome: "denied", actor: nil) - end - authentication_failed - rescue GithubClient::Error - if enrollment - SecurityAuditEvent.record!(action: "hub_admin.enroll", outcome: "failed", actor: nil) - end - authentication_failed - ensure - discard_oauth_flow - end - - private - - # Uses the existing callback without rotating or unlocking another browser Session. - def fresh_authorization_callback - response.headers["Cache-Control"] = "private, no-store" - resume_session - challenge = FreshAuthorization.find_by(id: session.delete(:fresh_authorization_id)) - raise FreshAuthorization::Invalid if challenge.nil? - - challenge.claim!(state: params[:state], browser_session: Current.session) - configuration = GithubAppConfiguration.current - raise FreshAuthorization::Invalid if configuration.nil? || params[:error].present? - - generation = session[:github_oauth_generation] - snapshot = configuration.oauth.credential_snapshot!(session) - profile = configuration.oauth.verified_profile!(session, code: params[:code], state: params[:state]) - SessionRevocation.with_generation!(generation) do - GithubAppConfiguration.with_credentials!(**snapshot) { challenge.verify!(profile) } - end - if challenge.pending_action.present? - redirect_to fresh_authorization_path(challenge.public_id), status: :see_other - else - redirect_to challenge.return_to, notice: t("fresh_authorization.succeeded"), status: :see_other - end - rescue FreshAuthorization::Invalid, GithubAppConfiguration::Oauth::InvalidState, - SessionRevocation::Superseded, GithubAppConfiguration::Superseded, ActiveRecord::RecordNotFound - if challenge - challenge.continuation.discard! - challenge.audit!("denied") - else - SecurityAuditEvent.record!(action: "fresh_authorization.verify", outcome: "denied") - end - redirect_to root_path, alert: t("fresh_authorization.failure"), status: :see_other - rescue GithubClient::Error - challenge&.continuation&.discard! - challenge&.audit!("failed") - redirect_to root_path, alert: t("fresh_authorization.failure"), status: :see_other - ensure - discard_oauth_flow - end - - # Failed callbacks stay consumed even if the App was removed during verification. - def discard_oauth_flow - %i[github_oauth_state github_oauth_verifier github_oauth_started_at github_oauth_generation - github_oauth_configuration_id github_oauth_credential_revision - hub_admin_enrollment_proof fresh_authorization_id].each do |key| - session.delete(key) - end - end - - # Returns the saved preview authorization path when it has the expected shape. - def authentication_return_to - value = session.delete(:authentication_return_to) - return if !value.to_s.match?(%r{\A(?:/preview/builds/[1-9]\d*/authorize(?:\?[^\r\n]*)?|/canvas/pair/[0-9A-F]{10})\z}) - - value - end - - # Returns one generic failure that keeps setup and repository state private. - def authentication_failed - redirect_to root_path, alert: I18n.t("authentication.failure") - end - - # Explains that this GitHub account cannot open private Hub pages. - def access_denied - redirect_to root_path, alert: I18n.t("authentication.access_denied") - end -end diff --git a/hub/app/controllers/github_webhooks_controller.rb b/hub/app/controllers/github_webhooks_controller.rb deleted file mode 100644 index 419c51e7..00000000 --- a/hub/app/controllers/github_webhooks_controller.rb +++ /dev/null @@ -1,37 +0,0 @@ -# Receives signed GitHub App webhooks without a browser session. -class GithubWebhooksController < ActionController::API - include SecurityRateLimiting - MAXIMUM_BODY_SIZE = GithubWebhook::MAXIMUM_BODY_SIZE - - # @route POST /github/webhooks (github_webhooks) - def create - return head :not_found if LocalDevelopment.enabled? - - return head :content_too_large if request.content_length.to_i > MAXIMUM_BODY_SIZE - - body = request.body.read(MAXIMUM_BODY_SIZE + 1) - return head :content_too_large if body.bytesize > MAXIMUM_BODY_SIZE - - configuration = GithubAppConfiguration.current - return head :unauthorized if configuration.nil? - - GithubWebhook.process! \ - configuration:, - body:, - delivery_id: request.headers["X-GitHub-Delivery"], - event: request.headers["X-GitHub-Event"], - signature: request.headers["X-Hub-Signature-256"] do |delivery, installation_id| - admitted = enforce_security_rate_limit(:webhooks, identity: "hub") - WebhookRedelivery.record_throttle!(configuration:, delivery:, installation_id:) if !admitted - admitted - end - head :accepted if !performed? - rescue GithubWebhook::Rejected - head :unauthorized - rescue GithubClient::Error, - GithubAppConfiguration::Installation::OwnerMismatch, - GithubAppConfiguration::Superseded, - ActiveRecord::StaleObjectError - head :service_unavailable - end -end diff --git a/hub/app/controllers/home_controller.rb b/hub/app/controllers/home_controller.rb deleted file mode 100644 index f0b95c69..00000000 --- a/hub/app/controllers/home_controller.rb +++ /dev/null @@ -1,13 +0,0 @@ -# Renders the public application entry point. -class HomeController < SuperglueController - allow_unauthenticated_access - - # @route GET / (root) - def index - resume_session - if authenticated? - flash.keep(:notice) - redirect_to repositories_path - end - end -end diff --git a/hub/app/controllers/hub_admin_enrollments_controller.rb b/hub/app/controllers/hub_admin_enrollments_controller.rb deleted file mode 100644 index 5f0b38e3..00000000 --- a/hub/app/controllers/hub_admin_enrollments_controller.rb +++ /dev/null @@ -1,37 +0,0 @@ -# Enrolls an operator only after both bootstrap-secret possession and fresh GitHub sign-in. -class HubAdminEnrollmentsController < SuperglueController - allow_unauthenticated_access - before_action { head :not_found if LocalDevelopment.enabled? } - before_action :resume_session - rate_limit to: 10, within: 1.minute, only: :create, - by: -> { trusted_request_ip }, with: -> { head :too_many_requests } - before_action -> { enforce_security_rate_limit(:oauth_initiation, identity: trusted_request_ip) }, only: :create - - rescue_from HubAdminEnrollment::InvalidProof, with: -> { head :not_found } - - # @route GET /operator/enrollment (hub_admin_enrollment) - def show - end - - # @route POST /operator/enrollment (operator_enrollment) - def create - enrollment, proof = HubAdminEnrollment.start!(params[:bootstrap_secret]) - configuration = GithubAppConfiguration.current - raise HubAdminEnrollment::InvalidProof, "Operator authorization failed" if configuration.nil? - - pending = {} - url = configuration.oauth.authorize_url(pending) - enrollment.bind_oauth!(pending.fetch(:github_oauth_state)) - pending[:hub_admin_enrollment_proof] = proof - session.delete(:fresh_authorization_id) - session.update(pending) - response.location = url - head :found - end - - private - - def security_audit_action - "hub_admin.enroll" if action_name == "create" - end -end diff --git a/hub/app/controllers/hub_admins_controller.rb b/hub/app/controllers/hub_admins_controller.rb deleted file mode 100644 index c6eaf716..00000000 --- a/hub/app/controllers/hub_admins_controller.rb +++ /dev/null @@ -1,159 +0,0 @@ -# Changes only existing Hub users' explicit roles, never their repository permissions. -class HubAdminsController < SuperglueController - include FreshAuthorizationRequired - - before_action :authorize_admin - before_action :load_target, only: %i[edit update] - before_action :confirm_change, only: %i[create update] - before_action :authorize_sensitive_action, only: %i[create update] - - rescue_from User::AdminRoleChangeDenied, with: :role_change_denied - rescue_from ActiveRecord::RecordNotFound, with: :target_not_found - rescue_from FreshAuthorization::Invalid, with: :authorization_expired - rescue_from ActiveRecord::RecordInvalid, with: :invalid_user - - # @route GET /hub_admins (hub_admins) - def index - relation = User.where(hub_admin: true).where.not(github_id: nil) - @administrator_count = relation.count - if params[:before].present? - return target_not_found if !params[:before].to_s.match?(/\A[1-9]\d{0,17}\z/) - - relation = relation.where(id: ...params[:before].to_i) - end - records = relation.order(id: :desc).limit(51).to_a - @users = records.first(50) - @next_path = hub_admins_path(before: @users.last.id) if records.length > 50 - record_security_audit(outcome: "succeeded") - end - - # @route GET /hub_admins/candidates (candidates_hub_admins) - def candidates - if !params[:query].nil? && !params[:query].is_a?(String) - record_security_audit(outcome: "denied") - render json: { errors: [ t("hub_admins.add_dialog.search_failed") ] }, status: :unprocessable_entity - return - end - - query = params[:query].to_s.strip - relation = User.where(hub_admin: false).where.not(github_id: nil) - if query.present? - pattern = "%#{User.sanitize_sql_like(query.first(100))}%" - relation = relation.where("login LIKE :query ESCAPE '\\' OR name LIKE :query ESCAPE '\\'", query: pattern) - end - records = relation.order(:login, :id).limit(51).to_a - record_security_audit(outcome: "succeeded") - render \ - json: { - users: records.first(50).map { { id: _1.id, login: _1.login, name: _1.name } }, - more: records.length > 50 - } - end - - # @route POST /hub_admins (hub_admins) - def create - User.designate_hub_admins! \ - actor: Current.user, browser_session: Current.session, user_ids: params[:user_ids] - @security_audit_recorded = true - redirect_to hub_admins_path, notice: t("hub_admins.added"), status: :see_other - end - - # @route GET /hub_admins/:id/edit (edit_hub_admin) - def edit - @operation = @target.hub_admin? ? "revoke" : "designate" - @last_admin = @target.hub_admin? && User.where(hub_admin: true).where.not(github_id: nil).count <= 1 - record_security_audit(outcome: "succeeded") - end - - # @route PATCH /hub_admins/:id (hub_admin) - # @route PUT /hub_admins/:id (hub_admin) - def update - @target.change_hub_admin! \ - actor: Current.user, browser_session: Current.session, operation: params[:operation] - @security_audit_recorded = true - if Current.user.reload.hub_admin? - redirect_to hub_admins_path, notice: t("hub_admins.changed"), status: :see_other - else - flash.now[:notice] = t("hub_admins.changed") - render_without_admin_access(status: :ok) - end - end - - private - - # Checks the persisted role before exposing users or creating a challenge. - def authorize_admin - return if User.where(id: Current.user.id, hub_admin: true).where.not(github_id: nil).exists? - - record_security_audit(outcome: "denied") - if action_name == "candidates" - render json: { errors: [ t("hub_admins.errors.not_authorized") ] }, status: :forbidden - return - end - - flash.now[:alert] = t("hub_admins.errors.not_authorized") - render_without_admin_access - end - - # Only resolved server-owned target IDs enter audit context. - def load_target - @target = User.where.not(github_id: nil).find(params[:id]) - Current.security_audit_context = Current.security_audit_context.merge(target_user_id: @target.id) - end - - def confirm_change - User.validate_hub_admin_selection!(params[:user_ids]) if action_name == "create" - return if params[:confirmed] == "1" && - (action_name == "create" || params[:operation].in?(%w[designate revoke])) - - role_change_denied(User::AdminRoleChangeDenied.new(:confirmation_required)) - end - - def authorize_sensitive_action - require_fresh_authorization \ - purpose: :hub_admin_management, - return_to: action_name == "create" ? hub_admins_path : edit_hub_admin_path(@target) - end - - def security_audit_action - return "hub_admin.designate" if action_name == "create" - return "hub_admin.view" if action_name != "update" - - operation = params[:operation].in?(%w[designate revoke]) ? params[:operation] : "change" - "hub_admin.#{operation}" - end - - def role_change_denied(error) - record_security_audit(outcome: "denied", actor: User.find_by(id: Current.user.id)) - if error.reason == :not_authorized - flash.now[:alert] = t("hub_admins.errors.not_authorized") - render_without_admin_access - else - redirect_to hub_admins_path, alert: t("hub_admins.errors.#{error.reason}"), status: :see_other - end - end - - # A removed operator may have no repository, so denial must render on its own. - def render_without_admin_access(status: :forbidden) - @access_denied = true - @users = [] - render "hub_admins/index", status: - end - - def target_not_found - record_security_audit(outcome: "denied") - redirect_to hub_admins_path, alert: t("hub_admins.errors.invalid_target"), status: :see_other - end - - def authorization_expired - record_security_audit(outcome: "denied") - redirect_to hub_admins_path, alert: t("fresh_authorization.required"), status: :see_other - end - - def invalid_user(error) - raise error if error.record.is_a?(SecurityAuditEvent) - - record_security_audit(outcome: "failed") - redirect_to hub_admins_path, alert: t("hub_admins.errors.save_failed"), status: :see_other - end -end diff --git a/hub/app/controllers/learning_decisions_controller.rb b/hub/app/controllers/learning_decisions_controller.rb deleted file mode 100644 index d51c3407..00000000 --- a/hub/app/controllers/learning_decisions_controller.rb +++ /dev/null @@ -1,104 +0,0 @@ -# Records a repository administrator's decision on a suggestion or a learning in its store. -# -# The store checks that the review digest is the one the person saw, so a suggestion that changed -# after the page loaded cannot be accepted or rejected by mistake. -class LearningDecisionsController < SuperglueController - include HasLearningWorkspace - - DECISIONS = %w[accept reject retire].freeze - DIGEST = /\Asha256:[0-9a-f]{64}\z/ - REASON_MAXIMUM = 500 - CONTENT_MAXIMUM = 2_000 - - rescue_from GithubClient::Error, with: -> { head :service_unavailable } - - # @route POST /repositories/:repository_github_id/learnings/:memory_id/decision (decision_repository_learnings) - def create - load_learning_workspace - return if performed? - return refuse if !valid? - - record_decision(Learning::Store.new(@repository)) - back(notice: t("learnings.decisions.done.#{decision}")) - rescue Learning::Error => error - record_security_audit(outcome: "failed") - back(alert: t("learnings.decisions.failed", reason: error.message)) - end - - private - - # Records the decision in the learning store, then its audit event. The store lives outside Hub's - # database, so its command runs outside any transaction, like Hub's other filesystem work. If Hub - # cannot save the event after the store recorded the decision, the request fails, and the store's - # own record of the decision still names the person who made it. - def record_decision(store) - case decision - when "accept" then accept(store) - when "reject" - store.call("reject", params[:memory_id], "--digest=#{params[:digest]}", "--by", person, "--reason=#{reason}") - when "retire" - store.call("retire", params[:memory_id], "--by", person, "--reason=#{reason}") - end - record_security_audit(outcome: "succeeded") - rescue ActiveRecord::ActiveRecordError - Rails.logger.error \ - "The learning store recorded #{security_audit_action} on #{params[:memory_id]} in " \ - "repository #{@repository.id}, but Hub could not save its security audit event" - raise - end - - def security_audit_action - DECISIONS.include?(decision) ? "learning.#{decision}" : "learning.decision" - end - - def decision - params[:decision].to_s - end - - def valid? - return false if DECISIONS.exclude?(decision) - return false if decision != "retire" && !params[:digest].to_s.match?(DIGEST) - return false if decision != "accept" && !reason.length.between?(1, REASON_MAXIMUM) - - content.nil? || content.length.between?(1, CONTENT_MAXIMUM) - end - - # Accepts the suggestion as written, or with the wording the person edited. - def accept(store) - if content.nil? - return store.call("accept", params[:memory_id], "--digest=#{params[:digest]}", "--by", person) - end - - Tempfile.create([ "learning-", ".txt" ]) do |file| - file.write(content) - file.flush - store.call \ - "accept", params[:memory_id], - "--digest=#{params[:digest]}", - "--by", person, - "--content-file", file.path - end - end - - def person - Learning.person_for(Current.user) - end - - def reason - params[:reason].to_s.strip - end - - # Edited wording, or nil when the person accepts the suggestion as written. - def content - params[:content].to_s.strip if params.key?(:content) - end - - def refuse - record_security_audit(outcome: "denied") - back(alert: t("learnings.decisions.invalid")) - end - - def back(**flash) - redirect_to repository_learnings_path(@repository.route_id), status: :see_other, **flash - end -end diff --git a/hub/app/controllers/learning_runs_controller.rb b/hub/app/controllers/learning_runs_controller.rb deleted file mode 100644 index e3113716..00000000 --- a/hub/app/controllers/learning_runs_controller.rb +++ /dev/null @@ -1,33 +0,0 @@ -# Lets a repository administrator ask Hub to look for learnings now instead of waiting a day. -class LearningRunsController < SuperglueController - include HasLearningWorkspace - - rescue_from GithubClient::Error, with: -> { head :service_unavailable } - - # @route POST /repositories/:repository_github_id/learnings/runs (repository_learnings_runs) - def create - load_learning_workspace - return if performed? - - if !LocalDevelopment.enabled? && !CopilotConnection.current&.usable? - record_security_audit(outcome: "denied") - return back(alert: t("learnings.runs.copilot_refused")) - end - - Learning::Run.transaction do - Learning::Run.request!(repository: @repository, user: Current.user) - record_security_audit(outcome: "succeeded") - end - back(notice: t("learnings.runs.requested")) - end - - private - - def security_audit_action - "learning.run" - end - - def back(**flash) - redirect_to repository_learnings_path(@repository.route_id), status: :see_other, **flash - end -end diff --git a/hub/app/controllers/learnings_controller.rb b/hub/app/controllers/learnings_controller.rb deleted file mode 100644 index c026509a..00000000 --- a/hub/app/controllers/learnings_controller.rb +++ /dev/null @@ -1,22 +0,0 @@ -# Shows a repository's administrators what Hub learned from its review comments: suggestions to -# decide on, accepted learnings, what an agent gets, and recent runs with their Copilot usage. -class LearningsController < SuperglueController - include HasLearningWorkspace - - RECENT_RUNS = 10 - - rescue_from GithubClient::Error, with: -> { head :service_unavailable } - - # @route GET /repositories/:repository_github_id/learnings (repository_learnings) - def show - load_learning_workspace - return if performed? - - @runs = Learning::Run.where(repository: @repository).recent.includes(:requested_by).limit(RECENT_RUNS).to_a - @connection = CopilotConnection.current - @overview = Learning::Overview.new(Learning::Store.new(@repository), viewer: Learning.person_for(Current.user)).load - rescue Learning::Error => error - @overview = Learning::Overview::EMPTY - @load_error = error.message - end -end diff --git a/hub/app/controllers/local_authentications_controller.rb b/hub/app/controllers/local_authentications_controller.rb deleted file mode 100644 index b29317c7..00000000 --- a/hub/app/controllers/local_authentications_controller.rb +++ /dev/null @@ -1,27 +0,0 @@ -# Starts the single local developer session without contacting GitHub. -class LocalAuthenticationsController < ApplicationController - allow_unauthenticated_access - - # Signs in only after repositories have been configured on disk. - # @route POST /auth/local (local_authentication) - def create - return head :not_found if !LocalDevelopment.enabled? - return head :not_found if request.base_url != Rails.configuration.x.hub_origin.delete_suffix("/") - - Repository::LocalDiscovery.register_repositories! - if Repository.active.where(github_id: nil).none? - return redirect_to \ - root_path, - alert: I18n.t("local_development.empty_repositories", directory: LocalDevelopment.repositories_root), - status: :see_other - end - - return_to = session.delete(:authentication_return_to) - user = User.local_developer! - start_new_session_for(user) - show_welcome = user.consume_welcome_sign_in! - redirect_to return_to || (show_welcome ? welcome_path : repositories_path), status: :see_other - rescue LocalDevelopment::Error => error - redirect_to root_path, alert: error.message, status: :see_other - end -end diff --git a/hub/app/controllers/mention_candidates_controller.rb b/hub/app/controllers/mention_candidates_controller.rb deleted file mode 100644 index 35c340da..00000000 --- a/hub/app/controllers/mention_candidates_controller.rb +++ /dev/null @@ -1,4 +0,0 @@ -# Provides suggestions only to authenticated readers of this prototype. -class MentionCandidatesController < ApplicationController - include MentionCandidates -end diff --git a/hub/app/controllers/new_prototypes_controller.rb b/hub/app/controllers/new_prototypes_controller.rb deleted file mode 100644 index e71191f7..00000000 --- a/hub/app/controllers/new_prototypes_controller.rb +++ /dev/null @@ -1,19 +0,0 @@ -# Presents the prototype brief for one readable repository. -class NewPrototypesController < SuperglueController - include HasRepositoryTemplates - - rescue_from GithubClient::Error, ArtifactStore::Error, Build::Templates::InvalidManifest, - with: :repository_unavailable - - # @route GET /repositories/:repository_github_id/new (repository_new_prototype) - def show - load_repository_templates - end - - private - - # Does not expose a repository when GitHub cannot confirm access. - def repository_unavailable - head :service_unavailable - end -end diff --git a/hub/app/controllers/notifications_controller.rb b/hub/app/controllers/notifications_controller.rb deleted file mode 100644 index e66cc5a0..00000000 --- a/hub/app/controllers/notifications_controller.rb +++ /dev/null @@ -1,93 +0,0 @@ -# Presents only the signed-in recipient's currently accessible conversation activity. -class NotificationsController < SuperglueController - include HasRepositoryWorkspace - before_action { response.headers["Cache-Control"] = "private, no-store" } - - rescue_from GithubClient::Error, with: :unavailable - - # @route GET /notifications (notifications) - def index - @notifications_unread_count = Notification.visible_to(Current.user).where(read_at: nil).count - end - - # @route GET /notifications/inbox (inbox_notifications) - def inbox - relation = Notification.visible_to(Current.user) - unread_count = relation.where(read_at: nil).count - relation = relation.where(read_at: nil) if params[:unread] == "true" - limit = params[:limit].presence || "50" - unless limit.is_a?(String) && limit.match?(/\A[1-9]\d*\z/) && limit.to_i <= 50 - raise ActionController::BadRequest - end - limit = limit.to_i - if params[:before].present? - raise ActionController::BadRequest unless params[:before].is_a?(String) && params[:before].match?(/\A[1-9]\d*\z/) - - relation = relation.where(id: ...params[:before].to_i) - end - - rows = relation.includes(:actor, review_comment: { prototype: :repository }) - .order(id: :desc).limit(limit + 1).to_a - reaction_ids = rows.select { _1.kind == "comment_reaction" }.map { _1.source_key.delete_prefix("reaction/").to_i } - reactions = ReviewCommentReaction.where(id: reaction_ids).pluck(:id, :content) - .to_h.transform_keys { "reaction/#{_1}" } - render json: { - notifications: rows.first(limit).map { notification_json(_1, reactions:) }, - nextBefore: rows.length > limit ? rows[limit - 1].id : nil, - unreadCount: unread_count - } - end - - # @route GET /notifications/:id (notification) - def show - notification = Notification.visible_to(Current.user).find(params[:id]) - render json: { commentId: notification.review_comment.id, version: notification.lock_version } - end - - # @route PATCH /notifications/:id (notification) - # @route PUT /notifications/:id (notification) - def update - relation = Notification.visible_to(Current.user) - notification = relation.find(params[:id]) - attributes = params.require(:notification).permit(:read, :version) - unless [ true, false ].include?(attributes[:read]) && attributes[:version].is_a?(Integer) && attributes[:version] >= 0 - raise ActionController::BadRequest - end - - notification.lock_version = attributes[:version] - notification.update! read_at: attributes[:read] ? Time.current : nil - render json: { - version: notification.lock_version, - read: notification.read_at.present?, - unreadCount: relation.where(read_at: nil).count - } - rescue ActiveRecord::StaleObjectError - head :conflict - end - - private - - def notification_json(notification, reactions:) - message = notification.review_comment - prototype = message.prototype - { - id: notification.id, - version: notification.lock_version, - kind: Notification::KINDS.fetch(notification.kind), - reactionContent: reactions[notification.source_key], - actor: { login: notification.actor.login, name: notification.actor.name, avatarUrl: notification.actor.avatar_url }, - excerpt: message.body.to_s.truncate(240), - createdAt: notification.created_at.iso8601(6), - read: notification.read_at.present?, - repository: prototype.repository.name_with_owner, - prototype: prototype.name.presence || prototype.branch_name, - target: { prototypeId: prototype.id, threadId: message.root_id || message.id, commentId: message.id }, - path: repository_prototype_path(prototype.repository.route_id, prototype, comment: message.id, notification: notification.id), - updatePath: notification_path(notification) - } - end - - def unavailable - head :service_unavailable - end -end diff --git a/hub/app/controllers/preview_authorizations_controller.rb b/hub/app/controllers/preview_authorizations_controller.rb deleted file mode 100644 index 96509658..00000000 --- a/hub/app/controllers/preview_authorizations_controller.rb +++ /dev/null @@ -1,55 +0,0 @@ -# Confirms Hub access before sending a browser to one Build preview host. -class PreviewAuthorizationsController < ApplicationController - allow_unauthenticated_access - rescue_from GithubClient::Error, with: :repository_unavailable - - # @route GET /preview/builds/:build_id/authorize (preview_build_authorization) - def show - return head :not_found if request.base_url != Rails.configuration.x.hub_origin.delete_suffix("/") - - resume_session - return continue_after_authentication if Current.user.nil? - - @build = successful_build - return head :not_found if !repository.authorization.authorized?(Current.user) - - grant = PreviewGrant.issue! \ - build: @build, - session: Current.session, - return_path: params[:return_path].presence || @build.prototype.preview_start_page - response.location = PreviewOrigin.current.url \ - @build.id, - path: "/__replica/authorize", - query: { grant: } - head :found - rescue ActiveRecord::RecordNotFound, ArgumentError - head :not_found - end - - private - - # Loads one successful Build through an active repository. - def successful_build - Build.joins(prototype: :repository) - .includes(prototype: :repository) - .merge(Repository.active) - .where(state: "successful") - .find(params[:build_id]) - end - - # Returns the Build's repository after the scoped lookup succeeds. - def repository - @build.prototype.repository - end - - # Remembers the authorization route and sends a signed-out user to Hub. - def continue_after_authentication - session[:authentication_return_to] = request.fullpath - redirect_to root_path - end - - # Returns unavailable when GitHub cannot confirm repository access. - def repository_unavailable - head :service_unavailable - end -end diff --git a/hub/app/controllers/preview_files_controller.rb b/hub/app/controllers/preview_files_controller.rb deleted file mode 100644 index aa3f9e8f..00000000 --- a/hub/app/controllers/preview_files_controller.rb +++ /dev/null @@ -1,125 +0,0 @@ -# Serves one successful Build from its dedicated preview host. -class PreviewFilesController < ActionController::Base - include PreviewFileRespondable - include PreviewControlsRespondable - before_action :load_preview, only: %i[bridge controls show] - rescue_from GithubClient::Error, with: :repository_unavailable - - BRIDGE_SOURCE = Rails.root.join \ - "app/javascript/features/install-replica-preview-bridge/index.js" - BRIDGE_ORIGIN_PLACEHOLDER = "\"__REPLICA_HUB_ORIGIN__\"" - BRIDGE_REVIEW_ENABLED_PLACEHOLDER = "\"__REPLICA_REVIEW_ENABLED__\"" - BRIDGE_TAG = "\n\n" - - # Exchanges a short-lived Hub grant for a host-only preview cookie. - # @route GET /__replica/preview/authorize (__replica_preview_authorize) - def authorize - grant = PreviewGrant.find_valid(params[:grant], build_id: preview_build_id) - return head :not_found if grant.nil? - - @build = grant.build - return head :not_found if !available_build? || !authorized?(grant.session) - return head :not_found if !PreviewGrant.consume(grant) - - cookies.encrypted[preview_cookie_name] = { - value: preview_cookie_value(grant.session), - expires: 8.hours.from_now, - httponly: true, - path: "/", - secure: request.ssl?, - same_site: :lax - } - redirect_to grant.return_path - end - - # Serves an exact Build file or the HTML entry point for a browser route. - # @route GET /__replica/preview/files (__replica_preview_files) - # @route GET /__replica/preview/files/*path - def show - serve_preview_file @build, html_append: "#{BRIDGE_TAG}#{PreviewControlsRespondable::CONTROLS_TAG}" - end - - # Serves Hub's bridge from a path that Build files cannot replace. - # @route GET /__replica/preview/bridge (__replica_preview_bridge) - def bridge - source = File.binread(BRIDGE_SOURCE) - hub_origin = Rails.configuration.x.hub_origin.delete_suffix("/") - if !source.include?(BRIDGE_ORIGIN_PLACEHOLDER) - raise "Preview bridge origin placeholder is missing" - end - if !source.include?(BRIDGE_REVIEW_ENABLED_PLACEHOLDER) - raise "Preview bridge review placeholder is missing" - end - - source = source.sub(BRIDGE_ORIGIN_PLACEHOLDER) { hub_origin.to_json } - source = source.sub(BRIDGE_REVIEW_ENABLED_PLACEHOLDER) { @build.prototype.reviewable?.to_s.to_json } - response.headers["Cache-Control"] = "private, no-store" - response.headers["X-Content-Type-Options"] = "nosniff" - response.headers["Cross-Origin-Resource-Policy"] = "same-origin" - render body: source, content_type: "text/javascript; charset=utf-8" - end - - private - - # Loads one successful Build after checking the signed-in preview cookie. - def load_preview - browser_session = preview_session - return redirect_to_hub if browser_session.nil? - - @build = Build.includes(prototype: :repository).find_by(id: preview_build_id) - return head :not_found if !available_build? - - head :not_found if !authorized?(browser_session) - end - - # Reports whether the host names one successful Build in an active repository. - def available_build? - @build&.state == "successful" && @build.prototype.repository.installation_state == "active" - end - - # Confirms that the saved browser session still has repository access. - def authorized?(browser_session) - @build.prototype.repository.authorization.authorized?(browser_session.user) && - Session.where(id: browser_session.id, user_id: browser_session.user_id).exists? - end - - # Sends an unauthenticated browser through the trusted Hub origin. - def redirect_to_hub - return_path = request.get_header(PreviewPathRouting::ORIGINAL_PATH) - return_path = "#{return_path}?#{request.query_string}" if request.query_string.present? - path = Rails.application.routes.url_helpers.preview_build_authorization_path \ - preview_build_id, - return_path: return_path - redirect_to "#{Rails.configuration.x.hub_origin.delete_suffix("/")}#{path}", allow_other_host: true - end - - # Returns the preview cookie name for the current environment. - def preview_cookie_name - return :"__Host-replica_preview" if Rails.env.production? - - LocalDevelopment.enabled? ? :replica_local_preview : :replica_preview - end - - # Returns the preview cookie data for one browser session. - def preview_cookie_value(browser_session) - { - "build_id" => @build.id, - "session_id" => browser_session.id, - "expires_at" => 8.hours.from_now.to_i - } - end - - # Returns a current browser session when its cookie matches the Build. - def preview_session - value = cookies.encrypted[preview_cookie_name] - return if !value.is_a?(Hash) || value["build_id"].to_i != preview_build_id.to_i - return if value["expires_at"].to_i <= Time.current.to_i - - Session.includes(:user).find_by(id: value["session_id"]) - end - - # Returns unavailable when GitHub cannot confirm repository access. - def repository_unavailable - head :service_unavailable - end -end diff --git a/hub/app/controllers/prototype_authors_controller.rb b/hub/app/controllers/prototype_authors_controller.rb deleted file mode 100644 index 08546f26..00000000 --- a/hub/app/controllers/prototype_authors_controller.rb +++ /dev/null @@ -1,27 +0,0 @@ -# Offers bounded registration-author choices only within an authorized repository. -class PrototypeAuthorsController < ApplicationController - include HasRepositoryWorkspace - - rescue_from GithubClient::Error, with: -> { head :service_unavailable } - - # @route GET /repositories/:repository_github_id/prototype_authors (repository_prototype_authors) - def index - load_repository_workspace - return if performed? - - page = @repository.prototype_listing.authors \ - user: Current.user, - query: params[:q], - cursor: params[:cursor] - response.headers["Cache-Control"] = "private, no-store" - render \ - json: { - authors: page.fetch(:authors).map do |author| - { id: author.id.to_s, login: author.login, name: author.name, avatarUrl: author.avatar_url } - end, - nextCursor: page.fetch(:next_cursor) - } - rescue Repository::PrototypeListing::InvalidQuery - head :bad_request - end -end diff --git a/hub/app/controllers/prototype_management_controller.rb b/hub/app/controllers/prototype_management_controller.rb deleted file mode 100644 index caa40606..00000000 --- a/hub/app/controllers/prototype_management_controller.rb +++ /dev/null @@ -1,57 +0,0 @@ -# Lists only branches a repository collaborator may add, alongside editable gallery entries. -class PrototypeManagementController < SuperglueController - include HasRepositoryWorkspace - - rescue_from GithubClient::Error, with: :repository_unavailable - - # @route GET /repositories/:repository_github_id/prototypes/manage (repository_prototype_management) - def show - load_repository_workspace(repository_choices: false) - return if performed? - return head :not_found if !@repository.authorization.sufficient?(@repository_permission, minimum: :write) - page = params[:page] - return head :not_found if page && (!page.is_a?(String) || !page.match?(/\A[1-9]\d{0,8}\z/)) - - @candidate_page = page ? page.to_i : 1 - @candidate_page_path = repository_prototype_management_path(@repository.route_id) - @registered_prototypes = @repository.reviewable_prototypes.active.registered.order(:branch_name) - @unregistered_prototypes = [] - @candidate_page_count = 0 - return if !request.format.json? - - case params[:props_at] - when "data.management" - load_candidates - when "data.repositoryChoices" - @repositories = authorized_repositories(@github_configuration, including: @repository) - else - head :not_found if params[:props_at] - end - rescue LocalDevelopment::Error => error - response.status = 422 - response.content_type = "text/plain; charset=utf-8" - self.response_body = error.message - end - - private - - # Checks available branches independently of the workspace and listed prototypes. - def load_candidates - @unregistered_prototypes, @candidate_page_count = @repository.prototype_candidates.available_page \ - Current.user, - permission: @repository_permission, - page: @candidate_page, - configuration: @github_configuration - head :not_found if @candidate_page > [ @candidate_page_count, 1 ].max - rescue GithubClient::Error, LocalDevelopment::Error => error - Rails.logger.warn("Prototype candidate check failed: #{error.class}") - @candidate_check_failed = true - @unregistered_prototypes = [] - @candidate_page_count = 0 - end - - # Does not present an incomplete branch list as authoritative. - def repository_unavailable - head :service_unavailable - end -end diff --git a/hub/app/controllers/prototype_registrations_controller.rb b/hub/app/controllers/prototype_registrations_controller.rb deleted file mode 100644 index 6c4b00c5..00000000 --- a/hub/app/controllers/prototype_registrations_controller.rb +++ /dev/null @@ -1,66 +0,0 @@ -# Registers a discovered branch or updates its Hub metadata for repository writers. -class PrototypeRegistrationsController < ApplicationController - rescue_from GithubClient::Error, with: :repository_unavailable - - # @route PATCH /repositories/:repository_github_id/prototypes/:prototype_id/registration (repository_prototype_registration) - # @route PUT /repositories/:repository_github_id/prototypes/:prototype_id/registration (repository_prototype_registration) - def update - repository = Repository.find_by_route_id!(params[:repository_github_id]) - permission = repository.authorization.permission_for(Current.user, fresh: true) - if !repository.authorization.sufficient?(permission, minimum: :write) - return head :not_found - end - - prototype = repository.reviewable_prototypes.find(params[:prototype_id]) - if !prototype.registered? && - !repository.prototype_candidates.eligible?(prototype, Current.user, permission:, fresh: true) - return head :not_found - end - - destination = destination_for(repository, prototype) - prototype.register! \ - **metadata_params, - author: Current.user, - registration_only: params[:intent] == "register_only" - redirect_to destination, status: :see_other - rescue ActiveRecord::RecordInvalid => error - redirect_to \ - destination, - alert: error.record.errors.full_messages.to_sentence, - status: :see_other - rescue LocalDevelopment::Error => error - response.status = 422 - response.content_type = "text/plain; charset=utf-8" - self.response_body = error.message - end - - private - - # Accepts only the review-facing fields that a writer may change. - def metadata_params - params.require(:prototype).permit(:name, :description, :start_page).to_h.symbolize_keys - end - - # Returns to the gallery, management page, or the same selected review Build. - def destination_for(repository, prototype) - return repository_path(repository.route_id) if params[:return_to] == "repository" - - return repository_prototype_management_path(repository.route_id) if params[:return_to] != "review" - - number = params[:build] - if number.present? - if !number.is_a?(String) || !number.match?(/\A[1-9]\d*\z/) || - !prototype.builds.where(state: "successful", number: number.to_i).exists? - raise ActiveRecord::RecordNotFound - end - end - - options = number.present? ? { build: number } : {} - repository_prototype_path(repository.route_id, prototype, **options) - end - - # Returns unavailable when GitHub cannot confirm access. - def repository_unavailable - head :service_unavailable - end -end diff --git a/hub/app/controllers/prototypes_controller.rb b/hub/app/controllers/prototypes_controller.rb deleted file mode 100644 index f6189e0d..00000000 --- a/hub/app/controllers/prototypes_controller.rb +++ /dev/null @@ -1,72 +0,0 @@ -# Shows a branch's selected successful Build after GitHub confirms access. -class PrototypesController < SuperglueController - include HasRepositoryWorkspace - - rescue_from GithubClient::Error, with: :repository_unavailable - - # @route GET /repositories/:repository_github_id/prototypes/:id (repository_prototype) - def show - load_repository_workspace - return if performed? - - @prototype = @repository.reviewable_prototypes.find(params[:id]) - @can_manage_share_links = @prototype.share_links_manageable_by? \ - Current.user, - repository_permission: @repository_permission - return redirect_legacy_history if params.key?(:before) - - @latest_build = @prototype.current_build || - @prototype.builds.chronological.first - @selected_build = selected_build - @build_options = @prototype.builds.chronological.to_a - @preview_path = PreviewGrant.validate_return_path( - params[:return_path].presence || @prototype.start_page || "/" - ) - @can_edit_details = @prototype.registered? && - @repository.authorization.sufficient?(@repository_permission, minimum: :write) && - (@repository.local? || @prototype.author_id == Current.user.id || - @prototype.builds.where(commit_author_github_id: Current.user.github_id).exists?) - @show_prototype_info = false - return if params[:view] == "minimized" || - PrototypeIntroduction.exists?(user: Current.user, prototype: @prototype) - - PrototypeIntroduction.transaction do - introduction = PrototypeIntroduction.record!(user: Current.user, prototype: @prototype) - @show_prototype_info = introduction.previously_new_record? - render :show - raise ActiveRecord::Rollback unless response.successful? - end - rescue ArgumentError - head :not_found - end - - private - - # Returns the requested successful Build or the latest successful Build. - def selected_build - builds = @prototype.builds.where(state: "successful") - return builds.chronological.first if !params.key?(:build) - - number = params[:build] - if !number.is_a?(String) || !number.match?(/\A[1-9]\d*\z/) - raise ActiveRecord::RecordNotFound - end - - builds.find_by!(number: number.to_i) - end - - # Moves a previously generated history URL to the dedicated history route. - def redirect_legacy_history - before = params[:before] - if !before.is_a?(String) || !before.match?(/\A[1-9]\d*\z/) - raise ActiveRecord::RecordNotFound - end - - redirect_to repository_prototype_builds_path(@repository.route_id, @prototype, before:) - end - - # Returns unavailable when GitHub cannot confirm repository access. - def repository_unavailable - head :service_unavailable - end -end diff --git a/hub/app/controllers/replica_browses_controller.rb b/hub/app/controllers/replica_browses_controller.rb deleted file mode 100644 index 36d3e71d..00000000 --- a/hub/app/controllers/replica_browses_controller.rb +++ /dev/null @@ -1,27 +0,0 @@ -# Shows the default branch's latest successful static Build without review controls. -class ReplicaBrowsesController < SuperglueController - include HasRepositoryWorkspace - - rescue_from GithubClient::Error, with: :repository_unavailable - - # @route GET /repositories/:repository_github_id/replica (repository_replica_browse) - def show - load_repository_workspace - return if performed? - - owner = @repository.replica_build_owner - @latest_build = owner&.current_build || owner&.builds&.chronological&.first - @selected_build = owner&.builds&.where(state: "successful")&.chronological&.first - @template = { "id" => "default", "name" => t("templates.default.name"), "path" => "/" } - @preview_path = PreviewGrant.validate_return_path(params.key?(:return_path) ? params[:return_path] : "/") - rescue ArgumentError - head :not_found - end - - private - - # Access failures must not return a successful empty Browse page. - def repository_unavailable - head :service_unavailable - end -end diff --git a/hub/app/controllers/repositories_controller.rb b/hub/app/controllers/repositories_controller.rb deleted file mode 100644 index 1d6dbf25..00000000 --- a/hub/app/controllers/repositories_controller.rb +++ /dev/null @@ -1,117 +0,0 @@ -# Renders only installed repositories the current GitHub user may read. -class RepositoriesController < SuperglueController - include HasRepositoryWorkspace - - rescue_from GithubClient::Error, with: :repository_unavailable - - # @route GET /repositories (repositories) - def index - Repository::LocalDiscovery.register_repositories! if LocalDevelopment.enabled? - - configuration = GithubAppConfiguration.current - repository = authorized_repositories(configuration).first - return head :not_found if repository.nil? - - flash.keep(:notice) - redirect_to repository_path(repository.route_id) - rescue LocalDevelopment::Error => error - local_error(error.message) - end - - # @route GET /repositories/:github_id (repository) - def show - load_repository_workspace - return if performed? - - @listing = @repository.prototype_listing.page \ - user: Current.user, - query: params[:q], - author: params[:author], - page: params[:page] - @prototypes = @listing.prototypes - ActiveRecord::Associations::Preloader.new(records: @prototypes, associations: [ :author, :current_build ]).call - prototype_ids = @prototypes.map(&:id) - @latest_builds = @prototypes.filter_map(&:current_build).index_by(&:prototype_id) - missing_prototype_ids = prototype_ids - @latest_builds.keys - fallback_builds = Build.where(prototype_id: missing_prototype_ids).latest_by_prototype - @latest_builds.merge!(fallback_builds.index_by(&:prototype_id)) - @review_builds = Build.where \ - prototype_id: prototype_ids, - state: "successful" - @review_builds = @review_builds.latest_by_prototype.index_by(&:prototype_id) - @workflow_status = @repository.local? ? :installed : workflow_status(GithubAppConfiguration.current) - @repository_writable = @repository.authorization.sufficient? \ - @repository_permission, - minimum: :write - if @repository_writable - begin - @recent_branches = @repository.prototype_candidates.recently_active_by \ - Current.user, - permission: @repository_permission - rescue GithubClient::Error, LocalDevelopment::Error - @candidate_check_failed = true - end - end - rescue Repository::PrototypeListing::InvalidQuery - head :bad_request - end - - # @route POST /repositories/:github_id/poll (poll_repository) - def poll - if LocalDevelopment.enabled? - return head :not_found if Current.user.github_id.present? - - repository = Repository.where(github_id: nil).find_by(id: params[:github_id]) - if repository.nil? || repository.installation_state != "active" || - !LocalDevelopment.repository_available?(repository.local_path, identity: repository.local_identity) - return local_error(I18n.t("local_development.repository_unavailable", - directory: LocalDevelopment.repositories_root)) - end - else - repository = Repository.find_by_route_id!(params[:github_id]) - end - configuration = GithubAppConfiguration.current - authorized = repository.authorization.authorized? \ - Current.user, - minimum: :write, - fresh: true, - configuration: configuration - return head :not_found if !authorized - - if LocalDevelopment.enabled? - repository.local_discovery.refresh!(requeue_pending: true) - redirect_to repository_path(repository.route_id), status: :see_other - else - configuration.discover_builds_later!(repository) - head :accepted - end - rescue LocalDevelopment::Error => error - if LocalDevelopment.enabled? && repository&.local? && - LocalDevelopment.repository_available?(repository.local_path, identity: repository.local_identity) - redirect_to repository_path(repository.route_id), alert: error.message, status: :see_other - else - local_error(error.message) - end - end - - private - - # Reports a missing local checkout without requiring a repository page to exist. - def local_error(message) - response.status = 422 - response.content_type = "text/plain; charset=utf-8" - self.response_body = message - end - - # Fails closed for one repository when GitHub cannot confirm access. - def repository_unavailable - head :service_unavailable - end - - # Keeps stored repository data available when the optional workflow check fails. - def workflow_status(configuration) - @repository.workflow_installation.status(configuration:) - rescue GithubClient::Error - :unavailable - end -end diff --git a/hub/app/controllers/repository_security_audits_controller.rb b/hub/app/controllers/repository_security_audits_controller.rb deleted file mode 100644 index cc942cea..00000000 --- a/hub/app/controllers/repository_security_audits_controller.rb +++ /dev/null @@ -1,19 +0,0 @@ -# Authorizes each read with GitHub before selecting only this repository's audit records. -class RepositorySecurityAuditsController < SuperglueController - include HasRepositoryWorkspace - include HasSecurityAuditPage - rescue_from GithubClient::Error, with: -> { head :service_unavailable } - - # @route GET /repositories/:repository_github_id/security_audit (repository_security_audit) - def show - load_repository_workspace(fresh: true) - return if performed? - return head :not_found if !@repository.authorization.sufficient?(@repository_permission, minimum: :admin) - - load_audit_scopes - load_audit_events \ - SecurityAuditEvent.retained.where(repository: @repository), - path: repository_security_audit_path(@repository.route_id) - record_security_audit(outcome: "succeeded") - end -end diff --git a/hub/app/controllers/repository_workflow_installations_controller.rb b/hub/app/controllers/repository_workflow_installations_controller.rb deleted file mode 100644 index 33c35a76..00000000 --- a/hub/app/controllers/repository_workflow_installations_controller.rb +++ /dev/null @@ -1,67 +0,0 @@ -# Opens a reviewable pull request that synchronizes the Replica workflow. -class RepositoryWorkflowInstallationsController < ApplicationController - rescue_from GithubClient::Error, with: :repository_unavailable - - # @route POST /repositories/:repository_github_id/workflow_installation (repository_workflow_installation) - def create - return head :not_found if LocalDevelopment.enabled? - - repository = Repository.active.find_by!(github_id: params[:repository_github_id]) - configuration = GithubAppConfiguration.current - authorized = repository.authorization.authorized? \ - Current.user, - minimum: :write, - fresh: true, - configuration: configuration - return head :not_found if !authorized - return if !enforce_security_rate_limit(:workflow_installation) - - open_pull_request(repository, configuration) - end - - private - - # Returns to the repository with a useful reason when GitHub cannot open the PR. - def open_pull_request(repository, configuration) - pull_request = repository.workflow_installation.create_pull_request!(configuration:) - redirect_to \ - pull_request_url(repository, pull_request.fetch("number")), - allow_other_host: true, - status: :see_other - rescue Repository::WorkflowInstallation::AlreadyInstalled - redirect_to \ - repository_path(repository.github_id), - notice: I18n.t("repositories.show.workflow.already_current"), - status: :see_other - rescue Repository::WorkflowInstallation::BranchConflict - workflow_error(repository, :branch_conflict) - rescue GithubClient::Forbidden, GithubClient::NotFound - workflow_error(repository, :permissions) - rescue GithubClient::RateLimited - workflow_error(repository, :rate_limited) - rescue GithubClient::Error - workflow_error(repository, :github_error) - rescue ArgumentError - workflow_error(repository, :unconfigured) - end - - def repository_unavailable - render \ - plain: I18n.t("repositories.show.workflow.errors.access_unavailable"), - status: :service_unavailable - end - - # Uses the existing repository alert without exposing GitHub's response body. - def workflow_error(repository, reason) - redirect_to \ - repository_path(repository.github_id), - alert: I18n.t("repositories.show.workflow.errors.#{reason}"), - status: :see_other - end - - # Builds a GitHub URL from validated repository and pull request identities. - def pull_request_url(repository, number) - github_url = Rails.configuration.x.github_url.delete_suffix("/") - "#{github_url}/#{repository.github_path}/pull/#{number}" - end -end diff --git a/hub/app/controllers/review_comment_reactions_controller.rb b/hub/app/controllers/review_comment_reactions_controller.rb deleted file mode 100644 index 73ace78d..00000000 --- a/hub/app/controllers/review_comment_reactions_controller.rb +++ /dev/null @@ -1,38 +0,0 @@ -# Adds and removes reactions from live review messages. -class ReviewCommentReactionsController < ApplicationController - include HasReviewCommentContext - include ReviewCommentMutations - - prepend_before_action :reject_large_request - - # @route POST /repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:review_comment_id/reactions (repository_prototype_review_comment_reactions) - def create - attributes = reaction_attributes - comment = @prototype.review_comments.not_deleted.find(params[:review_comment_id]) - comment.thread.add_reaction! \ - user: Current.user, - content: attributes[:content], - activity_version: positive_activity_version(attributes[:activity_version]) - - render_review_comment_change comment, status: :created - end - - # @route DELETE /repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:review_comment_id/reactions/:content (repository_prototype_review_comment_reaction) - def destroy - attributes = reaction_attributes - comment = @prototype.review_comments.find(params[:review_comment_id]) - comment.thread.remove_reaction! \ - user: Current.user, - content: params[:content], - activity_version: positive_activity_version(attributes[:activity_version]) - - render_review_comment_change comment - end - - private - - # Accepts reaction fields and the thread version shown to the reviewer. - def reaction_attributes - params.require(:review_comment_reaction).permit(:content, :activity_version) - end -end diff --git a/hub/app/controllers/review_comment_replies_controller.rb b/hub/app/controllers/review_comment_replies_controller.rb deleted file mode 100644 index 07ef09af..00000000 --- a/hub/app/controllers/review_comment_replies_controller.rb +++ /dev/null @@ -1,17 +0,0 @@ -# Adds replies to review comment threads. -class ReviewCommentRepliesController < ApplicationController - include HasReviewCommentContext - include ReviewCommentMutations - - prepend_before_action :reject_large_request, only: :create - - # @route POST /repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:review_comment_id/replies (repository_prototype_review_comment_replies) - def create - root = @prototype.review_comments.roots.find(params[:review_comment_id]) - reply = root.thread.create_reply! \ - user: Current.user, - body: comment_body - - render_review_comment_change reply, status: :created - end -end diff --git a/hub/app/controllers/review_comment_resolutions_controller.rb b/hub/app/controllers/review_comment_resolutions_controller.rb deleted file mode 100644 index d62b02a7..00000000 --- a/hub/app/controllers/review_comment_resolutions_controller.rb +++ /dev/null @@ -1,21 +0,0 @@ -# Changes resolution state for review comment threads. -class ReviewCommentResolutionsController < ApplicationController - include HasReviewCommentContext - include ReviewCommentMutations - - # @route POST /repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:review_comment_id/resolution (repository_prototype_review_comment_resolution) - def create - root = @prototype.review_comments.roots.find(params[:review_comment_id]) - root.thread.resolve_by!(Current.user) - - render_review_comment_change root - end - - # @route DELETE /repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:review_comment_id/resolution (repository_prototype_review_comment_resolution) - def destroy - root = @prototype.review_comments.roots.find(params[:review_comment_id]) - root.thread.reopen_by!(Current.user) - - render_review_comment_change root - end -end diff --git a/hub/app/controllers/review_comments_controller.rb b/hub/app/controllers/review_comments_controller.rb deleted file mode 100644 index 345be2bb..00000000 --- a/hub/app/controllers/review_comments_controller.rb +++ /dev/null @@ -1,4 +0,0 @@ -# Lists and changes review comments for one Prototype. -class ReviewCommentsController < ApplicationController - include ReviewCommentActions -end diff --git a/hub/app/controllers/review_thread_reads_controller.rb b/hub/app/controllers/review_thread_reads_controller.rb deleted file mode 100644 index 974bfc16..00000000 --- a/hub/app/controllers/review_thread_reads_controller.rb +++ /dev/null @@ -1,32 +0,0 @@ -# Records which thread activity the current reviewer has seen. -class ReviewThreadReadsController < ApplicationController - include HasReviewCommentContext - - prepend_before_action :reject_large_request, only: :create - - # @route POST /repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:review_comment_id/read (repository_prototype_review_comment_read) - def create - root = @prototype.review_comments.roots.find(params[:review_comment_id]) - root.thread.record_read_by! \ - Current.user, - activity_version: observed_activity_version - - render_review_comment_change root, broadcast: false - end - - # Clears only the current reviewer's read state. - # @route DELETE /repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:review_comment_id/read (repository_prototype_review_comment_read) - def destroy - root = @prototype.review_comments.roots.find(params[:review_comment_id]) - root.thread.record_unread_by! Current.user - render_review_comment_change root, broadcast: false - end - - private - - # Requires the browser to name the positive activity version it displayed. - def observed_activity_version - value = params.require(:review_thread_read).permit(:activity_version)[:activity_version] - positive_activity_version(value) - end -end diff --git a/hub/app/controllers/security_audits_controller.rb b/hub/app/controllers/security_audits_controller.rb deleted file mode 100644 index 7cd91a54..00000000 --- a/hub/app/controllers/security_audits_controller.rb +++ /dev/null @@ -1,14 +0,0 @@ -# Gives enrolled operators deployment records, never repository-content permission. -class SecurityAuditsController < SuperglueController - include HasSecurityAuditPage - - # @route GET /security_audit (security_audit) - def show - hub_admin = User.where(id: Current.user.id, hub_admin: true).where.not(github_id: nil).exists? - return head :not_found if !hub_admin - - load_audit_scopes - load_audit_events(SecurityAuditEvent.global.retained, path: security_audit_path) - record_security_audit(outcome: "succeeded") - end -end diff --git a/hub/app/controllers/session_revocations_controller.rb b/hub/app/controllers/session_revocations_controller.rb deleted file mode 100644 index e7329dde..00000000 --- a/hub/app/controllers/session_revocations_controller.rb +++ /dev/null @@ -1,91 +0,0 @@ -# Ends deployment authentication without changing repository or public-share policy. -class SessionRevocationsController < SuperglueController - include FreshAuthorizationRequired - - before_action :authorize_admin - before_action :confirm_revocation, only: :create - before_action :authorize_sensitive_action, only: :create - - rescue_from User::AdminRoleChangeDenied, with: :access_denied - rescue_from FreshAuthorization::Invalid, with: :authorization_expired - rescue_from ActiveRecord::RecordInvalid, with: :save_failed - - # @route GET /session_revocation (session_revocation) - def show - @browser_count = Session.count - @canvas_count = CanvasGrant.current.count - @pairing_count = CanvasPairing.where(consumed_at: nil).where("expires_at > ?", Time.current).count - record_security_audit(outcome: "succeeded") - end - - # @route POST /session_revocation (session_revocation) - def create - actor = Current.user - audit_context = Current.security_audit_context.merge(session_id: Current.session.id) - credentials = SessionRevocation.end_all!(actor:, browser_session: Current.session) - @security_audit_recorded = true - @ended = true - Current.session = nil - reset_session - disconnect(credentials, actor: actor, audit_context: audit_context) - render :show - end - - private - - # Matches Hub administrator management's persisted, GitHub-backed role guard. - def authorize_admin - return if User.where(id: Current.user.id, hub_admin: true).where.not(github_id: nil).exists? - - access_denied - end - - def confirm_revocation - if params[:cancelled] == "1" - record_security_audit(outcome: "denied") - redirect_to session_revocation_path, notice: t("session_revocation.cancelled"), status: :see_other - elsif params[:confirmed] != "1" - record_security_audit(outcome: "denied") - redirect_to session_revocation_path, alert: t("session_revocation.confirmation_required"), status: :see_other - end - end - - def authorize_sensitive_action - require_fresh_authorization \ - purpose: :session_revocation, return_to: session_revocation_path - end - - def security_audit_action - action_name == "create" ? "session_revocation.end_all" : "session_revocation.view" - end - - def access_denied(_error = nil) - record_security_audit(outcome: "denied") - @access_denied = true - flash.now[:alert] = t("session_revocation.not_authorized") - render :show, status: :forbidden - end - - def authorization_expired - record_security_audit(outcome: "denied") - redirect_to session_revocation_path, alert: t("fresh_authorization.required"), status: :see_other - end - - def save_failed(error) - raise error if error.record.is_a?(SecurityAuditEvent) - - record_security_audit(outcome: "failed") - redirect_to session_revocation_path, alert: t("session_revocation.save_failed"), status: :see_other - end - - # The database cutoff remains effective even if the cable transport is unavailable. - def disconnect(credentials, actor:, audit_context:) - SessionRevocation.disconnect!(credentials) - rescue ActiveRecord::ActiveRecordError, IOError, SystemCallError, Timeout::Error - @disconnect_failed = true - SecurityAuditEvent.record! \ - action: "session_revocation.disconnect", outcome: "failed", actor: actor, - context: audit_context - Rails.logger.error("Session revocation committed; live disconnect delivery failed.") - end -end diff --git a/hub/app/controllers/sessions_controller.rb b/hub/app/controllers/sessions_controller.rb deleted file mode 100644 index 5e02cf34..00000000 --- a/hub/app/controllers/sessions_controller.rb +++ /dev/null @@ -1,8 +0,0 @@ -# Revokes authenticated browser sessions. -class SessionsController < ApplicationController - # @route DELETE /session (session) - def destroy - terminate_session - redirect_to root_path - end -end diff --git a/hub/app/controllers/share_links_controller.rb b/hub/app/controllers/share_links_controller.rb deleted file mode 100644 index dbb19089..00000000 --- a/hub/app/controllers/share_links_controller.rb +++ /dev/null @@ -1,134 +0,0 @@ -# Lists, creates, and deletes public links for one Prototype. -class ShareLinksController < SuperglueController - include HasRepositoryWorkspace - include HasShareLinkContext - - before_action :load_share_link_context - before_action :require_public_sharing, only: %i[create destroy] - before_action :validate_control_parameters, only: [ :create, :destroy ] - before_action :validate_management_build, only: %i[create destroy] - - rescue_from GithubClient::Error, with: :repository_unavailable - rescue_from SharingSetting::Disabled, SharingSetting::Changed, with: :public_sharing_disabled - rescue_from ShareLinkCreatorRevocation::Changed, with: :creator_access_changed - - # Lists active share links for one Prototype. - # @route GET /repositories/:repository_github_id/prototypes/:prototype_id/share_links (repository_prototype_share_links) - def index - prepare_index - end - - # Creates one public link for a successful Build. - # @route POST /repositories/:repository_github_id/prototypes/:prototype_id/share_links (repository_prototype_share_links) - def create - share_link = create_share_link - return if performed? - - redirect_to management_path, status: :see_other - rescue ShareLink::InvalidExpiration, ActiveRecord::RecordInvalid => error - invalid_share_link(error) - end - - # Deletes one link so its public route stops working. - # @route DELETE /repositories/:repository_github_id/prototypes/:prototype_id/share_links/:id (repository_prototype_share_link) - def destroy - share_link = @prototype.share_links.find(params[:id]) - share_link.destroy! - redirect_to management_path, status: :see_other - end - - private - - def security_audit_action - { "create" => "share_link.create", "destroy" => "share_link.delete" }[action_name] - end - - # Loads one Prototype after checking current GitHub permission. - def load_share_link_context - @repository = Repository.find_by_route_id!(params[:repository_github_id]) - return head :not_found if @repository.local? - - load_repository_workspace(fresh: true) - return if performed? - - load_share_link_prototype - end - - # Loads the current successful Build and active links for the page. - def prepare_index - @public_sharing_enabled = SharingSetting.enabled? - @selected_build = selected_build - @latest_build = @prototype.current_build || @prototype.builds.chronological.first - @build_options = @prototype.builds.chronological.to_a - @can_manage_share_links = true - @share_links = (@public_sharing_enabled ? @prototype.share_links.active : @prototype.share_links.none) - .includes(:build, :created_by) - .order(created_at: :desc, id: :desc) - end - - def require_public_sharing - public_sharing_disabled if !SharingSetting.enabled? - end - - # Returns the requested successful Build or the latest successful Build. - def selected_build - builds = @prototype.builds.where(state: "successful") - return builds.chronological.first if !params.key?(:build) - - builds.find_by!(number: positive_build_number(params[:build])) - end - - # Returns to the same default or explicitly selected management view. - def management_path - repository_prototype_share_links_path \ - @repository.route_id, - @prototype, - **@control_parameters, - **(@management_build_number ? { build: @management_build_number } : {}) - end - - def validate_management_build - value = params[:management_build] - return if value.nil? - - number = positive_build_number(value) - @management_build_number = @prototype.builds.where(state: "successful").find_by!(number:).number - end - - def validate_control_parameters - @control_parameters = control_parameters - end - - def control_parameters - query = params[:control_query] - return {} if query.nil? || query == "" - - unless query.is_a?(String) && query.bytesize <= 8192 - raise ActionController::BadRequest, "Invalid control settings query." - end - - entries = URI.decode_www_form(query) - keys = entries.map(&:first) - unless URI.encode_www_form(entries).bytesize <= 8192 && keys.uniq.length == keys.length && - keys.all? { |key| key.match?(/\Arc\.[a-zA-Z][a-zA-Z0-9_-]{0,63}\.[a-zA-Z][a-zA-Z0-9_-]{0,63}\z/) } - raise ActionController::BadRequest, "Invalid control settings parameters." - end - entries.to_h - rescue ArgumentError - raise ActionController::BadRequest, "Invalid control settings encoding." - end - - # Returns a clear validation error without saving a partial link. - def invalid_share_link(error) - record_security_audit(outcome: "failed") - redirect_to \ - management_path, - alert: share_link_errors(error).to_sentence, - status: :see_other - end - - # Returns unavailable when GitHub cannot confirm repository access. - def repository_unavailable - head :service_unavailable - end -end diff --git a/hub/app/controllers/share_preview_authorizations_controller.rb b/hub/app/controllers/share_preview_authorizations_controller.rb deleted file mode 100644 index a7c913e7..00000000 --- a/hub/app/controllers/share_preview_authorizations_controller.rb +++ /dev/null @@ -1,32 +0,0 @@ -# Starts an anonymous preview for one active public share link. -class SharePreviewAuthorizationsController < ApplicationController - include PublicSharingRequired - allow_unauthenticated_access - rate_limit to: 60, within: 1.minute, only: :show, with: -> { head :too_many_requests } - before_action :set_public_headers - - # @route GET /share/:public_id/preview (share_preview_authorization) - def show - return head :not_found if request.base_url != Rails.configuration.x.hub_origin.delete_suffix("/") - - @share_link = ShareLink.find_available(params[:public_id]) - return head :not_found if @share_link.nil? - - grant = SharePreviewGrant.issue!(share_link: @share_link, return_path: params[:return_path].presence) - response.location = SharePreviewOrigin.current.url \ - @share_link.build_id, - path: "/__replica/authorize", - query: { grant: } - head :found - rescue ArgumentError, SharingSetting::Disabled - head :not_found - end - - private - - # Prevents browsers from retaining the public handoff response. - def set_public_headers - response.headers["Cache-Control"] = "private, no-store" - response.headers["Referrer-Policy"] = "no-referrer" - end -end diff --git a/hub/app/controllers/shared_builds_controller.rb b/hub/app/controllers/shared_builds_controller.rb deleted file mode 100644 index 0cb1fac5..00000000 --- a/hub/app/controllers/shared_builds_controller.rb +++ /dev/null @@ -1,21 +0,0 @@ -# Shows one public Build share without requiring GitHub sign-in. -class SharedBuildsController < SuperglueController - include PublicSharingRequired - allow_unauthenticated_access - rate_limit to: 60, within: 1.minute, only: :show, with: -> { head :too_many_requests } - before_action :set_public_headers - - # @route GET /share/:public_id (shared_build) - def show - @share_link = ShareLink.find_available(params[:public_id]) - head :not_found if @share_link.nil? - end - - private - - # Prevents browsers from retaining public share details. - def set_public_headers - response.headers["Cache-Control"] = "private, no-store" - response.headers["Referrer-Policy"] = "no-referrer" - end -end diff --git a/hub/app/controllers/shared_preview_files_controller.rb b/hub/app/controllers/shared_preview_files_controller.rb deleted file mode 100644 index f9bc25b0..00000000 --- a/hub/app/controllers/shared_preview_files_controller.rb +++ /dev/null @@ -1,79 +0,0 @@ -# Serves one public Build share from its dedicated preview host. -class SharedPreviewFilesController < ActionController::Base - include PublicSharingRequired - include PreviewFileRespondable - include PreviewControlsRespondable - before_action :load_preview, only: %i[controls show] - - # Exchanges a public share grant for a host-only preview cookie. - # @route GET /__replica/share/authorize (__replica_share_authorize) - def authorize - response.headers["Cache-Control"] = "private, no-store" - grant = SharePreviewGrant.find_valid(params[:grant], build_id: preview_build_id) - return head :not_found if grant.nil? - - @share_link = grant.share_link - @build = @share_link.build - return head :not_found if !SharePreviewGrant.consume(grant) - - cookie_expiration = [ 8.hours.from_now, @share_link.effective_expires_at ].compact.min - cookies.encrypted[share_preview_cookie_name] = { - value: { - "build_id" => @build.id, - "share_link_id" => @share_link.id, - "expires_at" => cookie_expiration.to_i - }, - expires: cookie_expiration, - httponly: true, - path: "/", - secure: request.ssl?, - same_site: :lax - } - redirect_to grant.return_path - end - - # Serves an exact shared Build file or its HTML entry point. - # @route GET /__replica/share/files (__replica_share_files) - # @route GET /__replica/share/files/*path - def show - serve_preview_file @build, html_append: PreviewControlsRespondable::CONTROLS_TAG - end - - # Returns not found for paths reserved from retained Build files. - # @route GET /__replica/share/not_found (__replica_share_not_found) - def unavailable - response.headers["Cache-Control"] = "private, no-store" - head :not_found - end - - private - - # Loads one Build after checking a current public share link. - def load_preview - response.headers["Cache-Control"] = "private, no-store" - value = share_preview_cookie - return head :not_found if value.nil? - - @share_link = ShareLink.includes(build: { prototype: :repository }) - .find_by(id: value["share_link_id"]) - return head :not_found if @share_link.nil? - return head :not_found if @share_link.build_id != preview_build_id - return head :not_found if !@share_link.available? - - @build = @share_link.build - end - - # Returns the public share cookie name for the current environment. - def share_preview_cookie_name - Rails.env.production? ? :"__Host-replica_share_preview" : :replica_share_preview - end - - # Returns a current public share cookie for this Build. - def share_preview_cookie - value = cookies.encrypted[share_preview_cookie_name] - return if !value.is_a?(Hash) || value["build_id"].to_i != preview_build_id.to_i - return if value["expires_at"].to_i <= Time.current.to_i - - value - end -end diff --git a/hub/app/controllers/sharing_settings_controller.rb b/hub/app/controllers/sharing_settings_controller.rb deleted file mode 100644 index f12e5dec..00000000 --- a/hub/app/controllers/sharing_settings_controller.rb +++ /dev/null @@ -1,86 +0,0 @@ -# Changes the deployment opt-in without granting access to repository content. -class SharingSettingsController < SuperglueController - include FreshAuthorizationRequired - - before_action :authorize_admin - before_action :confirm_change, only: :update - before_action :authorize_sensitive_action, only: :update - - rescue_from SharingSetting::ChangeDenied, with: :change_denied - rescue_from FreshAuthorization::Invalid, with: :authorization_expired - rescue_from ActiveRecord::RecordInvalid, with: :invalid_setting - - # @route GET /sharing_settings (sharing_settings) - def show - @enabled = SharingSetting.enabled? - record_security_audit(outcome: "succeeded") - end - - # @route PATCH /sharing_settings (sharing_settings) - # @route PUT /sharing_settings (sharing_settings) - def update - SharingSetting.change! \ - actor: Current.user, - browser_session: Current.session, - operation: params[:operation] - @security_audit_recorded = true - redirect_to sharing_settings_path, notice: t("sharing_settings.changed"), status: :see_other - end - - private - - # Check the saved role before exposing state or initiating a challenge. - def authorize_admin - return if User.where(id: Current.user.id, hub_admin: true).where.not(github_id: nil).exists? - - record_security_audit(outcome: "denied") - @access_denied = true - flash.now[:alert] = t("sharing_settings.errors.not_authorized") - render :show, status: :forbidden - end - - def confirm_change - return if params[:confirmed] == "1" && params[:operation].in?(%w[enable disable]) - - change_denied(SharingSetting::ChangeDenied.new(:confirmation_required)) - end - - def authorize_sensitive_action - require_fresh_authorization \ - purpose: :sharing_settings, - return_to: sharing_settings_path - end - - def security_audit_action - return "sharing_settings.view" if action_name != "update" - - operation = params[:operation].in?(%w[enable disable]) ? params[:operation] : "change" - "sharing_settings.#{operation}" - end - - def change_denied(error) - record_security_audit(outcome: "denied", actor: User.find_by(id: Current.user.id)) - if error.reason == :not_authorized - @access_denied = true - flash.now[:alert] = t("sharing_settings.errors.not_authorized") - render :show, status: :forbidden - else - redirect_to \ - sharing_settings_path, - alert: t("sharing_settings.errors.#{error.reason}"), - status: :see_other - end - end - - def authorization_expired - record_security_audit(outcome: "denied") - redirect_to sharing_settings_path, alert: t("fresh_authorization.required"), status: :see_other - end - - def invalid_setting(error) - raise error if error.record.is_a?(SecurityAuditEvent) - - record_security_audit(outcome: "failed") - redirect_to sharing_settings_path, alert: t("sharing_settings.errors.save_failed"), status: :see_other - end -end diff --git a/hub/app/controllers/superglue_controller.rb b/hub/app/controllers/superglue_controller.rb deleted file mode 100644 index 8652a4b4..00000000 --- a/hub/app/controllers/superglue_controller.rb +++ /dev/null @@ -1,4 +0,0 @@ -# Provides React rendering defaults to browser application pages. -class SuperglueController < ApplicationController - before_action :use_jsx_rendering_defaults -end diff --git a/hub/app/controllers/template_screenshots_controller.rb b/hub/app/controllers/template_screenshots_controller.rb deleted file mode 100644 index 9abd38c9..00000000 --- a/hub/app/controllers/template_screenshots_controller.rb +++ /dev/null @@ -1,34 +0,0 @@ -# Serves only declared template screenshots after checking repository read access. -class TemplateScreenshotsController < ApplicationController - include ArtifactRespondable - - rescue_from GithubClient::Error, Build::Templates::InvalidManifest, ArtifactStore::Error, - with: :repository_unavailable - - # @route GET /repositories/:repository_github_id/templates/:template_id/builds/:build_id/screenshots/:size (repository_template_screenshot) - def show - repository = Repository.find_by_route_id!(params[:repository_github_id]) - return head :not_found if !repository.authorization.authorized?(Current.user) - - owner = repository.replica_build_owner - build = owner&.builds&.where(state: "successful")&.find(params[:build_id]) - return head :not_found if !build - - id = params[:template_id] - return head :not_found if id != "default" && build.templates.entries.none? { _1.fetch("id") == id } - - return head :bad_request if params[:theme].present? && !%w[light dark].include?(params[:theme]) - - send_artifact \ - build.screenshot_path(params[:size], template_id: id, theme: params[:theme]), - fallback: params[:theme] == "dark" ? build.screenshot_path(params[:size], template_id: id) : nil, - content_type: "image/png", - filename: "#{id}-#{params[:size]}.png" - end - - private - - def repository_unavailable - head :service_unavailable - end -end diff --git a/hub/app/controllers/templates_controller.rb b/hub/app/controllers/templates_controller.rb deleted file mode 100644 index 63a1e915..00000000 --- a/hub/app/controllers/templates_controller.rb +++ /dev/null @@ -1,39 +0,0 @@ -# Redirects the old catalog and previews the default branch's templates. -class TemplatesController < SuperglueController - include HasRepositoryTemplates - - rescue_from GithubClient::Error, ArtifactStore::Error, Build::Templates::InvalidManifest, - with: :templates_unavailable - - # @route GET /repositories/:repository_github_id/templates (repository_templates) - def index - load_repository_workspace - return if performed? - - redirect_to repository_new_prototype_path(@repository.route_id) - end - - # @route GET /repositories/:repository_github_id/templates/:id (repository_template) - def show - load_repository_templates - return if performed? - - @template = if params[:id] == "default" - { "id" => "default", "name" => t("templates.default.name"), "path" => "/" } - else - @templates.find { _1.fetch("id") == params[:id] } || raise(ActiveRecord::RecordNotFound) - end - @preview_path = PreviewGrant.validate_return_path \ - params.key?(:return_path) ? params[:return_path] : @template.fetch("path") - render "replica_browses/show" - rescue ArgumentError - head :not_found - end - - private - - # Missing or corrupt retained metadata is a failure, not an empty template list. - def templates_unavailable - head :service_unavailable - end -end diff --git a/hub/app/controllers/welcome_controller.rb b/hub/app/controllers/welcome_controller.rb deleted file mode 100644 index 348a1897..00000000 --- a/hub/app/controllers/welcome_controller.rb +++ /dev/null @@ -1,16 +0,0 @@ -class WelcomeController < SuperglueController - include HasRepositoryWorkspace - - # @route GET /welcome (welcome) - def show - end - - # @route PATCH /welcome (welcome) - # @route PUT /welcome (welcome) - def update - Current.user.hide_welcome! - repository = hub_repository_workspace.fetch(:repository) - redirect_to welcome_path(repository_github_id: repository&.route_id), - notice: t("welcome.hidden"), status: :see_other - end -end diff --git a/hub/app/helpers/application_helper.rb b/hub/app/helpers/application_helper.rb deleted file mode 100644 index de6be794..00000000 --- a/hub/app/helpers/application_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module ApplicationHelper -end diff --git a/hub/app/helpers/notifications_helper.rb b/hub/app/helpers/notifications_helper.rb deleted file mode 100644 index 30f32ef3..00000000 --- a/hub/app/helpers/notifications_helper.rb +++ /dev/null @@ -1,11 +0,0 @@ -module NotificationsHelper - # Counts unread activity only after confirming current access to every source repository. - def notifications_unread_count - @notifications_unread_count ||= Notification.visible_to(Current.user).where(read_at: nil).count - end - - # Retains the boolean contract for callers that do not display a counter. - def notifications_unread? - notifications_unread_count.positive? - end -end diff --git a/hub/app/helpers/preview_controls_helper.rb b/hub/app/helpers/preview_controls_helper.rb deleted file mode 100644 index 4d04c61c..00000000 --- a/hub/app/helpers/preview_controls_helper.rb +++ /dev/null @@ -1,8 +0,0 @@ -module PreviewControlsHelper - # Separates local saved values by viewer, access mode and prototype without exposing private identity. - def preview_controls_identity(prototype, context:) - identity = [ context, prototype.repository_id, prototype.id, prototype.branch_name ].to_json - key = Rails.application.key_generator.generate_key("replica-build-controls-storage-v1", 32) - OpenSSL::HMAC.hexdigest("SHA256", key, identity) - end -end diff --git a/hub/app/javascript/application/build-visit-and-remote/index.test.ts b/hub/app/javascript/application/build-visit-and-remote/index.test.ts deleted file mode 100644 index cb7ac812..00000000 --- a/hub/app/javascript/application/build-visit-and-remote/index.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { SuperglueStore } from "@thoughtbot/superglue" -import { visit } from "@thoughtbot/superglue/action_creators" -import { describe, expect, it, vi } from "vitest" - -import { navigationRequest } from "@application/navigation-request" - -import { buildVisitAndRemote } from "." - -vi.mock("@thoughtbot/superglue/action_creators", () => ({ - remote: vi.fn(), - visit: vi.fn(), -})) - -describe("buildVisitAndRemote()", () => { - it.each([new Error("Network unavailable"), new DOMException("Visit cancelled", "AbortError")])( - "clears a pending navigation after %s", - async (failure) => { - const visitAction = { type: "test/visit" } - const dispatched: unknown[] = [] - vi.mocked(visit).mockReturnValue(visitAction as never) - const store = { - dispatch: vi.fn((action: unknown) => { - dispatched.push(action) - return action === visitAction ? Promise.reject(failure) : action - }), - } as unknown as SuperglueStore - const application = buildVisitAndRemote({ current: null }, store) - - await expect(application.visit("/repositories/1/prototypes/manage", {})).rejects.toBe(failure) - - expect(dispatched.some(navigationRequest.actions.started.match)).toBe(true) - expect(dispatched.some(navigationRequest.actions.finished.match)).toBe(true) - const state = dispatched.reduce>( - (current, action) => navigationRequest.reducer(current, action as never), - navigationRequest.getInitialState(), - ) - expect(state.path).toBeNull() - }, - ) -}) diff --git a/hub/app/javascript/application/build-visit-and-remote/index.ts b/hub/app/javascript/application/build-visit-and-remote/index.ts deleted file mode 100644 index 61986ab6..00000000 --- a/hub/app/javascript/application/build-visit-and-remote/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { - ApplicationRemote, - ApplicationVisit, - BuildVisitAndRemote, - SuperglueStore, -} from "@thoughtbot/superglue" -import { remote, visit } from "@thoughtbot/superglue/action_creators" - -import { navigationRequest } from "@application/navigation-request" - -let nextVisitRequestId = 0 - -/** Connects Superglue navigation to browser history and full-page fallbacks. */ -export const buildVisitAndRemote: BuildVisitAndRemote = (ref, store: SuperglueStore) => { - const appRemote: ApplicationRemote = (path, options) => { - const requestOptions = { ...options } - delete requestOptions.dataset - return store.dispatch(remote(path, requestOptions)) - } - - const appVisit: ApplicationVisit = (path, { dataset, ...options } = {}) => { - const requestId = ++nextVisitRequestId - store.dispatch(navigationRequest.actions.started({ id: requestId, path: String(path) })) - - return store - .dispatch(visit(path, options)) - .then((metadata) => { - if (metadata.needsRefresh) { - window.location.href = metadata.pageKey - return metadata - } - - ref.current?.navigateTo(metadata.pageKey, { - action: dataset?.sgReplace ? "replace" : metadata.navigationAction, - }) - return metadata - }) - .finally(() => { - store.dispatch(navigationRequest.actions.finished({ id: requestId })) - }) - } - - return { visit: appVisit, remote: appRemote } -} diff --git a/hub/app/javascript/application/build-visit-and-remote/management.test.tsx b/hub/app/javascript/application/build-visit-and-remote/management.test.tsx deleted file mode 100644 index fbf51897..00000000 --- a/hub/app/javascript/application/build-visit-and-remote/management.test.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react" -import { Application, NavigationContext } from "@thoughtbot/superglue" -import { useContext } from "react" -import { describe, expect, it, vi } from "vitest" - -import { store } from "@application/store" -import { HubThemeProvider } from "@hooks/use-hub-theme" -import managementStory from "@pages/prototype-management-page/stories" - -import ManagementView from "../../../views/prototype_management/show" -import { buildVisitAndRemote } from "." - -describe("async prototype management", () => { - it("mounts the shell and grafts independent section responses through Superglue", async () => { - const data = managementStory.args.data - const path = "/repositories/2001/prototypes/manage" - const initialPage = { - data: { - ...data, - sectionLoadId: "first-shell", - liveInbox: undefined, - notificationInbox: data.notificationInbox - ? { ...data.notificationInbox, copy: { ...data.notificationInbox.copy } } - : undefined, - repository: null, - management: { - ...data.management, - registered: data.management.registered.map((entry) => ({ ...entry })), - unlisted: [], - candidatePageCount: 0, - }, - repositoryChoices: { - ...data.repositoryChoices, - repositories: data.repositoryChoices.repositories - .slice(0, 1) - .map((repository) => ({ ...repository })), - }, - }, - componentIdentifier: "prototype_management/show", - assets: [], - defers: [], - fragments: [], - slices: {}, - renderedAt: 1, - restoreStrategy: "revisitOnly" as const, - } - let finishBranches!: (response: Response) => void - let finishRepositories!: (response: Response) => void - const branches = new Promise((resolve) => { - finishBranches = resolve - }) - const repositories = new Promise((resolve) => { - finishRepositories = resolve - }) - const fetchMock = vi.fn().mockImplementation((input) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url - return url.includes("props_at=data.management") ? branches : repositories - }) - vi.stubGlobal("fetch", fetchMock) - vi.spyOn(window, "scrollTo").mockImplementation(() => {}) - vi.stubGlobal( - "ResizeObserver", - class { - observe() {} - unobserve() {} - disconnect() {} - }, - ) - render( - - - , - ) - expect(fetchMock).toHaveBeenCalledTimes(2) - expect(screen.getByText(data.management.candidateLoadingLabel)).toBeVisible() - expect(screen.getByRole("button", { name: "Edit details: prototype/welcome" })).toBeEnabled() - expect(screen.queryByText(data.management.emptyLabel)).not.toBeInTheDocument() - fireEvent.click(screen.getByRole("button", { name: "Edit details: prototype/welcome" })) - const dialog = screen.getByRole("dialog", { name: data.management.editDialogHeading }) - const name = within(dialog).getByRole("textbox", { name: data.management.nameLabel }) - fireEvent.change(name, { target: { value: "Unsaved prototype name" } }) - - const repositoryResponse = Response.json({ - ...initialPage, - action: "graft", - path: "data.repositoryChoices", - data: data.repositoryChoices, - }) - Object.defineProperty(repositoryResponse, "url", { - value: `http://localhost${path}?props_at=data.repositoryChoices`, - }) - await act(async () => { - finishRepositories(repositoryResponse) - await repositories - }) - await waitFor(() => - expect(screen.queryByText(data.repositoryChoices.loadingLabel)).not.toBeInTheDocument(), - ) - fireEvent.click(screen.getByRole("button", { name: data.repositorySelectorLabel })) - expect(screen.getByRole("link", { name: "github/empty" })).toBeInTheDocument() - expect(screen.getByText(data.management.candidateLoadingLabel)).toBeVisible() - expect(name).toHaveValue("Unsaved prototype name") - - const branchResponse = Response.json({ - ...initialPage, - action: "graft", - path: "data.management", - data: data.management, - }) - Object.defineProperty(branchResponse, "url", { - value: `http://localhost${path}?props_at=data.management`, - }) - await act(async () => { - finishBranches(branchResponse) - await branches - }) - await waitFor(() => - expect(screen.getByRole("button", { name: "Add to Hub: prototype/new" })).toBeEnabled(), - ) - expect(screen.queryByText(data.management.candidateLoadingLabel)).not.toBeInTheDocument() - expect(screen.getByRole("dialog", { name: data.management.editDialogHeading })).toBe(dialog) - expect(name).toHaveValue("Unsaved prototype name") - expect(fetchMock).toHaveBeenCalledTimes(2) - }) - - it("reloads both sections once after a full visit replaces the shell at the same URL", async () => { - const data = structuredClone(managementStory.args.data) - const path = "/repositories/2001/prototypes/manage" - const shell = { - data: { - ...data, - sectionLoadId: "before-refresh", - liveInbox: undefined, - notificationInbox: data.notificationInbox - ? { ...data.notificationInbox, copy: { ...data.notificationInbox.copy } } - : undefined, - repository: null, - management: { - ...data.management, - registered: data.management.registered.map((entry) => ({ ...entry })), - unlisted: [], - candidatePageCount: 0, - }, - repositoryChoices: { - ...data.repositoryChoices, - repositories: data.repositoryChoices.repositories - .slice(0, 1) - .map((repository) => ({ ...repository })), - }, - }, - componentIdentifier: "prototype_management/show", - assets: [], - defers: [], - fragments: [], - slices: {}, - renderedAt: 1, - restoreStrategy: "revisitOnly" as const, - } - const fetchMock = vi.fn().mockImplementation((input) => { - const url = new URL( - typeof input === "string" ? input : input instanceof URL ? input.href : input.url, - "http://localhost", - ) - const section = url.searchParams.get("props_at") - const payload = section - ? { - ...shell, - action: "graft", - path: section, - data: section === "data.management" ? data.management : data.repositoryChoices, - } - : { ...shell, data: { ...shell.data, sectionLoadId: "after-refresh" } } - const response = Response.json(payload) - Object.defineProperty(response, "url", { value: url.href }) - return Promise.resolve(response) - }) - vi.stubGlobal("fetch", fetchMock) - vi.spyOn(window, "scrollTo").mockImplementation(() => {}) - vi.stubGlobal( - "ResizeObserver", - class { - observe() {} - unobserve() {} - disconnect() {} - }, - ) - function RefreshableManagement() { - const { visit } = useContext(NavigationContext) - return ( - <> - - - - ) - } - render( - - - , - ) - await waitFor(() => - expect(screen.getByRole("button", { name: "Add to Hub: prototype/new" })).toBeEnabled(), - ) - expect(fetchMock).toHaveBeenCalledTimes(2) - - fireEvent.click(screen.getByRole("button", { name: "Refresh management" })) - - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(5)) - await waitFor(() => - expect(screen.getByRole("button", { name: "Add to Hub: prototype/new" })).toBeEnabled(), - ) - expect(screen.queryByText(data.management.emptyLabel)).not.toBeInTheDocument() - const requests = fetchMock.mock.calls.map(([input]) => - typeof input === "string" ? input : input instanceof URL ? input.href : input.url, - ) - expect(requests.filter((url) => url.includes("props_at=data.management"))).toHaveLength(2) - expect(requests.filter((url) => url.includes("props_at=data.repositoryChoices"))).toHaveLength( - 2, - ) - expect(requests.filter((url) => !url.includes("props_at="))).toHaveLength(1) - }) -}) diff --git a/hub/app/javascript/application/mount-application/index.test.tsx b/hub/app/javascript/application/mount-application/index.test.tsx deleted file mode 100644 index 931d2834..00000000 --- a/hub/app/javascript/application/mount-application/index.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type * as Superglue from "@thoughtbot/superglue" -import { act } from "react" -import { describe, expect, it, vi } from "vitest" - -import { mountApplication } from "." - -vi.mock("@thoughtbot/superglue", async (importOriginal) => { - const original = await importOriginal() - - return { ...original, Application: () =>
Replica Hub
} -}) - -describe("mountApplication()", () => { - it("follows the user's system light or dark preference", () => { - document.body.innerHTML = '
' - - act(() => mountApplication()) - - expect(document.querySelector(".hub-theme")).toHaveAttribute("data-color-mode", "light") - }) -}) diff --git a/hub/app/javascript/application/mount-application/index.tsx b/hub/app/javascript/application/mount-application/index.tsx deleted file mode 100644 index 289eaf39..00000000 --- a/hub/app/javascript/application/mount-application/index.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Application } from "@thoughtbot/superglue" -import { createRoot } from "react-dom/client" - -import { buildVisitAndRemote } from "@application/build-visit-and-remote" -import { pageIdentifierToPageComponent } from "@application/page-mapping" -import { store } from "@application/store" -import { HubThemeProvider } from "@hooks/use-hub-theme" - -/** Mounts the server-provided Superglue page inside Hub's theme. */ -export function mountApplication() { - const element = document.getElementById("app") - if (!element) return - - createRoot(element).render( - - - , - ) -} diff --git a/hub/app/javascript/application/navigation-request/index.ts b/hub/app/javascript/application/navigation-request/index.ts deleted file mode 100644 index 79ec061d..00000000 --- a/hub/app/javascript/application/navigation-request/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createSlice } from "@reduxjs/toolkit" -import type { PayloadAction } from "@reduxjs/toolkit" - -export const navigationRequest = createSlice({ - name: "navigationRequest", - initialState: { - id: 0, - path: null as string | null, - }, - reducers: { - started(state, action: PayloadAction<{ id: number; path: string }>) { - state.id = action.payload.id - state.path = action.payload.path - }, - finished(state, action: PayloadAction<{ id: number }>) { - if (state.id === action.payload.id) { - state.path = null - } - }, - }, -}) diff --git a/hub/app/javascript/application/notifications-view.test.tsx b/hub/app/javascript/application/notifications-view.test.tsx deleted file mode 100644 index 06b61909..00000000 --- a/hub/app/javascript/application/notifications-view.test.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { act, fireEvent, render, screen, within } from "@testing-library/react" -import { afterEach, describe, expect, it, vi } from "vitest" - -import { HubThemeProvider } from "@hooks/use-hub-theme" -import stories from "@pages/notifications-page/stories" -import type { NotificationsPageData } from "@pages/notifications-page/types" - -import NotificationsView from "../../views/notifications" - -vi.mock("@hooks/use-page-data", () => ({ - usePageData: (): NotificationsPageData => ({ - ...stories.args.data, - repository: stories.args.data.repository - ? { - ...stories.args.data.repository, - analyticsLabel: "Analytics", - analyticsPath: "/repositories/2001/analytics", - } - : null, - securityAuditLabel: "Security audit", - securityAuditPath: "/repositories/2001/security-audit", - administrationLinks: [ - { section: "github", label: "GitHub App", path: "/github-app" }, - { section: "admins", label: "Hub administrators", path: "/hub-admins" }, - { section: "sharing", label: "Public sharing", path: "/sharing-settings" }, - { section: "sessions", label: "Sessions", path: "/session-revocation" }, - ], - }), -})) - -afterEach(() => vi.unstubAllGlobals()) - -describe("NotificationsView()", () => { - it.each([false, true])( - "keeps the account theme controls with collapsed=%s", - async (collapsed) => { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValue( - Response.json({ notifications: [], nextBefore: null, unreadCount: 0 }), - ), - ) - render(, { wrapper: HubThemeProvider }) - await screen.findByText("No activity yet") - if (collapsed) { - fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" })) - } - fireEvent.click(screen.getByRole("button", { name: stories.args.data.accountMenuLabel })) - for (const name of ["System", "Light", "Dark"]) { - expect(screen.getByRole("menuitemradio", { name })).toBeInTheDocument() - } - fireEvent.click(screen.getByRole("menuitemradio", { name: "Dark" })) - expect(document.documentElement).toHaveAttribute("data-color-mode", "dark") - }, - ) - - it("loads and displays a new comment notification for the prototype owner", async () => { - const notification = stories.args.notifications[0] - if (!notification) throw new Error("Expected an inbox notification fixture") - - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue( - Response.json({ - notifications: [{ ...notification, kind: "comment", excerpt: "New prototype feedback" }], - nextBefore: null, - unreadCount: 1, - }), - ), - ) - render() - - expect(await screen.findByText("New prototype feedback")).toBeInTheDocument() - expect(screen.getByText("commented on your prototype")).toBeInTheDocument() - expect(screen.getByRole("button", { name: "Mark as read: mona" })).toBeInTheDocument() - }) - - it.each([false, true])( - "retains analytics and administration navigation with collapsed=%s", - async (collapsed) => { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValue( - Response.json({ notifications: [], nextBefore: null, unreadCount: 0 }), - ), - ) - render() - await screen.findByText("No activity yet") - if (collapsed) { - fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" })) - } - for (const [name, path] of [ - ["Analytics", "/repositories/2001/analytics"], - ["Security audit", "/repositories/2001/security-audit"], - ["GitHub App", "/github-app"], - ["Hub administrators", "/hub-admins"], - ["Public sharing", "/sharing-settings"], - ["Sessions", "/session-revocation"], - ]) { - expect(screen.getByRole("link", { name })).toHaveAttribute("href", path) - } - }, - ) - - it.each([false, true])( - "keeps the hover preview available and synchronized on the inbox page with collapsed=%s", - async (collapsed) => { - const notification = stories.args.notifications[0] - if (!notification) throw new Error("Expected an inbox notification fixture") - - const readNotification = { - ...notification, - id: 2, - kind: "reply" as const, - read: true, - excerpt: "Previously read reply", - } - const page = { - notifications: [notification, readNotification], - nextBefore: null, - unreadCount: 1, - } - const fetch = vi - .fn() - .mockResolvedValueOnce(Response.json(page)) - .mockResolvedValueOnce(Response.json({ ...page, notifications: [notification] })) - .mockResolvedValueOnce(Response.json(page)) - .mockResolvedValueOnce(Response.json({ version: 1, read: true, unreadCount: 0 })) - .mockResolvedValueOnce( - Response.json({ - ...page, - notifications: [{ ...notification, read: true, version: 1 }, readNotification], - unreadCount: 0, - }), - ) - .mockResolvedValueOnce(Response.json({ version: 2, read: false, unreadCount: 1 })) - vi.stubGlobal("fetch", fetch) - render() - const fullPage = await screen.findByRole("region", { name: "Inbox" }) - await within(fullPage).findByText("Previously read reply") - if (collapsed) { - fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" })) - } - fireEvent.click(within(fullPage).getByRole("button", { name: "Unread" })) - await within(fullPage).findByRole("button", { name: "Mark as read: mona" }) - expect(within(fullPage).queryByText("Previously read reply")).not.toBeInTheDocument() - fireEvent.mouseEnter(screen.getByRole("link", { name: "Inbox, 1 unread items" })) - const preview = screen - .getAllByRole("region", { name: "Inbox" }) - .find((region) => region.dataset.mode === "preview") - if (!preview) throw new Error("Expected the inbox hover preview") - - await within(preview).findByText(notification.excerpt) - expect(within(preview).queryByText("Previously read reply")).not.toBeInTheDocument() - expect(fetch).toHaveBeenNthCalledWith( - 3, - new URL("http://localhost:3000/notifications/inbox?limit=10&unread=true"), - { credentials: "same-origin" }, - ) - fireEvent.click(within(preview).getByRole("button", { name: "Mark as read: mona" })) - await within(fullPage).findByText("You are all caught up") - expect(screen.getByRole("link", { name: "Inbox" })).toBeInTheDocument() - fireEvent.click(within(fullPage).getByRole("button", { name: "All" })) - await within(fullPage).findByText("Previously read reply") - const row = within(fullPage).getByText(notification.excerpt).closest("li") - if (!row) throw new Error("Expected the updated notification row") - - fireEvent.click(within(row).getByRole("button", { name: "Mark as unread: mona" })) - await within(preview).findByRole("button", { name: "Mark as read: mona" }) - expect(screen.getByRole("link", { name: "Inbox, 1 unread items" })).toBeInTheDocument() - expect(fetch).toHaveBeenLastCalledWith("/notifications/1", { - method: "PATCH", - credentials: "same-origin", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": stories.args.data.authenticityToken, - }, - body: JSON.stringify({ notification: { read: false, version: 1 } }), - }) - }, - ) - - it("sends the observed version and keeps the message mounted while updating its read state and count", async () => { - const notification = stories.args.notifications[0] - const fetch = vi - .fn() - .mockResolvedValueOnce( - Response.json({ notifications: [notification], nextBefore: null, unreadCount: 1 }), - ) - .mockResolvedValueOnce(Response.json({ version: 1, read: true, unreadCount: 0 })) - vi.stubGlobal("fetch", fetch) - render() - fireEvent.click(await screen.findByRole("button", { name: "Mark as read: mona" })) - await screen.findByRole("button", { name: "Mark as unread: mona" }) - expect(fetch).toHaveBeenCalledTimes(2) - expect(screen.getByRole("link", { name: "Inbox" })).toBeInTheDocument() - expect(fetch).toHaveBeenCalledWith("/notifications/1", { - method: "PATCH", - credentials: "same-origin", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": stories.args.data.authenticityToken, - }, - body: JSON.stringify({ notification: { read: true, version: 0 } }), - }) - }) - - it.each([200, 409])( - "ignores an old read update with status %s after switching filters", - async (status) => { - let finish!: (response: Response) => void - const pending = new Promise((resolve) => { - finish = resolve - }) - const fetch = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - notifications: stories.args.notifications, - nextBefore: null, - unreadCount: 1, - }), - ) - .mockReturnValueOnce(pending) - .mockResolvedValueOnce( - Response.json({ notifications: [], nextBefore: null, unreadCount: 0 }), - ) - vi.stubGlobal("fetch", fetch) - render() - fireEvent.click(await screen.findByRole("button", { name: "Mark as read: mona" })) - fireEvent.click(screen.getByRole("button", { name: "Unread" })) - await screen.findByText("You are all caught up") - await act(async () => { - finish(new Response(null, { status })) - await pending - }) - expect(screen.getByText("You are all caught up")).toBeInTheDocument() - expect(screen.queryByRole("alert")).not.toBeInTheDocument() - expect(fetch).toHaveBeenCalledTimes(3) - }, - ) -}) diff --git a/hub/app/javascript/application/page-mapping.ts b/hub/app/javascript/application/page-mapping.ts deleted file mode 100644 index 3e00a580..00000000 --- a/hub/app/javascript/application/page-mapping.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ComponentType } from "react" - -type PageModule = { default: ComponentType } - -const viewRoot = "../../views/" -const viewExtension = ".tsx" -const pages = import.meta.glob("../../views/**/*.tsx", { eager: true }) - -export const pageIdentifierToPageComponent = Object.fromEntries( - Object.entries(pages).map(([path, page]) => [ - path.slice(viewRoot.length, -viewExtension.length), - page.default, - ]), -) diff --git a/hub/app/javascript/application/prototype-notification-view.test.tsx b/hub/app/javascript/application/prototype-notification-view.test.tsx deleted file mode 100644 index 310b5752..00000000 --- a/hub/app/javascript/application/prototype-notification-view.test.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" -import { afterEach, describe, expect, it, vi } from "vitest" - -import prototypeStories from "@pages/prototypes-show-page/stories" -import type { PrototypesShowPageData } from "@pages/prototypes-show-page/types" -import inboxStories from "@pages/notifications-page/stories" -import { repliedReviewThread } from "@/storybook/fixtures/review/comments" -import type { ReviewThread } from "@utils/review-comment/types" - -import PrototypesShowView from "../../views/prototypes/show" - -let data: PrototypesShowPageData - -vi.mock("@hooks/use-page-data", () => ({ usePageData: () => data })) -vi.mock("@hooks/use-hub-theme", () => ({ - useHubTheme: () => ({ resolvedColorMode: "light" }), - useOptionalHubTheme: () => null, -})) -vi.mock("@hooks/use-prototype-viewer-url", () => ({ - usePrototypeViewerUrl: () => ({ viewerUrl: "/preview", minimized: false, setMinimized: vi.fn() }), -})) -vi.mock("@hooks/use-prototype-review", async () => { - const { useState } = await import("react") - return { - usePrototypeReview: () => { - const [selectedThread, selectThread] = useState(null) - return { - ...prototypeStories.args.review, - threads: [repliedReviewThread], - selectedThread, - selectThread, - } - }, - } -}) -afterEach(() => vi.unstubAllGlobals()) - -describe("PrototypesShowView() notification navigation", () => { - it.each(["root", "reply"])( - "focuses the exact %s before acknowledging its notification", - async (kind) => { - const message = kind === "root" ? repliedReviewThread.root : repliedReviewThread.replies[0] - if (!message) throw new Error("Expected a reply fixture") - - data = { - ...prototypeStories.args.data, - initialCommentId: message.id, - initialNotificationId: 7, - } - const fetch = vi.fn().mockImplementation((path: string) => { - const target = document.querySelector(`[data-review-message-id="${message.id}"]`) - expect(target).toHaveFocus() - expect(target).toHaveAttribute("data-notification-target", "true") - expect(path).toBe("/notifications/7") - return Promise.resolve( - Response.json({ commentId: message.id, version: 3, read: true, unreadCount: 0 }), - ) - }) - vi.stubGlobal("fetch", fetch) - render() - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)) - expect(fetch).toHaveBeenLastCalledWith("/notifications/7", { - method: "PATCH", - credentials: "same-origin", - headers: { "Content-Type": "application/json", "X-CSRF-Token": data.authenticityToken }, - body: JSON.stringify({ notification: { read: true, version: 3 } }), - }) - }, - ) - - it("does not acknowledge an unavailable source message", async () => { - data = { ...prototypeStories.args.data, initialCommentId: 999999, initialNotificationId: 7 } - const fetch = vi.fn() - vi.stubGlobal("fetch", fetch) - render() - await screen.findByText(data.reviewComments.copy.readErrorLabel) - expect(fetch).not.toHaveBeenCalled() - }) - - it("opens a first-visit deep link without the description dialog covering its message", async () => { - data = { - ...prototypeStories.args.data, - showPrototypeInfo: true, - initialCommentId: repliedReviewThread.root.id, - initialNotificationId: 7, - } - const fetch = vi.fn().mockImplementation(() => { - expect(screen.queryByRole("dialog", { name: data.prototypeName })).not.toBeInTheDocument() - return Promise.resolve( - Response.json({ - commentId: repliedReviewThread.root.id, - version: 3, - read: true, - unreadCount: 0, - }), - ) - }) - vi.stubGlobal("fetch", fetch) - render() - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)) - expect( - document.querySelector(`[data-review-message-id="${repliedReviewThread.root.id}"]`), - ).toHaveFocus() - expect(screen.queryByRole("dialog", { name: data.prototypeName })).not.toBeInTheDocument() - }) - - it("preserves the description dialog on ordinary first visits", () => { - data = { ...prototypeStories.args.data, showPrototypeInfo: true } - const fetch = vi.fn() - vi.stubGlobal("fetch", fetch) - render() - expect(screen.getByRole("dialog")).toBeInTheDocument() - expect(fetch).not.toHaveBeenCalled() - }) - - it("updates the sidebar count and loaded preview after acknowledging the last unread item", async () => { - const message = repliedReviewThread.root - data = { - ...prototypeStories.args.data, - initialCommentId: message.id, - initialNotificationId: 7, - notificationInbox: inboxStories.args.data.notificationInbox, - notificationsPath: "/notifications", - notificationsLabel: "Inbox", - notificationsUnreadCount: 1, - } - function unexpectedAcknowledgement() { - throw new Error("Expected a pending acknowledgement") - } - let acknowledge: (response: Response) => void = unexpectedAcknowledgement - const saved = new Promise((resolve) => { - acknowledge = resolve - }) - const fetch = vi.fn().mockImplementation((path: string | URL, options?: RequestInit) => { - if (options?.method === "PATCH") return saved - if (String(path).includes("/notifications/inbox")) { - return Promise.resolve( - Response.json({ - notifications: [ - { - ...inboxStories.args.notifications[0], - id: 7, - version: 3, - read: false, - updatePath: "/notifications/7", - }, - ], - nextBefore: null, - unreadCount: 1, - }), - ) - } - return Promise.resolve(Response.json({ commentId: message.id, version: 3 })) - }) - vi.stubGlobal("fetch", fetch) - render() - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)) - const inbox = screen.getByRole("link", { name: "Inbox, 1 unread items" }) - fireEvent.mouseEnter(inbox) - await screen.findByRole("button", { name: /^Mark as read:/ }) - await act(async () => { - acknowledge(Response.json({ version: 4, read: true, unreadCount: 0 })) - await saved - }) - expect(screen.getByRole("link", { name: "Inbox" })).not.toHaveTextContent("1") - expect(screen.getByText("You are all caught up")).toBeInTheDocument() - expect(screen.queryByRole("button", { name: /^Mark as unread:/ })).not.toBeInTheDocument() - expect(screen.queryByRole("button", { name: /^Mark as read:/ })).not.toBeInTheDocument() - }) - - it.each([ - { version: 4, read: false, unreadCount: 0 }, - { version: 4, read: true }, - ])("reports an invalid acknowledgement instead of applying it: %j", async (saved) => { - data = { - ...prototypeStories.args.data, - initialCommentId: repliedReviewThread.root.id, - initialNotificationId: 7, - } - vi.stubGlobal( - "fetch", - vi - .fn() - .mockImplementation((_path: string, options?: RequestInit) => - Promise.resolve( - Response.json( - options?.method === "PATCH" - ? saved - : { commentId: repliedReviewThread.root.id, version: 3 }, - ), - ), - ), - ) - render() - await screen.findByText(data.reviewComments.copy.readErrorLabel) - }) -}) diff --git a/hub/app/javascript/application/repositories-show-view/index.test.tsx b/hub/app/javascript/application/repositories-show-view/index.test.tsx deleted file mode 100644 index 0e97bb98..00000000 --- a/hub/app/javascript/application/repositories-show-view/index.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { configureStore } from "@reduxjs/toolkit" -import { act, render, screen } from "@testing-library/react" -import { Provider } from "react-redux" -import { describe, expect, it, vi } from "vitest" - -import { navigationRequest } from "@application/navigation-request" - -import RepositoriesShowView from "../../../views/repositories/show" - -const state = vi.hoisted(() => ({ - data: { - managePrototypesPath: "/repositories/1/prototypes/manage", - }, -})) - -vi.mock("@hooks/use-page-data", () => ({ - usePageData: () => state.data, -})) -vi.mock("@hooks/use-prototype-listing", () => ({ - usePrototypeListing: () => ({}), -})) -vi.mock("@pages/repositories-show-page", () => ({ - RepositoriesShowPage: ({ managingPrototypes }: { managingPrototypes: boolean }) => ( - {managingPrototypes ? "Managing prototypes" : "Idle"} - ), -})) - -describe("RepositoriesShowView()", () => { - it("clears the loading state when its navigation request finishes", () => { - const store = configureStore({ - reducer: { navigationRequest: navigationRequest.reducer }, - }) - render( - - - , - ) - expect(screen.getByText("Idle")).toBeInTheDocument() - - act(() => { - store.dispatch( - navigationRequest.actions.started({ - id: 1, - path: state.data.managePrototypesPath, - }), - ) - }) - expect(screen.getByText("Managing prototypes")).toBeInTheDocument() - - act(() => { - store.dispatch(navigationRequest.actions.finished({ id: 1 })) - }) - expect(screen.getByText("Idle")).toBeInTheDocument() - }) -}) diff --git a/hub/app/javascript/application/repository-inbox-view.test.tsx b/hub/app/javascript/application/repository-inbox-view.test.tsx deleted file mode 100644 index b0c141d4..00000000 --- a/hub/app/javascript/application/repository-inbox-view.test.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { configureStore } from "@reduxjs/toolkit" -import { fireEvent, render, screen, within } from "@testing-library/react" -import { Provider } from "react-redux" -import { afterEach, describe, expect, it, vi } from "vitest" - -import { navigationRequest } from "@application/navigation-request" -import repositoryStories from "@pages/repositories-show-page/stories" -import inboxStories from "@pages/notifications-page/stories" - -import RepositoriesShowView from "../../views/repositories/show" - -vi.mock("@hooks/use-page-data", () => ({ - usePageData: () => ({ - ...repositoryStories.args.data, - notificationInbox: inboxStories.args.data.notificationInbox, - notificationsPath: "/notifications", - notificationsLabel: "Inbox", - notificationsUnreadCount: 12, - }), -})) - -afterEach(() => vi.unstubAllGlobals()) - -describe("RepositoriesShowView() inbox", () => { - it("loads on hover, navigates with real links, and updates the sidebar without marking anything read on entry", async () => { - const store = configureStore({ - reducer: { navigationRequest: navigationRequest.reducer }, - }) - const notification = inboxStories.args.notifications[0] - if (!notification) throw new Error("Expected an inbox notification fixture") - - const fetch = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - notifications: [notification], - unreadCount: 12, - nextBefore: null, - }), - ) - .mockResolvedValueOnce(Response.json({ version: 1, read: true, unreadCount: 11 })) - .mockResolvedValueOnce( - Response.json({ - notifications: [{ ...notification, id: 2, excerpt: "Another unread notification" }], - unreadCount: 11, - nextBefore: null, - }), - ) - vi.stubGlobal("fetch", fetch) - render( - - - , - ) - expect(fetch).not.toHaveBeenCalled() - const inbox = screen.getByRole("link", { name: "Inbox, 12 unread items" }) - fireEvent.mouseEnter(inbox) - const preview = await screen.findByRole("region", { name: "Inbox" }) - const row = await within(preview).findByRole("link", { name: /mentioned you/ }) - expect(row).toHaveAttribute("href", notification.path) - expect(row).toHaveAttribute("data-sg-visit", "true") - expect(within(preview).getByRole("link", { name: "View all" })).toHaveAttribute( - "href", - "/notifications", - ) - expect(fetch).toHaveBeenCalledTimes(1) - fireEvent.click(within(preview).getByRole("button", { name: "Mark as read: mona" })) - await screen.findByRole("link", { name: "Inbox, 11 unread items" }) - await within(preview).findByText("Another unread notification") - expect( - within(preview).queryByRole("button", { name: "Mark as unread: mona" }), - ).not.toBeInTheDocument() - expect(fetch).toHaveBeenCalledTimes(3) - }) - - it("shows a retryable hover-preview error instead of an empty successful inbox", async () => { - const store = configureStore({ - reducer: { navigationRequest: navigationRequest.reducer }, - }) - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 503 }))) - render( - - - , - ) - fireEvent.mouseEnter(screen.getByRole("link", { name: "Inbox, 12 unread items" })) - await screen.findByText(inboxStories.args.data.notificationInbox.copy.error) - expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument() - expect(screen.queryByText("No activity yet")).not.toBeInTheDocument() - }) -}) diff --git a/hub/app/javascript/application/sidebar-inbox-views.test.tsx b/hub/app/javascript/application/sidebar-inbox-views.test.tsx deleted file mode 100644 index c764979f..00000000 --- a/hub/app/javascript/application/sidebar-inbox-views.test.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { configureStore } from "@reduxjs/toolkit" -import { fireEvent, render, screen, within } from "@testing-library/react" -import type { ReactNode } from "react" -import { Provider } from "react-redux" -import { afterEach, describe, expect, it, vi } from "vitest" - -import { navigationRequest } from "@application/navigation-request" -import { RepositoryWorkspace } from "@features/repository-workspace" -import type { RepositoryWorkspaceData } from "@features/repository-workspace/types" -import { HubWorkspace } from "@features/hub-workspace" -import type { HubWorkspaceData } from "@features/hub-workspace/types" -import repositoryStories from "@pages/repositories-show-page/stories" -import managementStories from "@pages/prototype-management-page/stories" -import inboxStories from "@pages/notifications-page/stories" - -import AnalyticsView from "../../views/analytics/show" -import BuildsIndexView from "../../views/builds" -import BuildsShowView from "../../views/builds/show" -import NewPrototypesView from "../../views/new_prototypes/show" -import PrototypeManagementView from "../../views/prototype_management/show" -import PrototypesShowView from "../../views/prototypes/show" -import ReplicaBrowsesShowView from "../../views/replica_browses/show" -import RepositoriesShowView from "../../views/repositories/show" -import ShareLinksIndexView from "../../views/share_links" -import SecurityAuditView from "../../views/security_audits/show" -import RepositorySecurityAuditView from "../../views/repository_security_audits/show" -import GithubAppView from "../../views/github_apps/show" -import GithubAppResetView from "../../views/github_apps/reset_confirmation" -import GithubAppCredentialsView from "../../views/github_app_credentials/edit" -import GithubAppWebhookView from "../../views/github_app_webhook_rotations/show" -import HubAdminsView from "../../views/hub_admins" -import HubAdminConfirmationView from "../../views/hub_admins/edit" -import SessionRevocationView from "../../views/session_revocations/show" -import SharingSettingsView from "../../views/sharing_settings/show" -import FreshAuthorizationView from "../../views/fresh_authorizations/show" - -let collapsed = false -let shell: "repository" | "workspace" | "administrationWorkspace" | "authenticatedWorkspace" = - "repository" - -function SidebarPage({ data }: { data: RepositoryWorkspaceData }) { - return -} - -function HubSidebarPage({ - data, -}: { - data: { - workspace?: RepositoryWorkspaceData - administrationWorkspace?: HubWorkspaceData - authenticatedWorkspace?: HubWorkspaceData - } -}) { - if (data.workspace) return - - const workspace = data.administrationWorkspace ?? data.authenticatedWorkspace - if (!workspace) throw new Error("Expected a Hub workspace fixture") - - return -} - -vi.mock("@pages/analytics-page", () => ({ AnalyticsPage: SidebarPage })) -vi.mock("@pages/builds-index-page", () => ({ BuildsIndexPage: SidebarPage })) -vi.mock("@pages/builds-show-page", () => ({ BuildsShowPage: SidebarPage })) -vi.mock("@pages/new-prototype-page", () => ({ NewPrototypePage: SidebarPage })) -vi.mock("@pages/prototype-management-page", () => ({ PrototypeManagementPage: SidebarPage })) -vi.mock("@pages/prototypes-show-page", () => ({ PrototypesShowPage: SidebarPage })) -vi.mock("@pages/replica-browse-page", () => ({ ReplicaBrowsePage: SidebarPage })) -vi.mock("@pages/repositories-show-page", () => ({ RepositoriesShowPage: SidebarPage })) -vi.mock("@pages/share-links-index-page", () => ({ ShareLinksIndexPage: SidebarPage })) -vi.mock("@pages/security-audit-page", () => ({ SecurityAuditPage: HubSidebarPage })) -vi.mock("@pages/github-app-page", () => ({ GithubAppPage: HubSidebarPage })) -vi.mock("@pages/hub-admins-page", () => ({ HubAdminsPage: HubSidebarPage })) -vi.mock("@pages/hub-admin-confirmation-page", () => ({ HubAdminConfirmationPage: HubSidebarPage })) -vi.mock("@pages/session-revocation-page", () => ({ SessionRevocationPage: HubSidebarPage })) -vi.mock("@pages/sharing-settings-page", () => ({ SharingSettingsPage: HubSidebarPage })) -vi.mock("@pages/fresh-authorization-page", () => ({ FreshAuthorizationPage: HubSidebarPage })) -vi.mock("@hooks/use-page-data", () => ({ - usePageData: () => { - const data = { - ...repositoryStories.args.data, - management: managementStories.args.data.management, - repositoryChoices: managementStories.args.data.repositoryChoices, - notificationInbox: inboxStories.args.data.notificationInbox, - notificationsPath: "/notifications", - notificationsLabel: "Inbox", - notificationsUnreadCount: 12, - } - if (shell === "repository") return data - - return { - [shell]: { - ...data, - productName: "Replica", - repositoriesLabel: "Repositories", - repositoriesPath: "/repositories", - repository: data, - }, - } - }, -})) -vi.mock("@hooks/use-page-section", () => ({ - usePageSection: () => ({ loading: false, failed: false, retry: () => {} }), -})) -vi.mock("@hooks/use-hub-theme", () => ({ - useHubTheme: () => ({ resolvedColorMode: "light" }), - useOptionalHubTheme: () => null, -})) -vi.mock("@hooks/use-prototype-review", () => ({ - usePrototypeReview: () => ({ - threads: [], - selectedThread: null, - comments: { loading: false, loadFailed: false }, - bridge: { route: { pathname: "/", search: "", hash: "" } }, - }), -})) -vi.mock("@hooks/use-preview-bridge", () => ({ - usePreviewBridge: () => ({ - route: { pathname: "/", search: "", hash: "" }, - handleFrameLoad: () => {}, - }), -})) -vi.mock("@hooks/use-prototype-viewer-url", () => ({ - usePrototypeViewerUrl: () => ({ - viewerUrl: "/preview", - minimized: false, - setMinimized: () => {}, - }), -})) -vi.mock("@hooks/use-prototype-control-query", () => ({ - useControlQuery: () => ({ query: "", values: {}, notice: null }), -})) -vi.mock("@hooks/use-prototype-share-control-settings", () => ({ - useShareControlSettings: () => ({ error: null }), -})) - -afterEach(() => { - vi.unstubAllGlobals() - shell = "repository" -}) - -const views = [ - ["repositories", RepositoriesShowView], - ["analytics", AnalyticsView], - ["new prototype", NewPrototypesView], - ["prototype management", PrototypeManagementView], - ["prototype review", PrototypesShowView], - ["Build history", BuildsIndexView], - ["Build details", BuildsShowView], - ["share links", ShareLinksIndexView], - ["template preview", ReplicaBrowsesShowView], -] as const - -function renderWithNavigationStore(view: ReactNode) { - const store = configureStore({ - reducer: { navigationRequest: navigationRequest.reducer }, - }) - return render({view}) -} - -describe("Sidebar inbox previews", () => { - it.each( - [ - ["Hub audit", SecurityAuditView, "administrationWorkspace"], - ["repository audit", RepositorySecurityAuditView, "workspace"], - ["GitHub App", GithubAppView, "administrationWorkspace"], - ["GitHub App reset", GithubAppResetView, "administrationWorkspace"], - ["GitHub App credentials", GithubAppCredentialsView, "administrationWorkspace"], - ["webhook rotation", GithubAppWebhookView, "administrationWorkspace"], - ["Hub admins", HubAdminsView, "administrationWorkspace"], - ["admin confirmation", HubAdminConfirmationView, "administrationWorkspace"], - ["session revocation", SessionRevocationView, "administrationWorkspace"], - ["sharing settings", SharingSettingsView, "administrationWorkspace"], - ["fresh authorization", FreshAuthorizationView, "administrationWorkspace"], - ["denied admin page", HubAdminsView, "authenticatedWorkspace"], - ].flatMap(([name, View, workspace]) => - [false, true].map((collapsed) => ({ name, View, workspace, collapsed })), - ), - )( - "preserves the preview on $name with collapsed=$collapsed", - async ({ View, workspace, collapsed: value }) => { - if (typeof View !== "function" || typeof workspace !== "string") { - throw new Error("Expected a workspace view fixture") - } - if ( - workspace !== "workspace" && - workspace !== "administrationWorkspace" && - workspace !== "authenticatedWorkspace" - ) { - throw new Error("Expected a workspace projection") - } - shell = workspace - collapsed = value - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue( - Response.json({ - notifications: inboxStories.args.notifications, - unreadCount: 12, - nextBefore: null, - }), - ), - ) - renderWithNavigationStore() - const inbox = screen.getByRole("link", { name: "Inbox, 12 unread items" }) - fireEvent.mouseEnter(inbox) - const preview = await screen.findByRole("region", { name: "Inbox" }) - await within(preview).findByRole("link", { name: /mentioned you/ }) - expect(inbox.closest("aside")).toHaveAttribute("data-collapsed", String(value)) - expect(inbox.closest("aside")).not.toContainElement(preview) - fireEvent.focus(within(preview).getByRole("link", { name: "View all" })) - expect(preview).toBeInTheDocument() - fireEvent.keyDown(preview, { key: "Escape" }) - expect(screen.queryByRole("region", { name: "Inbox" })).not.toBeInTheDocument() - }, - ) - - it.each( - views.flatMap(([name, View]) => - [false, true].map((collapsed) => ({ - name, - View, - collapsed, - state: collapsed ? "collapsed" : "expanded", - })), - ), - )("opens lazily on the $name page with a $state sidebar", async ({ View, collapsed: value }) => { - collapsed = value - const fetch = vi.fn().mockResolvedValue( - Response.json({ - notifications: inboxStories.args.notifications, - unreadCount: 12, - nextBefore: null, - }), - ) - vi.stubGlobal("fetch", fetch) - renderWithNavigationStore() - expect(fetch).not.toHaveBeenCalled() - const inbox = screen.getByRole("link", { name: "Inbox, 12 unread items" }) - expect(inbox.closest("aside")).toHaveAttribute("data-collapsed", String(value)) - fireEvent.mouseEnter(inbox) - const preview = await screen.findByRole("region", { name: "Inbox" }) - await within(preview).findByRole("link", { name: /mentioned you/ }) - expect(fetch).toHaveBeenCalledExactlyOnceWith( - new URL("http://localhost:3000/notifications/inbox?limit=10&unread=true"), - { credentials: "same-origin" }, - ) - expect(within(preview).getByRole("link", { name: "View all" })).toHaveAttribute( - "href", - "/notifications", - ) - }) -}) diff --git a/hub/app/javascript/application/store.ts b/hub/app/javascript/application/store.ts deleted file mode 100644 index a10ada59..00000000 --- a/hub/app/javascript/application/store.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { configureStore } from "@reduxjs/toolkit" -import { beforeFetch, beforeRemote, beforeVisit, rootReducer } from "@thoughtbot/superglue" - -import { navigationRequest } from "@application/navigation-request" - -const { pages, superglue } = rootReducer - -export const store = configureStore({ - devTools: import.meta.env.DEV, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware({ - serializableCheck: { - ignoredActions: [beforeFetch.type, beforeVisit.type, beforeRemote.type], - }, - }), - reducer: { navigationRequest: navigationRequest.reducer, pages, superglue }, -}) - -export type AppState = ReturnType diff --git a/hub/app/javascript/components/hub-empty-state/index.test.tsx b/hub/app/javascript/components/hub-empty-state/index.test.tsx index 53d2c68f..abcdd0e7 100644 --- a/hub/app/javascript/components/hub-empty-state/index.test.tsx +++ b/hub/app/javascript/components/hub-empty-state/index.test.tsx @@ -46,7 +46,6 @@ describe("HubEmptyState()", () => { const action = screen.getByRole("link", { name: "Build prototype" }) expect(action).toHaveAttribute("href", "/repositories/2001/new") - expect(action).toHaveAttribute("data-sg-visit", "true") expect(screen.queryByRole("button")).not.toBeInTheDocument() }) }) diff --git a/hub/app/javascript/components/hub-empty-state/index.tsx b/hub/app/javascript/components/hub-empty-state/index.tsx index 29639d35..2bbc1a96 100644 --- a/hub/app/javascript/components/hub-empty-state/index.tsx +++ b/hub/app/javascript/components/hub-empty-state/index.tsx @@ -42,13 +42,7 @@ export function HubEmptyState({ {actionHref ? ( - ) : actionLabel ? ( diff --git a/hub/app/javascript/components/hub-icon-button/index.test.tsx b/hub/app/javascript/components/hub-icon-button/index.test.tsx index 857c4bba..cb45446a 100644 --- a/hub/app/javascript/components/hub-icon-button/index.test.tsx +++ b/hub/app/javascript/components/hub-icon-button/index.test.tsx @@ -56,7 +56,6 @@ describe("HubIconButton()", () => { ref={ref} as="a" href="/builds/3" - data-sg-visit="true" aria-label="Build details" description="View the build record" icon={Icon} @@ -65,7 +64,6 @@ describe("HubIconButton()", () => { const link = screen.getByRole("link", { name: "Build details" }) expect(link).toHaveAttribute("href", "/builds/3") - expect(link).toHaveAttribute("data-sg-visit", "true") expect(link).toHaveAccessibleDescription("View the build record") expect(ref.current).toBe(link) expect(link.className).toContain("Primer_Brand__Button-module__Button") diff --git a/hub/app/javascript/components/prototype-card/index.tsx b/hub/app/javascript/components/prototype-card/index.tsx index dfa9025b..468532a1 100644 --- a/hub/app/javascript/components/prototype-card/index.tsx +++ b/hub/app/javascript/components/prototype-card/index.tsx @@ -43,7 +43,7 @@ export function PrototypeCard({ ) const title = ( - + {prototype.name} ) diff --git a/hub/app/javascript/components/repository-navigation-item/index.test.tsx b/hub/app/javascript/components/repository-navigation-item/index.test.tsx index 808b37cc..8c7c5941 100644 --- a/hub/app/javascript/components/repository-navigation-item/index.test.tsx +++ b/hub/app/javascript/components/repository-navigation-item/index.test.tsx @@ -1,6 +1,4 @@ -import { configureStore } from "@reduxjs/toolkit" import { fireEvent, render, screen } from "@testing-library/react" -import { rootReducer, setup, type ApplicationVisit } from "@thoughtbot/superglue" import { describe, expect, it, vi } from "vitest" import { GitBranchIcon, SidebarExpandIcon } from "@primer/octicons-react" @@ -8,7 +6,7 @@ import { GitBranchIcon, SidebarExpandIcon } from "@primer/octicons-react" import { RepositoryNavigationItem } from "." describe("RepositoryNavigationItem()", () => { - it("keeps the selected repository destination as a link", () => { + it("keeps the selected repository destination as a native link", () => { render( { const link = screen.getByRole("link", { name: "Prototypes" }) expect(link).toHaveAttribute("href", "/repositories/2001") - expect(link).toHaveAttribute("data-sg-visit", "true") expect(link).toHaveAttribute("aria-current", "page") }) - it.each([ - { name: "ordinary", event: {}, intercepted: true }, - { name: "Command", event: { metaKey: true }, intercepted: false }, - { name: "Control", event: { ctrlKey: true }, intercepted: false }, - { name: "Shift", event: { shiftKey: true }, intercepted: false }, - { name: "Alt", event: { altKey: true }, intercepted: false }, - { name: "middle button", event: { button: 1 }, intercepted: false }, - ])("uses existing Superglue delegation for $name clicks", ({ event, intercepted }) => { - const visit = vi.fn().mockResolvedValue(undefined) - const { ujs } = setup({ - baseUrl: window.location.origin, - path: "/", - initialPage: { - componentIdentifier: "sidebar-test", - data: {}, - assets: [], - fragments: [], - defers: [], - slices: {}, - renderedAt: 0, - restoreStrategy: "revisitOnly", - }, - store: configureStore({ reducer: rootReducer }), - navigatorRef: { current: null }, - buildVisitAndRemote: () => ({ - visit, - remote: vi.fn(), - }), - }) + it("opens external destinations separately without sharing opener access", () => { render( -
- -
, + , ) - const link = screen.getByRole("link", { name: "Prototypes" }) - const label = screen.getByText("Prototypes") - const click = new MouseEvent("click", { bubbles: true, cancelable: true, ...event }) - - fireEvent(label, click) - expect(click.defaultPrevented).toBe(intercepted) - if (intercepted) { - expect(visit).toHaveBeenCalledExactlyOnceWith("/repositories/2001", { - method: "GET", - dataset: { sgVisit: "true" }, - }) - } else { - expect(visit).not.toHaveBeenCalled() - } - expect(link).toHaveAttribute("href", "/repositories/2001") - expect(link).not.toHaveAttribute("role") - expect(link).not.toHaveAttribute("tabindex") + const link = screen.getByRole("link", { name: "Source" }) + expect(link).toHaveAttribute("target", "_blank") + expect(link).toHaveAttribute("rel", "noopener noreferrer") }) it("uses the same row as a labelled collapse button", () => { @@ -95,7 +49,6 @@ describe("RepositoryNavigationItem()", () => { const button = screen.getByRole("button", { name: "Collapse sidebar" }) expect(button).toHaveAttribute("aria-expanded", "true") - expect(button).not.toHaveAttribute("data-sg-visit") fireEvent.click(button) expect(onClick).toHaveBeenCalledOnce() }) diff --git a/hub/app/javascript/components/repository-navigation-item/index.tsx b/hub/app/javascript/components/repository-navigation-item/index.tsx index 2c6ed956..70820c4c 100644 --- a/hub/app/javascript/components/repository-navigation-item/index.tsx +++ b/hub/app/javascript/components/repository-navigation-item/index.tsx @@ -53,7 +53,6 @@ export function RepositoryNavigationItem({ return href !== undefined ? ( 0 ? "true" : undefined} data-active={selected ? "true" : undefined} diff --git a/hub/app/javascript/components/template-card/index.test.tsx b/hub/app/javascript/components/template-card/index.test.tsx index 97721b78..cd179815 100644 --- a/hub/app/javascript/components/template-card/index.test.tsx +++ b/hub/app/javascript/components/template-card/index.test.tsx @@ -19,7 +19,6 @@ describe("TemplateCard()", () => { const link = screen.getByRole("link", { name: "Open template: Welcome" }) expect(screen.getAllByRole("link")).toHaveLength(1) expect(link).toHaveAttribute("href", template.path) - expect(link).toHaveAttribute("data-sg-visit", "true") expect(link).toHaveClass("prototype-card__review-link") const preview = screen.getByRole("group", { name: "Preview of Welcome" }) expect(preview).toHaveClass("scrolling-screenshot") diff --git a/hub/app/javascript/components/template-card/index.tsx b/hub/app/javascript/components/template-card/index.tsx index 394da154..5f3313f9 100644 --- a/hub/app/javascript/components/template-card/index.tsx +++ b/hub/app/javascript/components/template-card/index.tsx @@ -35,7 +35,6 @@ export function TemplateCard({
diff --git a/hub/app/javascript/entrypoints/application.tsx b/hub/app/javascript/entrypoints/application.tsx index f5d25575..ca589d06 100644 --- a/hub/app/javascript/entrypoints/application.tsx +++ b/hub/app/javascript/entrypoints/application.tsx @@ -1,4 +1,17 @@ import "@/tokens/base.css" -import { mountApplication } from "@application/mount-application" +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" -document.addEventListener("DOMContentLoaded", mountApplication) +import { ReplicaLogo } from "@components/replica-logo" +import { HubThemeProvider } from "@hooks/use-hub-theme" + +const root = document.getElementById("root") +if (!root) throw new Error("The Hub document is missing its React root.") + +createRoot(root).render( + + + + + , +) diff --git a/hub/app/javascript/features/conversation-inbox/index.tsx b/hub/app/javascript/features/conversation-inbox/index.tsx index 46cccddd..f606b942 100644 --- a/hub/app/javascript/features/conversation-inbox/index.tsx +++ b/hub/app/javascript/features/conversation-inbox/index.tsx @@ -81,7 +81,6 @@ export function ConversationInbox({
diff --git a/hub/app/javascript/pages/fresh-authorization-page/view.test.tsx b/hub/app/javascript/pages/fresh-authorization-page/view.test.tsx deleted file mode 100644 index 49b39f80..00000000 --- a/hub/app/javascript/pages/fresh-authorization-page/view.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { ThemeProvider } from "@primer/react-brand" -import { render } from "@testing-library/react" -import { StrictMode } from "react" -import { describe, expect, it, vi } from "vitest" - -import { usePageData } from "@hooks/use-page-data" - -import FreshAuthorizationsShowView from "../../../views/fresh_authorizations/show" - -vi.mock("@hooks/use-page-data", () => ({ usePageData: vi.fn() })) -vi.mock("@hooks/use-workspace-inbox", () => ({ - useWorkspaceInbox: (data: unknown) => data, -})) - -describe("FreshAuthorizationsShowView()", () => { - it("automatically submits the continuation once with its protected form fields", () => { - vi.mocked(usePageData).mockReturnValue({ - productName: "Replica Hub", - pageTitle: "Continue action", - heading: "GitHub account verified", - description: "Continuing your action.", - submitLabel: "Continue", - submitPath: "/github_app/credentials", - authenticityToken: "csrf-token", - continuation: { id: "opaque-authorization-id", method: "patch" }, - }) - const submit = vi.spyOn(HTMLFormElement.prototype, "requestSubmit").mockImplementation(() => {}) - render( - - - - - , - ) - expect(submit).toHaveBeenCalledTimes(1) - const form = submit.mock.instances[0] as HTMLFormElement - expect(form).toHaveAttribute("action", "/github_app/credentials") - expect(Object.fromEntries(new FormData(form).entries())).toEqual({ - authenticity_token: "csrf-token", - authorization_continuation_id: "opaque-authorization-id", - _method: "patch", - }) - }) -}) diff --git a/hub/app/javascript/pages/github-app-setup-register-page/index.test.tsx b/hub/app/javascript/pages/github-app-setup-register-page/index.test.tsx index 04a2367b..4325aea3 100644 --- a/hub/app/javascript/pages/github-app-setup-register-page/index.test.tsx +++ b/hub/app/javascript/pages/github-app-setup-register-page/index.test.tsx @@ -21,7 +21,6 @@ describe("GithubAppSetupRegisterPage()", () => { const form = container.querySelector("form") expect(form).toHaveAttribute("action", "https://github.com/settings/apps/new") - expect(form).not.toHaveAttribute("data-sg-visit") expect(container.querySelector("input[name=manifest]")).toHaveValue('{"name":"Replica Hub"}') }) }) diff --git a/hub/app/javascript/pages/prototype-management-page/index.test.tsx b/hub/app/javascript/pages/prototype-management-page/index.test.tsx index 1d05346f..46fea4d4 100644 --- a/hub/app/javascript/pages/prototype-management-page/index.test.tsx +++ b/hub/app/javascript/pages/prototype-management-page/index.test.tsx @@ -16,7 +16,6 @@ describe("PrototypeManagementPage()", () => { ).toHaveClass("repository-page-header", "prototype-management-page__header") const back = screen.getByRole("link", { name: data.backLabel }) expect(back).toHaveAttribute("href", data.prototypesPath) - expect(back).toHaveAttribute("data-sg-visit", "true") expect(back.textContent).toBe("") expect(screen.getByRole("heading", { name: "Manage prototypes", level: 2 })).toBeInTheDocument() expect( diff --git a/hub/app/javascript/pages/prototype-management-page/index.tsx b/hub/app/javascript/pages/prototype-management-page/index.tsx index 19f0a55e..41692509 100644 --- a/hub/app/javascript/pages/prototype-management-page/index.tsx +++ b/hub/app/javascript/pages/prototype-management-page/index.tsx @@ -35,7 +35,6 @@ export function PrototypeManagementPage({ ({ startAreaSelection: vi.fn(), startElementSelection: vi.fn(), })) -const REVIEW_COMMENTS_SUBSCRIPTION = vi.hoisted(() => - vi.fn<(options: ReviewCommentsSubscriptionOptions) => void>(), -) vi.mock("@hooks/use-review-comments", () => ({ useReviewComments: () => ({ @@ -399,10 +394,6 @@ vi.mock("@hooks/use-review-comments", () => ({ }), })) -vi.mock("@hooks/use-review-comments-subscription", () => ({ - useReviewCommentsSubscription: REVIEW_COMMENTS_SUBSCRIPTION, -})) - vi.mock("@hooks/use-preview-bridge", () => ({ usePreviewBridge: (options: unknown) => { BRIDGE.options(options) @@ -1406,14 +1397,9 @@ describe("PrototypesShowPage()", () => { }) it("keeps the fixed controls outside the preview and shows every Build state", () => { - const visit = vi.fn() render( - - - + , ) @@ -1422,10 +1408,6 @@ describe("PrototypesShowPage()", () => { "href", "/repositories/2001/prototypes/1", ) - expect(screen.getByRole("link", { name: "Welcome prototype" })).toHaveAttribute( - "data-sg-visit", - "true", - ) expect(screen.queryByRole("link", { name: "github/prototype" })).not.toBeInTheDocument() expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeInTheDocument() expect(screen.getByRole("link", { name: "by @octocat" })).toBeInTheDocument() @@ -1460,7 +1442,6 @@ describe("PrototypesShowPage()", () => { name: "Close and return to the prototype gallery", }) expect(close).toHaveAttribute("href", "/repositories/2001") - expect(close).toHaveAttribute("data-sg-visit", "true") expect(close).not.toHaveTextContent("Close") expect(close).toHaveClass("review-shell__close-action") expect(close.className).toContain("Button--size-small") @@ -1506,20 +1487,7 @@ describe("PrototypesShowPage()", () => { "href", "/repositories/2001/prototypes/1?build=1", ) - fireEvent.click(screen.getByRole("menuitem", { name: "v1" }).querySelector("a")!) - expect(visit).toHaveBeenCalledExactlyOnceWith("/repositories/2001/prototypes/1?build=1", { - method: "GET", - dataset: { sgVisit: "true" }, - }) if (buildSelector.getAttribute("aria-expanded") !== "true") fireEvent.click(buildSelector) - fireEvent.keyDown(screen.getByRole("menuitem", { name: /v3.*Failed to build/ }), { - key: "Enter", - }) - expect(visit).toHaveBeenLastCalledWith("/repositories/2001/prototypes/1/builds/3", { - method: "GET", - dataset: { sgVisit: "true" }, - }) - expect(visit).toHaveBeenCalledTimes(2) expect(screen.getByTitle("prototype/welcome, build 2 preview")).toHaveAttribute( "src", "http://b12.preview.replica.localhost:19500/", @@ -1539,28 +1507,7 @@ describe("PrototypesShowPage()", () => { within(menu).getByRole("menuitem", { name: label }).querySelector("a"), ).toHaveAttribute("href", path) } - fireEvent.click(within(menu).getByRole("menuitem", { name: "Share links" }).querySelector("a")!) - expect(visit).toHaveBeenLastCalledWith("/repositories/2001/prototypes/1/share_links", { - method: "GET", - dataset: { sgVisit: "true" }, - }) if (moreActions.getAttribute("aria-expanded") !== "true") fireEvent.click(moreActions) - fireEvent.keyDown( - within(screen.getByRole("menu", { name: "More actions" })).getByRole("menuitem", { - name: "Build details", - }), - { key: "Enter" }, - ) - expect(visit).toHaveBeenLastCalledWith("/repositories/2001/prototypes/1/builds/2", { - method: "GET", - dataset: { sgVisit: "true" }, - }) - const subscriptionOptions = REVIEW_COMMENTS_SUBSCRIPTION.mock.calls.at(-1)?.[0] - expect(subscriptionOptions).toMatchObject({ - repositoryId: 2001, - prototypeId: 1, - }) - expect(subscriptionOptions?.onInvalidation).toEqual(expect.any(Function)) expect( screen.getByRole("button", { name: "Thread started by @octocat, 2 comments" }), diff --git a/hub/app/javascript/pages/replica-browse-page/adapter.test.tsx b/hub/app/javascript/pages/replica-browse-page/adapter.test.tsx deleted file mode 100644 index 247a44bf..00000000 --- a/hub/app/javascript/pages/replica-browse-page/adapter.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react" -import { useState } from "react" -import { describe, expect, it, vi } from "vitest" - -import { replicaBrowseData } from "@/storybook/fixtures/replica-browse" -import type { ReviewRoute } from "@utils/review-anchor/types" - -import ReplicaBrowsesShowView from "../../../views/replica_browses/show" - -const state = vi.hoisted(() => ({ data: {} })) - -vi.mock("@hooks/use-page-data", () => ({ - usePageData: () => state.data, -})) -vi.mock("@hooks/use-hub-theme", () => ({ - useHubTheme: () => ({ resolvedColorMode: "light" }), - useOptionalHubTheme: () => null, -})) -vi.mock("@hooks/use-preview-bridge", () => ({ - usePreviewBridge: function useBridge() { - const [route, setRoute] = useState(null) - return { - route, - handleFrameLoad: () => setRoute({ pathname: "/old-page", search: "?old=1", hash: "#old" }), - } - }, -})) -vi.mock("@pages/replica-browse-page", () => ({ - ReplicaBrowsePage: ({ - route, - onFrameLoad, - }: { - route: ReviewRoute | null - onFrameLoad: () => void - }) => , -})) - -describe("ReplicaBrowsesShowView()", () => { - it("resets bridge state when switching templates even if their preview URLs match", () => { - state.data = { ...replicaBrowseData, templateId: "welcome" } - const page = render() - fireEvent.click(screen.getByRole("button", { name: "Initial route" })) - expect(screen.getByRole("button", { name: "/old-page" })).toBeInTheDocument() - state.data = { ...replicaBrowseData, templateId: "field-notes" } - page.rerender() - expect(screen.getByRole("button", { name: "Initial route" })).toBeInTheDocument() - }) -}) diff --git a/hub/app/javascript/pages/replica-browse-page/index.test.tsx b/hub/app/javascript/pages/replica-browse-page/index.test.tsx index bc4baeae..25ce4f6e 100644 --- a/hub/app/javascript/pages/replica-browse-page/index.test.tsx +++ b/hub/app/javascript/pages/replica-browse-page/index.test.tsx @@ -3,16 +3,11 @@ import { act, fireEvent, render, screen, within } from "@testing-library/react" import { afterEach, describe, expect, it, vi } from "vitest" import { HubThemeProvider } from "@hooks/use-hub-theme" -import { usePageData } from "@hooks/use-page-data" -import type { PreviewBridgeCommand } from "@utils/preview-bridge/types" import { localReplicaBrowseData, replicaBrowseData } from "@/storybook/fixtures/replica-browse" -import ReplicaBrowsesShowView from "../../../views/replica_browses/show" import { ReplicaBrowsePage } from "." import type { ReplicaBrowsePageProps } from "./types" -vi.mock("@hooks/use-page-data", () => ({ usePageData: vi.fn() })) - function renderPage(overrides: Partial = {}) { return render( @@ -669,450 +664,3 @@ describe("ReplicaBrowsePage()", () => { expect(screen.getByTitle("main replica preview")).toBeInTheDocument() }) }) - -describe("ReplicaBrowsesShowView()", () => { - const originalUrl = window.location.href - - afterEach(() => { - window.history.replaceState(null, "", originalUrl) - }) - - it("syncs bridge navigation into the Hub URL without pinning a Build or exposing review controls", () => { - window.history.replaceState( - { retained: true }, - "", - "/repositories/2001/replica?build=99&before=invalid&filter=all", - ) - vi.mocked(usePageData).mockReturnValue(replicaBrowseData) - render( - - - , - ) - const frame = screen.getByTitle("main replica preview") - const route = { pathname: "/settings", search: "?tab=profile", hash: "#details" } - act(() => { - window.dispatchEvent( - new MessageEvent("message", { - data: { - protocolVersion: 1, - requestId: "browse-navigation", - type: "replica:bridge:route-changed", - payload: { route }, - }, - origin: "http://b1.preview.replica.localhost:19500", - source: frame.contentWindow, - }), - ) - }) - - const url = new URL(window.location.href) - expect(url.pathname).toBe("/repositories/2001/replica") - expect(url.searchParams.get("return_path")).toBe("/settings?tab=profile#details") - expect(url.searchParams.has("build")).toBe(false) - expect(url.searchParams.has("before")).toBe(false) - expect(url.searchParams.has("view")).toBe(false) - expect(url.searchParams.get("filter")).toBe("all") - expect(window.history.state).toEqual({ retained: true }) - expect(screen.getByRole("textbox", { name: replicaBrowseData.previewUrlLabel })).toHaveValue( - url.toString(), - ) - expect(frame).toHaveAttribute("src", replicaBrowseData.selectedBuild!.previewUrl) - expect(screen.queryByRole("button", { name: /comment|maximize/i })).not.toBeInTheDocument() - expect(screen.getByRole("button", { name: replicaBrowseData.minimizeHubLabel })).toBeEnabled() - }) - - it("keeps the restored section link in Hub while loading the frame without its fragment", () => { - const destination = "/settings?tab=profile#details" - window.history.replaceState( - null, - "", - `/repositories/2001/replica?return_path=${encodeURIComponent(destination)}`, - ) - const previewUrl = `http://b1.preview.replica.localhost:19500${destination}` - vi.mocked(usePageData).mockReturnValue({ - ...replicaBrowseData, - selectedBuild: { ...replicaBrowseData.selectedBuild!, previewUrl }, - }) - render( - - - , - ) - const frame = screen.getByTitle("main replica preview") - fireEvent.load(frame) - - const url = new URL(window.location.href) - expect(url.searchParams.get("return_path")).toBe(destination) - expect(url.searchParams.has("build")).toBe(false) - expect(frame).toHaveAttribute( - "src", - "http://b1.preview.replica.localhost:19500/settings?tab=profile", - ) - expect(screen.getByRole("textbox", { name: replicaBrowseData.previewUrlLabel })).toHaveValue( - url.toString(), - ) - }) - - it("reopens the About route minimized without pinning a Build or adding review controls", () => { - window.history.replaceState( - { retained: true }, - "", - "/repositories/1/replica?return_path=%2F%23%2Fabout&build=99&before=invalid&view=minimized", - ) - vi.mocked(usePageData).mockReturnValue({ - ...replicaBrowseData, - minimizedPreview: true, - selectedBuild: { - ...replicaBrowseData.selectedBuild!, - previewUrl: "http://b1.preview.replica.localhost:19500/#/about", - }, - }) - render( - - - , - ) - - const url = new URL(window.location.href) - expect(url.searchParams.has("build")).toBe(false) - expect(url.searchParams.has("before")).toBe(false) - expect(url.searchParams.get("return_path")).toBe("/#/about") - expect(url.searchParams.get("view")).toBe("minimized") - expect(window.history.state).toEqual({ retained: true }) - expect(document.querySelector(".review-shell__preview--minimized")).toBeInTheDocument() - expect(screen.queryByRole("button", { name: /comment/i })).not.toBeInTheDocument() - expect(screen.queryByRole("link", { name: "Open in new tab" })).not.toBeInTheDocument() - expect(screen.getByRole("toolbar")).toBeInTheDocument() - const frame = screen.getByTitle("main replica preview") - expect(frame).toHaveAttribute("src", "http://b1.preview.replica.localhost:19500/#/about") - - fireEvent.click(screen.getByRole("button", { name: replicaBrowseData.maximizeHubLabel })) - expect(screen.getByTitle("main replica preview")).toBe(frame) - expect(new URL(window.location.href).searchParams.has("view")).toBe(false) - expect(new URL(window.location.href).searchParams.get("return_path")).toBe("/#/about") - expect(screen.getByRole("textbox", { name: replicaBrowseData.previewUrlLabel })).toHaveValue( - window.location.href, - ) - }) - - it("shares minimized links while navigation continues in the same unpinned frame", () => { - window.history.replaceState( - { retained: true }, - "", - "/repositories/1/replica?return_path=%2F%23%2Fabout&filter=all#hub", - ) - vi.mocked(usePageData).mockReturnValue({ - ...replicaBrowseData, - selectedBuild: { - ...replicaBrowseData.selectedBuild!, - previewUrl: "http://b1.preview.replica.localhost:19500/#/about", - }, - }) - render( - - - , - ) - const frame = screen.getByTitle("main replica preview") - const frameWindow = frame.contentWindow - const source = frame.getAttribute("src") - fireEvent.click(screen.getByRole("button", { name: replicaBrowseData.minimizeHubLabel })) - expect(new URL(window.location.href).searchParams.get("return_path")).toBe("/#/about") - expect(new URL(window.location.href).searchParams.get("view")).toBe("minimized") - - act(() => { - window.dispatchEvent( - new MessageEvent("message", { - data: { - protocolVersion: 1, - requestId: "browse-navigation", - type: "replica:bridge:route-changed", - payload: { - route: { pathname: "/settings", search: "?tab=a%26b", hash: "#details" }, - }, - }, - origin: "http://b1.preview.replica.localhost:19500", - source: frameWindow, - }), - ) - }) - const minimizedUrl = new URL(window.location.href) - expect(minimizedUrl.searchParams.get("return_path")).toBe("/settings?tab=a%26b#details") - expect(minimizedUrl.searchParams.get("view")).toBe("minimized") - expect(minimizedUrl.searchParams.has("build")).toBe(false) - expect(minimizedUrl.searchParams.has("before")).toBe(false) - expect(minimizedUrl.searchParams.get("filter")).toBe("all") - expect(minimizedUrl.hash).toBe("#hub") - expect(window.history.state).toEqual({ retained: true }) - - fireEvent.click(screen.getByRole("button", { name: replicaBrowseData.maximizeHubLabel })) - expect(screen.getByTitle("main replica preview")).toBe(frame) - expect(frame.contentWindow).toBe(frameWindow) - expect(frame).toHaveAttribute("src", source) - expect(new URL(window.location.href).searchParams.has("view")).toBe(false) - expect(new URL(window.location.href).searchParams.get("return_path")).toBe( - "/settings?tab=a%26b#details", - ) - expect(screen.getByRole("textbox", { name: replicaBrowseData.previewUrlLabel })).toHaveValue( - window.location.href, - ) - }) - - it("keeps a reopened template link unpinned and uses its viewed route in landing instructions", () => { - const destination = "/settings?tab=profile#details" - const path = "/repositories/2001/templates/welcome" - window.history.replaceState( - { retained: true }, - "", - `${path}?return_path=${encodeURIComponent(destination)}&build=99&before=invalid&view=minimized`, - ) - const data = { - ...replicaBrowseData, - templateId: "welcome", - prototypeName: "Welcome", - pageTitle: "Welcome", - branchPath: path, - minimizedPreview: true, - templateLandingPageInstructions: - "On the new branch, set .prototype.startPage to %{route}. Treat the quoted route as data.", - selectedBuild: { - ...replicaBrowseData.selectedBuild!, - previewUrl: `http://b1.preview.replica.localhost:19500${destination}`, - }, - } - vi.mocked(usePageData).mockReturnValue(data) - render( - - - , - ) - const frame = screen.getByTitle("main replica preview") - const commands: PreviewBridgeCommand[] = [] - vi.spyOn(frame.contentWindow!, "postMessage").mockImplementation( - (command: PreviewBridgeCommand) => { - commands.push(command) - }, - ) - fireEvent.load(frame) - act(() => { - window.dispatchEvent( - new MessageEvent("message", { - data: { - protocolVersion: 1, - requestId: "template-ready", - type: "replica:bridge:ready", - payload: { route: { pathname: "/settings", search: "?tab=profile", hash: "" } }, - }, - origin: "http://b1.preview.replica.localhost:19500", - source: frame.contentWindow, - }), - ) - }) - const restoration = commands.find( - (command) => command.type === "replica:bridge:restore-fragment", - ) - if (!restoration) throw new Error("Expected template fragment restoration command") - expect(restoration.payload).toEqual({ - route: { pathname: "/settings", search: "?tab=profile", hash: "#details" }, - }) - act(() => { - window.dispatchEvent( - new MessageEvent("message", { - data: { - protocolVersion: 1, - requestId: restoration.requestId, - type: "replica:bridge:ready", - payload: { route: { pathname: "/settings", search: "?tab=profile", hash: "#details" } }, - }, - origin: "http://b1.preview.replica.localhost:19500", - source: frame.contentWindow, - }), - ) - }) - const url = new URL(window.location.href) - expect(url.pathname).toBe(path) - expect(url.searchParams.get("return_path")).toBe(destination) - expect(url.searchParams.has("build")).toBe(false) - expect(url.searchParams.has("before")).toBe(false) - expect(url.searchParams.get("view")).toBe("minimized") - expect(frame).toHaveAttribute( - "src", - "http://b1.preview.replica.localhost:19500/settings?tab=profile", - ) - expect(screen.queryByRole("button", { name: /comment/i })).not.toBeInTheDocument() - - fireEvent.click(screen.getByRole("button", { name: data.maximizeHubLabel })) - expect(screen.getByTitle("main replica preview")).toBe(frame) - expect(screen.getByRole("heading", { name: "Welcome" })).toBeInTheDocument() - expect(screen.getByRole("textbox", { name: data.previewUrlLabel })).toHaveValue( - window.location.href, - ) - fireEvent.click(screen.getByRole("button", { name: data.branchPlaceholder })) - fireEvent.change(screen.getByRole("textbox", { name: data.branchPlaceholder }), { - target: { value: "Simplify this layout" }, - }) - act(() => { - window.dispatchEvent( - new MessageEvent("message", { - data: { - protocolVersion: 1, - requestId: "template-navigation", - type: "replica:bridge:route-changed", - payload: { route: { pathname: "/", search: "?filter=a%26b", hash: "#/about" } }, - }, - origin: "http://b1.preview.replica.localhost:19500", - source: frame.contentWindow, - }), - ) - }) - expect(new URL(window.location.href).searchParams.get("return_path")).toBe( - "/?filter=a%26b#/about", - ) - fireEvent.click(screen.getByRole("button", { name: data.branchSubmitLabel })) - const launcher = screen.getByRole("link", { name: "Open Copilot App" }).getAttribute("href")! - const params = new URL(new URL(launcher).searchParams.get("open")!).searchParams - expect(params.get("base_branch")).toBe("main") - expect(params.get("prompt")).toContain('set .prototype.startPage to "/?filter=a%26b#/about"') - expect(params.get("prompt")).not.toContain("preview.replica.localhost") - }) - - it("resets the brief, reported route and minimized state when switching templates", () => { - window.history.replaceState(null, "", "/repositories/2001/templates/default") - vi.mocked(usePageData).mockReturnValue(replicaBrowseData) - const page = render( - - - , - ) - const frame = screen.getByTitle("main replica preview") - fireEvent.click(screen.getByRole("button", { name: replicaBrowseData.branchPlaceholder })) - fireEvent.change(screen.getByRole("textbox", { name: replicaBrowseData.branchPlaceholder }), { - target: { value: "Default draft" }, - }) - act(() => { - window.dispatchEvent( - new MessageEvent("message", { - data: { - protocolVersion: 1, - requestId: "default-navigation", - type: "replica:bridge:route-changed", - payload: { route: { pathname: "/stale", search: "", hash: "" } }, - }, - origin: "http://b1.preview.replica.localhost:19500", - source: frame.contentWindow, - }), - ) - }) - fireEvent.click(screen.getByRole("button", { name: replicaBrowseData.minimizeHubLabel })) - window.history.replaceState(null, "", "/repositories/2001/templates/welcome") - vi.mocked(usePageData).mockReturnValue({ - ...replicaBrowseData, - templateId: "welcome", - prototypeName: "Welcome", - pageTitle: "Welcome", - selectedBuild: { - ...replicaBrowseData.selectedBuild!, - previewUrl: "http://b1.preview.replica.localhost:19500/#/welcome", - }, - }) - page.rerender( - - - , - ) - expect(screen.getByTitle("main replica preview")).not.toBe(frame) - expect(new URL(window.location.href).searchParams.get("return_path")).toBe("/#/welcome") - expect(new URL(window.location.href).searchParams.has("view")).toBe(false) - expect(screen.queryByRole("toolbar")).not.toBeInTheDocument() - fireEvent.click(screen.getByRole("button", { name: replicaBrowseData.branchPlaceholder })) - expect(screen.getByRole("textbox", { name: replicaBrowseData.branchPlaceholder })).toHaveValue( - "", - ) - }) - - it.each([false, true])( - "keeps review shortcuts and writes disabled when minimized is %s", - (minimized) => { - window.history.replaceState(null, "", "/repositories/2001/replica") - vi.mocked(usePageData).mockReturnValue({ ...replicaBrowseData, minimizedPreview: minimized }) - const fetch = vi.fn() - vi.stubGlobal("fetch", fetch) - render( - - - , - ) - const frame = screen.getByTitle("main replica preview") - const postMessage = vi.spyOn(frame.contentWindow!, "postMessage") - fireEvent.load(frame) - act(() => { - window.dispatchEvent( - new MessageEvent("message", { - data: { - protocolVersion: 1, - requestId: "ready", - type: "replica:bridge:ready", - payload: { route: { pathname: "/", search: "", hash: "" } }, - }, - origin: "http://b1.preview.replica.localhost:19500", - source: frame.contentWindow, - }), - ) - }) - postMessage.mockClear() - - fireEvent.keyDown(window, { key: "c" }) - fireEvent.keyDown(window, { key: "C", shiftKey: true }) - for (const type of [ - "replica:bridge:element-selection-requested", - "replica:bridge:area-selection-requested", - "replica:bridge:sidebar-toggle-requested", - ]) { - act(() => { - window.dispatchEvent( - new MessageEvent("message", { - data: { protocolVersion: 1, requestId: "shortcut", type, payload: {} }, - origin: "http://b1.preview.replica.localhost:19500", - source: frame.contentWindow, - }), - ) - }) - } - - expect(postMessage).not.toHaveBeenCalled() - expect(fetch).not.toHaveBeenCalled() - expect(screen.queryByRole("button", { name: /comment/i })).not.toBeInTheDocument() - expect(screen.queryByRole("complementary", { name: /comments/i })).not.toBeInTheDocument() - expect(document.querySelector(".review-comment-markers")).toBeNull() - expect(document.querySelector(".review-comment-composer")).toBeNull() - }, - ) - - it("does not minimize a direct link before a successful Build exists", () => { - window.history.replaceState( - null, - "", - "/repositories/2001/replica?view=minimized&build=99&before=invalid", - ) - vi.mocked(usePageData).mockReturnValue({ - ...replicaBrowseData, - selectedBuild: undefined, - minimizedPreview: true, - }) - render( - - - , - ) - expect(document.querySelector(".review-workspace-layer")).toHaveAttribute( - "data-minimized", - "false", - ) - expect(screen.getByRole("heading", { name: "No replica build yet" })).toBeInTheDocument() - expect(screen.queryByRole("toolbar")).not.toBeInTheDocument() - expect(screen.queryByRole("button", { name: /minimize|maximize/i })).not.toBeInTheDocument() - expect(new URL(window.location.href).searchParams.has("build")).toBe(false) - expect(new URL(window.location.href).searchParams.has("before")).toBe(false) - }) -}) diff --git a/hub/app/javascript/pages/repositories-show-page/index.test.tsx b/hub/app/javascript/pages/repositories-show-page/index.test.tsx index 76f01a62..773ee6af 100644 --- a/hub/app/javascript/pages/repositories-show-page/index.test.tsx +++ b/hub/app/javascript/pages/repositories-show-page/index.test.tsx @@ -175,10 +175,6 @@ describe("RepositoriesShowPage()", () => { "href", PAGE_DATA.newPrototypePath, ) - expect(screen.getByRole("link", { name: "Build prototype" })).toHaveAttribute( - "data-sg-visit", - "true", - ) const visual = container.querySelector(".hub-empty-state__visual svg") expect(visual).toHaveClass("octicon-git-branch") expect(visual).toHaveAttribute("width", "48") @@ -300,7 +296,6 @@ describe("RepositoriesShowPage()", () => { "href", "/repositories/2001/prototypes/1", ) - expect(screen.getByRole("link", { name: "Welcome" })).toHaveAttribute("data-sg-visit", "true") const collapseButton = screen.getByRole("button", { name: "Collapse sidebar" }) expect(collapseButton).toHaveTextContent("Collapse sidebar") expect(collapseButton.querySelector("svg")).toHaveClass("octicon-sidebar-expand") @@ -497,10 +492,8 @@ describe("RepositoriesShowPage()", () => { expect(passingRow.querySelector(".prototype-card__build-label svg")).toBeNull() const prototypeLink = screen.getByRole("link", { name: "Welcome" }) expect(prototypeLink).toHaveAttribute("href", PAGE_DATA.prototypes[0]!.path) - expect(prototypeLink).toHaveAttribute("data-sg-visit", "true") const ownerLink = passingRow.querySelector(".prototype-card__list-owner") expect(ownerLink).toHaveAttribute("href", "https://github.com/octocat") - expect(ownerLink).not.toHaveAttribute("data-sg-visit") expect(ownerLink?.querySelector(".prototype-card__list-owner-name")).toHaveTextContent( "@octocat", ) diff --git a/hub/app/javascript/pages/repositories-show-page/index.tsx b/hub/app/javascript/pages/repositories-show-page/index.tsx index d857f8df..ce649857 100644 --- a/hub/app/javascript/pages/repositories-show-page/index.tsx +++ b/hub/app/javascript/pages/repositories-show-page/index.tsx @@ -68,7 +68,6 @@ export function RepositoriesShowPage({ ({ "data-sg-visit": "true" })} labels={{ prev: data.listing.copy.previous, prevAriaLabel: data.listing.copy.previous, diff --git a/hub/app/javascript/pages/welcome-page/index.test.tsx b/hub/app/javascript/pages/welcome-page/index.test.tsx index 428a3b82..c3b7ec9c 100644 --- a/hub/app/javascript/pages/welcome-page/index.test.tsx +++ b/hub/app/javascript/pages/welcome-page/index.test.tsx @@ -72,10 +72,6 @@ describe("WelcomePage()", () => { "href", data.workspace.repository?.newPrototypePath, ) - expect(screen.getByRole("link", { name: data.createLabel })).toHaveAttribute( - "data-sg-visit", - "true", - ) for (const label of [data.readmeLabel, data.setupLabel, data.createLabel]) { expect(screen.getByRole("heading", { level: 2, name: label })).toBeInTheDocument() } diff --git a/hub/app/javascript/pages/welcome-page/index.tsx b/hub/app/javascript/pages/welcome-page/index.tsx index cf516883..d43f7e6f 100644 --- a/hub/app/javascript/pages/welcome-page/index.tsx +++ b/hub/app/javascript/pages/welcome-page/index.tsx @@ -129,7 +129,6 @@ export function WelcomePage({ data }: { data: WelcomePageData }) {