From bd215bc586aa0060d2071448f8839e8269cee385 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Thu, 30 Jul 2026 00:09:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(identity):=20NHN=20KCP=20=ED=9C=B4?= =?UTF-8?q?=EB=8C=80=ED=8F=B0=20=EB=B3=B8=EC=9D=B8=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=ED=94=8C=EB=9F=AC=EA=B7=B8=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메일 인증 대신 휴대폰 본인확인을 쓸 수 있게 하는 플러그인을 추가한다. 가입·비밀번호 재설정·성인인증 등 코어 IDV 가 강제하는 모든 지점에서 동작하며, 테스트 모드로 계약 없이 전 흐름을 확인할 수 있다. 구현·검수 과정에서 드러난 저장소 전역 결함을 함께 처리했다. - 외래키 컬럼의 한국어 comment 가 방출되지 않던 문제 (소스 38건 교정 + 기설치본 백필 업그레이드 스텝 7종). ->comment 를 ->constrained 뒤에 체인하면 예외 없이 통과하면서 comment 가 조용히 사라진다 - 로케일 전환 후 확장 액션 핸들러가 소실되던 문제 (플러그인 3종에 재등록 진입점 노출) - 본인인증 상태 폴링 응답이 누적 시도 횟수를 함께 내보내던 문제 - 인증 안내 문구와 실제 진행 방식의 폭 경계 불일치 (768~1023px). 엔진과 같은 값을 같은 방법으로 읽도록 교정 - transition_overlay_target 이 replace 없이 무효이던 관리자 목록 40곳. 로딩 표시가 나오지 않아도 경고가 남지 않아 화면을 봐야만 알 수 있었다 두 인증 플러그인의 코어 최소 요구 버전을 7.0.6 으로 올린다. 운영 모드 자격증명 필수 검사가 7.0.6 의 설정 저장 검증 표면에 의존하며, 그보다 낮은 코어에서는 그 검사가 조용히 통과해 빈 자격증명으로 저장할 수 있었다. 재발 차단으로 정적 검사 룰 4종을 신설하고, 검사 도구가 미커밋 신규 확장을 "검사 파일 0" 으로 통과시키던 사각을 함께 막았다. --- AGENTS.md | 6 +- CHANGELOG.md | 2 + .../Admin/UpdatePluginSettingsRequest.php | 19 +- app/Services/IdentityVerificationService.php | 13 +- .../2026_04_01_000013_create_menus_table.php | 2 +- ...4_01_000017_create_user_consents_table.php | 2 +- ...2026_04_01_000018_create_modules_table.php | 2 +- ...2026_04_01_000019_create_plugins_table.php | 2 +- ...1_000021_create_template_layouts_table.php | 2 +- ..._create_template_layout_versions_table.php | 2 +- ...reate_template_layout_extensions_table.php | 2 +- ...4_01_000027_create_activity_logs_table.php | 4 +- ..._create_template_layout_previews_table.php | 2 +- ...02_create_notification_templates_table.php | 4 +- ..._000004_create_notification_logs_table.php | 6 +- ...reate_identity_verification_logs_table.php | 4 +- ..._27_000001_create_language_packs_table.php | 2 +- ...reate_identity_message_templates_table.php | 8 +- ...mplate_layout_extension_versions_table.php | 2 +- ...eate_template_layout_attachments_table.php | 8 +- ...ate_template_custom_translations_table.php | 8 +- docs/backend/api/README.md | 5 +- docs/backend/api/identity.md | 3 +- docs/database-guide.md | 33 + docs/extension/module-assets.md | 42 + docs/frontend/actions-handlers-navigation.md | 18 + docs/frontend/responsive-layout.md | 23 + .../CHANGELOG.md | 10 + .../backend/ja/messages.php | 3 +- .../frontend/ja.json | 16 +- .../language-pack.json | 2 +- .../CHANGELOG.md | 17 + .../backend/ja/exceptions.php | 14 + .../backend/ja/messages.php | 45 + .../frontend/ja.json | 111 + .../language-pack.json | 30 + .../seed/manifest.json | 4 + modules/_bundled/sirsoft-board/CHANGELOG.md | 4 + ...000008_create_boards_report_logs_table.php | 2 +- ...000001_drop_board_mail_templates_table.php | 6 +- .../admin/admin_board_posts_index.json | 20 +- .../admin/admin_board_reports_index.json | 15 +- .../04_BackfillForeignKeyColumnComments.php | 175 + .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 4 + ...create_ecommerce_product_reviews_table.php | 4 +- ..._ecommerce_product_review_images_table.php | 4 +- ...01_drop_ecommerce_mail_templates_table.php | 6 +- ...e_ecommerce_mileage_transactions_table.php | 4 +- ...reate_ecommerce_mileage_balances_table.php | 2 +- .../_partial_order_info.json | 3 +- .../_partial_order_datagrid.json | 4 +- .../02_BackfillForeignKeyColumnComments.php | 206 + modules/_bundled/sirsoft-page/CHANGELOG.md | 2 + ...4_01_000002_create_page_versions_table.php | 2 +- .../layouts/admin/admin_page_list.json | 8 + .../sirsoft-page/upgrades/Upgrade_1_0_2.php | 16 + .../01_BackfillForeignKeyColumnComments.php | 175 + pint.json | 6 + plugins/_bundled/sirsoft-gdpr/CHANGELOG.md | 2 + plugins/_bundled/sirsoft-gdpr/components.json | 2 +- ...000001_create_gdpr_user_consents_table.php | 4 +- ...eate_gdpr_user_consent_histories_table.php | 4 +- ...0003_create_gdpr_policy_versions_table.php | 7 +- .../sirsoft-gdpr/dist/js/plugin.iife.js | 3 +- .../__tests__/handlerReregistration.test.ts | 80 + .../sirsoft-gdpr/resources/js/index.ts | 26 +- .../sirsoft-gdpr/upgrades/Upgrade_1_0_2.php | 16 + .../01_BackfillForeignKeyColumnComments.php | 181 + .../_bundled/sirsoft-marketing/CHANGELOG.md | 6 + .../_bundled/sirsoft-marketing/composer.json | 2 +- ...1_create_user_marketing_consents_table.php | 4 +- ...user_marketing_consent_histories_table.php | 4 +- .../sirsoft-marketing/package-lock.json | 4036 ++++++++--------- .../_bundled/sirsoft-marketing/package.json | 2 +- .../_bundled/sirsoft-marketing/plugin.json | 2 +- .../upgrades/Upgrade_1_0_1.php | 16 + .../01_BackfillForeignKeyColumnComments.php | 178 + .../sirsoft-pay_kginicis/CHANGELOG.md | 1 + .../admin_order_list_test_badge.json | 4 +- .../_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md | 1 + .../admin_order_list_test_badge.json | 4 +- .../sirsoft-pay_nicepayments/CHANGELOG.md | 1 + .../admin_order_list_test_badge.json | 4 +- .../CHANGELOG.md | 12 + .../components.json | 2 +- .../composer.json | 2 +- ...1_create_inicis_identity_records_table.php | 4 +- .../dist/js/plugin.iife.js | 3 +- .../lang/en/messages.php | 1 + .../lang/ko/messages.php | 3 +- .../package-lock.json | 2022 +++++++++ .../package.json | 2 +- .../sirsoft-verification_kginicis/plugin.json | 4 +- .../__tests__/handlerReregistration.test.ts | 80 + .../resources/js/index.ts | 25 +- .../resources/lang/ko.json | 16 +- .../InicisPopupBridgeController.php | 13 +- .../ValidateInicisSettingsListener.php | 14 +- .../CompleteInicisRecordAfterRegisterTest.php | 16 +- .../scenarios/adult-verification-guard.yaml | 2 + .../upgrades/Upgrade_1_0_2.php | 16 + .../01_BackfillForeignKeyColumnComments.php | 175 + .../sirsoft-verification_nhnkcp/CHANGELOG.md | 44 + .../sirsoft-verification_nhnkcp/LICENSE | 47 + .../components.json | 10 + .../sirsoft-verification_nhnkcp/composer.json | 21 + .../config/settings/defaults.json | 26 + ...1_create_nhnkcp_identity_records_table.php | 67 + ..._create_nhnkcp_cert_transactions_table.php | 60 + .../dist/js/plugin.iife.js | 2 + .../docs/api/README.md | 14 + .../docs/api/identity.md | 158 + .../editor-spec.json | 148 + .../lang/en/exceptions.php | 16 + .../lang/en/messages.php | 52 + .../lang/ko/exceptions.php | 16 + .../lang/ko/messages.php | 53 + .../package-lock.json | 1137 +++++ .../sirsoft-verification_nhnkcp/package.json | 19 + .../sirsoft-verification_nhnkcp/plugin.json | 32 + .../sirsoft-verification_nhnkcp/plugin.php | 256 ++ .../extensions/identity_provider_nhnkcp.json | 214 + .../extensions/mypage_identity_card.json | 291 ++ .../js/__tests__/failureRetryFlow.test.ts | 279 ++ .../__tests__/handlerReregistration.test.ts | 92 + .../identityProviderNhnkcpExtension.test.ts | 145 + .../layouts/mypageIdentityCard.test.tsx | 163 + .../layouts/pluginSettingsLayout.test.tsx | 190 + .../js/__tests__/mobileReturn.test.ts | 156 + .../js/__tests__/startAuthHandler.test.ts | 332 ++ .../resources/js/index.ts | 792 ++++ .../resources/lang/en.json | 111 + .../resources/lang/ko.json | 111 + .../layouts/admin/plugin_settings.json | 720 +++ .../src/Enums/KcpDuplicateField.php | 19 + .../Exceptions/AlreadyConsumedException.php | 33 + .../src/Exceptions/DecryptException.php | 33 + .../src/Exceptions/EncryptException.php | 34 + .../Exceptions/IdentityDuplicateException.php | 48 + .../src/Exceptions/RemoteCallException.php | 35 + .../Http/Controllers/KcpBridgeController.php | 126 + .../Controllers/KcpCallbackController.php | 54 + .../MyKcpIdentityShowController.php | 50 + .../Http/Resources/KcpIdentityResource.php | 106 + .../src/Identity/KcpIdentityProvider.php | 849 ++++ .../AssertNoDuplicateKcpIdentity.php | 137 + .../Listeners/CleanKcpRecordOnUserDelete.php | 67 + .../CleanKcpRecordOnUserWithdraw.php | 65 + .../CompleteKcpRecordAfterRegister.php | 138 + .../Listeners/RegisterKcpProviderListener.php | 76 + .../Listeners/ValidateKcpSettingsListener.php | 123 + .../src/Models/KcpCertTransaction.php | 69 + .../src/Models/KcpIdentityRecord.php | 138 + .../KcpVerificationServiceProvider.php | 80 + .../KcpCertTransactionRepository.php | 118 + .../KcpCertTransactionRepositoryInterface.php | 55 + .../KcpIdentityLogQueryRepository.php | 92 + ...KcpIdentityLogQueryRepositoryInterface.php | 43 + .../KcpIdentityRecordRepository.php | 111 + .../KcpIdentityRecordRepositoryInterface.php | 66 + .../src/Services/KcpCallbackOutcome.php | 99 + .../src/Services/KcpCallbackResolver.php | 275 ++ .../src/Services/KcpCertClient.php | 351 ++ .../src/Services/KcpCertClientInterface.php | 58 + .../Services/KcpDuplicateIdentityChecker.php | 77 + .../src/Services/KcpIdentityCardService.php | 35 + .../src/Support/KcpCertCrypto.php | 135 + .../src/Support/KcpIdentityHasher.php | 47 + .../src/routes/api.php | 19 + .../src/routes/web.php | 28 + .../Controllers/KcpCallbackControllerTest.php | 186 + .../MyKcpIdentityShowControllerTest.php | 166 + .../KcpAdultVerificationGuardTest.php | 195 + .../Identity/KcpCallbackResolverTest.php | 563 +++ .../Identity/KcpDuplicateVerifyGuardTest.php | 213 + .../KcpIdentityProviderBindingTest.php | 94 + .../KcpInstallDefaultPurposeProviderTest.php | 111 + .../Identity/KcpRequestChallengeTest.php | 184 + .../KcpTestModeBirthdayFallbackTest.php | 170 + .../KcpVerifyStorageAtomicityTest.php | 207 + .../AssertNoDuplicateKcpIdentityTest.php | 211 + .../CleanKcpRecordOnUserDeleteTest.php | 103 + .../CleanKcpRecordOnUserWithdrawTest.php | 123 + .../CompleteKcpRecordAfterRegisterTest.php | 262 ++ .../KcpLiveModeSettingsValidationTest.php | 178 + .../tests/Playwright/fixtures/nhnkcp-auth.ts | 25 + .../tests/Playwright/playwright.config.ts | 93 + .../nhnkcp-verification-settings.spec.ts | 66 + .../user/locale-switch-start-auth.spec.ts | 72 + .../tests/PluginTestCase.php | 246 + .../Unit/Identity/KcpAdultAgeBoundaryTest.php | 73 + .../Unit/Identity/KcpLiveSiteCdPrefixTest.php | 140 + .../tests/Unit/Services/KcpCertClientTest.php | 390 ++ .../tests/Unit/Support/KcpCertCryptoTest.php | 120 + .../tests/scenarios/adult-guard.yaml | 27 + .../scenarios/birthday-availability.yaml | 38 + .../tests/scenarios/callback-verify.yaml | 66 + .../tests/scenarios/cert-crypto.yaml | 30 + .../tests/scenarios/challenge-issue.yaml | 29 + .../tests/scenarios/duplicate-block.yaml | 42 + .../tests/scenarios/guest-flow.yaml | 22 + .../scenarios/handler-reregistration.yaml | 29 + .../tests/scenarios/lifecycle.yaml | 27 + .../tests/scenarios/mobile-return.yaml | 33 + .../tests/scenarios/mypage.yaml | 30 + .../tests/scenarios/pii-cleanup.yaml | 19 + .../scenarios/provider-ui-injection.yaml | 30 + .../tests/scenarios/settings-validation.yaml | 36 + .../vite.config.ts | 34 + .../vitest.config.ts | 17 + .../troubleshooting-components.test.ts | 34 + .../_bundled/sirsoft-admin_basic/CHANGELOG.md | 6 +- .../admin-base-transition-overlay.test.tsx | 44 + ...admin-identity-challenge-fullpage.test.tsx | 20 + .../layouts/admin_role_list.json | 1 + .../layouts/admin_schedule_list.json | 1 + .../layouts/admin_user_list.json | 16 +- .../layouts/auth/identity_challenge.json | 11 +- .../admin_schedule_list/_tab_schedules.json | 18 + templates/_bundled/sirsoft-basic/CHANGELOG.md | 1 + .../identity-challenge-fullpage.test.tsx | 20 + .../layouts/auth/identity_challenge.json | 11 +- .../EnforceIdentityPolicyTokenBypassTest.php | 17 +- .../BackfillForeignKeyColumnCommentsTest.php | 262 ++ upgrades/Upgrade_7_0_6.php | 4 + .../03_BackfillForeignKeyColumnComments.php | 263 ++ 226 files changed, 21048 insertions(+), 2192 deletions(-) create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/CHANGELOG.md create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/backend/ja/exceptions.php create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/backend/ja/messages.php create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/frontend/ja.json create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/language-pack.json create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/seed/manifest.json create mode 100644 modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/04_BackfillForeignKeyColumnComments.php create mode 100644 modules/_bundled/sirsoft-ecommerce/upgrades/data/1.0.5/migrations/02_BackfillForeignKeyColumnComments.php create mode 100644 modules/_bundled/sirsoft-page/upgrades/Upgrade_1_0_2.php create mode 100644 modules/_bundled/sirsoft-page/upgrades/data/1.0.2/migrations/01_BackfillForeignKeyColumnComments.php create mode 100644 pint.json create mode 100644 plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/handlerReregistration.test.ts create mode 100644 plugins/_bundled/sirsoft-gdpr/upgrades/Upgrade_1_0_2.php create mode 100644 plugins/_bundled/sirsoft-gdpr/upgrades/data/1.0.2/migrations/01_BackfillForeignKeyColumnComments.php create mode 100644 plugins/_bundled/sirsoft-marketing/upgrades/Upgrade_1_0_1.php create mode 100644 plugins/_bundled/sirsoft-marketing/upgrades/data/1.0.1/migrations/01_BackfillForeignKeyColumnComments.php create mode 100644 plugins/_bundled/sirsoft-verification_kginicis/package-lock.json create mode 100644 plugins/_bundled/sirsoft-verification_kginicis/resources/js/__tests__/handlerReregistration.test.ts create mode 100644 plugins/_bundled/sirsoft-verification_kginicis/upgrades/Upgrade_1_0_2.php create mode 100644 plugins/_bundled/sirsoft-verification_kginicis/upgrades/data/1.0.2/migrations/01_BackfillForeignKeyColumnComments.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/LICENSE create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/components.json create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/composer.json create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/config/settings/defaults.json create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/database/migrations/2026_07_28_000001_create_nhnkcp_identity_records_table.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/database/migrations/2026_07_28_000002_create_nhnkcp_cert_transactions_table.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/dist/js/plugin.iife.js create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/identity.md create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/editor-spec.json create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/lang/en/exceptions.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/lang/en/messages.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/lang/ko/exceptions.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/lang/ko/messages.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/package-lock.json create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/package.json create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/plugin.json create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/plugin.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/resources/extensions/identity_provider_nhnkcp.json create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/resources/extensions/mypage_identity_card.json create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/failureRetryFlow.test.ts create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/handlerReregistration.test.ts create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/identityProviderNhnkcpExtension.test.ts create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/layouts/mypageIdentityCard.test.tsx create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/layouts/pluginSettingsLayout.test.tsx create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/mobileReturn.test.ts create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/startAuthHandler.test.ts create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/index.ts create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/resources/lang/en.json create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/resources/lang/ko.json create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/resources/layouts/admin/plugin_settings.json create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Enums/KcpDuplicateField.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Exceptions/AlreadyConsumedException.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Exceptions/DecryptException.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Exceptions/EncryptException.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Exceptions/IdentityDuplicateException.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Exceptions/RemoteCallException.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Controllers/KcpBridgeController.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Controllers/KcpCallbackController.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Controllers/MyKcpIdentityShowController.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Resources/KcpIdentityResource.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Identity/KcpIdentityProvider.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/AssertNoDuplicateKcpIdentity.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/CleanKcpRecordOnUserDelete.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/CleanKcpRecordOnUserWithdraw.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/CompleteKcpRecordAfterRegister.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/RegisterKcpProviderListener.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/ValidateKcpSettingsListener.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Models/KcpCertTransaction.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Models/KcpIdentityRecord.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Providers/KcpVerificationServiceProvider.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpCertTransactionRepository.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpCertTransactionRepositoryInterface.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpIdentityLogQueryRepository.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpIdentityLogQueryRepositoryInterface.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpIdentityRecordRepository.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpIdentityRecordRepositoryInterface.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpCallbackOutcome.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpCallbackResolver.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpCertClient.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpCertClientInterface.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpDuplicateIdentityChecker.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpIdentityCardService.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Support/KcpCertCrypto.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Support/KcpIdentityHasher.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/routes/api.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/routes/web.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Controllers/KcpCallbackControllerTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Controllers/MyKcpIdentityShowControllerTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpAdultVerificationGuardTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpCallbackResolverTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpDuplicateVerifyGuardTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpIdentityProviderBindingTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpInstallDefaultPurposeProviderTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpRequestChallengeTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpTestModeBirthdayFallbackTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpVerifyStorageAtomicityTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/AssertNoDuplicateKcpIdentityTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/CleanKcpRecordOnUserDeleteTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/CleanKcpRecordOnUserWithdrawTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/CompleteKcpRecordAfterRegisterTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Settings/KcpLiveModeSettingsValidationTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/fixtures/nhnkcp-auth.ts create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/playwright.config.ts create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/specs/admin/nhnkcp-verification-settings.spec.ts create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/specs/user/locale-switch-start-auth.spec.ts create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/PluginTestCase.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Identity/KcpAdultAgeBoundaryTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Identity/KcpLiveSiteCdPrefixTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Services/KcpCertClientTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Support/KcpCertCryptoTest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/adult-guard.yaml create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/birthday-availability.yaml create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/callback-verify.yaml create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/cert-crypto.yaml create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/challenge-issue.yaml create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/duplicate-block.yaml create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/guest-flow.yaml create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/handler-reregistration.yaml create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/lifecycle.yaml create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/mobile-return.yaml create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/mypage.yaml create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/pii-cleanup.yaml create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/provider-ui-injection.yaml create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/settings-validation.yaml create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/vite.config.ts create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/vitest.config.ts create mode 100644 tests/Feature/Upgrade/BackfillForeignKeyColumnCommentsTest.php create mode 100644 upgrades/data/7.0.6/migrations/03_BackfillForeignKeyColumnComments.php diff --git a/AGENTS.md b/AGENTS.md index 89301c65..8b4ebbea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,7 +153,7 @@ | 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 35 / 291 | -### 확장 API 레퍼런스 (11개 확장, 자동 스캔) +### 확장 API 레퍼런스 (12개 확장, 자동 스캔) > 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음). @@ -170,6 +170,7 @@ | `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 | | `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 | | `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 | +| `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 | @@ -760,6 +761,8 @@ Controller → Request → Service → RepositoryInterface → Repository → Mo 절대 금지: DB CASCADE에 의존한 삭제 → Service에서 명시적 삭제 (훅/파일/로깅 보장) 절대 금지: 로케일 하드코딩 → config('app.supported_locales') 사용 필수: 마이그레이션 한국어 comment 필수, down() 구현 필수 +필수: FK 컬럼의 ->comment() 는 ->constrained()/->references()/->on() 앞에 둔다 (뒤에 두면 comment 가 컬럼이 아닌 FK 정의에 부착되어 조용히 사라진다) +필수: 소스 교정만으로는 기설치본이 낫지 않는다 — 마이그레이션은 재실행되지 않으므로 업그레이드 스텝 백필을 함께 작성 주의: ResponseHelper::success($messageKey, $data) — 메시지가 첫 번째 인수 ``` @@ -806,6 +809,7 @@ BaseApiController (최상위) 필수: 확장 코드 변경 시 manifest 버전 업 (미변경 시 업데이트 감지 불가) 필수: 버전 업 시 CHANGELOG.md 기록 — Keep a Changelog 표준 (미기록 시 버전 업 불가) 필수: StorageInterface 사용 (Storage::disk() 직접 호출 금지) +필수: ActionDispatcher 에 핸들러를 등록하는 확장은 재등록 진입점을 window 전역에 고정 이름으로 노출 — 모듈 window.__[Name].initModule, 플러그인 window.__[Name].initPlugin (미노출 시 로케일 전환 후 해당 확장 액션이 전부 무반응, 에러·토스트 없음). 진입점은 핸들러 재등록만 수행 필수: 확장 미들웨어는 getMiddleware() 로 부착 대상(targets) 명시 선언 (self-gate) — SP Kernel 미들웨어 그룹 직접 조작·라우트 파일 자기 미들웨어 FQCN 부착 금지, 무규율 전역 개입 금지 필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용 필수: 모든 확장 작업은 Artisan 커맨드로 수행 diff --git a/CHANGELOG.md b/CHANGELOG.md index 02270118..bf541358 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,8 @@ - 데이터베이스 연결이 일시적으로 끊기거나 확장 설치가 중간에 실패한 직후 확장 목록을 다시 만들면, 설치된 모듈·플러그인 정보가 통째로 비워지면서 게시판·쇼핑몰 등 모든 확장 화면이 500 오류를 내던 문제를 수정했습니다. 이제 확장 폴더가 그대로 남아 있는데 목록만 비어 있으면 기존 정보를 지우지 않고 보존하며, 왜 갱신하지 않았는지 로그에 남깁니다. - 확장 목록이 손상된 상태에서 복구 명령을 실행하면 목록은 되살아나지만 확장 기능 연결이 비어 있는 채로 남아, 명령을 두 번 실행해야 완전히 복구되던 문제를 수정했습니다. 이제 한 번 실행으로 모두 복구됩니다. +- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다). +- 본인인증 진행 상태를 조회하는 화면용 응답에 지금까지의 인증 시도 횟수가 함께 실려 나가던 것을 빼도록 바로잡았습니다. 원래 내보내지 않기로 한 값이며, 잠금 직전까지 시도 횟수를 맞춰 보는 데 쓰일 수 있었습니다. 화면 동작에는 영향이 없습니다. - 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다. - 모듈·플러그인의 연동 기능이 실행된 뒤로 같은 요청의 나머지 처리가 요청 정보를 잃어버리던 문제를 수정했습니다. 백그라운드 작업을 별도 프로세스로 돌리지 않는 설정에서 이 현상이 나타나, 뒤이어 실행되는 연동 기능이 필요한 정보를 찾지 못하고 조용히 건너뛰었습니다. 대표적으로 비회원 장바구니에 담아 두고 로그인하면 담은 상품이 사라졌습니다. - 모듈·플러그인 업데이트가 실패하거나 도중에 중단된 뒤, 확장은 정상인데 그 관리자 화면만 "페이지를 찾을 수 없습니다"로 나오고 하루가 지나야 돌아오던 문제를 수정했습니다. 업데이트가 진행되는 동안 사이트를 열어 본 사람이 있으면 그 순간의 "이 확장은 사용 중이 아님" 상태가 기억되는데, 실패로 끝나면 그 기억이 정리되지 않았습니다. 이제 실패로 끝나도 즉시 정리되며, 잘못된 상태가 애초에 기억되지 않도록 함께 보완했습니다. diff --git a/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php b/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php index 3cd0a073..f988857f 100644 --- a/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php +++ b/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php @@ -106,7 +106,24 @@ class UpdatePluginSettingsRequest extends FormRequest $rules[$field] = $fieldRules; } - return HookManager::applyFilters('core.plugin_settings.update_rules', $rules, $identifier); + // audit:allow core-formrequest-hook-filter reason: 훅 이름이 표준(`{대상}.{동작}_validation_rules`)과 + // 어긋난 상태로 이미 공개되어 있다(`core.plugin_settings.update_rules`). 같은 이탈이 코어에 + // 6개 더 있으며(core.auth.validate_reset_token_rules / core.auth.verify_password_rules / + // core.extension.changelog_rules / core.layout.get_validation_messages / + // core.search.validation_rules / core.user.upload_avatar_rules), 이름을 바꾸면 이 훅을 + // 구독 중인 제3자 확장이 조용히 동작을 멈춘다. 일괄 개명은 PO 판단 사항이라 여기서 + // 단독으로 바꾸지 않는다. + // + // 3번째 인자로 이번 요청의 입력값을 함께 넘긴다. 확장이 "현재 입력한 모드에 따라 + // 다른 필드를 필수로 만드는" 조건부 규칙을 만들려면 입력값이 필요한데, 그것이 없어 + // 확장이 request() 를 직접 들여다보던 것을 없애기 위함이다. + // 기존 2인자 구독자는 그대로 동작한다(초과 인자는 무시된다). + return HookManager::applyFilters( + 'core.plugin_settings.update_rules', + $rules, + $identifier, + $this->all(), + ); } /** diff --git a/app/Services/IdentityVerificationService.php b/app/Services/IdentityVerificationService.php index 4a7cee70..4c35b60f 100644 --- a/app/Services/IdentityVerificationService.php +++ b/app/Services/IdentityVerificationService.php @@ -174,11 +174,11 @@ class IdentityVerificationService * 비동기 검증 흐름(Stripe Identity / 토스인증 push / 외부 redirect 콜백 대기) 에서 클라이언트가 * `GET /api/identity/challenges/{id}` 로 상태를 폴링할 때 사용합니다. * - * 반환 필드는 코드 본체·내부 metadata 를 제외한 공개 안전 필드만: - * - id / status / provider_id / purpose / render_hint / expires_at / attempts / max_attempts / public_payload + * 반환 필드는 코드 본체·내부 metadata·시도 횟수를 제외한 공개 안전 필드만: + * - id / status / provider_id / purpose / render_hint / expires_at / max_attempts / public_payload * - * attempts/max_attempts 는 프론트 풀페이지 (`auth/identity_challenge.json`) 가 "남은 시도 횟수" UI - * 카운트다운에 사용한다. URL 직접 진입(외부 redirect 콜백 후) 흐름에서도 정확한 한도 표시 보장. + * max_attempts 는 정책 상수라 노출해도 무방하며, 프론트 풀페이지 (`auth/identity_challenge.json`) 가 + * 시도 한도 표시에 사용한다. 누적 시도 횟수(attempts)는 싣지 않는다 — 사유는 반환 지점 주석 참조. * * @param string $challengeId Challenge UUID * @return array|null 공개 상태 또는 null (없는 경우) @@ -207,8 +207,9 @@ class IdentityVerificationService 'render_hint' => $log->render_hint, 'expires_at' => optional($log->expires_at)->toIso8601String(), // 시도 횟수(attempts)는 공개 폴링 응답에 싣지 않는다 — challenge id 만 알면 누구나 - // 조회할 수 있는 경로라, 남의 인증 시도가 몇 번 실패했는지가 그대로 드러난다. - // 화면의 '남은 시도 횟수' 는 모달이 자기 시도를 세어 표시하므로 영향이 없다. + // 조회할 수 있는 경로라, 남의 인증 시도가 몇 번 실패했는지가 드러나고 잠금 직전까지 + // 시도 횟수를 맞춰 보는 데도 쓰일 수 있다. 상한(max_attempts)은 정책 상수라 노출해도 + // 무방하며, 화면의 '남은 시도 횟수' 는 모달이 자기 시도를 세어 표시하므로 영향이 없다. 'max_attempts' => (int) $log->max_attempts, 'public_payload' => $publicPayload, ]; diff --git a/database/migrations/2026_04_01_000013_create_menus_table.php b/database/migrations/2026_04_01_000013_create_menus_table.php index 0cc17622..86ba377d 100644 --- a/database/migrations/2026_04_01_000013_create_menus_table.php +++ b/database/migrations/2026_04_01_000013_create_menus_table.php @@ -24,7 +24,7 @@ return new class extends Migration $table->string('extension_type', 20)->nullable()->comment('확장 소유 타입: core(코어), module(모듈), plugin(플러그인), NULL(사용자 정의)'); $table->string('extension_identifier', 255)->nullable()->comment('확장 식별자 (예: core, sirsoft-board, sirsoft-payment)'); $table->text('user_overrides')->nullable()->comment('유저가 수정한 필드명 목록 (예: ["name", "icon", "order"])'); - $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('등록자 ID'); + $table->foreignId('created_by')->nullable()->comment('등록자 ID')->constrained('users')->nullOnDelete(); $table->timestamps(); $table->index(['parent_id', 'order']); diff --git a/database/migrations/2026_04_01_000017_create_user_consents_table.php b/database/migrations/2026_04_01_000017_create_user_consents_table.php index add90569..e7e487c5 100644 --- a/database/migrations/2026_04_01_000017_create_user_consents_table.php +++ b/database/migrations/2026_04_01_000017_create_user_consents_table.php @@ -14,7 +14,7 @@ return new class extends Migration { Schema::create('user_consents', function (Blueprint $table) { $table->id()->comment('동의 이력 ID'); - $table->foreignId('user_id')->constrained('users')->cascadeOnDelete()->comment('사용자 ID'); + $table->foreignId('user_id')->comment('사용자 ID')->constrained('users')->cascadeOnDelete(); $table->string('consent_type', 30)->comment('동의 유형: terms, privacy'); $table->timestamp('agreed_at')->comment('동의 일시'); $table->timestamp('revoked_at')->nullable()->comment('철회 일시 (향후 플러그인 확장용)'); diff --git a/database/migrations/2026_04_01_000018_create_modules_table.php b/database/migrations/2026_04_01_000018_create_modules_table.php index 662708c7..8b0007d8 100644 --- a/database/migrations/2026_04_01_000018_create_modules_table.php +++ b/database/migrations/2026_04_01_000018_create_modules_table.php @@ -28,7 +28,7 @@ return new class extends Migration $table->boolean('update_available')->default(false)->comment('업데이트 가능 여부'); $table->string('update_source', 20)->nullable()->comment('업데이트 출처 (github, pending, bundled)'); $table->mediumText('config')->nullable()->comment('모듈 설정 정보'); - $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('모듈 생성자 ID'); + $table->foreignId('created_by')->nullable()->comment('모듈 생성자 ID')->constrained('users')->nullOnDelete(); $table->timestamps(); }); diff --git a/database/migrations/2026_04_01_000019_create_plugins_table.php b/database/migrations/2026_04_01_000019_create_plugins_table.php index 0b4c8fef..dbebe0f5 100644 --- a/database/migrations/2026_04_01_000019_create_plugins_table.php +++ b/database/migrations/2026_04_01_000019_create_plugins_table.php @@ -28,7 +28,7 @@ return new class extends Migration $table->string('github_changelog_url', 512)->nullable()->comment('GitHub 변경 내역 URL'); $table->enum('status', ['active', 'inactive', 'installing', 'uninstalling', 'updating'])->default('inactive')->index()->comment('상태 (active: 활성화, inactive: 비활성화, installing: 설치 중, uninstalling: 제거 중, updating: 업데이트 중)'); $table->mediumText('hooks')->nullable()->comment('훅 설정 정보'); - $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('플러그인 생성자 ID'); + $table->foreignId('created_by')->nullable()->comment('플러그인 생성자 ID')->constrained('users')->nullOnDelete(); $table->timestamps(); }); diff --git a/database/migrations/2026_04_01_000021_create_template_layouts_table.php b/database/migrations/2026_04_01_000021_create_template_layouts_table.php index abafad1e..aa360c90 100644 --- a/database/migrations/2026_04_01_000021_create_template_layouts_table.php +++ b/database/migrations/2026_04_01_000021_create_template_layouts_table.php @@ -14,7 +14,7 @@ return new class extends Migration { Schema::create('template_layouts', function (Blueprint $table) { $table->id()->comment('레이아웃 ID'); - $table->foreignId('template_id')->constrained('templates')->cascadeOnDelete()->comment('템플릿 ID'); + $table->foreignId('template_id')->comment('템플릿 ID')->constrained('templates')->cascadeOnDelete(); $table->string('name')->comment('레이아웃 이름 (예: dashboard, users, user_edit)'); $table->longText('content')->comment('레이아웃 JSON 내용'); $table->string('extends')->nullable()->index()->comment('부모 레이아웃 이름 (예: layouts/_admin_base)'); diff --git a/database/migrations/2026_04_01_000022_create_template_layout_versions_table.php b/database/migrations/2026_04_01_000022_create_template_layout_versions_table.php index cd51bb60..ee4c1c2b 100644 --- a/database/migrations/2026_04_01_000022_create_template_layout_versions_table.php +++ b/database/migrations/2026_04_01_000022_create_template_layout_versions_table.php @@ -14,7 +14,7 @@ return new class extends Migration { Schema::create('template_layout_versions', function (Blueprint $table) { $table->id()->comment('버전 ID'); - $table->foreignId('layout_id')->constrained('template_layouts')->cascadeOnDelete()->comment('레이아웃 ID'); + $table->foreignId('layout_id')->comment('레이아웃 ID')->constrained('template_layouts')->cascadeOnDelete(); $table->unsignedInteger('version')->comment('버전 번호 (자동 증가)'); $table->longText('content')->comment('레이아웃 JSON 스냅샷'); $table->text('changes_summary')->nullable()->comment('변경 요약 JSON: {"added": 3, "removed": 2, "is_restored": false, "restored_from": null}'); diff --git a/database/migrations/2026_04_01_000023_create_template_layout_extensions_table.php b/database/migrations/2026_04_01_000023_create_template_layout_extensions_table.php index e585832c..e7b71e96 100644 --- a/database/migrations/2026_04_01_000023_create_template_layout_extensions_table.php +++ b/database/migrations/2026_04_01_000023_create_template_layout_extensions_table.php @@ -14,7 +14,7 @@ return new class extends Migration { Schema::create('template_layout_extensions', function (Blueprint $table) { $table->id()->comment('확장 ID'); - $table->foreignId('template_id')->constrained('templates')->cascadeOnDelete()->comment('템플릿 ID (g7_templates 참조)'); + $table->foreignId('template_id')->comment('템플릿 ID (g7_templates 참조)')->constrained('templates')->cascadeOnDelete(); $table->string('extension_type', 50)->comment('확장 타입: extension_point=확장점 방식, overlay=ID 기반 오버레이 방식'); $table->string('target_name')->comment('타겟 이름 (extension_point: 확장점명, overlay: 레이아웃명)'); $table->string('source_type', 50)->comment('출처 타입: template=템플릿(오버라이드용), module=모듈, plugin=플러그인'); diff --git a/database/migrations/2026_04_01_000027_create_activity_logs_table.php b/database/migrations/2026_04_01_000027_create_activity_logs_table.php index 8b455435..78f21e7d 100644 --- a/database/migrations/2026_04_01_000027_create_activity_logs_table.php +++ b/database/migrations/2026_04_01_000027_create_activity_logs_table.php @@ -15,12 +15,12 @@ return new class extends Migration $table->id()->comment('활동 로그 ID'); $table->string('log_type', 20)->index()->comment('로그 유형 (admin: 관리자, user: 사용자, system: 시스템)'); $table->nullableMorphs('loggable'); - $table->foreignId('user_id')->nullable()->index()->constrained('users')->nullOnDelete()->comment('사용자 ID'); + $table->foreignId('user_id')->nullable()->index()->comment('사용자 ID')->constrained('users')->nullOnDelete(); $table->string('action', 50)->index()->comment('액션 유형 (created, updated, deleted, login, export 등)'); $table->text('description')->comment('액션 상세 설명'); $table->mediumText('properties')->nullable()->comment('변경 상세 데이터 (old/new 값)'); $table->string('ip_address', 45)->nullable()->comment('IP 주소 (IPv6 대응)'); - $table->string('user_agent', 500)->nullable()->comment('User Agent'); + $table->string('user_agent', 500)->nullable()->comment('접속 브라우저 정보 (User Agent)'); $table->timestamp('created_at')->useCurrent()->index()->comment('생성일시'); }); } diff --git a/database/migrations/2026_04_01_000658_create_template_layout_previews_table.php b/database/migrations/2026_04_01_000658_create_template_layout_previews_table.php index 63426551..74c993b3 100644 --- a/database/migrations/2026_04_01_000658_create_template_layout_previews_table.php +++ b/database/migrations/2026_04_01_000658_create_template_layout_previews_table.php @@ -14,7 +14,7 @@ return new class extends Migration Schema::create('template_layout_previews', function (Blueprint $table) { $table->id()->comment('ID'); $table->char('token', 36)->unique()->comment('미리보기 URL 토큰 (UUID)'); - $table->foreignId('template_id')->constrained('templates')->cascadeOnDelete()->comment('템플릿 ID'); + $table->foreignId('template_id')->comment('템플릿 ID')->constrained('templates')->cascadeOnDelete(); $table->string('layout_name', 255)->comment('미리보기 대상 레이아웃 이름'); $table->longText('content')->comment('편집 중인 레이아웃 JSON'); $table->unsignedBigInteger('admin_id')->comment('미리보기 생성 관리자 ID'); diff --git a/database/migrations/2026_04_08_000002_create_notification_templates_table.php b/database/migrations/2026_04_08_000002_create_notification_templates_table.php index 14caae94..afebfe61 100644 --- a/database/migrations/2026_04_08_000002_create_notification_templates_table.php +++ b/database/migrations/2026_04_08_000002_create_notification_templates_table.php @@ -15,14 +15,14 @@ return new class extends Migration { Schema::create('notification_templates', function (Blueprint $table) { $table->id()->comment('ID'); - $table->foreignId('definition_id')->constrained('notification_definitions')->cascadeOnDelete()->comment('알림 정의 ID'); + $table->foreignId('definition_id')->comment('알림 정의 ID')->constrained('notification_definitions')->cascadeOnDelete(); $table->string('channel', 50)->comment('채널: mail, database, fcm'); $table->text('subject')->nullable()->comment('다국어 제목 ({"ko": "...", "en": "..."})'); $table->mediumText('body')->comment('다국어 본문 ({"ko": "...", "en": "..."})'); $table->boolean('is_active')->default(true)->comment('해당 채널 활성 여부'); $table->boolean('is_default')->default(true)->comment('시더 생성 여부'); $table->text('user_overrides')->nullable()->comment('사용자가 수정한 필드명 목록'); - $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자'); + $table->foreignId('updated_by')->nullable()->comment('수정자')->constrained('users')->nullOnDelete(); $table->timestamps(); $table->unique(['definition_id', 'channel']); diff --git a/database/migrations/2026_04_08_000004_create_notification_logs_table.php b/database/migrations/2026_04_08_000004_create_notification_logs_table.php index 43139b71..ae60ab37 100644 --- a/database/migrations/2026_04_08_000004_create_notification_logs_table.php +++ b/database/migrations/2026_04_08_000004_create_notification_logs_table.php @@ -19,12 +19,10 @@ return new class extends Migration $table->string('notification_type', 100)->comment('알림 타입: welcome, order_confirmed 등'); $table->string('extension_type', 20)->default('core')->comment('확장 타입: core, module, plugin'); $table->string('extension_identifier', 100)->default('core')->comment('확장 식별자'); - $table->foreignId('recipient_user_id')->nullable()->constrained('users')->nullOnDelete() - ->comment('수신자 회원 ID (회원인 경우)'); + $table->foreignId('recipient_user_id')->nullable()->comment('수신자 회원 ID (회원인 경우)')->constrained('users')->nullOnDelete(); $table->string('recipient_identifier', 255)->comment('수신자 식별자 (채널별: 이메일, 디바이스토큰, user_id 등)'); $table->string('recipient_name', 255)->nullable()->comment('수신자 표시명 (발송 시점 스냅샷)'); - $table->foreignId('sender_user_id')->nullable()->constrained('users')->nullOnDelete() - ->comment('발송자 회원 ID (null=시스템 자동)'); + $table->foreignId('sender_user_id')->nullable()->comment('발송자 회원 ID (null=시스템 자동)')->constrained('users')->nullOnDelete(); $table->string('subject', 500)->nullable()->comment('렌더링된 제목'); $table->longText('body')->nullable()->comment('렌더링된 본문'); $table->string('status', 20)->default('sent')->comment('상태: sent, failed, skipped'); diff --git a/database/migrations/2026_04_23_000101_create_identity_verification_logs_table.php b/database/migrations/2026_04_23_000101_create_identity_verification_logs_table.php index 455f5456..ff2999d5 100644 --- a/database/migrations/2026_04_23_000101_create_identity_verification_logs_table.php +++ b/database/migrations/2026_04_23_000101_create_identity_verification_logs_table.php @@ -26,9 +26,9 @@ return new class extends Migration $table->foreignId('user_id') ->nullable() ->index() + ->comment('사용자 탈퇴 시 NULL 로 유지 — 감사 이력 보존 (CASCADE 금지 규정 준수)') ->constrained('users') - ->nullOnDelete() - ->comment('사용자 탈퇴 시 NULL 로 유지 — 감사 이력 보존 (CASCADE 금지 규정 준수)'); + ->nullOnDelete(); $table->string('target_hash', 64)->index()->comment('SHA256(email|phone) — PII 원본 저장 회피'); $table->string('status', 32)->index()->comment('인증 challenge 생명주기 상태 (requested|sent|processing|verified|failed|expired|cancelled|policy_violation_logged) — App\\Enums\\IdentityVerificationStatus enum'); diff --git a/database/migrations/2026_04_27_000001_create_language_packs_table.php b/database/migrations/2026_04_27_000001_create_language_packs_table.php index 661ddd06..ebc7775a 100644 --- a/database/migrations/2026_04_27_000001_create_language_packs_table.php +++ b/database/migrations/2026_04_27_000001_create_language_packs_table.php @@ -39,7 +39,7 @@ return new class extends Migration $table->json('manifest')->comment('language-pack.json 전체 스냅샷'); $table->string('source_type', 30)->nullable()->comment('설치 소스 유형 (zip/github/url/bundled/bundled_with_extension)'); $table->string('source_url', 500)->nullable()->comment('설치 소스 URL 또는 경로'); - $table->foreignId('installed_by')->nullable()->constrained('users')->nullOnDelete()->comment('설치자 사용자 ID'); + $table->foreignId('installed_by')->nullable()->comment('설치자 사용자 ID')->constrained('users')->nullOnDelete(); $table->timestamp('installed_at')->nullable()->comment('설치 시각'); $table->timestamp('activated_at')->nullable()->comment('활성화 시각'); $table->timestamps(); diff --git a/database/migrations/2026_04_28_000002_create_identity_message_templates_table.php b/database/migrations/2026_04_28_000002_create_identity_message_templates_table.php index 5366dffa..9b07a964 100644 --- a/database/migrations/2026_04_28_000002_create_identity_message_templates_table.php +++ b/database/migrations/2026_04_28_000002_create_identity_message_templates_table.php @@ -19,9 +19,9 @@ return new class extends Migration Schema::create('identity_message_templates', function (Blueprint $table) { $table->id()->comment('ID'); $table->foreignId('definition_id') + ->comment('메시지 정의 ID (FK)') ->constrained('identity_message_definitions') - ->cascadeOnDelete() - ->comment('메시지 정의 ID (FK)'); + ->cascadeOnDelete(); $table->string('channel', 20)->comment('메시지 템플릿 채널 (mail 현재 / sms 등 향후) — IdentityVerificationChannel 과는 별개의 도메인 분류'); $table->text('subject')->nullable() ->comment('다국어 제목 ({"ko":"...", "en":"..."}) — mail 채널에서만 의미'); @@ -32,9 +32,9 @@ return new class extends Migration ->comment('운영자가 수정한 필드명 목록 (예: ["subject","body","is_active"])'); $table->foreignId('updated_by') ->nullable() + ->comment('수정자 (사용자 삭제 시 NULL)') ->constrained('users') - ->nullOnDelete() - ->comment('수정자 (사용자 삭제 시 NULL)'); + ->nullOnDelete(); $table->timestamps(); $table->unique(['definition_id', 'channel'], 'idx_identity_message_tpl_def_channel_unique'); diff --git a/database/migrations/2026_05_22_000001_create_template_layout_extension_versions_table.php b/database/migrations/2026_05_22_000001_create_template_layout_extension_versions_table.php index 14922d61..4bc6bcec 100644 --- a/database/migrations/2026_05_22_000001_create_template_layout_extension_versions_table.php +++ b/database/migrations/2026_05_22_000001_create_template_layout_extension_versions_table.php @@ -14,7 +14,7 @@ return new class extends Migration { Schema::create('template_layout_extension_versions', function (Blueprint $table) { $table->id()->comment('버전 ID'); - $table->foreignId('extension_id')->constrained('template_layout_extensions')->cascadeOnDelete()->comment('레이아웃 확장 ID'); + $table->foreignId('extension_id')->comment('레이아웃 확장 ID')->constrained('template_layout_extensions')->cascadeOnDelete(); $table->unsignedInteger('version')->comment('버전 번호 (자동 증가)'); $table->longText('content')->comment('확장 정의 JSON 스냅샷'); $table->text('changes_summary')->nullable()->comment('변경 요약 JSON: {"added": 3, "removed": 2, "is_restored": false, "restored_from": null}'); diff --git a/database/migrations/2026_05_29_000001_create_template_layout_attachments_table.php b/database/migrations/2026_05_29_000001_create_template_layout_attachments_table.php index c677e2a4..fa197f18 100644 --- a/database/migrations/2026_05_29_000001_create_template_layout_attachments_table.php +++ b/database/migrations/2026_05_29_000001_create_template_layout_attachments_table.php @@ -18,9 +18,9 @@ return new class extends Migration Schema::create('template_layout_attachments', function (Blueprint $table) { $table->id()->comment('첨부 파일 ID'); $table->foreignId('template_id') + ->comment('소속 템플릿 ID') ->constrained('templates') - ->cascadeOnDelete() - ->comment('소속 템플릿 ID'); + ->cascadeOnDelete(); $table->string('layout_name', 150)->nullable()->comment('사용 출처 레이아웃 이름'); $table->string('disk', 50)->comment('스토리지 디스크 이름'); $table->string('path', 500)->comment('스토리지 내 파일 경로'); @@ -29,9 +29,9 @@ return new class extends Migration $table->unsignedBigInteger('size')->comment('파일 크기(바이트)'); $table->foreignId('created_by') ->nullable() + ->comment('업로더 사용자 ID') ->constrained('users') - ->nullOnDelete() - ->comment('업로더 사용자 ID'); + ->nullOnDelete(); $table->timestamps(); $table->index(['template_id', 'layout_name'], 'index_template_layout'); diff --git a/database/migrations/2026_06_02_120115_create_template_custom_translations_table.php b/database/migrations/2026_06_02_120115_create_template_custom_translations_table.php index 8c06035b..3f762924 100644 --- a/database/migrations/2026_06_02_120115_create_template_custom_translations_table.php +++ b/database/migrations/2026_06_02_120115_create_template_custom_translations_table.php @@ -14,16 +14,16 @@ return new class extends Migration Schema::create('template_custom_translations', function (Blueprint $table) { $table->id()->comment('커스텀 다국어 키 ID'); $table->foreignId('template_id') + ->comment('소속 템플릿 ID') ->constrained('templates') - ->cascadeOnDelete() - ->comment('소속 템플릿 ID'); + ->cascadeOnDelete(); $table->string('layout_name', 150)->nullable()->comment('생성 출처 레이아웃 이름'); $table->string('translation_key', 255)->comment('다국어 키 ($t: 참조 경로)'); $table->json('values')->comment('로케일별 번역 값'); $table->json('user_overrides')->nullable()->comment('사용자 수정 보존 추적'); $table->enum('status', ['active', 'orphaned'])->default('active')->comment('상태 (active: 활성, orphaned: 고아)'); - $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('생성자'); - $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자'); + $table->foreignId('created_by')->nullable()->comment('생성자')->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->comment('수정자')->constrained('users')->nullOnDelete(); $table->timestamps(); $table->unique(['template_id', 'translation_key'], 'template_custom_translations_template_key_unique'); diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index b7ef5357..9a6e257f 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -201,7 +201,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } > 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다. -- **확장 수**: 10 · **엔드포인트 수**: 384 +- **확장 수**: 13 · **엔드포인트 수**: 385 | 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 | | --- | --- | --- | --- | @@ -214,6 +214,9 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } | `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 | | `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 12 | | `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 | +| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 | +| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 | | `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 | +| `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 | diff --git a/docs/backend/api/identity.md b/docs/backend/api/identity.md index 8a79b952..89ba8027 100644 --- a/docs/backend/api/identity.md +++ b/docs/backend/api/identity.md @@ -1883,8 +1883,7 @@ _단건 응답: `data` 객체의 필드._ | purpose | string | `sensitive_action` | 인증 목적 (signup\|password_reset\|self_update\|sensitive_action\|login\|*module-defined*) — 코어 5종은 App\Enums\IdentityVerificationPurpose enum, 모듈/플러그인은 declaredPurposes 레지스트리 | | render_hint | string | `text_code` | 프론트 렌더 힌트 (text_code\|link\|external_redirect) | | expires_at | string | `2026-05-12T18:14:19+00:00` | expires 일시 | -| attempts | integer | `3` | 시도 횟수 | -| max_attempts | integer | `5` | 허용 최대 시도 횟수 | +| max_attempts | integer | `5` | 허용 최대 시도 횟수 (정책 상수). 누적 시도 횟수(attempts)는 잠금 직전까지 시도 횟수를 맞춰 보는 데 쓰일 수 있어 본 응답에 포함하지 않는다 | | public_payload | array | `[]` | 프론트 렌더에 필요한 공개 안전 페이로드 (민감 metadata 제외, 프로바이더별 UI 힌트 — 없으면 빈 배열) | **응답 예시** diff --git a/docs/database-guide.md b/docs/database-guide.md index 92ae1124..22ccae0b 100644 --- a/docs/database-guide.md +++ b/docs/database-guide.md @@ -126,6 +126,39 @@ return new class extends Migration }; ``` +### 컬럼 comment 와 FK 체인 순서 + +`->comment()` 는 `->constrained()` / `->references()` / `->on()` **앞**에 둔다. 뒤에 체인하면 comment 가 컬럼이 아니라 외래키 정의에 부착되어 DB 에 방출되지 않는다. + +```php +// ✅ comment 가 컬럼에 부착된다 +$table->foreignId('user_id') + ->nullable() + ->comment('사용자 ID') + ->constrained('users') + ->nullOnDelete(); + +// ❌ comment 가 사라진다 — COLUMN_COMMENT 가 빈 문자열로 생성된다 +$table->foreignId('user_id') + ->nullable() + ->constrained('users') + ->nullOnDelete() + ->comment('사용자 ID'); +``` + +원인은 반환 타입 전이다. `ForeignIdColumnDefinition::constrained()` 는 `$this->references(...)->on(...)` 을 반환하며 이것은 `ColumnDefinition` 이 아니라 `ForeignKeyDefinition` 이다. `ForeignKeyDefinition` 은 `Fluent` 라서 `->comment()` 호출이 예외 없이 통과하지만, 그 값은 외래키 커맨드의 속성으로만 남고 MySQL grammar 의 `compileForeign` 은 comment 를 다루지 않는다. 그래서 **에러도 경고도 없이 조용히 사라진다.** + +```sql +-- 잘못된 순서로 생성된 테이블의 실측 결과 +SELECT COLUMN_NAME, COLUMN_COMMENT FROM information_schema.COLUMNS ... +g7_menus name [메뉴 이름 (다국어 JSON)] +g7_menus created_by [] ← 소스에는 comment 가 적혀 있다 +``` + +이미 `migrate` 를 마친 설치본은 마이그레이션을 다시 실행하지 않으므로, 소스를 교정해도 기존 DB 의 comment 는 비어 있는 채로 남는다. 소스 교정과 **업그레이드 스텝 백필**을 함께 수행한다. 백필은 comment 가 비어 있을 때만 채워 운영자가 직접 넣은 값을 보존하고, 자료형·NULL 허용·기본값을 현재 스키마에서 읽어 그대로 재적용해 설명 외에는 아무 것도 바꾸지 않는다. + +면제: `// audit:allow migration-fk-comment-order <사유>` 인라인 주석 (해당 구문에 부착) + ### 마이그레이션 멱등성 ``` diff --git a/docs/extension/module-assets.md b/docs/extension/module-assets.md index 98413cd7..3993ec73 100644 --- a/docs/extension/module-assets.md +++ b/docs/extension/module-assets.md @@ -263,8 +263,50 @@ export function initModule(): void { // IIFE 빌드 시 즉시 실행 initModule(); + +// 코어 재초기화 시 재등록 진입점 노출 (아래 "코어 재초기화 시 핸들러 재등록" 참조) +(window as any).__SirsoftEcommerce = { + identifier: MODULE_IDENTIFIER, + initModule, +}; ``` +### 코어 재초기화 시 핸들러 재등록 + +로케일 전환처럼 `TemplateApp` 이 다시 초기화되는 시점에 ActionDispatcher 는 **새 인스턴스로 교체**된다. +이때 앞서 등록해 둔 확장 핸들러는 전부 사라지므로, 코어가 각 확장에 재등록을 요청한다. +요청 방식은 **window 전역 객체에서 약속된 이름의 함수를 찾아 호출**하는 것 하나뿐이다. + +| 확장 타입 | 전역 객체 | 재등록 진입점 | +| --- | --- | --- | +| 모듈 | `window.__[ModuleName]` | `initModule()` | +| 플러그인 | `window.__[PluginName]` | `initPlugin()` | +| 템플릿 | `window.G7TemplateHandlers` | 코어가 직접 재등록 (확장 작업 불필요) | + +```typescript +// 플러그인 엔트리 파일 (index.ts) +function initPlugin(): void { + registerHandlersWithRetry(); // 핸들러 재등록만 수행 +} + +initPlugin(); + +(window as any).__SirsoftDaumPostcode = { + identifier: PLUGIN_IDENTIFIER, + initPlugin, +}; +``` + +이름은 고정이다. 전역 객체를 노출하지 않거나 진입점 이름이 다르면(`init`, `bootstrap`, `setup` 등) +코어는 그 확장을 재등록 대상에서 조용히 건너뛴다. 그 결과는 다음과 같다: + +- 사용자가 언어를 한 번 바꾼 뒤부터 해당 확장의 모든 액션이 **무반응**이 된다 +- 핸들러가 없으므로 dispatch 는 그대로 무시된다 — 콘솔 에러도, 토스트도, 네트워크 요청도 없다 +- 새로고침하면 정상으로 돌아오므로 재현 조건을 모르면 원인 추적이 어렵다 + +진입점은 **핸들러 재등록만** 수행한다. 최초 진입 1회로 충분한 작업(리다이렉트 복귀 처리, +MutationObserver·인터셉터 설치, DOM 주입 등)은 넣지 않는다 — 재초기화마다 중복 실행된다. + ### 핸들러 정의 ```typescript diff --git a/docs/frontend/actions-handlers-navigation.md b/docs/frontend/actions-handlers-navigation.md index 06c286aa..cfabbe7f 100644 --- a/docs/frontend/actions-handlers-navigation.md +++ b/docs/frontend/actions-handlers-navigation.md @@ -67,6 +67,24 @@ - 지정 시: 해당 ID 의 DOM 요소에만 spinner mount. 요소 미발견 시 `transition_overlay.fallback_target` → `#app` 순으로 3단계 폴백 - `replace: true` 아닌 일반 navigate(다른 path)는 `handleRouteChange` 경로로 가며 이 옵션은 효과 없음 +`replace: true` 는 이 옵션의 **전제 조건**이다. 같은 `params` 에 함께 두지 않으면 엔진이 값을 +읽지 않아 오버레이가 표시되지 않는다. 키를 잘못 적은 것도 아니고 값이 틀린 것도 아니라 경고·예외가 +전혀 남지 않으므로, 화면을 직접 보지 않으면 무효 상태를 알아차릴 수 없다. 검색·필터 변경처럼 +목록을 다시 그리는 이동에도 동일하게 적용된다. + +```json +// ❌ 조용히 무시된다 — 오버레이가 뜨지 않는다 +{ "handler": "navigate", "params": { "path": "/admin/users", "mergeQuery": true, + "transition_overlay_target": "users_data_grid__body" } } + +// ✅ replace 를 함께 선언 +{ "handler": "navigate", "params": { "path": "/admin/users", "mergeQuery": true, "replace": true, + "transition_overlay_target": "users_data_grid__body" } } +``` + +정적 검사가 이 조합을 강제한다. 의도적으로 오버레이를 끄려면 키 자체를 지우고, 예외가 필요하면 +액션 노드 `comment` 에 `audit:allow layout-transition-overlay-target-requires-replace <사유>` 를 남긴다. + **DataGrid body 영역 한정 spinner 컨벤션**: 목록 페이지 페이지네이션 시 **pagination 영역은 제외하고 그리드 본문만** spinner 를 표시하려면 `transition_overlay_target` 에 DataGrid 의 `${id}__body` suffix 를 사용한다. DataGrid composite 컴포넌트는 root Div 에 `id` 를, 테이블/카드 목록을 감싸는 내부 wrapper Div 에 `${id}__body` 를 자동으로 부여한다 (pagination 은 body wrapper 밖의 형제). diff --git a/docs/frontend/responsive-layout.md b/docs/frontend/responsive-layout.md index d92e15ac..bbccfe3c 100644 --- a/docs/frontend/responsive-layout.md +++ b/docs/frontend/responsive-layout.md @@ -68,6 +68,29 @@ | `desktop` | 1024px 이상 | 데스크톱 전용 | | `portable` | 0 ~ 1023px | 모바일 + 태블릿 (비데스크톱) | +### 확장 코드에서 같은 경계를 판정할 때 + +모듈·플러그인이 화면 폭에 따라 다른 동작을 고르는 경우(팝업 대신 페이지 전환 등), 그 판정은 +레이아웃이 `responsive` 로 띄우는 안내와 **같은 값을 같은 방법으로** 읽어야 한다. + +```ts +// ✅ 엔진과 동일 — ResponsiveManager 가 window.innerWidth 로 breakpoint 를 정한다 +const PORTABLE_MAX_WIDTH = 1023; +const isPortable = window.innerWidth <= PORTABLE_MAX_WIDTH; + +// ❌ 경계에서 어긋난다 +const isPortable = window.matchMedia('(max-width: 1023px)').matches; +``` + +`matchMedia` 는 CSS 픽셀 기준이라 `devicePixelRatio` 가 정수가 아닌 환경에서 `window.innerWidth` +와 1px 어긋난다. 실측(Chrome, dPR 1.0000000447)에서 `innerWidth === 1023` 인데 +`matchMedia('(max-width: 1023px)')` 가 `false` 였다. 이 어긋남은 딱 경계 폭에서만 나타나므로 +개발 중에는 드러나지 않고, 화면은 "페이지로 이동합니다" 라고 안내해 놓고 실제로는 팝업이 열리는 +형태로 사용자에게만 보인다. + +경계값 자체도 상수로 두고 SSoT(`ResponsiveManager` 의 `portable` 정의)를 주석에 밝힌다 — +프리셋 범위가 바뀌면 확장도 함께 고쳐야 한다는 신호가 코드에 남는다. + ### portable 프리셋 사용 규칙 `portable` 프리셋은 `mobile`과 `tablet`을 합친 범위입니다. 다음 규칙을 준수하세요: diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/CHANGELOG.md index 01c43184..8846f99c 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/CHANGELOG.md @@ -4,6 +4,16 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.2] - 2026-07-29 + +### Added + +- 본인인증 결과 중계 페이지 제목의 일본어 번역 추가 — 인증 창에서 결과를 전달하는 중계 페이지의 제목이 일본어 로케일로 표시됩니다. + +### Changed + +- 설정 화면의 중복 가입 관련 항목 일본어 표기를 원문 용어 정비에 맞춰 갱신했습니다 (「同一人識別」 → 「重複登録ブロック」 계열). + ## [1.0.1] - 2026-07-14 ### Changed diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/backend/ja/messages.php index ea66ea55..4da0eca7 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/backend/ja/messages.php @@ -10,7 +10,7 @@ return [ 'test_mode' => 'テストモード', 'live_mid' => 'ライブMID(SRBプレフィックス)', 'live_api_key' => 'ライブAPIキー', - 'duplicate_field' => '同一人識別基準', + 'duplicate_field' => '重複チェック基準', 'duplicate_block_enabled' => [ 'label' => '重複登録ブロック', 'description' => '有効化時に本人認証を通過した人が以前に別のメールアドレスで登録した場合、登録を拒否します。家族携帯電話共有またはB2Bシナリオなど、1人が複数のアカウントを登録する必要がある場合は無効化してください。この設定とは関係なく、同一メールアドレスの再登録は常にブロックされます(コア基本動作)。', @@ -40,4 +40,5 @@ return [ 'description' => '必ずKGイニシスプロバイダーのみにマッピングしてください。メール/SMSプロバイダーは生年月日を返さないため、成人かどうかの判定ができません。誤りマッピング時に未成年ユーザーに成人向けコンテンツが露出する可能性があります。', ], ], + 'bridge_page_title' => '本人認証結果', ]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/frontend/ja.json index 28f7717e..b7cbadac 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/frontend/ja.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/frontend/ja.json @@ -3,7 +3,7 @@ "description": "KGイニシス統合認証の本人確認サービスをG7コア本人認証インフラに接続するプラグインです。", "settings": { "title": "KGイニシス本人確認設定", - "description": "運用モードとライブ資格情報、同一人識別基準を設定します。", + "description": "運用モードとライブ資格情報、重複登録ブロック基準を設定します。", "info": { "activation": "本人認証の有効化は [環境設定 > 本人認証] から設定します。", "logs": "イニシス認証履歴は [管理者 > 本人認証履歴] から「KGイニシス本人確認」でフィルターして閲覧できます。" @@ -17,13 +17,13 @@ "live_mid_hint": "イニシスライブ加盟店MIDはSRBで始まります (例:SRB1234567)。運用モード使用前に必須入力です。", "live_api_key": "ライブAPIキー", "section": { - "duplicate_field": "同一人識別" + "duplicate_field": "重複登録ブロック" }, "fields": { "duplicate_field": { "label": "重複チェック基準", - "hint": "DIは本加盟店でのみ同一人識別、CIはすべての機関で共通識別が可能です。(デフォルト:DI)", - "warning": "運用中に変更した場合、既存登録者の同一人チェックが一時的に不完全になる可能性があります。" + "hint": "DIは本加盟店でのみ重複登録を確認し、CIはすべての機関で共通して識別できます。(デフォルト:DI)", + "warning": "運用中に変更した場合、既存登録者の重複チェックが一時的に不完全になる可能性があります。" }, "duplicate_block_enabled": { "label": "重複登録ブロック", @@ -34,10 +34,10 @@ } }, "duplicate_field": { - "di": "DI (加盟店従属同一人識別値)", - "ci": "CI (全機関共通同一人識別値)", - "hint": "DIは本加盟店でのみ同一人識別、CIはすべての機関で共通識別が可能です。", - "warning": "運用中に変更した場合、既存登録者の同一人チェックが一時的に不完全になる可能性があります。" + "di": "DI (加盟店従属の固有値)", + "ci": "CI (全機関共通の固有値)", + "hint": "DIは本加盟店でのみ重複登録を確認し、CIはすべての機関で共通して識別できます。", + "warning": "運用中に変更した場合、既存登録者の重複チェックが一時的に不完全になる可能性があります。" } }, "modal": { diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/language-pack.json index 8516a335..ddaddf17 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/language-pack.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 plugin (sirsoft-verification_kginicis) Japanese language pack (bundled)", "ja": "G7 プラグイン (sirsoft-verification_kginicis) 日本語 言語パック(バンドル)" }, - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "scope": "plugin", "target_identifier": "sirsoft-verification_kginicis", diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/CHANGELOG.md new file mode 100644 index 00000000..d582add6 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +이 언어팩의 모든 주요 변경사항을 기록합니다. +형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, +[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. + +## [1.0.0] - 2026-07-29 + +### Added + +- NHN KCP 휴대폰 본인확인 플러그인(sirsoft-verification_nhnkcp)의 일본어 번들 언어팩 초기 제공 +- 본인확인 안내 창 문구(시작 안내·진행 중 안내·모바일 페이지 이동 안내·팝업 차단 안내)의 일본어 번역 +- 실패 안내 문구(취소·성인 인증 필요·중복 가입·인증기관 통신 실패·인증 정보 불완전·인증 세션 만료·저장 실패)의 일본어 번역 +- 관리자 환경설정 문구(테스트 모드·운영 인증 정보·사이트 코드·암호화 키·웹사이트 ID·중복 가입 차단 기준)의 일본어 번역 +- 마이페이지 본인확인 카드 문구(이름·생년월일·휴대폰·인증 일시·성인 여부, 생년월일 미제공·성인 여부 확인 불가 안내 포함)의 일본어 번역 +- 레이아웃 편집기 상태 변종 라벨(본인확인 완료 / 생년월일 미제공 / 내역 없음)의 일본어 번역 +- 본인확인 결과 중계 페이지 제목의 일본어 번역 — 인증 창에서 결과를 전달하는 중계 페이지의 제목이 일본어 로케일로 표시됩니다. diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/backend/ja/exceptions.php b/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/backend/ja/exceptions.php new file mode 100644 index 00000000..d8984901 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/backend/ja/exceptions.php @@ -0,0 +1,14 @@ + '本人認証リクエストデータを準備できませんでした。しばらく後に再度お試しください。', + 'decrypt_failed' => '本人認証情報の復号化に失敗しました。再度お試しください。', + 'remote_call_failed' => 'NHN KCP認証サーバーとの通信に失敗しました。しばらく後に再度お試しください。', + 'not_found' => '本人認証情報が見つかりません。認証を再度実行してください。', + 'already_consumed' => '既に処理済みの本人認証です。', + 'duplicate_register' => '本人認証で登録済みのアカウントが既に存在します。ログインまたはパスワード再設定をご利用ください。', + 'binding_mismatch' => '本人認証情報が対象アカウントと一致していません。', + 'not_adult' => '成人認証が必要なサービスです。満19歳以上のみご利用いただけます。', + 'incomplete_identity' => '本人認証情報が完全ではありません。別の認証方法で再度お試しください。', + 'storage_failed' => '本人認証情報の保存に失敗しました。しばらく後に再度お試しください。', +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/backend/ja/messages.php new file mode 100644 index 00000000..ea3f74b1 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/backend/ja/messages.php @@ -0,0 +1,45 @@ + 'NHN KCP 携帯電話本人確認', + 'description' => 'NHN KCP の携帯電話本人確認サービスを G7 コア本人認証インフラストラクチャに接続するプラグインです。', + 'channels' => [ + 'phone' => '携帯電話', + ], + 'settings' => [ + 'test_mode' => 'テストモード', + 'test_site_cd' => 'テスト サイトコード', + 'test_enc_key' => 'テスト 暗号化キー', + 'live_site_cd' => '運営 サイトコード (SM プレフィックス)', + 'live_enc_key' => '運営 暗号化キー', + 'web_siteid' => 'ウェブサイト ID', + 'live_site_cd_attribute' => '運営 サイトコード', + 'live_enc_key_attribute' => '運営 暗号化キー', + 'duplicate_field' => '重複チェック基準', + 'duplicate_block_enabled' => [ + 'label' => '重複登録ブロック', + 'description' => '有効化した場合、本人確認を完了したユーザーが以前に異なるメールアドレスで登録した履歴がある場合は登録を拒否します。ファミリー携帯電話の共有または B2B シナリオなど、1 人が複数のアカウントを登録する必要がある場合は無効化してください。この設定に関わらず、同一メールアドレスの再登録は常にブロックされます (コア デフォルト動作)。', + ], + ], + 'card' => [ + 'title' => '本人認証情報', + 'method' => '認証方式', + 'method_value' => 'NHN KCP 携帯電話本人確認', + 'verified_at' => '認証日時', + 'name' => '実名', + 'birthday' => '生年月日', + 'phone' => '携帯電話', + 'is_adult' => [ + 'label' => '成人否定状況', + 'true' => '成人', + 'false' => '未成年者', + ], + ], + 'purposes' => [ + 'adult_verification' => [ + 'label' => '成人認証 (NHN KCP 本人確認専用)', + 'description' => '必ず NHN KCP プロバイダーのみにマッピングしてください。メール/SMS プロバイダーは生年月日を返さないため成人判定ができません。誤ったマッピングの場合、成人向けコンテンツが未成年ユーザーに公開される可能性があります。', + ], + ], + 'bridge_page_title' => '本人確認結果', +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/frontend/ja.json new file mode 100644 index 00000000..922c8e17 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/frontend/ja.json @@ -0,0 +1,111 @@ +{ + "editor": { + "data_source": { + "nhnkcpRecord": "本人認証記録", + "settings": "本人認証設定" + }, + "state": { + "test_mode_on": "テストモード オン", + "test_mode_off": "ライブモード", + "identity_challenge_nhnkcp": "本人認証ガイダンス画面 (NHN KCP)", + "record_verified": "本人認証完了 (成人)", + "record_birthday_missing": "本人認証完了 (生年月日未提供)", + "record_none": "本人認証履歴なし" + } + }, + "title": "NHN KCP 携帯電話本人認証", + "description": "NHN KCP の携帯電話本人認証サービスを G7 コア本人認証インフラストラクチャに接続するプラグインです。", + "settings": { + "title": "NHN KCP 携帯電話本人認証設定", + "description": "ライブモードと運用認証情報、重複登録ブロック基準を設定します。", + "info": { + "activation": "本人認証の有効化は [環境設定 > 本人認証] で設定します。", + "logs": "認証履歴は [管理者 > 本人認証履歴] で「NHN KCP 携帯電話本人認証」でフィルタリングして閲覧できます。" + }, + "test_mode": "テストモード", + "test_mode_hint": "テストモードでは KCP テスト環境に接続され、実際の認証手数料は請求されません。", + "live_mode_warning_title": "ライブモード有効化のお知らせ", + "live_mode_warning_body": "ライブモードは実際の加盟店サイトコードで本人認証を実行します。運用サイトコードと暗号化キーを入力してから運用してください。", + "section_live_keys": "運用認証情報 (テストモード OFF 時は必須)", + "live_site_cd": "サイトコード", + "live_site_cd_hint": "KCP が発行したサイトコードの前の SM を除いた値のみ入力してください。SM は自動的に付加されます。", + "live_enc_key": "暗号化キー", + "live_enc_key_hint": "保存時に暗号化して保管されます。", + "web_siteid": "ウェブサイト ID", + "web_siteid_hint": "KCP 加盟店管理画面で発行されたサイト識別コードを入力してください。重複登録確認情報 (DI) の受信に使用されます。", + "section": { + "duplicate_field": "重複登録ブロック" + }, + "fields": { + "duplicate_field": { + "label": "重複チェック基準", + "hint": "DI はこのサイトでのみ重複登録を確認し、CI はすべての機関で共通に識別できます。(デフォルト: DI)" + }, + "duplicate_block_enabled": { + "label": "重複登録ブロック", + "hint": "有効化時、本人認証を通過した人が以前に別のメールアドレスで登録したことがあれば登録を拒否します。家族の携帯電話共有または B2B シナリオで 1 人が複数のアカウント登録が必要な場合は無効化してください。(デフォルト: 有効化)", + "note": "この設定に関わらず、同一メールアドレスの再登録は常にブロックされます (コア デフォルト動作)。" + } + }, + "duplicate_field": { + "di": "DI — サイト別固有値 (推奨)", + "ci": "CI — サービス共通固有値", + "warning": "運用中に変更すると、既存登録者の重複チェックが一時的に不完全になる可能性があります。" + } + }, + "modal": { + "title": "携帯電話本人認証", + "description": "KCP 認証画面で通信事業者認証を進めてください。", + "mobile_description": "認証画面に移動します。認証完了後、自動的にこの画面に戻ります。", + "adult_title": "成人認証", + "adult_description": "満19歳以上の成人否か確認するため、携帯電話本人認証を進めます。認証後、自動的に前の画面に戻ります。", + "start_button": "本人認証開始", + "test_mode_notice": "テストモードです。実際の認証は発生しません。", + "in_progress": { + "title": "認証画面で本人認証を進めてください", + "hint": "認証画面が表示されない場合は、ブラウザのポップアップブロックを解除してください。ウィンドウを閉じると再試行できます。" + }, + "popup_blocked": "ポップアップがブロックされました。ブラウザのポップアップ許可設定後、再試行してください。", + "payload_not_ready": "本人認証の準備が完了していません。しばらく経ってから再試行してください。", + "mobile_return_success": "本人認証が完了しました。続けて進めてください。" + }, + "card": { + "title": "本人認証情報", + "method": "認証方式", + "verified_at": "認証日時", + "name": "名前", + "birthday": "生年月日", + "phone": "携帯電話", + "not_provided": "提供されていません", + "is_adult": { + "label": "成人否か", + "true": "確認済み", + "false": "未確認 (満19歳未満)", + "unknown": "確認不可 (生年月日未提供)" + }, + "pending": { + "title": "まだ本人認証履歴がありません。", + "hint": "本人認証が必要な作業 (パスワード再設定、情報変更など) を進める際に認証できます。" + }, + "provider_badge": "NHN KCP" + }, + "errors": { + "cancelled": "本人認証をキャンセルしました。", + "not_adult": "成人認証が必要なサービスです。満19歳以上のみご利用できます。", + "duplicate": "すでに同一の名義で登録されたアカウントがあります。ログインまたはパスワード検索を使用してください。", + "remote_call_failed": "認証機関との通信に失敗しました。しばらく経ってから再試行してください。", + "decrypt_failed": "正常な認証ではありません。再試行してください。", + "incomplete_identity": "正常な認証ではありません。再試行してください。", + "not_found": "認証セッションが期限切れになりました。最初から再試行してください。", + "already_consumed": "認証セッションが期限切れになりました。最初から再試行してください。", + "binding_mismatch": "本人認証情報が対象アカウントと一致しません。", + "storage_failed": "本人認証情報の保存に失敗しました。しばらく経ってから再試行してください。", + "verify_failed": "本人認証に失敗しました。再試行してください。" + }, + "purposes": { + "adult_verification": { + "label": "成人認証 (NHN KCP 本人認証専用)", + "description": "必ず NHN KCP プロバイダーにのみマッピングしてください。メール/SMS プロバイダーは生年月日を返さないため、成人否か判定ができません。" + } + } +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/language-pack.json new file mode 100644 index 00000000..06779e32 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/language-pack.json @@ -0,0 +1,30 @@ +{ + "identifier": "g7-plugin-sirsoft-verification_nhnkcp-ja", + "namespace": "g7", + "vendor": "sirsoft", + "name": { + "ko": "G7 플러그인 (sirsoft-verification_nhnkcp) 일본어 언어팩", + "en": "G7 plugin (sirsoft-verification_nhnkcp) Japanese language pack", + "ja": "G7 プラグイン (sirsoft-verification_nhnkcp) 日本語 言語パック" + }, + "description": { + "ko": "G7 플러그인 (sirsoft-verification_nhnkcp) 일본어 언어팩 (번들)", + "en": "G7 plugin (sirsoft-verification_nhnkcp) Japanese language pack (bundled)", + "ja": "G7 プラグイン (sirsoft-verification_nhnkcp) 日本語 言語パック(バンドル)" + }, + "version": "1.0.0", + "license": "MIT", + "scope": "plugin", + "target_identifier": "sirsoft-verification_nhnkcp", + "locale": "ja", + "locale_name": "Japanese", + "locale_native_name": "日本語", + "text_direction": "ltr", + "g7_version": ">=7.0.0-beta.4", + "requires": { + "target_version": null, + "depends_on_core_locale": true + }, + "github_url": "", + "github_changelog_url": "" +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/seed/manifest.json b/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/seed/manifest.json new file mode 100644 index 00000000..26dd4872 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-verification_nhnkcp-ja/seed/manifest.json @@ -0,0 +1,4 @@ +{ + "name": "NHN KCP 携帯電話本人確認", + "description": "NHN KCP 携帯電話本人確認(V2 REST)をG7コアIDVインフラにProviderとして登録するプラグイン" +} diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 2880ccc9..057d9acf 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -29,6 +29,7 @@ - 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 글을 수정하면서 미리 올려 둔 파일을 첨부하면 저장은 되는데 첨부만 연결되지 않던 문제를 수정했습니다. 관리자 화면에서는 정상 연결되어 같은 요청이 화면에 따라 다르게 동작했습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 개별 게시판을 수정할 때 목록 표시 개수·제목/내용 길이·댓글 깊이·첨부 개수 등 숫자 항목의 허용 범위가 화면에 표시되지 않던 문제를 수정했습니다. 게시판 환경설정(기본값) 화면에는 범위가 표시되는데 개별 게시판 화면에는 없어, 범위를 벗어난 값을 넣어도 저장을 눌러 봐야 알 수 있었습니다. 이제 두 화면이 같은 범위를 안내합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다). - 관리자 게시글 목록에서 글을 열어 이전글·다음글로 넘어가거나 수정 후 돌아올 때 보고 있던 페이지·검색어·분류가 사라지던 문제를 수정했습니다. 목록 ↔ 상세 ↔ 이전/다음 글 ↔ 작성·수정 폼 어디로 오가든 목록 상태가 유지되며, 신고 관리와 게시판 목록도 동일하게 개선됐습니다. 새로고침 버튼도 걸어 둔 검색·필터를 지운 채 새로 부르지 않고 보고 있던 목록을 그대로 다시 부릅니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 게시글 목록에서 뒤쪽 페이지를 보던 중 검색하거나 게시 상태를 바꾸면 그 페이지 번호가 그대로 유지돼 결과가 없는 빈 화면이 열리던 문제를 수정했습니다. 검색과 상태 변경은 1페이지부터 보여주며, 사용자가 고른 표시 개수와 정렬은 그대로 유지됩니다. 검색창에서 엔터를 치는 경우와 검색 버튼을 누르는 경우가 서로 다르게 동작하던 것도 함께 맞췄습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 첨부파일을 사용하지 않는 게시판을 아무것도 바꾸지 않고 저장해도 "허용 파일 확장자를 최소 1개 이상 입력하세요" 오류가 나며 저장이 막히던 문제를 수정했습니다. 첨부를 쓰지 않는 게시판에서는 허용 확장자를 비워 두어도 정상적으로 저장됩니다. 게시판 환경설정의 기본값도 동일하게 동작합니다. (#78 @jiwonpapa 님께서 제보해주셨습니다.) @@ -49,6 +50,9 @@ - 대댓글 깊이가 게시판 설정값을 넘어 계속 달리던 문제를 수정했습니다. 예전에는 6단계부터 들여쓰기가 더 들어가지 않고 설정한 최대 깊이도 지켜지지 않았습니다. 이제 설정한 깊이까지만 답글을 달 수 있고 화면의 단계 표시도 정확해집니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.) - 기존에 쌓인 댓글 중 깊이가 어긋났거나 다른 게시글의 댓글을 부모로 가진 것을 업데이트 시 자동으로 정리합니다. 잘못 연결된 댓글은 삭제하지 않고 최상위 댓글로 옮기며, 내용은 그대로 보존됩니다. - 연동 기능이 블라인드·삭제된 글에 댓글을 등록하려 할 때 "댓글 등록에 실패했습니다" 라는 서버 오류만 표시되던 문제를 수정했습니다. 이제 글쓰기 화면에서와 똑같이 왜 등록할 수 없는지 사유가 표시됩니다. +- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다. +- 관리자 화면 일부 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다. +- 관리자 화면 일부 버튼·배지가 다크 모드에서 밝은 색 그대로 표시되던 문제를 수정했습니다. ## [1.0.2] - 2026-07-14 diff --git a/modules/_bundled/sirsoft-board/database/migrations/2026_04_01_000008_create_boards_report_logs_table.php b/modules/_bundled/sirsoft-board/database/migrations/2026_04_01_000008_create_boards_report_logs_table.php index b233959b..3c65f5f6 100644 --- a/modules/_bundled/sirsoft-board/database/migrations/2026_04_01_000008_create_boards_report_logs_table.php +++ b/modules/_bundled/sirsoft-board/database/migrations/2026_04_01_000008_create_boards_report_logs_table.php @@ -15,7 +15,7 @@ return new class extends Migration // 신고 로그 Schema::create('boards_report_logs', function (Blueprint $table) { $table->id()->comment('신고 로그 ID'); - $table->foreignId('report_id')->constrained('boards_reports')->cascadeOnDelete()->comment('케이스 ID (boards_reports.id)'); + $table->foreignId('report_id')->comment('케이스 ID (boards_reports.id)')->constrained('boards_reports')->cascadeOnDelete(); $table->unsignedBigInteger('reporter_id')->nullable()->comment('신고자 ID (탈퇴 시 NULL)'); $table->mediumText('snapshot')->nullable()->comment('신고 당시 게시물 스냅샷 (JSON: board_name, title, content, content_mode, author_name)'); $table->string('reason_type', 50)->nullable()->comment('신고 사유 유형 (abuse, hate_speech, spam, copyright, privacy, misinformation, sexual, violence, other)'); diff --git a/modules/_bundled/sirsoft-board/database/migrations/2026_04_13_000001_drop_board_mail_templates_table.php b/modules/_bundled/sirsoft-board/database/migrations/2026_04_13_000001_drop_board_mail_templates_table.php index 042b6e7f..cb2a0b2d 100644 --- a/modules/_bundled/sirsoft-board/database/migrations/2026_04_13_000001_drop_board_mail_templates_table.php +++ b/modules/_bundled/sirsoft-board/database/migrations/2026_04_13_000001_drop_board_mail_templates_table.php @@ -21,7 +21,9 @@ return new class extends Migration } if (! Schema::hasTable('notification_templates')) { - throw new \RuntimeException( + // audit:allow i18n-throw-hardcoded-korean reason: 마이그레이션 선행조건 가드 — + // 콘솔에서 개발자·운영자가 보는 진단 메시지이며 사용자 화면에 닿지 않는다. + throw new RuntimeException( 'board_mail_templates 제거 전 notification_templates 가 존재해야 합니다. ' .'코어 Upgrade_7_0_0_beta_2 + 게시판 Upgrade_1_0_0_beta_2 업그레이드 스텝을 먼저 실행하세요.' ); @@ -52,7 +54,7 @@ return new class extends Migration $table->boolean('is_active')->default(true)->comment('활성 여부'); $table->boolean('is_default')->default(true)->comment('시더 생성 여부'); $table->text('user_overrides')->nullable()->comment('유저가 수정한 필드명 목록'); - $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자'); + $table->foreignId('updated_by')->nullable()->comment('수정자')->constrained('users')->nullOnDelete(); $table->timestamps(); }); } diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_posts_index.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_posts_index.json index a77b2db0..55f2f9a9 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_posts_index.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_posts_index.json @@ -164,7 +164,7 @@ "name": "Icon", "props": { "name": "refresh", - "className": "w-5 h-5" + "className": "text-xl" } } ] @@ -194,7 +194,7 @@ "name": "Icon", "props": { "name": "plus", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -320,7 +320,7 @@ "name": "Icon", "props": { "name": "{{_global.showAdvancedSearch ? 'chevron-up' : 'chevron-down'}}", - "className": "w-4 h-4 text-white dark:text-gray-300" + "className": "text-base text-white dark:text-gray-300" } } ] @@ -373,6 +373,7 @@ "category": "{{$event.target.value === 'all' ? '' : $event.target.value}}", "page": 1 }, + "replace": true, "transition_overlay_target": "posts_data_grid__body" } } @@ -672,6 +673,7 @@ "sort_order": "{{$event.target.value.split('_').pop()}}", "page": 1 }, + "replace": true, "transition_overlay_target": "posts_data_grid__body" } } @@ -714,6 +716,7 @@ "per_page": "{{$event.target.value}}", "page": 1 }, + "replace": true, "transition_overlay_target": "posts_data_grid__body" } } @@ -785,7 +788,7 @@ "name": "Icon", "props": { "name": "level-up-alt", - "className": "w-3.5 h-3.5 fa-rotate-90 mt-1 text-gray-400 dark:text-gray-500" + "className": "text-sm fa-rotate-90 mt-1 text-gray-400 dark:text-gray-500" }, "if": "{{row.is_reply}}" }, @@ -819,7 +822,7 @@ "name": "Icon", "props": { "name": "lock", - "className": "w-3.5 h-3.5 text-gray-500 dark:text-gray-400" + "className": "text-sm text-gray-500 dark:text-gray-400" }, "if": "{{row.is_secret}}" }, @@ -859,7 +862,7 @@ "name": "Icon", "props": { "name": "paperclip", - "className": "w-3.5 h-3.5 text-gray-400 dark:text-gray-500" + "className": "text-sm text-gray-400 dark:text-gray-500" }, "if": "{{row.has_attachment}}" }, @@ -879,7 +882,7 @@ "name": "Span", "if": "{{row?.is_new}}", "props": { - "className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-red-500 text-white" + "className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-red-500 dark:bg-red-600 text-white" }, "text": "N" }, @@ -1019,6 +1022,7 @@ "query": { "page": "{{$args[0]}}" }, + "replace": true, "transition_overlay_target": "posts_data_grid__body" } } @@ -1101,7 +1105,7 @@ "type": "basic", "name": "Button", "props": { - "className": "px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed", + "className": "px-4 py-2 bg-red-600 dark:bg-red-700 text-white rounded-lg hover:bg-red-700 dark:hover:bg-red-800 disabled:opacity-50 disabled:cursor-not-allowed", "disabled": "{{_global.deleteModalLoading}}" }, "actions": [ diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_reports_index.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_reports_index.json index 90d8a56d..b5636299 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_reports_index.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_reports_index.json @@ -136,7 +136,7 @@ "name": "Icon", "props": { "name": "arrows-rotate", - "className": "w-5 h-5" + "className": "text-xl" } } ] @@ -324,7 +324,7 @@ "name": "Icon", "props": { "name": "{{_global.showAdvancedSearch ? 'chevron-up' : 'chevron-down'}}", - "className": "w-4 h-4 text-white dark:text-gray-300" + "className": "text-base text-white dark:text-gray-300" } } ] @@ -520,7 +520,7 @@ "name": "Icon", "props": { "name": "{{_global.showAdvancedSearch ? 'chevron-up' : 'chevron-down'}}", - "className": "w-4 h-4 text-white dark:text-gray-300" + "className": "text-base text-white dark:text-gray-300" } } ] @@ -2192,6 +2192,7 @@ "query": { "page": "{{$args[0]}}" }, + "replace": true, "transition_overlay_target": "reports_data_grid__body" } } @@ -2302,7 +2303,7 @@ "name": "Icon", "props": { "name": "exclamation-triangle", - "className": "w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" + "className": "text-base text-red-600 dark:text-red-400 flex-shrink-0" } }, { @@ -2328,7 +2329,7 @@ "name": "Icon", "props": { "name": "exclamation-triangle", - "className": "w-4 h-4 text-yellow-600 dark:text-yellow-400 flex-shrink-0" + "className": "text-base text-yellow-600 dark:text-yellow-400 flex-shrink-0" } }, { @@ -2354,7 +2355,7 @@ "name": "Icon", "props": { "name": "eye", - "className": "w-4 h-4 text-orange-600 dark:text-orange-400 flex-shrink-0" + "className": "text-base text-orange-600 dark:text-orange-400 flex-shrink-0" } }, { @@ -2380,7 +2381,7 @@ "name": "Icon", "props": { "name": "spinner", - "className": "w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin mr-2" + "className": "text-xl text-blue-600 dark:text-blue-400 animate-spin mr-2" } }, { diff --git a/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/04_BackfillForeignKeyColumnComments.php b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/04_BackfillForeignKeyColumnComments.php new file mode 100644 index 00000000..f8970b4b --- /dev/null +++ b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/04_BackfillForeignKeyColumnComments.php @@ -0,0 +1,175 @@ +foreignId('user_id')->constrained('users')->comment('사용자 ID')` 형태는 comment 가 + * 컬럼이 아니라 외래키 정의에 부착되어 DB 에 생성되지 않습니다. `constrained()` 가 컬럼 정의가 + * 아닌 외래키 정의를 돌려주기 때문입니다. 마이그레이션 소스는 교정했지만, 이미 설치를 마친 + * 사이트는 마이그레이션을 다시 실행하지 않으므로 설명이 비어 있는 채로 남습니다. + * + * 멱등: 설명이 이미 있는 컬럼은 건드리지 않습니다. 재실행해도 결과가 같습니다. + * + * 안전: + * - 설명이 **비어 있을 때만** 채웁니다 — 운영자가 직접 넣어 둔 설명을 덮어쓰지 않습니다. + * - 자료형·NULL 허용·기본값·자동증가를 현재 스키마에서 읽어 그대로 재적용하므로 설명 외에는 + * 아무 것도 바뀌지 않습니다. + * - MySQL 계열에서만 동작합니다(다른 DB 는 건너뜁니다). + * + * 실패 정책: 컬럼 단위로 실패를 흡수합니다 — 한 컬럼이 실패해도 나머지를 계속 처리합니다. + * + * V-1 안전: `Illuminate\Support\Facades\DB` 와 로컬 private 헬퍼만 사용하고, 대상 목록을 + * 본 클래스에 동결 상수로 둡니다(마이그레이션 파일·모델 미참조). + */ +class BackfillForeignKeyColumnComments implements DataMigration +{ + /** + * 보정 대상 (1.0.3 시점 동결) — 테이블 => [컬럼 => 설명]. + */ + private const TARGETS = [ + 'boards_report_logs' => [ + 'report_id' => '케이스 ID (boards_reports.id)', + ], + ]; + + /** + * 마이그레이션 식별자 (로그용). + * + * @return string 식별자 + */ + public function name(): string + { + return 'BackfillForeignKeyColumnComments'; + } + + /** + * 비어 있는 외래키 컬럼 설명을 채웁니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + public function run(UpgradeContext $context): void + { + if (! in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) { + $context->logger->info('[1.0.3] 컬럼 설명 보정 — MySQL 계열이 아니어서 건너뜁니다'); + + return; + } + + $filled = 0; + $skipped = 0; + $failed = 0; + + foreach (self::TARGETS as $table => $columns) { + $prefixed = $context->table($table); + + foreach ($columns as $column => $comment) { + try { + $meta = $this->columnMeta($prefixed, $column); + + if ($meta === null || trim((string) $meta->COLUMN_COMMENT) !== '') { + $skipped++; + + continue; + } + + DB::statement($this->buildModifyStatement($prefixed, $column, $meta, $comment)); + $filled++; + } catch (\Throwable $e) { + $failed++; + $context->logger->warning(sprintf( + '[1.0.3] 컬럼 설명 보정 실패 (계속 진행): %s.%s — %s', + $prefixed, + $column, + $e->getMessage(), + )); + } + } + } + + $context->logger->info(sprintf( + '[1.0.3] 외래키 컬럼 설명 보정 — 채움 %d건 / 대상 아님 %d건 / 실패 %d건', + $filled, + $skipped, + $failed, + )); + } + + /** + * 컬럼의 현재 스키마 메타데이터를 조회합니다. + * + * @param string $table 프리픽스가 적용된 테이블명 + * @param string $column 컬럼명 + * @return object|null 컬럼 메타 (테이블/컬럼 부재 시 null) + */ + private function columnMeta(string $table, string $column): ?object + { + $rows = DB::select( + 'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', + [$table, $column], + ); + + return $rows[0] ?? null; + } + + /** + * 설명만 덧붙이는 MODIFY COLUMN 문을 조립합니다. + * + * @param string $table 프리픽스가 적용된 테이블명 + * @param string $column 컬럼명 + * @param object $meta columnMeta() 결과 + * @param string $comment 넣을 설명 + * @return string 실행할 SQL + */ + private function buildModifyStatement(string $table, string $column, object $meta, string $comment): string + { + $sql = sprintf( + 'ALTER TABLE %s MODIFY COLUMN %s %s', + $this->quoteIdentifier($table), + $this->quoteIdentifier($column), + $meta->COLUMN_TYPE, + ); + + $sql .= $meta->IS_NULLABLE === 'YES' ? ' NULL' : ' NOT NULL'; + + if ($meta->COLUMN_DEFAULT !== null) { + $sql .= ' DEFAULT '.$this->quoteValue((string) $meta->COLUMN_DEFAULT); + } + + if (stripos(trim((string) $meta->EXTRA), 'auto_increment') !== false) { + $sql .= ' AUTO_INCREMENT'; + } + + return $sql.' COMMENT '.$this->quoteValue($comment); + } + + /** + * 식별자를 백틱으로 감쌉니다. + * + * @param string $identifier 테이블/컬럼명 + * @return string 이스케이프된 식별자 + */ + private function quoteIdentifier(string $identifier): string + { + return '`'.str_replace('`', '``', $identifier).'`'; + } + + /** + * 문자열 값을 SQL 리터럴로 인용합니다. + * + * @param string $value 값 + * @return string 인용된 리터럴 + */ + private function quoteValue(string $value): string + { + return DB::connection()->getPdo()->quote($value); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index a937b2ea..7831a85f 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -39,6 +39,7 @@ - 부분취소 후에도 주문의 부가세가 취소 전 금액으로 남아 과세표준과 어긋나던 문제를 수정했습니다. 취소로 과세표준이 줄면 부가세도 함께 다시 계산되며, 계산에는 주문 당시의 세율이 사용됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 주문의 부가세가 항상 0으로 표시되던 문제를 수정했습니다. 부가세는 부분취소를 거친 주문에서만 채워져 같은 목록에서도 주문마다 세금 표기가 달랐습니다. 이제 주문을 만들 때 계산해 기록하며, 상품별 세율이 다르면 상품별로 계산해 합산합니다. 기존 주문의 부가세는 업데이트 시 자동으로 채워지며, 채운 주문 건수가 업그레이드 로그에 남습니다(이미 값이 있는 주문은 건드리지 않습니다). (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 주문서의 마일리지 '사용 가능 최대' 안내가 실제 결제 기준 금액과 최소 사용액을 반영하도록 수정했습니다. 이전에는 할인·배송비 반영 전 상품합계를 기준으로 계산해 실제로 쓸 수 있는 금액보다 크게 안내되었고, 최소 사용액에 못 미치는 경우에도 사용 가능한 것처럼 보였습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다). - 휴대폰으로 관리자 주문 목록을 볼 때 왼쪽 위 '뒤로' 버튼을 누르면 이전 화면이 아니라 빈 페이지로 넘어가던 문제를 수정했습니다. - 관리자 주문·상품·쿠폰·배송정책·리뷰·마일리지 목록에서 상세나 등록/수정 화면에 다녀오면 걸어 둔 필터와 페이지가 사라지던 문제를 수정했습니다. 필터를 여러 개 건 상태에서 주문 하나를 확인하고 돌아와도 조건이 그대로 유지됩니다. 정렬·표시 개수를 바꾸거나 새로고침해도 걸어 둔 조건은 그대로입니다. 반대로 초기화 버튼과 프리셋 적용은 종전처럼 조건을 새로 세팅합니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 브랜드·상품 공통정보·상품 고시정보 목록에서 검색어(브랜드는 정렬 포함)를 걸어 둔 채 새로고침 버튼을 누르면 걸어 둔 검색이 풀려 전체 목록으로 돌아가던 문제를 수정했습니다. 이제 새로고침은 보던 조건 그대로 다시 불러옵니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) @@ -63,6 +64,9 @@ - 카테고리를 자기 자신이나 자기 하위 카테고리 아래로 옮길 때 화면이 응답하지 않고 해당 카테고리가 목록에서 사라지던 문제를 수정했습니다. 카테고리 수정과 순서 변경 양쪽 모두에 적용됩니다. - 카테고리를 최상위로 옮길 때 상위 연결은 풀렸지만 계층 정보(경로·단계)가 이전 값 그대로 남아, 하위 카테고리의 위치가 어긋나 보이던 문제를 수정했습니다. - 기존에 잘못 연결된 카테고리가 있으면 업데이트 시 자동으로 정리하고 계층 정보를 다시 계산합니다. 카테고리는 삭제하지 않고 최상위로 옮깁니다. +- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다. +- 관리자 화면 일부 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다. +- 관리자 화면 일부 버튼·배지가 다크 모드에서 밝은 색 그대로 표시되던 문제를 수정했습니다. ## [1.0.4] - 2026-07-16 diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000039_create_ecommerce_product_reviews_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000039_create_ecommerce_product_reviews_table.php index 83e9da0b..a935b05d 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000039_create_ecommerce_product_reviews_table.php +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000039_create_ecommerce_product_reviews_table.php @@ -33,8 +33,8 @@ return new class extends Migration $table->text('reply_content')->nullable()->comment('판매자 답변 내용'); $table->string('reply_content_mode', 10)->default('text')->comment('답변 콘텐츠 모드: text / html'); $table->foreignId('reply_admin_id')->nullable() - ->constrained('users')->nullOnDelete() - ->comment('답변 등록 관리자 ID'); + ->comment('답변 등록 관리자 ID') + ->constrained('users')->nullOnDelete(); $table->timestamp('replied_at')->nullable()->comment('답변 등록일시'); $table->timestamp('reply_updated_at')->nullable()->comment('답변 수정일시'); diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000040_create_ecommerce_product_review_images_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000040_create_ecommerce_product_review_images_table.php index 80e4ef88..9f761ea0 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000040_create_ecommerce_product_review_images_table.php +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000040_create_ecommerce_product_review_images_table.php @@ -43,8 +43,8 @@ return new class extends Migration // 감사 필드 $table->foreignId('created_by')->nullable() - ->constrained('users')->nullOnDelete() - ->comment('업로더 ID'); + ->comment('업로더 ID') + ->constrained('users')->nullOnDelete(); $table->timestamps(); $table->softDeletes()->comment('소프트 삭제 일시'); diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_13_000001_drop_ecommerce_mail_templates_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_13_000001_drop_ecommerce_mail_templates_table.php index a76b0baf..21c4d8fd 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_13_000001_drop_ecommerce_mail_templates_table.php +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_13_000001_drop_ecommerce_mail_templates_table.php @@ -21,7 +21,9 @@ return new class extends Migration } if (! Schema::hasTable('notification_templates')) { - throw new \RuntimeException( + // audit:allow i18n-throw-hardcoded-korean reason: 마이그레이션 선행조건 가드 — + // 콘솔에서 개발자·운영자가 보는 진단 메시지이며 사용자 화면에 닿지 않는다. + throw new RuntimeException( 'ecommerce_mail_templates 제거 전 notification_templates 가 존재해야 합니다. ' .'코어 Upgrade_7_0_0_beta_2 + 이커머스 Upgrade_1_0_0_beta_2 업그레이드 스텝을 먼저 실행하세요.' ); @@ -52,7 +54,7 @@ return new class extends Migration $table->boolean('is_active')->default(true)->comment('활성 여부'); $table->boolean('is_default')->default(true)->comment('시더 생성 여부'); $table->text('user_overrides')->nullable()->comment('유저가 수정한 필드명 목록'); - $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자'); + $table->foreignId('updated_by')->nullable()->comment('수정자')->constrained('users')->nullOnDelete(); $table->timestamps(); }); } diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_06_11_000001_create_ecommerce_mileage_transactions_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_06_11_000001_create_ecommerce_mileage_transactions_table.php index 7e2df623..302af31e 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_06_11_000001_create_ecommerce_mileage_transactions_table.php +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_06_11_000001_create_ecommerce_mileage_transactions_table.php @@ -13,13 +13,13 @@ return new class extends Migration { Schema::create('ecommerce_mileage_transactions', function (Blueprint $table) { $table->id()->comment('거래 ID'); - $table->foreignId('user_id')->constrained('users')->cascadeOnDelete()->comment('회원 ID'); + $table->foreignId('user_id')->comment('회원 ID')->constrained('users')->cascadeOnDelete(); $table->string('currency', 10)->default('KRW')->comment('통화 코드 (주문 기준통화 스냅샷)'); $table->string('type', 30)->comment('거래 유형 (MileageTransactionTypeEnum)'); $table->decimal('amount', 12, 2)->comment('거래 금액 (양수=적립, 음수=차감)'); $table->decimal('remaining_amount', 12, 2)->default(0)->comment('잔여 금액 (적립건만 양수, FIFO 차감용)'); $table->decimal('balance_after', 12, 2)->comment('거래 후 잔액 (감사 스냅샷, 베스트에포트)'); - $table->foreignId('order_id')->nullable()->constrained('ecommerce_orders')->nullOnDelete()->comment('관련 주문 ID'); + $table->foreignId('order_id')->nullable()->comment('관련 주문 ID')->constrained('ecommerce_orders')->nullOnDelete(); // 옵션 병합 시 하드 삭제(SoftDeletes 미사용)되므로 FK 미설정 — unsignedBigInteger + 인덱스만 유지 $table->unsignedBigInteger('order_option_id')->nullable()->comment('관련 주문옵션 ID (병합 하드삭제 대응 — FK 미설정)'); $table->unsignedBigInteger('order_cancel_id')->nullable()->comment('관련 주문취소 ID (복원 멱등 기준)'); diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_06_11_000004_create_ecommerce_mileage_balances_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_06_11_000004_create_ecommerce_mileage_balances_table.php index 3488fa82..2cb07fbf 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_06_11_000004_create_ecommerce_mileage_balances_table.php +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_06_11_000004_create_ecommerce_mileage_balances_table.php @@ -15,7 +15,7 @@ return new class extends Migration // 진실의 원천(SSoT)은 ecommerce_mileage_transactions(원장)이며, 본 테이블은 표시 전용 캐시. Schema::create('ecommerce_mileage_balances', function (Blueprint $table) { $table->id()->comment('잔액 캐시 ID'); - $table->foreignId('user_id')->constrained('users')->cascadeOnDelete()->comment('회원 ID'); + $table->foreignId('user_id')->comment('회원 ID')->constrained('users')->cascadeOnDelete(); $table->string('currency', 10)->default('KRW')->comment('통화 코드 (통화별 행)'); $table->decimal('available', 12, 2)->default(0)->comment('사용 가능 잔액 (활성 lot SUM 스냅샷)'); $table->decimal('pending', 12, 2)->default(0)->comment('적립 예정 (미취소·earn ledger 부재 옵션 적립액 합)'); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_order_info.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_order_info.json index f3cdd2ef..7663a0bf 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_order_info.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_order_info.json @@ -243,7 +243,7 @@ "name": "Button", "props": { "type": "button", - "className": "px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed", + "className": "px-4 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-800 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed", "disabled": "{{!_local.selectedProducts || _local.selectedProducts.length === 0 || order?.data?.abilities?.can_update !== true}}" }, "text": "$t:sirsoft-ecommerce.admin.order.detail.order_info.batch_change", @@ -1307,6 +1307,7 @@ "orderer_uuid": "", "page": 1 }, + "replace": true, "transition_overlay_target": "order_product_datagrid__body" } } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_list/_partial_order_datagrid.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_list/_partial_order_datagrid.json index bc0fed8d..cbd6948e 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_list/_partial_order_datagrid.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_list/_partial_order_datagrid.json @@ -184,7 +184,7 @@ "name": "Icon", "props": { "name": "arrow-down-tray", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -610,6 +610,7 @@ "search_keyword": "", "page": 1 }, + "replace": true, "transition_overlay_target": "order_datagrid__body" } } @@ -639,6 +640,7 @@ "orderer_uuid": "", "page": 1 }, + "replace": true, "transition_overlay_target": "order_datagrid__body" } } diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.0.5/migrations/02_BackfillForeignKeyColumnComments.php b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.0.5/migrations/02_BackfillForeignKeyColumnComments.php new file mode 100644 index 00000000..ae44fc7e --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.0.5/migrations/02_BackfillForeignKeyColumnComments.php @@ -0,0 +1,206 @@ +foreignId('user_id')->constrained('users')->comment('사용자 ID')` 형태는 comment 가 + * 컬럼이 아니라 외래키 정의에 부착되어 DB 에 생성되지 않습니다. `constrained()` 가 컬럼 정의가 + * 아닌 외래키 정의를 돌려주기 때문입니다. 마이그레이션 소스는 교정했지만, 이미 설치를 마친 + * 사이트는 마이그레이션을 다시 실행하지 않으므로 설명이 비어 있는 채로 남습니다. + * + * 멱등: 설명이 이미 있는 컬럼은 건드리지 않습니다. 재실행해도 결과가 같습니다. + * + * 안전: + * - 설명이 **비어 있을 때만** 채웁니다 — 운영자가 직접 넣어 둔 설명을 덮어쓰지 않습니다. + * - 자료형·NULL 허용·기본값·자동증가를 현재 스키마에서 읽어 그대로 재적용하므로 설명 외에는 + * 아무 것도 바뀌지 않습니다. + * - MySQL 계열에서만 동작합니다(다른 DB 는 건너뜁니다). + * + * 실패 정책: 컬럼 단위로 실패를 흡수합니다 — 한 컬럼이 실패해도 나머지를 계속 처리합니다. + * + * V-1 안전: `Illuminate\Support\Facades\DB` 와 로컬 private 헬퍼만 사용하고, 대상 목록을 + * 본 클래스에 동결 상수로 둡니다(마이그레이션 파일·모델 미참조). + */ +class BackfillForeignKeyColumnComments implements DataMigration +{ + /** + * 보정 대상 (1.0.5 시점 동결) — 테이블 => [컬럼 => 설명]. + */ + private const TARGETS = [ + 'ecommerce_mileage_balances' => [ + 'user_id' => '회원 ID', + ], + 'ecommerce_mileage_transactions' => [ + 'order_id' => '관련 주문 ID', + 'user_id' => '회원 ID', + ], + 'ecommerce_order_cancel_options' => [ + 'order_cancel_id' => '취소 FK', + 'order_id' => '주문 FK', + 'order_option_id' => '주문옵션 FK', + 'processed_by' => '처리 관리자', + ], + 'ecommerce_order_cancels' => [ + 'cancelled_by' => '취소 요청자', + 'order_id' => '주문 FK', + ], + 'ecommerce_order_refund_options' => [ + 'order_id' => '주문 FK', + 'order_option_id' => '주문옵션 FK', + 'order_refund_id' => '환불 FK', + 'processed_by' => '처리 관리자', + ], + 'ecommerce_order_refunds' => [ + 'order_cancel_id' => '취소 FK (추후 polymorphic 전환 가능)', + 'order_id' => '주문 FK', + 'processed_by' => '처리 관리자', + ], + 'ecommerce_product_review_images' => [ + 'created_by' => '업로더 ID', + ], + 'ecommerce_product_reviews' => [ + 'reply_admin_id' => '답변 등록 관리자 ID', + ], + ]; + + /** + * 마이그레이션 식별자 (로그용). + * + * @return string 식별자 + */ + public function name(): string + { + return 'BackfillForeignKeyColumnComments'; + } + + /** + * 비어 있는 외래키 컬럼 설명을 채웁니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + public function run(UpgradeContext $context): void + { + if (! in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) { + $context->logger->info('[1.0.5] 컬럼 설명 보정 — MySQL 계열이 아니어서 건너뜁니다'); + + return; + } + + $filled = 0; + $skipped = 0; + $failed = 0; + + foreach (self::TARGETS as $table => $columns) { + $prefixed = $context->table($table); + + foreach ($columns as $column => $comment) { + try { + $meta = $this->columnMeta($prefixed, $column); + + if ($meta === null || trim((string) $meta->COLUMN_COMMENT) !== '') { + $skipped++; + + continue; + } + + DB::statement($this->buildModifyStatement($prefixed, $column, $meta, $comment)); + $filled++; + } catch (\Throwable $e) { + $failed++; + $context->logger->warning(sprintf( + '[1.0.5] 컬럼 설명 보정 실패 (계속 진행): %s.%s — %s', + $prefixed, + $column, + $e->getMessage(), + )); + } + } + } + + $context->logger->info(sprintf( + '[1.0.5] 외래키 컬럼 설명 보정 — 채움 %d건 / 대상 아님 %d건 / 실패 %d건', + $filled, + $skipped, + $failed, + )); + } + + /** + * 컬럼의 현재 스키마 메타데이터를 조회합니다. + * + * @param string $table 프리픽스가 적용된 테이블명 + * @param string $column 컬럼명 + * @return object|null 컬럼 메타 (테이블/컬럼 부재 시 null) + */ + private function columnMeta(string $table, string $column): ?object + { + $rows = DB::select( + 'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', + [$table, $column], + ); + + return $rows[0] ?? null; + } + + /** + * 설명만 덧붙이는 MODIFY COLUMN 문을 조립합니다. + * + * @param string $table 프리픽스가 적용된 테이블명 + * @param string $column 컬럼명 + * @param object $meta columnMeta() 결과 + * @param string $comment 넣을 설명 + * @return string 실행할 SQL + */ + private function buildModifyStatement(string $table, string $column, object $meta, string $comment): string + { + $sql = sprintf( + 'ALTER TABLE %s MODIFY COLUMN %s %s', + $this->quoteIdentifier($table), + $this->quoteIdentifier($column), + $meta->COLUMN_TYPE, + ); + + $sql .= $meta->IS_NULLABLE === 'YES' ? ' NULL' : ' NOT NULL'; + + if ($meta->COLUMN_DEFAULT !== null) { + $sql .= ' DEFAULT '.$this->quoteValue((string) $meta->COLUMN_DEFAULT); + } + + if (stripos(trim((string) $meta->EXTRA), 'auto_increment') !== false) { + $sql .= ' AUTO_INCREMENT'; + } + + return $sql.' COMMENT '.$this->quoteValue($comment); + } + + /** + * 식별자를 백틱으로 감쌉니다. + * + * @param string $identifier 테이블/컬럼명 + * @return string 이스케이프된 식별자 + */ + private function quoteIdentifier(string $identifier): string + { + return '`'.str_replace('`', '``', $identifier).'`'; + } + + /** + * 문자열 값을 SQL 리터럴로 인용합니다. + * + * @param string $value 값 + * @return string 인용된 리터럴 + */ + private function quoteValue(string $value): string + { + return DB::connection()->getPdo()->quote($value); + } +} diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index 8feaecb1..8d663bb7 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -23,10 +23,12 @@ ### Fixed +- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다). - 관리자 페이지 목록에서 상세나 수정 화면에 다녀오면 보고 있던 페이지와 검색 조건이 사라지고 1페이지로 되돌아가던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 페이지 활동 로그에서 "제목"·"내용" 변경 항목의 이름이 비어 보이던 문제를 수정했습니다. - 다른 페이지의 주소로 특정 페이지의 이전 버전 내용을 열람할 수 있던 문제를 수정했습니다. - 다른 페이지에 속한 버전으로 복원을 시도하면 "복원에 실패했습니다" 라는 서버 오류가 나던 문제를 수정했습니다. 이제 열람과 동일하게 "찾을 수 없음" 으로 안내합니다. +- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다. ## [1.0.1] - 2026-07-08 diff --git a/modules/_bundled/sirsoft-page/database/migrations/2026_04_01_000002_create_page_versions_table.php b/modules/_bundled/sirsoft-page/database/migrations/2026_04_01_000002_create_page_versions_table.php index 3a7f1824..28c09d77 100644 --- a/modules/_bundled/sirsoft-page/database/migrations/2026_04_01_000002_create_page_versions_table.php +++ b/modules/_bundled/sirsoft-page/database/migrations/2026_04_01_000002_create_page_versions_table.php @@ -14,7 +14,7 @@ return new class extends Migration { Schema::create('page_versions', function (Blueprint $table) { $table->id()->comment('버전 ID'); - $table->foreignId('page_id')->constrained('pages')->cascadeOnDelete()->comment('페이지 ID'); + $table->foreignId('page_id')->comment('페이지 ID')->constrained('pages')->cascadeOnDelete(); $table->unsignedInteger('version')->comment('버전 번호'); $table->text('title')->comment('제목 스냅샷 (다국어 JSON)'); $table->mediumText('content')->nullable()->comment('본문 스냅샷 (다국어 JSON)'); diff --git a/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json b/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json index b78d66c7..1f71bb3c 100644 --- a/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json +++ b/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json @@ -243,6 +243,7 @@ "filters[0][operator]": "like", "page": 1 }, + "replace": true, "transition_overlay_target": "pages_data_grid__body" } } @@ -268,6 +269,7 @@ "filters[0][operator]": "like", "page": 1 }, + "replace": true, "transition_overlay_target": "pages_data_grid__body" } } @@ -362,6 +364,7 @@ "filters[0][operator]": "like", "page": 1 }, + "replace": true, "transition_overlay_target": "pages_data_grid__body" } } @@ -389,6 +392,7 @@ "filters[0][operator]": "like", "page": 1 }, + "replace": true, "transition_overlay_target": "pages_data_grid__body" } } @@ -470,6 +474,7 @@ "published": "{{$event.target.value}}", "page": 1 }, + "replace": true, "transition_overlay_target": "pages_data_grid__body" } } @@ -513,6 +518,7 @@ "sort_order": "{{$event.target.value.split('_').pop()}}", "page": 1 }, + "replace": true, "transition_overlay_target": "pages_data_grid__body" } } @@ -555,6 +561,7 @@ "per_page": "{{$event.target.value}}", "page": 1 }, + "replace": true, "transition_overlay_target": "pages_data_grid__body" } } @@ -820,6 +827,7 @@ "query": { "page": "{{$args[0]}}" }, + "replace": true, "transition_overlay_target": "pages_data_grid__body" } }, diff --git a/modules/_bundled/sirsoft-page/upgrades/Upgrade_1_0_2.php b/modules/_bundled/sirsoft-page/upgrades/Upgrade_1_0_2.php new file mode 100644 index 00000000..a3c9d6dc --- /dev/null +++ b/modules/_bundled/sirsoft-page/upgrades/Upgrade_1_0_2.php @@ -0,0 +1,16 @@ +comment()` 가 + * `->constrained()` 뒤에 체인되어 컬럼이 아닌 외래키 정의에 부착되던 문제를 소스에서 + * 교정했으나, 기설치본은 마이그레이션이 재실행되지 않아 그대로 남기 때문이다. + * + * 모든 비즈니스 로직은 data/1.0.2/migrations/ 로 격리(AbstractUpgradeStep 규약). + */ +class Upgrade_1_0_2 extends AbstractUpgradeStep {} diff --git a/modules/_bundled/sirsoft-page/upgrades/data/1.0.2/migrations/01_BackfillForeignKeyColumnComments.php b/modules/_bundled/sirsoft-page/upgrades/data/1.0.2/migrations/01_BackfillForeignKeyColumnComments.php new file mode 100644 index 00000000..1ca07595 --- /dev/null +++ b/modules/_bundled/sirsoft-page/upgrades/data/1.0.2/migrations/01_BackfillForeignKeyColumnComments.php @@ -0,0 +1,175 @@ +foreignId('user_id')->constrained('users')->comment('사용자 ID')` 형태는 comment 가 + * 컬럼이 아니라 외래키 정의에 부착되어 DB 에 생성되지 않습니다. `constrained()` 가 컬럼 정의가 + * 아닌 외래키 정의를 돌려주기 때문입니다. 마이그레이션 소스는 교정했지만, 이미 설치를 마친 + * 사이트는 마이그레이션을 다시 실행하지 않으므로 설명이 비어 있는 채로 남습니다. + * + * 멱등: 설명이 이미 있는 컬럼은 건드리지 않습니다. 재실행해도 결과가 같습니다. + * + * 안전: + * - 설명이 **비어 있을 때만** 채웁니다 — 운영자가 직접 넣어 둔 설명을 덮어쓰지 않습니다. + * - 자료형·NULL 허용·기본값·자동증가를 현재 스키마에서 읽어 그대로 재적용하므로 설명 외에는 + * 아무 것도 바뀌지 않습니다. + * - MySQL 계열에서만 동작합니다(다른 DB 는 건너뜁니다). + * + * 실패 정책: 컬럼 단위로 실패를 흡수합니다 — 한 컬럼이 실패해도 나머지를 계속 처리합니다. + * + * V-1 안전: `Illuminate\Support\Facades\DB` 와 로컬 private 헬퍼만 사용하고, 대상 목록을 + * 본 클래스에 동결 상수로 둡니다(마이그레이션 파일·모델 미참조). + */ +class BackfillForeignKeyColumnComments implements DataMigration +{ + /** + * 보정 대상 (1.0.2 시점 동결) — 테이블 => [컬럼 => 설명]. + */ + private const TARGETS = [ + 'page_versions' => [ + 'page_id' => '페이지 ID', + ], + ]; + + /** + * 마이그레이션 식별자 (로그용). + * + * @return string 식별자 + */ + public function name(): string + { + return 'BackfillForeignKeyColumnComments'; + } + + /** + * 비어 있는 외래키 컬럼 설명을 채웁니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + public function run(UpgradeContext $context): void + { + if (! in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) { + $context->logger->info('[1.0.2] 컬럼 설명 보정 — MySQL 계열이 아니어서 건너뜁니다'); + + return; + } + + $filled = 0; + $skipped = 0; + $failed = 0; + + foreach (self::TARGETS as $table => $columns) { + $prefixed = $context->table($table); + + foreach ($columns as $column => $comment) { + try { + $meta = $this->columnMeta($prefixed, $column); + + if ($meta === null || trim((string) $meta->COLUMN_COMMENT) !== '') { + $skipped++; + + continue; + } + + DB::statement($this->buildModifyStatement($prefixed, $column, $meta, $comment)); + $filled++; + } catch (\Throwable $e) { + $failed++; + $context->logger->warning(sprintf( + '[1.0.2] 컬럼 설명 보정 실패 (계속 진행): %s.%s — %s', + $prefixed, + $column, + $e->getMessage(), + )); + } + } + } + + $context->logger->info(sprintf( + '[1.0.2] 외래키 컬럼 설명 보정 — 채움 %d건 / 대상 아님 %d건 / 실패 %d건', + $filled, + $skipped, + $failed, + )); + } + + /** + * 컬럼의 현재 스키마 메타데이터를 조회합니다. + * + * @param string $table 프리픽스가 적용된 테이블명 + * @param string $column 컬럼명 + * @return object|null 컬럼 메타 (테이블/컬럼 부재 시 null) + */ + private function columnMeta(string $table, string $column): ?object + { + $rows = DB::select( + 'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', + [$table, $column], + ); + + return $rows[0] ?? null; + } + + /** + * 설명만 덧붙이는 MODIFY COLUMN 문을 조립합니다. + * + * @param string $table 프리픽스가 적용된 테이블명 + * @param string $column 컬럼명 + * @param object $meta columnMeta() 결과 + * @param string $comment 넣을 설명 + * @return string 실행할 SQL + */ + private function buildModifyStatement(string $table, string $column, object $meta, string $comment): string + { + $sql = sprintf( + 'ALTER TABLE %s MODIFY COLUMN %s %s', + $this->quoteIdentifier($table), + $this->quoteIdentifier($column), + $meta->COLUMN_TYPE, + ); + + $sql .= $meta->IS_NULLABLE === 'YES' ? ' NULL' : ' NOT NULL'; + + if ($meta->COLUMN_DEFAULT !== null) { + $sql .= ' DEFAULT '.$this->quoteValue((string) $meta->COLUMN_DEFAULT); + } + + if (stripos(trim((string) $meta->EXTRA), 'auto_increment') !== false) { + $sql .= ' AUTO_INCREMENT'; + } + + return $sql.' COMMENT '.$this->quoteValue($comment); + } + + /** + * 식별자를 백틱으로 감쌉니다. + * + * @param string $identifier 테이블/컬럼명 + * @return string 이스케이프된 식별자 + */ + private function quoteIdentifier(string $identifier): string + { + return '`'.str_replace('`', '``', $identifier).'`'; + } + + /** + * 문자열 값을 SQL 리터럴로 인용합니다. + * + * @param string $value 값 + * @return string 인용된 리터럴 + */ + private function quoteValue(string $value): string + { + return DB::connection()->getPdo()->quote($value); + } +} diff --git a/pint.json b/pint.json new file mode 100644 index 00000000..61be8463 --- /dev/null +++ b/pint.json @@ -0,0 +1,6 @@ +{ + "preset": "laravel", + "rules": { + "no_superfluous_phpdoc_tags": false + } +} diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index 29867e42..4ff710e5 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -22,9 +22,11 @@ - 「동의하지 않고 계속하기」를 눌러도 필수 쿠키가 「동의함」으로 기록되지 않도록 바로잡았습니다 — 필수 쿠키는 서비스 이용에 반드시 필요하여 동의 대상이 아닙니다. - 거부 시 안내 문구를 「동의하지 않고 계속합니다」로 바로잡았습니다 (이전에는 「동의가 저장되었습니다」로 잘못 표시). - 관리자 환경설정의 「쿠키 배너 노출」 안내 문구를 실제 동작에 맞게 바로잡았습니다 — 이 토글은 배너와 자동 차단만 제어하며, 마이페이지 「쿠키 동의 관리」 카드는 동의·철회 이력이 있는 회원에게 이 토글과 무관하게 항상 노출됩니다. +- 사이트 언어를 바꾼 뒤 쿠키 동의를 저장하면 차단 해제·재차단이 즉시 반영되지 않던 문제를 수정했습니다. 페이지를 새로 고쳐야 반영되던 증상입니다. ### Fixed +- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다). - 관리자 환경설정의 쿠키 카테고리·차단 도메인 목록에서 항목이 여러 개일 때 화면 요소 식별자가 중복되던 문제를 바로잡았습니다. - 관리자 환경설정의 카테고리 「필수」 배지가 다크 모드에서 배경·글자색이 적용되지 않던 문제를 바로잡았습니다. - 관리자 환경설정의 「운영 주체 / 개인정보처리방침」 섹션 제목 영역이 다른 카드와 다르게 표시되던 문제를 바로잡았습니다. diff --git a/plugins/_bundled/sirsoft-gdpr/components.json b/plugins/_bundled/sirsoft-gdpr/components.json index 5d7da8ca..aed4c8b6 100644 --- a/plugins/_bundled/sirsoft-gdpr/components.json +++ b/plugins/_bundled/sirsoft-gdpr/components.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "identifier": "sirsoft-gdpr", - "version": "1.0.0", + "version": "1.0.2", "components": { "basic": [], "composite": [], diff --git a/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_04_27_000001_create_gdpr_user_consents_table.php b/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_04_27_000001_create_gdpr_user_consents_table.php index 3ceeefeb..b3363a49 100644 --- a/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_04_27_000001_create_gdpr_user_consents_table.php +++ b/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_04_27_000001_create_gdpr_user_consents_table.php @@ -17,9 +17,9 @@ return new class extends Migration Schema::create('gdpr_user_consents', function (Blueprint $table) { $table->id()->comment('행 ID'); $table->foreignId('user_id') + ->comment('사용자 ID') ->constrained('users') - ->cascadeOnDelete() - ->comment('사용자 ID'); + ->cascadeOnDelete(); $table->string('consent_key', 50)->comment('동의 항목 키 (cookie_necessary 등)'); $table->string('consent_category', 30)->nullable()->comment('동의 분류 (cookie 등)'); $table->boolean('is_consented')->default(false)->comment('현재 동의 여부'); diff --git a/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_04_27_000002_create_gdpr_user_consent_histories_table.php b/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_04_27_000002_create_gdpr_user_consent_histories_table.php index 2c5830c6..ab362d75 100644 --- a/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_04_27_000002_create_gdpr_user_consent_histories_table.php +++ b/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_04_27_000002_create_gdpr_user_consent_histories_table.php @@ -18,9 +18,9 @@ return new class extends Migration $table->id()->comment('행 ID'); $table->foreignId('user_id') ->nullable() + ->comment('사용자 ID (게스트면 NULL, 삭제 시 NULL 익명화)') ->constrained('users') - ->nullOnDelete() - ->comment('사용자 ID (게스트면 NULL, 삭제 시 NULL 익명화)'); + ->nullOnDelete(); $table->string('session_id', 100)->nullable()->comment('게스트 세션 ID (회원이면 NULL)'); $table->string('consent_key', 50)->comment('동의 항목 키'); $table->string('action', 20)->comment('변경 유형 (granted/revoked/acknowledged)'); diff --git a/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_05_12_000003_create_gdpr_policy_versions_table.php b/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_05_12_000003_create_gdpr_policy_versions_table.php index 90cea7b9..b4295b02 100644 --- a/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_05_12_000003_create_gdpr_policy_versions_table.php +++ b/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_05_12_000003_create_gdpr_policy_versions_table.php @@ -4,6 +4,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +use Plugins\Sirsoft\Gdpr\Plugin; return new class extends Migration { @@ -22,9 +23,9 @@ return new class extends Migration $table->json('snapshot')->comment('발행 시점 settings 스냅샷 (cookie_categories + blocked_domains + privacy_policy_slug 등)'); $table->foreignId('created_by') ->nullable() + ->comment('발행 운영자 user_id (운영자 삭제 시 NULL — 이력은 영구 보존)') ->constrained('users') - ->nullOnDelete() - ->comment('발행 운영자 user_id (운영자 삭제 시 NULL — 이력은 영구 보존)'); + ->nullOnDelete(); $table->timestamp('created_at')->nullable()->comment('발행 일시 (UPDATED_AT 없음 — 불변 레코드)'); $table->index('created_at'); @@ -45,7 +46,7 @@ return new class extends Migration // 빈 snapshot 으로 두면 회원이 v1 에 동의했을 때 "어떤 정책에 동의했는지" 입증 불가. // cookie_categories 는 settings 컬럼이 string 이라 json_encode 된 형태이므로 snapshot // 에는 디코드된 배열로 정규화 (snapshot 스키마는 항상 객체/배열 — admin 측 발행 경로와 일치). - $defaults = (new \Plugins\Sirsoft\Gdpr\Plugin())->getConfigValues(); + $defaults = (new Plugin)->getConfigValues(); if (isset($defaults['cookie_categories']) && is_string($defaults['cookie_categories'])) { $decoded = json_decode($defaults['cookie_categories'], true); $defaults['cookie_categories'] = is_array($decoded) ? $decoded : []; diff --git a/plugins/_bundled/sirsoft-gdpr/dist/js/plugin.iife.js b/plugins/_bundled/sirsoft-gdpr/dist/js/plugin.iife.js index 11404a61..f4a59814 100644 --- a/plugins/_bundled/sirsoft-gdpr/dist/js/plugin.iife.js +++ b/plugins/_bundled/sirsoft-gdpr/dist/js/plugin.iife.js @@ -1 +1,2 @@ -(function(){"use strict";function Q(e,t){const n=e.toLowerCase(),r=t.toLowerCase();if(r.startsWith("*.")){const o=r.slice(1);return n.endsWith(o)&&n!==o.slice(1)}return n===r}function v(e,t){if(typeof window>"u"||typeof URL>"u")return null;let n,r;try{const o=new URL(e,window.location.href);r=o.protocol,n=o.hostname}catch{return null}if(r!=="http:"&&r!=="https:"||n===""||n===window.location.hostname)return null;for(const[o,s]of Object.entries(t))if(o!=="necessary"){for(const i of s)if(Q(n,i))return o}return null}const w="data-gdpr-category",h="gdpr-blocked-embed",x="data-gdpr-original",b=[`script[${w}]`,`iframe[${w}]`,"script[src]","iframe[src]","img[src]","link[href]"].join(", ");let l=null,g={categories:{},needs_renewal:!1},R={};function j(e){if(e&&typeof e=="object"&&"categories"in e){const t=e;return{categories:{...t.categories},needs_renewal:t.needs_renewal===!0}}return{categories:{...e},needs_renewal:!1}}function N(e){g=j(e),l!==null&&p()}function V(e){R={...e},l!==null&&p()}function Z(){typeof document>"u"||l!==null||(p(),l=new MutationObserver(e=>{for(const t of e)t.addedNodes.forEach(n=>{n instanceof HTMLElement&&te(n)})}),l.observe(document.documentElement,{childList:!0,subtree:!0}),window.addEventListener("gdpr:consent-changed",ee))}function ee(e){const t=e.detail;t!==null&&typeof t=="object"&&(g=j(t)),p()}function te(e){re(e)&&k(e),e.querySelectorAll(b).forEach(t=>k(t))}function p(){typeof document>"u"||(document.querySelectorAll(`.${h}`).forEach(e=>{const t=e.dataset.gdprBlockedCategory;t&&_(t)&&oe(e)}),document.querySelectorAll(b).forEach(e=>k(e)))}function k(e){if(e.classList.contains(h)||e.hasAttribute("data-gdpr-blocked-category"))return;const t=e.getAttribute(w);if(t!==null&&t!==""){if(_(t))return;H(e,t);return}const n=ne(e);if(n===null)return;const r=v(n,R);r!==null&&(_(r)||H(e,r))}function ne(e){return e instanceof HTMLScriptElement||e instanceof HTMLIFrameElement||e instanceof HTMLImageElement?e.getAttribute("src"):e instanceof HTMLLinkElement?e.getAttribute("href"):null}function re(e){if(typeof e.matches!="function")return!1;try{return e.matches(b)}catch{return!1}}function H(e,t){const n=document.createElement("div");n.className=h,n.dataset.gdprBlockedCategory=t,n.dataset.gdprOriginalTag=e.tagName.toLowerCase(),n.setAttribute(x,e.outerHTML),n.setAttribute("aria-hidden","true"),n.style.display="none",e.replaceWith(n)}function oe(e){const t=e.getAttribute(x);if(!t){e.remove();return}const n=document.createElement("div");n.innerHTML=t;const r=n.firstElementChild;r!==null?e.replaceWith(r):e.remove()}function _(e){return e==="necessary"?!0:g.needs_renewal?!1:g.categories[e]===!0}const se=500,ie=["click","keydown","change","submit","pointerdown","touchstart"];let B=!1,C=0;function ce(e){e.isTrusted&&(C=Date.now())}function ae(){if(!B){B=!0;for(const e of ie){const t=ce;window.addEventListener(e,t,{capture:!0,passive:!0})}}}function M(){const e=window.event;return e&&e.isTrusted?!0:C===0?!1:Date.now()-C<=se}const u=["XSRF-TOKEN","laravel_session","laravel_maintenance","gdpr_session"];let A=!1,a=null,y={functionalConsented:!1,necessaryAllowlist:u};function le(e){const t=e.indexOf("=");return t<=0?null:e.substring(0,t).trim()}function ue(e){return y.necessaryAllowlist.includes(e)}function fe(e){const t=le(e);return t===null?!1:!!(de(e)||ue(t)||y.functionalConsented||M())}function de(e){const t=e.toLowerCase(),n=t.match(/max-age\s*=\s*(-?\d+)/);return!!(n&&parseInt(n[1],10)<=0||t.includes("expires=thu, 01 jan 1970")||t.includes("expires=thu, 01-jan-1970"))}function ge(e){if(A)return;if(A=!0,y=e,a=Object.getOwnPropertyDescriptor(Document.prototype,"cookie")??null,!a||!a.set||!a.get){A=!1;return}const t=a.set,n=a.get;Object.defineProperty(Document.prototype,"cookie",{configurable:!0,get(){return n.call(this)},set(r){fe(r)&&t.call(this,r)}})}function P(e){y=e}const f=[{key:"g7_locale",storage:"localStorage",matchType:"exact"},{key:"auth_token",storage:"localStorage",matchType:"exact"},{key:"g7_cache_version",storage:"localStorage",matchType:"exact"},{key:"g7_cart_key",storage:"localStorage",matchType:"exact"},{key:"g7-devtools-panel",storage:"localStorage",matchType:"exact"},{key:"g7_devtools_",matchType:"prefix"},{key:"g7_filters_",matchType:"prefix"},{key:"g7_columns_",matchType:"prefix"},{key:"g7_order_",matchType:"prefix"}];let $=!1,E=null,m={functionalConsented:!1,necessaryAllowlist:f};function pe(e,t){for(const n of m.necessaryAllowlist){if(n.storage&&n.storage!==t)continue;const r=n.matchType??"exact";if(r==="exact"&&n.key===e||r==="prefix"&&e.startsWith(n.key))return!0}return!1}function ye(e,t){return!!(pe(e,t)||m.functionalConsented||M())}function me(e){if($)return;$=!0,m=e;const t=Storage.prototype;E=t.setItem,t.setItem=function(n,r){const o=this===window.sessionStorage?"sessionStorage":"localStorage";ye(n,o)&&E&&E.call(this,n,r)}}function G(e){m=e}function we(e,t,n){for(const r of n){if(r.storage&&r.storage!==t)continue;const o=r.matchType??"exact";if(o==="exact"&&r.key===e||o==="prefix"&&e.startsWith(r.key))return!0}return!1}function U(e,t,n){const r=[];for(let o=0;o-1?r.substring(0,o):r).trim();if(s&&!e.includes(s))try{document.cookie=`${s}=; Max-Age=0; Path=/`}catch{}}}function W(e={}){const t=e.storageAllowlist??f,n=e.cookieAllowlist??u;U(window.localStorage,"localStorage",t),U(window.sessionStorage,"sessionStorage",t),he(n)}let T=null;function be(){if(typeof window>"u")return null;const t=window.G7Core?.api;return t&&typeof t.get=="function"?t:null}async function K(e){const t=be();if(t)try{return await t.get(e)}catch{return null}try{const n=await fetch("/api"+e,{method:"GET",headers:{Accept:"application/json"},credentials:"same-origin"});return n.ok?await n.json():null}catch{return null}}async function ke(){if(T)return T;const t=(await K("/plugins/sirsoft-gdpr/settings"))?.data;return t?(T=t,t):null}async function q(){const e=await K("/plugins/sirsoft-gdpr/consent/cookie/status"),t=e?.data?.consents;return t==null?null:{categories:t,needs_renewal:e?.data?.needs_renewal===!0,current_policy_version:String(e?.data?.current_policy_version??"")}}const S="data-gdpr-blocked-src",L="data-gdpr-blocked-category",_e="data-gdpr-category";let F=!1,d={blockedDomains:{},consent:{necessary:!0},needs_renewal:!1};function D(e){return e==="necessary"?!0:d.needs_renewal===!0?!1:d.consent?.[e]===!0}function Ce(e,t){const n=e.getAttribute(_e);if(n!==null&&n!=="")return D(n)?null:n;const r=v(t,d.blockedDomains);return r===null||D(r)?null:r}function Ae(e){if(d={blockedDomains:e.blockedDomains??{},consent:e.consent??{necessary:!0},needs_renewal:e.needs_renewal===!0},F||typeof HTMLScriptElement>"u")return;const t=Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype,"src");t===void 0||typeof t.set!="function"||(Object.defineProperty(HTMLScriptElement.prototype,"src",{configurable:!0,enumerable:t.enumerable??!0,get(){return t.get?.call(this)??""},set(n){const r=Ce(this,n);if(r!==null){this.setAttribute(S,n),this.setAttribute(L,r);return}t.set.call(this,n)}}),F=!0)}function I(e){d={blockedDomains:e.blockedDomains??{},consent:e.consent??{necessary:!0},needs_renewal:e.needs_renewal===!0}}function Y(e){if(!D(e)||typeof document>"u")return;document.querySelectorAll(`script[${L}="${e}"]`).forEach(n=>{const r=n.getAttribute(S);if(r===null||r==="")return;const o=document.createElement("script");for(const i of Array.from(n.attributes))i.name===S||i.name===L||i.name==="src"||o.setAttribute(i.name,i.value);const s=n.parentNode;s!==null&&(s.replaceChild(o,n),o.src=r)})}const O="sirsoft-gdpr";function z(){if(typeof window>"u")return{};const n=window.G7Config?.plugins?.[O]?.blocked_domains;if(n==null||typeof n!="object")return{};const r={};for(const[o,s]of Object.entries(n)){if(!Array.isArray(s))continue;const i=s.filter(c=>typeof c=="string"&&c!=="");i.length>0&&(r[o]=i)}return r}function Ee(){return typeof window>"u"?!1:window.G7Config?.plugins?.[O]?.banner_enabled===!0}function J(){const e=window.G7Core?.getActionDispatcher?.();if(!e){setTimeout(J,100);return}e.registerHandler(`${O}.syncConsent`,async()=>{const t=await q();if(t===null)return;const n=!t.needs_renewal&&t.categories.functional===!0;N({categories:t.categories,needs_renewal:t.needs_renewal});const r=z();if(I({blockedDomains:r,consent:t.categories,needs_renewal:t.needs_renewal}),G({functionalConsented:n,necessaryAllowlist:f}),P({functionalConsented:n,necessaryAllowlist:u}),n||W(),!t.needs_renewal)for(const[o,s]of Object.entries(t.categories))s===!0&&o!=="necessary"&&Y(o)})}async function X(){if(typeof window>"u")return;if(J(),Ee()){const c=z();Ae({blockedDomains:c,consent:{necessary:!0}}),ae(),me({functionalConsented:!1,necessaryAllowlist:f}),ge({functionalConsented:!1,necessaryAllowlist:u})}const t=await ke();if(!t||!(t.banner_enabled===!0))return;const r=t.blocked_domains??{};V(r),I({blockedDomains:r,consent:{necessary:!0}}),Z();const s=await q(),i=s!==null&&!s.needs_renewal&&s.categories.functional===!0;if(G({functionalConsented:i,necessaryAllowlist:f}),P({functionalConsented:i,necessaryAllowlist:u}),i||W(),s!==null&&(N({categories:s.categories,needs_renewal:s.needs_renewal}),I({blockedDomains:r,consent:s.categories,needs_renewal:s.needs_renewal}),!s.needs_renewal))for(const[c,Te]of Object.entries(s.categories))Te===!0&&c!=="necessary"&&Y(c)}typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>{X()}):X())})(); +var SirsoftGdpr=(function(p){"use strict";function Z(e,t){const n=e.toLowerCase(),r=t.toLowerCase();if(r.startsWith("*.")){const o=r.slice(1);return n.endsWith(o)&&n!==o.slice(1)}return n===r}function R(e,t){if(typeof window>"u"||typeof URL>"u")return null;let n,r;try{const o=new URL(e,window.location.href);r=o.protocol,n=o.hostname}catch{return null}if(r!=="http:"&&r!=="https:"||n===""||n===window.location.hostname)return null;for(const[o,i]of Object.entries(t))if(o!=="necessary"){for(const s of i)if(Z(n,s))return o}return null}const b="data-gdpr-category",_="gdpr-blocked-embed",j="data-gdpr-original",k=[`script[${b}]`,`iframe[${b}]`,"script[src]","iframe[src]","img[src]","link[href]"].join(", ");let l=null,y={categories:{},needs_renewal:!1},N={};function P(e){if(e&&typeof e=="object"&&"categories"in e){const t=e;return{categories:{...t.categories},needs_renewal:t.needs_renewal===!0}}return{categories:{...e},needs_renewal:!1}}function H(e){y=P(e),l!==null&&m()}function ee(e){N={...e},l!==null&&m()}function te(){typeof document>"u"||l!==null||(m(),l=new MutationObserver(e=>{for(const t of e)t.addedNodes.forEach(n=>{n instanceof HTMLElement&&re(n)})}),l.observe(document.documentElement,{childList:!0,subtree:!0}),window.addEventListener("gdpr:consent-changed",ne))}function ne(e){const t=e.detail;t!==null&&typeof t=="object"&&(y=P(t)),m()}function re(e){ie(e)&&C(e),e.querySelectorAll(k).forEach(t=>C(t))}function m(){typeof document>"u"||(document.querySelectorAll(`.${_}`).forEach(e=>{const t=e.dataset.gdprBlockedCategory;t&&A(t)&&se(e)}),document.querySelectorAll(k).forEach(e=>C(e)))}function C(e){if(e.classList.contains(_)||e.hasAttribute("data-gdpr-blocked-category"))return;const t=e.getAttribute(b);if(t!==null&&t!==""){if(A(t))return;M(e,t);return}const n=oe(e);if(n===null)return;const r=R(n,N);r!==null&&(A(r)||M(e,r))}function oe(e){return e instanceof HTMLScriptElement||e instanceof HTMLIFrameElement||e instanceof HTMLImageElement?e.getAttribute("src"):e instanceof HTMLLinkElement?e.getAttribute("href"):null}function ie(e){if(typeof e.matches!="function")return!1;try{return e.matches(k)}catch{return!1}}function M(e,t){const n=document.createElement("div");n.className=_,n.dataset.gdprBlockedCategory=t,n.dataset.gdprOriginalTag=e.tagName.toLowerCase(),n.setAttribute(j,e.outerHTML),n.setAttribute("aria-hidden","true"),n.style.display="none",e.replaceWith(n)}function se(e){const t=e.getAttribute(j);if(!t){e.remove();return}const n=document.createElement("div");n.innerHTML=t;const r=n.firstElementChild;r!==null?e.replaceWith(r):e.remove()}function A(e){return e==="necessary"?!0:y.needs_renewal?!1:y.categories[e]===!0}const ce=500,ae=["click","keydown","change","submit","pointerdown","touchstart"];let B=!1,E=0;function le(e){e.isTrusted&&(E=Date.now())}function ue(){if(!B){B=!0;for(const e of ae){const t=le;window.addEventListener(e,t,{capture:!0,passive:!0})}}}function G(){const e=window.event;return e&&e.isTrusted?!0:E===0?!1:Date.now()-E<=ce}const u=["XSRF-TOKEN","laravel_session","laravel_maintenance","gdpr_session"];let T=!1,a=null,w={functionalConsented:!1,necessaryAllowlist:u};function fe(e){const t=e.indexOf("=");return t<=0?null:e.substring(0,t).trim()}function de(e){return w.necessaryAllowlist.includes(e)}function ge(e){const t=fe(e);return t===null?!1:!!(pe(e)||de(t)||w.functionalConsented||G())}function pe(e){const t=e.toLowerCase(),n=t.match(/max-age\s*=\s*(-?\d+)/);return!!(n&&parseInt(n[1],10)<=0||t.includes("expires=thu, 01 jan 1970")||t.includes("expires=thu, 01-jan-1970"))}function ye(e){if(T)return;if(T=!0,w=e,a=Object.getOwnPropertyDescriptor(Document.prototype,"cookie")??null,!a||!a.set||!a.get){T=!1;return}const t=a.set,n=a.get;Object.defineProperty(Document.prototype,"cookie",{configurable:!0,get(){return n.call(this)},set(r){ge(r)&&t.call(this,r)}})}function $(e){w=e}const f=[{key:"g7_locale",storage:"localStorage",matchType:"exact"},{key:"auth_token",storage:"localStorage",matchType:"exact"},{key:"g7_cache_version",storage:"localStorage",matchType:"exact"},{key:"g7_cart_key",storage:"localStorage",matchType:"exact"},{key:"g7-devtools-panel",storage:"localStorage",matchType:"exact"},{key:"g7_devtools_",matchType:"prefix"},{key:"g7_filters_",matchType:"prefix"},{key:"g7_columns_",matchType:"prefix"},{key:"g7_order_",matchType:"prefix"}];let U=!1,S=null,h={functionalConsented:!1,necessaryAllowlist:f};function me(e,t){for(const n of h.necessaryAllowlist){if(n.storage&&n.storage!==t)continue;const r=n.matchType??"exact";if(r==="exact"&&n.key===e||r==="prefix"&&e.startsWith(n.key))return!0}return!1}function we(e,t){return!!(me(e,t)||h.functionalConsented||G())}function he(e){if(U)return;U=!0,h=e;const t=Storage.prototype;S=t.setItem,t.setItem=function(n,r){const o=this===window.sessionStorage?"sessionStorage":"localStorage";we(n,o)&&S&&S.call(this,n,r)}}function W(e){h=e}function be(e,t,n){for(const r of n){if(r.storage&&r.storage!==t)continue;const o=r.matchType??"exact";if(o==="exact"&&r.key===e||o==="prefix"&&e.startsWith(r.key))return!0}return!1}function F(e,t,n){const r=[];for(let o=0;o-1?r.substring(0,o):r).trim();if(i&&!e.includes(i))try{document.cookie=`${i}=; Max-Age=0; Path=/`}catch{}}}function K(e={}){const t=e.storageAllowlist??f,n=e.cookieAllowlist??u;F(window.localStorage,"localStorage",t),F(window.sessionStorage,"sessionStorage",t),_e(n)}let L=null;function ke(){if(typeof window>"u")return null;const t=window.G7Core?.api;return t&&typeof t.get=="function"?t:null}async function q(e){const t=ke();if(t)try{return await t.get(e)}catch{return null}try{const n=await fetch("/api"+e,{method:"GET",headers:{Accept:"application/json"},credentials:"same-origin"});return n.ok?await n.json():null}catch{return null}}async function Ce(){if(L)return L;const t=(await q("/plugins/sirsoft-gdpr/settings"))?.data;return t?(L=t,t):null}async function Y(){const e=await q("/plugins/sirsoft-gdpr/consent/cookie/status"),t=e?.data?.consents;return t==null?null:{categories:t,needs_renewal:e?.data?.needs_renewal===!0,current_policy_version:String(e?.data?.current_policy_version??"")}}const D="data-gdpr-blocked-src",I="data-gdpr-blocked-category",Ae="data-gdpr-category";let z=!1,d={blockedDomains:{},consent:{necessary:!0},needs_renewal:!1};function O(e){return e==="necessary"?!0:d.needs_renewal===!0?!1:d.consent?.[e]===!0}function Ee(e,t){const n=e.getAttribute(Ae);if(n!==null&&n!=="")return O(n)?null:n;const r=R(t,d.blockedDomains);return r===null||O(r)?null:r}function Te(e){if(d={blockedDomains:e.blockedDomains??{},consent:e.consent??{necessary:!0},needs_renewal:e.needs_renewal===!0},z||typeof HTMLScriptElement>"u")return;const t=Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype,"src");t===void 0||typeof t.set!="function"||(Object.defineProperty(HTMLScriptElement.prototype,"src",{configurable:!0,enumerable:t.enumerable??!0,get(){return t.get?.call(this)??""},set(n){const r=Ee(this,n);if(r!==null){this.setAttribute(D,n),this.setAttribute(I,r);return}t.set.call(this,n)}}),z=!0)}function v(e){d={blockedDomains:e.blockedDomains??{},consent:e.consent??{necessary:!0},needs_renewal:e.needs_renewal===!0}}function J(e){if(!O(e)||typeof document>"u")return;document.querySelectorAll(`script[${I}="${e}"]`).forEach(n=>{const r=n.getAttribute(D);if(r===null||r==="")return;const o=document.createElement("script");for(const s of Array.from(n.attributes))s.name===D||s.name===I||s.name==="src"||o.setAttribute(s.name,s.value);const i=n.parentNode;i!==null&&(i.replaceChild(o,n),o.src=r)})}const g="sirsoft-gdpr";function X(){if(typeof window>"u")return{};const n=window.G7Config?.plugins?.[g]?.blocked_domains;if(n==null||typeof n!="object")return{};const r={};for(const[o,i]of Object.entries(n)){if(!Array.isArray(i))continue;const s=i.filter(c=>typeof c=="string"&&c!=="");s.length>0&&(r[o]=s)}return r}function Se(){return typeof window>"u"?!1:window.G7Config?.plugins?.[g]?.banner_enabled===!0}function x(){const e=window.G7Core?.getActionDispatcher?.();if(!e){setTimeout(x,100);return}e.registerHandler(`${g}.syncConsent`,async()=>{const t=await Y();if(t===null)return;const n=!t.needs_renewal&&t.categories.functional===!0;H({categories:t.categories,needs_renewal:t.needs_renewal});const r=X();if(v({blockedDomains:r,consent:t.categories,needs_renewal:t.needs_renewal}),W({functionalConsented:n,necessaryAllowlist:f}),$({functionalConsented:n,necessaryAllowlist:u}),n||K(),!t.needs_renewal)for(const[o,i]of Object.entries(t.categories))i===!0&&o!=="necessary"&&J(o)})}async function Q(){if(typeof window>"u")return;if(x(),Se()){const c=X();Te({blockedDomains:c,consent:{necessary:!0}}),ue(),he({functionalConsented:!1,necessaryAllowlist:f}),ye({functionalConsented:!1,necessaryAllowlist:u})}const t=await Ce();if(!t||!(t.banner_enabled===!0))return;const r=t.blocked_domains??{};ee(r),v({blockedDomains:r,consent:{necessary:!0}}),te();const i=await Y(),s=i!==null&&!i.needs_renewal&&i.categories.functional===!0;if(W({functionalConsented:s,necessaryAllowlist:f}),$({functionalConsented:s,necessaryAllowlist:u}),s||K(),i!==null&&(H({categories:i.categories,needs_renewal:i.needs_renewal}),v({blockedDomains:r,consent:i.categories,needs_renewal:i.needs_renewal}),!i.needs_renewal))for(const[c,Le]of Object.entries(i.categories))Le===!0&&c!=="necessary"&&J(c)}function V(){x()}return typeof window<"u"&&(window.__SirsoftGdpr={identifier:g,initPlugin:V}),typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>{Q()}):Q()),p.PLUGIN_IDENTIFIER=g,p.initPlugin=V,Object.defineProperty(p,Symbol.toStringTag,{value:"Module"}),p})({}); +//# sourceMappingURL=plugin.iife.js.map diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/handlerReregistration.test.ts b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/handlerReregistration.test.ts new file mode 100644 index 00000000..300dfa26 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/handlerReregistration.test.ts @@ -0,0 +1,80 @@ +/** + * @file handlerReregistration.test.ts + * @description 코어 재초기화(로케일 전환 등) 후 syncConsent 핸들러 재등록 회귀 검증 + * + * TemplateApp 은 ActionDispatcher 를 새로 만든 뒤 + * `window.__[Plugin].initPlugin()` 을 호출해 플러그인이 핸들러를 다시 등록하게 한다. + * 이 이름으로 노출하지 않으면 재초기화 이후 동의 저장 직후의 차단 엔진·인터셉터 동기화가 + * 조용히 멈춘다 (핸들러 미등록 → dispatch 무시 → 에러도 로그도 없음). + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { initPlugin, PLUGIN_IDENTIFIER } from '../index'; + +const HANDLER_NAME = `${PLUGIN_IDENTIFIER}.syncConsent`; + +/** 코어 ActionDispatcher 를 흉내낸다 — 재초기화 시 새 인스턴스가 만들어진다. */ +function createDispatcher() { + const handlers = new Map(); + + return { + handlers, + registerHandler: vi.fn((name: string, handler: unknown) => { + handlers.set(name, handler); + }), + }; +} + +function installG7Core(dispatcher: ReturnType): void { + (window as any).G7Core = { + getActionDispatcher: () => dispatcher, + }; +} + +describe('코어 재초기화 시 핸들러 재등록', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + delete (window as any).G7Core; + vi.restoreAllMocks(); + }); + + it('window 전역에 initPlugin 을 노출한다 (코어가 이 이름으로만 재호출한다)', () => { + const exposed = (window as any).__SirsoftGdpr; + + expect(exposed).toBeDefined(); + expect(typeof exposed.initPlugin).toBe('function'); + }); + + it('새 ActionDispatcher 에 syncConsent 핸들러를 다시 등록한다', () => { + const first = createDispatcher(); + installG7Core(first); + initPlugin(); + expect(first.handlers.has(HANDLER_NAME)).toBe(true); + + // 로케일 전환 — 코어가 ActionDispatcher 를 새로 만든다 (기존 핸들러 전부 소실) + const second = createDispatcher(); + installG7Core(second); + expect(second.handlers.has(HANDLER_NAME)).toBe(false); + + initPlugin(); + + expect(second.handlers.has(HANDLER_NAME)).toBe(true); + expect(typeof second.handlers.get(HANDLER_NAME)).toBe('function'); + }); + + it('재초기화 시점에 ActionDispatcher 가 아직 없으면 준비될 때까지 재시도한다', () => { + (window as any).G7Core = undefined; + + initPlugin(); + + const dispatcher = createDispatcher(); + installG7Core(dispatcher); + vi.advanceTimersByTime(100); + + expect(dispatcher.handlers.has(HANDLER_NAME)).toBe(true); + }); +}); diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/index.ts b/plugins/_bundled/sirsoft-gdpr/resources/js/index.ts index 9bff5a10..827ae972 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/index.ts +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/index.ts @@ -250,6 +250,28 @@ async function bootstrap(): Promise { } } +/** + * 코어 재초기화 시 호출되는 진입점. + * + * 로케일 전환 등으로 TemplateApp 이 ActionDispatcher 를 새로 만들면 + * `TemplateApp.reinitializePluginHandlers()` 가 `window.__[Plugin].initPlugin()` 을 호출한다. + * 이 이름으로 노출하지 않으면 재초기화 후 syncConsent 핸들러가 소실되어 + * 동의 저장 직후 차단 엔진·인터셉터 동기화가 조용히 멈춘다. + * + * 핸들러 재등록만 수행한다 — 차단 엔진/인터셉터는 이미 설치되어 있으므로 재설치하지 않는다. + */ +function initPlugin(): void { + registerSyncHandler(); +} + +if (typeof window !== 'undefined') { + (window as unknown as Record).__SirsoftGdpr = { + identifier: PLUGIN_IDENTIFIER, + // 코어 재초기화 시 핸들러 재등록 진입점 — 이름 고정 (TemplateApp.reinitializePluginHandlers) + initPlugin, + }; +} + if (typeof document !== 'undefined') { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { @@ -258,4 +280,6 @@ if (typeof document !== 'undefined') { } else { void bootstrap(); } -} \ No newline at end of file +} + +export { initPlugin, PLUGIN_IDENTIFIER }; \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-gdpr/upgrades/Upgrade_1_0_2.php b/plugins/_bundled/sirsoft-gdpr/upgrades/Upgrade_1_0_2.php new file mode 100644 index 00000000..e8384d41 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/upgrades/Upgrade_1_0_2.php @@ -0,0 +1,16 @@ +comment()` 가 + * `->constrained()` 뒤에 체인되어 컬럼이 아닌 외래키 정의에 부착되던 문제를 소스에서 + * 교정했으나, 기설치본은 마이그레이션이 재실행되지 않아 그대로 남기 때문이다. + * + * 모든 비즈니스 로직은 data/1.0.2/migrations/ 로 격리(AbstractUpgradeStep 규약). + */ +class Upgrade_1_0_2 extends AbstractUpgradeStep {} diff --git a/plugins/_bundled/sirsoft-gdpr/upgrades/data/1.0.2/migrations/01_BackfillForeignKeyColumnComments.php b/plugins/_bundled/sirsoft-gdpr/upgrades/data/1.0.2/migrations/01_BackfillForeignKeyColumnComments.php new file mode 100644 index 00000000..d726c882 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/upgrades/data/1.0.2/migrations/01_BackfillForeignKeyColumnComments.php @@ -0,0 +1,181 @@ +foreignId('user_id')->constrained('users')->comment('사용자 ID')` 형태는 comment 가 + * 컬럼이 아니라 외래키 정의에 부착되어 DB 에 생성되지 않습니다. `constrained()` 가 컬럼 정의가 + * 아닌 외래키 정의를 돌려주기 때문입니다. 마이그레이션 소스는 교정했지만, 이미 설치를 마친 + * 사이트는 마이그레이션을 다시 실행하지 않으므로 설명이 비어 있는 채로 남습니다. + * + * 멱등: 설명이 이미 있는 컬럼은 건드리지 않습니다. 재실행해도 결과가 같습니다. + * + * 안전: + * - 설명이 **비어 있을 때만** 채웁니다 — 운영자가 직접 넣어 둔 설명을 덮어쓰지 않습니다. + * - 자료형·NULL 허용·기본값·자동증가를 현재 스키마에서 읽어 그대로 재적용하므로 설명 외에는 + * 아무 것도 바뀌지 않습니다. + * - MySQL 계열에서만 동작합니다(다른 DB 는 건너뜁니다). + * + * 실패 정책: 컬럼 단위로 실패를 흡수합니다 — 한 컬럼이 실패해도 나머지를 계속 처리합니다. + * + * V-1 안전: `Illuminate\Support\Facades\DB` 와 로컬 private 헬퍼만 사용하고, 대상 목록을 + * 본 클래스에 동결 상수로 둡니다(마이그레이션 파일·모델 미참조). + */ +class BackfillForeignKeyColumnComments implements DataMigration +{ + /** + * 보정 대상 (1.0.2 시점 동결) — 테이블 => [컬럼 => 설명]. + */ + private const TARGETS = [ + 'gdpr_policy_versions' => [ + 'created_by' => '발행 운영자 user_id (운영자 삭제 시 NULL — 이력은 영구 보존)', + ], + 'gdpr_user_consent_histories' => [ + 'user_id' => '사용자 ID (게스트면 NULL, 삭제 시 NULL 익명화)', + ], + 'gdpr_user_consents' => [ + 'user_id' => '사용자 ID', + ], + ]; + + /** + * 마이그레이션 식별자 (로그용). + * + * @return string 식별자 + */ + public function name(): string + { + return 'BackfillForeignKeyColumnComments'; + } + + /** + * 비어 있는 외래키 컬럼 설명을 채웁니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + public function run(UpgradeContext $context): void + { + if (! in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) { + $context->logger->info('[1.0.2] 컬럼 설명 보정 — MySQL 계열이 아니어서 건너뜁니다'); + + return; + } + + $filled = 0; + $skipped = 0; + $failed = 0; + + foreach (self::TARGETS as $table => $columns) { + $prefixed = $context->table($table); + + foreach ($columns as $column => $comment) { + try { + $meta = $this->columnMeta($prefixed, $column); + + if ($meta === null || trim((string) $meta->COLUMN_COMMENT) !== '') { + $skipped++; + + continue; + } + + DB::statement($this->buildModifyStatement($prefixed, $column, $meta, $comment)); + $filled++; + } catch (\Throwable $e) { + $failed++; + $context->logger->warning(sprintf( + '[1.0.2] 컬럼 설명 보정 실패 (계속 진행): %s.%s — %s', + $prefixed, + $column, + $e->getMessage(), + )); + } + } + } + + $context->logger->info(sprintf( + '[1.0.2] 외래키 컬럼 설명 보정 — 채움 %d건 / 대상 아님 %d건 / 실패 %d건', + $filled, + $skipped, + $failed, + )); + } + + /** + * 컬럼의 현재 스키마 메타데이터를 조회합니다. + * + * @param string $table 프리픽스가 적용된 테이블명 + * @param string $column 컬럼명 + * @return object|null 컬럼 메타 (테이블/컬럼 부재 시 null) + */ + private function columnMeta(string $table, string $column): ?object + { + $rows = DB::select( + 'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', + [$table, $column], + ); + + return $rows[0] ?? null; + } + + /** + * 설명만 덧붙이는 MODIFY COLUMN 문을 조립합니다. + * + * @param string $table 프리픽스가 적용된 테이블명 + * @param string $column 컬럼명 + * @param object $meta columnMeta() 결과 + * @param string $comment 넣을 설명 + * @return string 실행할 SQL + */ + private function buildModifyStatement(string $table, string $column, object $meta, string $comment): string + { + $sql = sprintf( + 'ALTER TABLE %s MODIFY COLUMN %s %s', + $this->quoteIdentifier($table), + $this->quoteIdentifier($column), + $meta->COLUMN_TYPE, + ); + + $sql .= $meta->IS_NULLABLE === 'YES' ? ' NULL' : ' NOT NULL'; + + if ($meta->COLUMN_DEFAULT !== null) { + $sql .= ' DEFAULT '.$this->quoteValue((string) $meta->COLUMN_DEFAULT); + } + + if (stripos(trim((string) $meta->EXTRA), 'auto_increment') !== false) { + $sql .= ' AUTO_INCREMENT'; + } + + return $sql.' COMMENT '.$this->quoteValue($comment); + } + + /** + * 식별자를 백틱으로 감쌉니다. + * + * @param string $identifier 테이블/컬럼명 + * @return string 이스케이프된 식별자 + */ + private function quoteIdentifier(string $identifier): string + { + return '`'.str_replace('`', '``', $identifier).'`'; + } + + /** + * 문자열 값을 SQL 리터럴로 인용합니다. + * + * @param string $value 값 + * @return string 인용된 리터럴 + */ + private function quoteValue(string $value): string + { + return DB::connection()->getPdo()->quote($value); + } +} diff --git a/plugins/_bundled/sirsoft-marketing/CHANGELOG.md b/plugins/_bundled/sirsoft-marketing/CHANGELOG.md index e2ca6ad6..8ff7ed36 100644 --- a/plugins/_bundled/sirsoft-marketing/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-marketing/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.1] - 2026-07-29 + +### Fixed + +- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원을 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다). + ## [1.0.0] - 2026-07-01 ### Added diff --git a/plugins/_bundled/sirsoft-marketing/composer.json b/plugins/_bundled/sirsoft-marketing/composer.json index 0c2854d9..843aa4ee 100644 --- a/plugins/_bundled/sirsoft-marketing/composer.json +++ b/plugins/_bundled/sirsoft-marketing/composer.json @@ -2,7 +2,7 @@ "name": "plugins/sirsoft-marketing", "description": "Marketing consent and subscription management plugin for Gnuboard7 platform", "type": "library", - "version": "1.0.0", + "version": "1.0.1", "autoload": { "psr-4": { "Plugins\\Sirsoft\\Marketing\\": ["src/", "./"] diff --git a/plugins/_bundled/sirsoft-marketing/database/migrations/2026_04_01_000001_create_user_marketing_consents_table.php b/plugins/_bundled/sirsoft-marketing/database/migrations/2026_04_01_000001_create_user_marketing_consents_table.php index 230604a9..0f295bd2 100644 --- a/plugins/_bundled/sirsoft-marketing/database/migrations/2026_04_01_000001_create_user_marketing_consents_table.php +++ b/plugins/_bundled/sirsoft-marketing/database/migrations/2026_04_01_000001_create_user_marketing_consents_table.php @@ -17,9 +17,9 @@ return new class extends Migration Schema::create('user_marketing_consents', function (Blueprint $table) { $table->id()->comment('고유 ID'); $table->foreignId('user_id') + ->comment('사용자 ID') ->constrained('users') - ->cascadeOnDelete() - ->comment('사용자 ID'); + ->cascadeOnDelete(); $table->string('consent_key', 30)->comment('동의 항목 키 (email_subscription, marketing_consent 등)'); $table->boolean('is_consented')->default(false)->comment('현재 동의 여부'); $table->timestamp('consented_at')->nullable()->comment('최근 동의 일시'); diff --git a/plugins/_bundled/sirsoft-marketing/database/migrations/2026_04_01_000002_create_user_marketing_consent_histories_table.php b/plugins/_bundled/sirsoft-marketing/database/migrations/2026_04_01_000002_create_user_marketing_consent_histories_table.php index c0ab9ce9..92f04acb 100644 --- a/plugins/_bundled/sirsoft-marketing/database/migrations/2026_04_01_000002_create_user_marketing_consent_histories_table.php +++ b/plugins/_bundled/sirsoft-marketing/database/migrations/2026_04_01_000002_create_user_marketing_consent_histories_table.php @@ -17,9 +17,9 @@ return new class extends Migration Schema::create('user_marketing_consent_histories', function (Blueprint $table) { $table->id()->comment('고유 ID'); $table->foreignId('user_id') + ->comment('사용자 ID') ->constrained('users') - ->cascadeOnDelete() - ->comment('사용자 ID'); + ->cascadeOnDelete(); $table->string('channel_key', 30)->comment('동의 항목 키 (email_subscription, marketing_consent 등)'); $table->string('action', 10)->comment('변경 유형 (granted/revoked)'); $table->string('source', 20)->comment('변경 경로 (register/profile/admin)'); diff --git a/plugins/_bundled/sirsoft-marketing/package-lock.json b/plugins/_bundled/sirsoft-marketing/package-lock.json index 33f31f05..a0b5d390 100644 --- a/plugins/_bundled/sirsoft-marketing/package-lock.json +++ b/plugins/_bundled/sirsoft-marketing/package-lock.json @@ -1,2022 +1,2022 @@ { - "name": "@g7/sirsoft-marketing", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@g7/sirsoft-marketing", - "version": "1.0.0", - "devDependencies": { - "jsdom": "^27.4.0", - "typescript": "^5.3.3", - "vite": "^5.4.14", - "vitest": "^2.1.8" - } - }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@asamuzakjp/css-color": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", - "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^3.0.0", - "@csstools/css-color-parser": "^4.0.1", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.5" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", - "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", - "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.1.1" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", - "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", - "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", - "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", - "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", - "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", - "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", - "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", - "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", - "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", - "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", - "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", - "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", - "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", - "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", - "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", - "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", - "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", - "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", - "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", - "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", - "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", - "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", - "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", - "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", - "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", - "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/cssstyle": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", - "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^4.1.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.21", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.4" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/data-urls": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", - "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^15.1.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/data-urls/node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsdom": { - "version": "27.4.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", - "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@acemir/cssom": "^0.9.28", - "@asamuzakjp/dom-selector": "^6.7.6", - "@exodus/bytes": "^1.6.0", - "cssstyle": "^5.3.4", - "data-urls": "^6.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.0", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.1.0", - "ws": "^8.18.3", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", - "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.0", - "@rollup/rollup-android-arm64": "4.60.0", - "@rollup/rollup-darwin-arm64": "4.60.0", - "@rollup/rollup-darwin-x64": "4.60.0", - "@rollup/rollup-freebsd-arm64": "4.60.0", - "@rollup/rollup-freebsd-x64": "4.60.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", - "@rollup/rollup-linux-arm-musleabihf": "4.60.0", - "@rollup/rollup-linux-arm64-gnu": "4.60.0", - "@rollup/rollup-linux-arm64-musl": "4.60.0", - "@rollup/rollup-linux-loong64-gnu": "4.60.0", - "@rollup/rollup-linux-loong64-musl": "4.60.0", - "@rollup/rollup-linux-ppc64-gnu": "4.60.0", - "@rollup/rollup-linux-ppc64-musl": "4.60.0", - "@rollup/rollup-linux-riscv64-gnu": "4.60.0", - "@rollup/rollup-linux-riscv64-musl": "4.60.0", - "@rollup/rollup-linux-s390x-gnu": "4.60.0", - "@rollup/rollup-linux-x64-gnu": "4.60.0", - "@rollup/rollup-linux-x64-musl": "4.60.0", - "@rollup/rollup-openbsd-x64": "4.60.0", - "@rollup/rollup-openharmony-arm64": "4.60.0", - "@rollup/rollup-win32-arm64-msvc": "4.60.0", - "@rollup/rollup-win32-ia32-msvc": "4.60.0", - "@rollup/rollup-win32-x64-gnu": "4.60.0", - "@rollup/rollup-win32-x64-msvc": "4.60.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", - "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.27" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", - "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-url": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", - "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" + "name": "@g7/sirsoft-marketing", + "version": "1.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@g7/sirsoft-marketing", + "version": "1.0.1", + "devDependencies": { + "jsdom": "^27.4.0", + "typescript": "^5.3.3", + "vite": "^5.4.14", + "vitest": "^2.1.8" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } + } } diff --git a/plugins/_bundled/sirsoft-marketing/package.json b/plugins/_bundled/sirsoft-marketing/package.json index b4238608..c8f1ac8d 100644 --- a/plugins/_bundled/sirsoft-marketing/package.json +++ b/plugins/_bundled/sirsoft-marketing/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-marketing", - "version": "1.0.0", + "version": "1.0.1", "description": "G7 마케팅 동의 플러그인 프론트엔드 에셋", "private": true, "type": "module", diff --git a/plugins/_bundled/sirsoft-marketing/plugin.json b/plugins/_bundled/sirsoft-marketing/plugin.json index 196d4ab5..d44d9ce7 100644 --- a/plugins/_bundled/sirsoft-marketing/plugin.json +++ b/plugins/_bundled/sirsoft-marketing/plugin.json @@ -5,7 +5,7 @@ "ko": "마케팅 동의", "en": "Marketing Consent" }, - "version": "1.0.0", + "version": "1.0.1", "description": { "ko": "이메일 구독, 마케팅 동의, 제3자 제공 동의 등을 관리하는 플러그인", "en": "Plugin for managing email subscriptions, marketing consent, and third-party data sharing consent" diff --git a/plugins/_bundled/sirsoft-marketing/upgrades/Upgrade_1_0_1.php b/plugins/_bundled/sirsoft-marketing/upgrades/Upgrade_1_0_1.php new file mode 100644 index 00000000..0bdc0c23 --- /dev/null +++ b/plugins/_bundled/sirsoft-marketing/upgrades/Upgrade_1_0_1.php @@ -0,0 +1,16 @@ +comment()` 가 + * `->constrained()` 뒤에 체인되어 컬럼이 아닌 외래키 정의에 부착되던 문제를 소스에서 + * 교정했으나, 기설치본은 마이그레이션이 재실행되지 않아 그대로 남기 때문이다. + * + * 모든 비즈니스 로직은 data/1.0.1/migrations/ 로 격리(AbstractUpgradeStep 규약). + */ +class Upgrade_1_0_1 extends AbstractUpgradeStep {} diff --git a/plugins/_bundled/sirsoft-marketing/upgrades/data/1.0.1/migrations/01_BackfillForeignKeyColumnComments.php b/plugins/_bundled/sirsoft-marketing/upgrades/data/1.0.1/migrations/01_BackfillForeignKeyColumnComments.php new file mode 100644 index 00000000..4d8f575f --- /dev/null +++ b/plugins/_bundled/sirsoft-marketing/upgrades/data/1.0.1/migrations/01_BackfillForeignKeyColumnComments.php @@ -0,0 +1,178 @@ +foreignId('user_id')->constrained('users')->comment('사용자 ID')` 형태는 comment 가 + * 컬럼이 아니라 외래키 정의에 부착되어 DB 에 생성되지 않습니다. `constrained()` 가 컬럼 정의가 + * 아닌 외래키 정의를 돌려주기 때문입니다. 마이그레이션 소스는 교정했지만, 이미 설치를 마친 + * 사이트는 마이그레이션을 다시 실행하지 않으므로 설명이 비어 있는 채로 남습니다. + * + * 멱등: 설명이 이미 있는 컬럼은 건드리지 않습니다. 재실행해도 결과가 같습니다. + * + * 안전: + * - 설명이 **비어 있을 때만** 채웁니다 — 운영자가 직접 넣어 둔 설명을 덮어쓰지 않습니다. + * - 자료형·NULL 허용·기본값·자동증가를 현재 스키마에서 읽어 그대로 재적용하므로 설명 외에는 + * 아무 것도 바뀌지 않습니다. + * - MySQL 계열에서만 동작합니다(다른 DB 는 건너뜁니다). + * + * 실패 정책: 컬럼 단위로 실패를 흡수합니다 — 한 컬럼이 실패해도 나머지를 계속 처리합니다. + * + * V-1 안전: `Illuminate\Support\Facades\DB` 와 로컬 private 헬퍼만 사용하고, 대상 목록을 + * 본 클래스에 동결 상수로 둡니다(마이그레이션 파일·모델 미참조). + */ +class BackfillForeignKeyColumnComments implements DataMigration +{ + /** + * 보정 대상 (1.0.1 시점 동결) — 테이블 => [컬럼 => 설명]. + */ + private const TARGETS = [ + 'user_marketing_consent_histories' => [ + 'user_id' => '사용자 ID', + ], + 'user_marketing_consents' => [ + 'user_id' => '사용자 ID', + ], + ]; + + /** + * 마이그레이션 식별자 (로그용). + * + * @return string 식별자 + */ + public function name(): string + { + return 'BackfillForeignKeyColumnComments'; + } + + /** + * 비어 있는 외래키 컬럼 설명을 채웁니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + public function run(UpgradeContext $context): void + { + if (! in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) { + $context->logger->info('[1.0.1] 컬럼 설명 보정 — MySQL 계열이 아니어서 건너뜁니다'); + + return; + } + + $filled = 0; + $skipped = 0; + $failed = 0; + + foreach (self::TARGETS as $table => $columns) { + $prefixed = $context->table($table); + + foreach ($columns as $column => $comment) { + try { + $meta = $this->columnMeta($prefixed, $column); + + if ($meta === null || trim((string) $meta->COLUMN_COMMENT) !== '') { + $skipped++; + + continue; + } + + DB::statement($this->buildModifyStatement($prefixed, $column, $meta, $comment)); + $filled++; + } catch (\Throwable $e) { + $failed++; + $context->logger->warning(sprintf( + '[1.0.1] 컬럼 설명 보정 실패 (계속 진행): %s.%s — %s', + $prefixed, + $column, + $e->getMessage(), + )); + } + } + } + + $context->logger->info(sprintf( + '[1.0.1] 외래키 컬럼 설명 보정 — 채움 %d건 / 대상 아님 %d건 / 실패 %d건', + $filled, + $skipped, + $failed, + )); + } + + /** + * 컬럼의 현재 스키마 메타데이터를 조회합니다. + * + * @param string $table 프리픽스가 적용된 테이블명 + * @param string $column 컬럼명 + * @return object|null 컬럼 메타 (테이블/컬럼 부재 시 null) + */ + private function columnMeta(string $table, string $column): ?object + { + $rows = DB::select( + 'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', + [$table, $column], + ); + + return $rows[0] ?? null; + } + + /** + * 설명만 덧붙이는 MODIFY COLUMN 문을 조립합니다. + * + * @param string $table 프리픽스가 적용된 테이블명 + * @param string $column 컬럼명 + * @param object $meta columnMeta() 결과 + * @param string $comment 넣을 설명 + * @return string 실행할 SQL + */ + private function buildModifyStatement(string $table, string $column, object $meta, string $comment): string + { + $sql = sprintf( + 'ALTER TABLE %s MODIFY COLUMN %s %s', + $this->quoteIdentifier($table), + $this->quoteIdentifier($column), + $meta->COLUMN_TYPE, + ); + + $sql .= $meta->IS_NULLABLE === 'YES' ? ' NULL' : ' NOT NULL'; + + if ($meta->COLUMN_DEFAULT !== null) { + $sql .= ' DEFAULT '.$this->quoteValue((string) $meta->COLUMN_DEFAULT); + } + + if (stripos(trim((string) $meta->EXTRA), 'auto_increment') !== false) { + $sql .= ' AUTO_INCREMENT'; + } + + return $sql.' COMMENT '.$this->quoteValue($comment); + } + + /** + * 식별자를 백틱으로 감쌉니다. + * + * @param string $identifier 테이블/컬럼명 + * @return string 이스케이프된 식별자 + */ + private function quoteIdentifier(string $identifier): string + { + return '`'.str_replace('`', '``', $identifier).'`'; + } + + /** + * 문자열 값을 SQL 리터럴로 인용합니다. + * + * @param string $value 값 + * @return string 인용된 리터럴 + */ + private function quoteValue(string $value): string + { + return DB::connection()->getPdo()->quote($value); + } +} diff --git a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md index 90ecc776..a547fa5d 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md @@ -14,6 +14,7 @@ - 관리자 주문 목록에서 주문번호를 눌러 상세로 들어갈 때 걸어 둔 필터와 페이지가 사라지던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 주문 목록에서 '이 주문자의 주문 검색'을 실행하면 검색창은 비어 보이는데 직전 검색어가 계속 적용돼 결과가 어긋나던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 해외결제(CBT) 연결 진단을 실행했을 때 결과 안내가 실제 문장 대신 내부 식별자로 표시되던 문제를 수정했습니다. +- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다. ## [1.0.1] - 2026-07-16 diff --git a/plugins/_bundled/sirsoft-pay_kginicis/resources/extensions/admin_order_list_test_badge.json b/plugins/_bundled/sirsoft-pay_kginicis/resources/extensions/admin_order_list_test_badge.json index 555f4f84..56be6b1c 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/resources/extensions/admin_order_list_test_badge.json +++ b/plugins/_bundled/sirsoft-pay_kginicis/resources/extensions/admin_order_list_test_badge.json @@ -178,14 +178,14 @@ "handler": "sequence", "actions": [ { "handler": "setState", "params": { "target": "_local", "filter.ordererUuid": "{{row.user?.uuid}}", "filter.searchField": "all", "filter.searchKeyword": "", "ordererSearchResults": "{{[{ uuid: row.user?.uuid, name: row.user?.name || row.address?.orderer_name, email: row.user?.email || '' }]}}" } }, - { "comment": "이 주문자의 주문만 보기 — 앞선 setState 가 검색어를 비우므로 URL 의 검색 조건도 함께 비운다. 비우지 않으면 화면 입력창은 빈 채로 이전 검색어가 계속 적용된다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "orderer_uuid": "{{row.user?.uuid}}", "search_field": "", "search_keyword": "", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } } + { "comment": "이 주문자의 주문만 보기 — 앞선 setState 가 검색어를 비우므로 URL 의 검색 조건도 함께 비운다. 비우지 않으면 화면 입력창은 빈 채로 이전 검색어가 계속 적용된다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "orderer_uuid": "{{row.user?.uuid}}", "search_field": "", "search_keyword": "", "page": 1 }, "replace": true, "transition_overlay_target": "order_datagrid__body" } } ] }, "search_by_orderer_guest": { "handler": "sequence", "actions": [ { "handler": "setState", "params": { "target": "_local", "filter.searchField": "orderer_name", "filter.searchKeyword": "{{row.address?.orderer_name || row.user?.name}}", "filter.ordererUuid": "" } }, - { "comment": "비회원 주문자명으로 검색 — 회원 지정 필터(orderer_uuid)를 함께 비우지 않으면 이름 검색과 회원 필터가 겹쳐 결과가 비어 보인다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "search_field": "orderer_name", "search_keyword": "{{row.address?.orderer_name || row.user?.name}}", "orderer_uuid": "", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } } + { "comment": "비회원 주문자명으로 검색 — 회원 지정 필터(orderer_uuid)를 함께 비우지 않으면 이름 검색과 회원 필터가 겹쳐 결과가 비어 보인다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "search_field": "orderer_name", "search_keyword": "{{row.address?.orderer_name || row.user?.name}}", "orderer_uuid": "", "page": 1 }, "replace": true, "transition_overlay_target": "order_datagrid__body" } } ] } } diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md index 869ce5d6..84467b09 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md @@ -12,6 +12,7 @@ - 관리자 주문 목록에서 '이 주문자의 주문 검색'을 실행하면 검색창은 비어 보이는데 직전 검색어가 계속 적용돼 결과가 어긋나던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 오류 안내가 뜨기는 하지만 내용이 비어 있던 문제를 수정했습니다. 설정 저장에 실패하면 서버가 알려 준 사유가 그대로 표시됩니다. - 설정 화면의 연동 점검 결과 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다. +- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다. ## [1.0.0] - 2026-07-16 diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/resources/extensions/admin_order_list_test_badge.json b/plugins/_bundled/sirsoft-pay_nhnkcp/resources/extensions/admin_order_list_test_badge.json index f1a2689f..7c24dec4 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/resources/extensions/admin_order_list_test_badge.json +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/resources/extensions/admin_order_list_test_badge.json @@ -199,14 +199,14 @@ "handler": "sequence", "actions": [ { "handler": "setState", "params": { "target": "_local", "filter.ordererUuid": "{{row.user?.uuid}}", "filter.searchField": "all", "filter.searchKeyword": "", "ordererSearchResults": "{{[{ uuid: row.user?.uuid, name: row.user?.name || row.address?.orderer_name, email: row.user?.email || '' }]}}" } }, - { "comment": "이 주문자의 주문만 보기 — 앞선 setState 가 검색어를 비우므로 URL 의 검색 조건도 함께 비운다. 비우지 않으면 화면 입력창은 빈 채로 이전 검색어가 계속 적용된다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "orderer_uuid": "{{row.user?.uuid}}", "search_field": "", "search_keyword": "", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } } + { "comment": "이 주문자의 주문만 보기 — 앞선 setState 가 검색어를 비우므로 URL 의 검색 조건도 함께 비운다. 비우지 않으면 화면 입력창은 빈 채로 이전 검색어가 계속 적용된다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "orderer_uuid": "{{row.user?.uuid}}", "search_field": "", "search_keyword": "", "page": 1 }, "replace": true, "transition_overlay_target": "order_datagrid__body" } } ] }, "search_by_orderer_guest": { "handler": "sequence", "actions": [ { "handler": "setState", "params": { "target": "_local", "filter.searchField": "orderer_name", "filter.searchKeyword": "{{row.address?.orderer_name || row.user?.name}}", "filter.ordererUuid": "" } }, - { "comment": "비회원 주문자명으로 검색 — 회원 지정 필터(orderer_uuid)를 함께 비우지 않으면 이름 검색과 회원 필터가 겹쳐 결과가 비어 보인다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "search_field": "orderer_name", "search_keyword": "{{row.address?.orderer_name || row.user?.name}}", "orderer_uuid": "", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } } + { "comment": "비회원 주문자명으로 검색 — 회원 지정 필터(orderer_uuid)를 함께 비우지 않으면 이름 검색과 회원 필터가 겹쳐 결과가 비어 보인다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "search_field": "orderer_name", "search_keyword": "{{row.address?.orderer_name || row.user?.name}}", "orderer_uuid": "", "page": 1 }, "replace": true, "transition_overlay_target": "order_datagrid__body" } } ] } } diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md index 1bc36e63..8e9dd53d 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md @@ -12,6 +12,7 @@ - 관리자 주문 목록에서 '이 주문자의 주문 검색'을 실행하면 검색창은 비어 보이는데 직전 검색어가 계속 적용돼 결과가 어긋나던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 오류 안내가 뜨기는 하지만 내용이 비어 있던 문제를 수정했습니다. 설정 저장에 실패하면 서버가 알려 준 사유가 그대로 표시됩니다. - 설정 화면의 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다. +- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다. ## [1.0.0] - 2026-07-16 diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/resources/extensions/admin_order_list_test_badge.json b/plugins/_bundled/sirsoft-pay_nicepayments/resources/extensions/admin_order_list_test_badge.json index bcefd210..a440cf56 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/resources/extensions/admin_order_list_test_badge.json +++ b/plugins/_bundled/sirsoft-pay_nicepayments/resources/extensions/admin_order_list_test_badge.json @@ -177,14 +177,14 @@ "handler": "sequence", "actions": [ { "handler": "setState", "params": { "target": "_local", "filter.ordererUuid": "{{row.user?.uuid}}", "filter.searchField": "all", "filter.searchKeyword": "", "ordererSearchResults": "{{[{ uuid: row.user?.uuid, name: row.user?.name || row.address?.orderer_name, email: row.user?.email || '' }]}}" } }, - { "comment": "이 주문자의 주문만 보기 — 앞선 setState 가 검색어를 비우므로 URL 의 검색 조건도 함께 비운다. 비우지 않으면 화면 입력창은 빈 채로 이전 검색어가 계속 적용된다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "orderer_uuid": "{{row.user?.uuid}}", "search_field": "", "search_keyword": "", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } } + { "comment": "이 주문자의 주문만 보기 — 앞선 setState 가 검색어를 비우므로 URL 의 검색 조건도 함께 비운다. 비우지 않으면 화면 입력창은 빈 채로 이전 검색어가 계속 적용된다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "orderer_uuid": "{{row.user?.uuid}}", "search_field": "", "search_keyword": "", "page": 1 }, "replace": true, "transition_overlay_target": "order_datagrid__body" } } ] }, "search_by_orderer_guest": { "handler": "sequence", "actions": [ { "handler": "setState", "params": { "target": "_local", "filter.searchField": "orderer_name", "filter.searchKeyword": "{{row.address?.orderer_name || row.user?.name}}", "filter.ordererUuid": "" } }, - { "comment": "비회원 주문자명으로 검색 — 회원 지정 필터(orderer_uuid)를 함께 비우지 않으면 이름 검색과 회원 필터가 겹쳐 결과가 비어 보인다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "search_field": "orderer_name", "search_keyword": "{{row.address?.orderer_name || row.user?.name}}", "orderer_uuid": "", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } } + { "comment": "비회원 주문자명으로 검색 — 회원 지정 필터(orderer_uuid)를 함께 비우지 않으면 이름 검색과 회원 필터가 겹쳐 결과가 비어 보인다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "search_field": "orderer_name", "search_keyword": "{{row.address?.orderer_name || row.user?.name}}", "orderer_uuid": "", "page": 1 }, "replace": true, "transition_overlay_target": "order_datagrid__body" } } ] } } diff --git a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md index 4945b928..73e6708a 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this plugin will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.0.2] - 2026-07-29 + +### Changed + +- 코어 최소 요구 버전을 7.0.6 으로 상향했습니다. 운영 모드 전환 시 라이브 자격증명을 필수로 검사하는 기능이 코어 7.0.6 의 설정 저장 검증 표면을 사용합니다. 그보다 낮은 코어에서는 이 검사가 동작하지 않아 라이브 모드를 빈 자격증명으로 저장할 수 있으므로, 플러그인을 업데이트하기 전에 코어를 7.0.6 이상으로 먼저 업데이트해 주세요. +- 설정 화면의 중복 가입 관련 항목 이름을 「중복체크 기준」·「중복 가입 차단」으로 통일했습니다. 같은 화면에 다른 본인확인 수단의 카드가 함께 놓일 때 용어가 갈리지 않도록 맞췄습니다. + +### Fixed + +- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다). +- 사이트 언어를 바꾼 뒤에는 「본인확인 시작」을 눌러도 인증창이 열리지 않던 문제를 수정했습니다. 아무 반응도 오류 안내도 없어 원인을 알기 어려운 증상이었으며, 페이지를 새로 고치면 정상 동작했습니다. + ## [1.0.1] - 2026-07-14 ### Security diff --git a/plugins/_bundled/sirsoft-verification_kginicis/components.json b/plugins/_bundled/sirsoft-verification_kginicis/components.json index 11c15737..d7dcd54e 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/components.json +++ b/plugins/_bundled/sirsoft-verification_kginicis/components.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "identifier": "sirsoft-verification_kginicis", - "version": "1.0.1", + "version": "1.0.2", "components": { "basic": [], "composite": [], diff --git a/plugins/_bundled/sirsoft-verification_kginicis/composer.json b/plugins/_bundled/sirsoft-verification_kginicis/composer.json index 68ee5576..35ec03c0 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/composer.json +++ b/plugins/_bundled/sirsoft-verification_kginicis/composer.json @@ -1,7 +1,7 @@ { "name": "plugins/sirsoft-verification_kginicis", "description": "KG Inicis Identity Verification provider for G7", - "version": "1.0.1", + "version": "1.0.2", "type": "library", "authors": [ { diff --git a/plugins/_bundled/sirsoft-verification_kginicis/database/migrations/2026_05_08_000001_create_inicis_identity_records_table.php b/plugins/_bundled/sirsoft-verification_kginicis/database/migrations/2026_05_08_000001_create_inicis_identity_records_table.php index 0a22c700..55bb3bf6 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/database/migrations/2026_05_08_000001_create_inicis_identity_records_table.php +++ b/plugins/_bundled/sirsoft-verification_kginicis/database/migrations/2026_05_08_000001_create_inicis_identity_records_table.php @@ -20,8 +20,8 @@ return new class extends Migration $table->id()->comment('고유 ID'); $table->foreignId('user_id') ->unique() - ->constrained('users') - ->comment('사용자 ID — UNIQUE 1:1. CASCADE 미설정 (탈퇴/삭제 시 listener 명시 삭제)'); + ->comment('사용자 ID — UNIQUE 1:1. CASCADE 미설정 (탈퇴/삭제 시 listener 명시 삭제)') + ->constrained('users'); $table->uuid('latest_log_id') ->nullable() ->comment('현재 PII 가 발급된 challenge UUID — identity_verification_logs.id 참조 (감사 link)'); diff --git a/plugins/_bundled/sirsoft-verification_kginicis/dist/js/plugin.iife.js b/plugins/_bundled/sirsoft-verification_kginicis/dist/js/plugin.iife.js index a22c7990..663c14e9 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/dist/js/plugin.iife.js +++ b/plugins/_bundled/sirsoft-verification_kginicis/dist/js/plugin.iife.js @@ -1 +1,2 @@ -var SirsoftVerificationKginicis=function(s){"use strict";const o="sirsoft-verification_kginicis",w="inicis",A="width=400,height=640,scrollbars=yes,resizable=yes",D="https://sa.inicis.com/auth",I="identity-challenge-modal",l={info:(...e)=>console.info(`[${o}]`,...e),warn:(...e)=>console.warn(`[${o}]`,...e),error:(...e)=>console.error(`[${o}]`,...e)};function a(){return window.G7Core??null}function v(){return`${window.location.origin}/plugins/${o}/plugin/inicis/callback`}function C(e,i){const t=document.createElement("form");t.setAttribute("name","saForm"),t.setAttribute("method","POST"),t.setAttribute("action",D),t.setAttribute("target",i),t.setAttribute("accept-charset","UTF-8"),t.style.display="none";const u={mid:e.mid,reqSvcCd:e.reqSvcCd,mTxId:e.mtxid,authHash:e.authHash,flgFixedUser:e.flgFixedUser,successUrl:v(),failUrl:v(),reservedMsg:e.reservedMsg};for(const[d,c]of Object.entries(u)){const r=document.createElement("input");r.type="hidden",r.name=d,r.value=c,t.appendChild(r)}document.body.appendChild(t),t.submit(),window.setTimeout(()=>{try{t.remove()}catch{}},0)}function _(e){const t=a()?.state?.set;typeof t=="function"&&t({identityChallenge:{providerInProgress:e}})}const L={NOT_ADULT:"$t:sirsoft-verification_kginicis.errors.not_adult",INVALID_AUTH_URL:"$t:sirsoft-verification_kginicis.errors.invalid_auth_url",DECRYPT_FAILED:"$t:sirsoft-verification_kginicis.errors.decrypt_failed",REMOTE_CALL_FAILED:"$t:sirsoft-verification_kginicis.errors.remote_call_failed",NOT_FOUND:"$t:sirsoft-verification_kginicis.errors.not_found",ALREADY_CONSUMED:"$t:sirsoft-verification_kginicis.errors.already_consumed",IDENTITY_BINDING_MISMATCH:"$t:sirsoft-verification_kginicis.errors.binding_mismatch",INCOMPLETE_IDENTITY:"$t:sirsoft-verification_kginicis.errors.incomplete_identity",STORAGE_FAILED:"$t:sirsoft-verification_kginicis.errors.storage_failed"},P="$t:sirsoft-verification_kginicis.errors.verify_failed",S=new Set(["NOT_ADULT"]);function T(e){return L[e]??P}function U(){const e=a()?.identity?.markDomainNoticeShown;if(typeof e=="function")try{e()}catch{}}async function g(e,i="error"){const t=a()?.dispatch;if(typeof t=="function")try{await t({handler:"toast",params:{type:i,message:e}})}catch{}}function O(e){const i=a()?.dispatch;return typeof i!="function"?Promise.resolve():i({handler:"sequence",params:{actions:[{handler:"resolveIdentityChallenge",params:e},{handler:"closeModal",target:I}]}}).catch(()=>{})}async function h(){const e=a(),i=e?.state?.getGlobal?.()?.identityChallenge??e?.state?.get?.()?.identityChallenge;if(!i){l.error("_global.identityChallenge 미설정 — 코어 모달 진입 흐름 확인 필요");return}const t=i.public_payload;if(!t?.mid||!t?.mtxid||!t?.authHash){l.error("public_payload 의 mid/mtxid/authHash 부재 — Provider.requestChallenge 응답 확인 필요",t),await g("본인인증 페이로드가 준비되지 않았습니다. 잠시 후 다시 시도해 주세요.");return}const u="sa_popup",d=window.open("",u,A);if(!d){l.error("window.open null — 팝업 차단됨"),await g("팝업이 차단되었습니다. 브라우저 팝업 허용 설정 후 다시 시도해 주세요.");return}_(!0),C(t,u);let c=!1,r=null,f=null;const p=()=>{f&&(window.removeEventListener("message",f),f=null),r!==null&&(window.clearInterval(r),r=null)};f=y=>{if(y.origin!==window.location.origin)return;const n=y.data;if(!n||n.type!=="identity_result"||c)return;c=!0,p();try{d.close()}catch{}_(!1);const b=n.verification_token?{result:"verified",token:n.verification_token}:n.identity_error?{result:"failed",failureCode:n.identity_error}:{result:"cancelled"};n.identity_error&&(S.has(n.identity_error)&&U(),g(T(n.identity_error),"error")),O(b)},window.addEventListener("message",f),r=window.setInterval(()=>{if(c){p();return}d.closed&&(c=!0,p(),_(!1))},500)}function E(){const i=a()?.getActionDispatcher;if(typeof i!="function")return!1;const t=i();return!t||typeof t.registerHandler!="function"?!1:(t.registerHandler(`${o}.startAuth`,h,{category:"plugin",source:o}),l.info("startAuth handler registered"),!0)}function m(){if(E())return;let e=0;const i=window.setInterval(()=>{e++,(E()||e>=50)&&(window.clearInterval(i),e>=50&&l.warn("G7Core ActionDispatcher 미준비 — handler 등록 실패"))},100)}return m(),window.__SirsoftVerificationKginicis={identifier:o,init:m,startAuthHandler:h},s.MODAL_ID=I,s.PLUGIN_IDENTIFIER=o,s.PROVIDER_ID=w,s.init=m,s.startAuthHandler=h,Object.defineProperty(s,Symbol.toStringTag,{value:"Module"}),s}({}); +var SirsoftVerificationKginicis=(function(o){"use strict";const s="sirsoft-verification_kginicis",D="inicis",C="width=400,height=640,scrollbars=yes,resizable=yes",L="https://sa.inicis.com/auth",I="identity-challenge-modal",l={info:(...e)=>console.info(`[${s}]`,...e),warn:(...e)=>console.warn(`[${s}]`,...e),error:(...e)=>console.error(`[${s}]`,...e)};function a(){return window.G7Core??null}function v(){return`${window.location.origin}/plugins/${s}/plugin/inicis/callback`}function P(e,i){const t=document.createElement("form");t.setAttribute("name","saForm"),t.setAttribute("method","POST"),t.setAttribute("action",L),t.setAttribute("target",i),t.setAttribute("accept-charset","UTF-8"),t.style.display="none";const f={mid:e.mid,reqSvcCd:e.reqSvcCd,mTxId:e.mtxid,authHash:e.authHash,flgFixedUser:e.flgFixedUser,successUrl:v(),failUrl:v(),reservedMsg:e.reservedMsg};for(const[d,c]of Object.entries(f)){const r=document.createElement("input");r.type="hidden",r.name=d,r.value=c,t.appendChild(r)}document.body.appendChild(t),t.submit(),window.setTimeout(()=>{try{t.remove()}catch{}},0)}function _(e){const t=a()?.state?.set;typeof t=="function"&&t({identityChallenge:{providerInProgress:e}})}const S={NOT_ADULT:"$t:sirsoft-verification_kginicis.errors.not_adult",INVALID_AUTH_URL:"$t:sirsoft-verification_kginicis.errors.invalid_auth_url",DECRYPT_FAILED:"$t:sirsoft-verification_kginicis.errors.decrypt_failed",REMOTE_CALL_FAILED:"$t:sirsoft-verification_kginicis.errors.remote_call_failed",NOT_FOUND:"$t:sirsoft-verification_kginicis.errors.not_found",ALREADY_CONSUMED:"$t:sirsoft-verification_kginicis.errors.already_consumed",IDENTITY_BINDING_MISMATCH:"$t:sirsoft-verification_kginicis.errors.binding_mismatch",INCOMPLETE_IDENTITY:"$t:sirsoft-verification_kginicis.errors.incomplete_identity",STORAGE_FAILED:"$t:sirsoft-verification_kginicis.errors.storage_failed"},T="$t:sirsoft-verification_kginicis.errors.verify_failed",U=new Set(["NOT_ADULT"]);function O(e){return S[e]??T}function b(){const e=a()?.identity?.markDomainNoticeShown;if(typeof e=="function")try{e()}catch{}}async function g(e,i="error"){const t=a()?.dispatch;if(typeof t=="function")try{await t({handler:"toast",params:{type:i,message:e}})}catch{}}function N(e){const i=a()?.dispatch;return typeof i!="function"?Promise.resolve():i({handler:"sequence",params:{actions:[{handler:"resolveIdentityChallenge",params:e},{handler:"closeModal",target:I}]}}).catch(()=>{})}async function h(){const e=a(),i=e?.state?.getGlobal?.()?.identityChallenge??e?.state?.get?.()?.identityChallenge;if(!i){l.error("_global.identityChallenge 미설정 — 코어 모달 진입 흐름 확인 필요");return}const t=i.public_payload;if(!t?.mid||!t?.mtxid||!t?.authHash){l.error("public_payload 의 mid/mtxid/authHash 부재 — Provider.requestChallenge 응답 확인 필요",t),await g("본인인증 페이로드가 준비되지 않았습니다. 잠시 후 다시 시도해 주세요.");return}const f="sa_popup",d=window.open("",f,C);if(!d){l.error("window.open null — 팝업 차단됨"),await g("팝업이 차단되었습니다. 브라우저 팝업 허용 설정 후 다시 시도해 주세요.");return}_(!0),P(t,f);let c=!1,r=null,u=null;const p=()=>{u&&(window.removeEventListener("message",u),u=null),r!==null&&(window.clearInterval(r),r=null)};u=A=>{if(A.origin!==window.location.origin)return;const n=A.data;if(!n||n.type!=="identity_result"||c)return;c=!0,p();try{d.close()}catch{}_(!1);const R=n.verification_token?{result:"verified",token:n.verification_token}:n.identity_error?{result:"failed",failureCode:n.identity_error}:{result:"cancelled"};n.identity_error&&(U.has(n.identity_error)&&b(),g(O(n.identity_error),"error")),N(R)},window.addEventListener("message",u),r=window.setInterval(()=>{if(c){p();return}d.closed&&(c=!0,p(),_(!1))},500)}function y(){const i=a()?.getActionDispatcher;if(typeof i!="function")return!1;const t=i();return!t||typeof t.registerHandler!="function"?!1:(t.registerHandler(`${s}.startAuth`,h,{category:"plugin",source:s}),l.info("startAuth handler registered"),!0)}function E(){if(y())return;let e=0;const i=window.setInterval(()=>{e++,(y()||e>=50)&&(window.clearInterval(i),e>=50&&l.warn("G7Core ActionDispatcher 미준비 — handler 등록 실패"))},100)}function w(){E()}function m(){E()}return m(),window.__SirsoftVerificationKginicis={identifier:s,init:m,initPlugin:w,startAuthHandler:h},o.MODAL_ID=I,o.PLUGIN_IDENTIFIER=s,o.PROVIDER_ID=D,o.init=m,o.initPlugin=w,o.startAuthHandler=h,Object.defineProperty(o,Symbol.toStringTag,{value:"Module"}),o})({}); +//# sourceMappingURL=plugin.iife.js.map diff --git a/plugins/_bundled/sirsoft-verification_kginicis/lang/en/messages.php b/plugins/_bundled/sirsoft-verification_kginicis/lang/en/messages.php index cf147e51..884a4602 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-verification_kginicis/lang/en/messages.php @@ -3,6 +3,7 @@ declare(strict_types=1); return [ + 'bridge_page_title' => 'Identity verification result', 'title' => 'KG Inicis Identity Verification', 'description' => 'Plugin that connects KG Inicis identity verification service to G7 core IDV infrastructure.', diff --git a/plugins/_bundled/sirsoft-verification_kginicis/lang/ko/messages.php b/plugins/_bundled/sirsoft-verification_kginicis/lang/ko/messages.php index b0b27047..1ba136d2 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-verification_kginicis/lang/ko/messages.php @@ -3,6 +3,7 @@ declare(strict_types=1); return [ + 'bridge_page_title' => '본인인증 결과', 'title' => 'KG이니시스 본인확인', 'description' => 'KG이니시스 통합인증의 본인확인 서비스를 G7 코어 본인인증 인프라에 연결하는 플러그인입니다.', @@ -17,7 +18,7 @@ return [ // 검증 에러 메시지의 필드 이름용 짧은 라벨 (화면 라벨의 부가 설명 "(SRB 프리픽스)" 제외) 'live_mid_attribute' => '라이브 MID', 'live_api_key_attribute' => '라이브 API 키', - 'duplicate_field' => '동일인 식별 기준', + 'duplicate_field' => '중복체크 기준', 'duplicate_block_enabled' => [ 'label' => '중복 가입 차단', 'description' => '활성화 시 본인인증을 통과한 사람이 이전에 다른 이메일로 가입한 적이 있으면 가입을 거부합니다. 가족 휴대폰 공유 또는 B2B 시나리오 등에서 한 사람이 여러 계정을 가입해야 한다면 비활성화하세요. 이 설정과 무관하게 동일 이메일 재가입은 항상 차단됩니다 (코어 기본 동작).', diff --git a/plugins/_bundled/sirsoft-verification_kginicis/package-lock.json b/plugins/_bundled/sirsoft-verification_kginicis/package-lock.json new file mode 100644 index 00000000..7105f07d --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_kginicis/package-lock.json @@ -0,0 +1,2022 @@ +{ + "name": "@g7/sirsoft-verification_kginicis", + "version": "1.0.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@g7/sirsoft-verification_kginicis", + "version": "1.0.2", + "devDependencies": { + "jsdom": "^27.4.0", + "typescript": "^5.3.3", + "vite": "^5.4.14", + "vitest": "^2.1.8" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.24", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz", + "integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/plugins/_bundled/sirsoft-verification_kginicis/package.json b/plugins/_bundled/sirsoft-verification_kginicis/package.json index 4a8c6579..d4bd1d1f 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/package.json +++ b/plugins/_bundled/sirsoft-verification_kginicis/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-verification_kginicis", - "version": "1.0.1", + "version": "1.0.2", "type": "module", "private": true, "scripts": { diff --git a/plugins/_bundled/sirsoft-verification_kginicis/plugin.json b/plugins/_bundled/sirsoft-verification_kginicis/plugin.json index e52cc5cb..8f448c28 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/plugin.json +++ b/plugins/_bundled/sirsoft-verification_kginicis/plugin.json @@ -5,13 +5,13 @@ "ko": "KG이니시스 본인인증", "en": "KG Inicis Identity Verification" }, - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "description": { "ko": "KG이니시스 통합인증의 본인확인(reqSvcCd=03)을 G7 코어 IDV 인프라에 Provider 로 등록하는 플러그인", "en": "KG Inicis Identity Verification (reqSvcCd=03) provider for G7 core IDV infrastructure" }, - "g7_version": ">=7.0.0", + "g7_version": ">=7.0.6", "dependencies": { "modules": {}, "plugins": {} diff --git a/plugins/_bundled/sirsoft-verification_kginicis/resources/js/__tests__/handlerReregistration.test.ts b/plugins/_bundled/sirsoft-verification_kginicis/resources/js/__tests__/handlerReregistration.test.ts new file mode 100644 index 00000000..b6886fc1 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_kginicis/resources/js/__tests__/handlerReregistration.test.ts @@ -0,0 +1,80 @@ +/** + * @file handlerReregistration.test.ts + * @description 코어 재초기화(로케일 전환 등) 후 startAuth 핸들러 재등록 회귀 검증 + * + * TemplateApp 은 ActionDispatcher 를 새로 만든 뒤 + * `window.__[Plugin].initPlugin()` 을 호출해 플러그인이 핸들러를 다시 등록하게 한다. + * 이 이름으로 노출하지 않으면 재초기화 이후 본인확인 시작 버튼이 무반응이 된다 + * (핸들러 미등록 → dispatch 무시 → 콘솔 에러도 토스트도 없음). + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { initPlugin, PLUGIN_IDENTIFIER } from '../index'; + +const HANDLER_NAME = `${PLUGIN_IDENTIFIER}.startAuth`; + +/** 코어 ActionDispatcher 를 흉내낸다 — 재초기화 시 새 인스턴스가 만들어진다. */ +function createDispatcher() { + const handlers = new Map(); + + return { + handlers, + registerHandler: vi.fn((name: string, handler: unknown) => { + handlers.set(name, handler); + }), + }; +} + +function installG7Core(dispatcher: ReturnType): void { + (window as any).G7Core = { + getActionDispatcher: () => dispatcher, + }; +} + +describe('코어 재초기화 시 핸들러 재등록', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + delete (window as any).G7Core; + vi.restoreAllMocks(); + }); + + it('window 전역에 initPlugin 을 노출한다 (코어가 이 이름으로만 재호출한다)', () => { + const exposed = (window as any).__SirsoftVerificationKginicis; + + expect(exposed).toBeDefined(); + expect(typeof exposed.initPlugin).toBe('function'); + }); + + it('새 ActionDispatcher 에 startAuth 핸들러를 다시 등록한다', () => { + const first = createDispatcher(); + installG7Core(first); + initPlugin(); + expect(first.handlers.has(HANDLER_NAME)).toBe(true); + + // 로케일 전환 — 코어가 ActionDispatcher 를 새로 만든다 (기존 핸들러 전부 소실) + const second = createDispatcher(); + installG7Core(second); + expect(second.handlers.has(HANDLER_NAME)).toBe(false); + + initPlugin(); + + expect(second.handlers.has(HANDLER_NAME)).toBe(true); + expect(typeof second.handlers.get(HANDLER_NAME)).toBe('function'); + }); + + it('재초기화 시점에 ActionDispatcher 가 아직 없으면 준비될 때까지 재시도한다', () => { + (window as any).G7Core = undefined; + + initPlugin(); + + const dispatcher = createDispatcher(); + installG7Core(dispatcher); + vi.advanceTimersByTime(100); + + expect(dispatcher.handlers.has(HANDLER_NAME)).toBe(true); + }); +}); diff --git a/plugins/_bundled/sirsoft-verification_kginicis/resources/js/index.ts b/plugins/_bundled/sirsoft-verification_kginicis/resources/js/index.ts index 4c0cb4da..11fb9de5 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/resources/js/index.ts +++ b/plugins/_bundled/sirsoft-verification_kginicis/resources/js/index.ts @@ -334,7 +334,10 @@ function registerHandlers(): boolean { return true; } -function init(): void { +/** + * ActionDispatcher 가 준비될 때까지 재시도하며 핸들러를 등록한다. + */ +function registerHandlersWithRetry(): void { if (registerHandlers()) return; let retries = 0; @@ -349,6 +352,22 @@ function init(): void { }, 100); } +/** + * 코어 재초기화 시 호출되는 진입점. + * + * 로케일 전환 등으로 TemplateApp 이 ActionDispatcher 를 새로 만들면 + * `TemplateApp.reinitializePluginHandlers()` 가 `window.__[Plugin].initPlugin()` 을 호출한다. + * 이 이름으로 노출하지 않으면 재초기화 후 startAuth 핸들러가 소실되어 + * 본인확인 시작 버튼이 무반응이 된다. + */ +function initPlugin(): void { + registerHandlersWithRetry(); +} + +function init(): void { + registerHandlersWithRetry(); +} + // 테스트 환경에서는 vitest 가 jsdom 으로 window 를 제공하지만 G7Core 를 직접 mock 하므로 // 자동 init 을 건너뛰도록 한다 (`import.meta.env.MODE === 'test'` 시). if (typeof import.meta === 'undefined' || (import.meta as any).env?.MODE !== 'test') { @@ -358,8 +377,10 @@ if (typeof import.meta === 'undefined' || (import.meta as any).env?.MODE !== 'te (window as any).__SirsoftVerificationKginicis = { identifier: PLUGIN_IDENTIFIER, init, + // 코어 재초기화 시 핸들러 재등록 진입점 — 이름 고정 (TemplateApp.reinitializePluginHandlers) + initPlugin, startAuthHandler, }; // 테스트 / 외부 도구가 import 로 직접 호출할 수 있도록 named export 도 노출 -export { startAuthHandler, init, PLUGIN_IDENTIFIER, PROVIDER_ID, MODAL_ID }; \ No newline at end of file +export { startAuthHandler, init, initPlugin, PLUGIN_IDENTIFIER, PROVIDER_ID, MODAL_ID }; \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-verification_kginicis/resources/lang/ko.json b/plugins/_bundled/sirsoft-verification_kginicis/resources/lang/ko.json index fad2be1b..4974d4d6 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-verification_kginicis/resources/lang/ko.json @@ -9,7 +9,7 @@ "description": "KG이니시스 통합인증의 본인확인 서비스를 G7 코어 본인인증 인프라에 연결하는 플러그인입니다.", "settings": { "title": "KG이니시스 본인확인 설정", - "description": "운영 모드와 라이브 자격증명, 동일인 식별 기준을 설정합니다.", + "description": "운영 모드와 라이브 자격증명, 중복 가입 차단 기준을 설정합니다.", "info": { "activation": "본인인증 활성화는 [환경설정 > 본인인증] 에서 설정합니다.", "logs": "이니시스 인증 이력은 [관리자 > 본인인증 이력] 에서 'KG이니시스 본인확인' 으로 필터하여 조회할 수 있습니다." @@ -23,13 +23,13 @@ "live_mid_hint": "이니시스 라이브 가맹점 MID 는 SRB 로 시작합니다 (예: SRB1234567). 운영 모드 사용 전 필수 입력.", "live_api_key": "라이브 API 키", "section": { - "duplicate_field": "동일인 식별" + "duplicate_field": "중복 가입 차단" }, "fields": { "duplicate_field": { "label": "중복체크 기준", - "hint": "DI 는 본 가맹점에서만 동일인 식별, CI 는 모든 기관에서 공통 식별 가능합니다. (기본값: DI)", - "warning": "운영 중 변경 시 기존 가입자의 동일인 체크가 일시적으로 불완전해질 수 있습니다." + "hint": "DI 는 본 가맹점에서만 중복 가입 여부를 확인하고, CI 는 모든 기관에서 공통으로 식별할 수 있습니다. (기본값: DI)", + "warning": "운영 중 변경 시 기존 가입자의 중복 체크가 일시적으로 불완전해질 수 있습니다." }, "duplicate_block_enabled": { "label": "중복 가입 차단", @@ -40,10 +40,10 @@ } }, "duplicate_field": { - "di": "DI (가맹점 종속 동일인 식별값)", - "ci": "CI (전 기관 공통 동일인 식별값)", - "hint": "DI 는 본 가맹점에서만 동일인 식별, CI 는 모든 기관에서 공통 식별 가능합니다.", - "warning": "운영 중 변경 시 기존 가입자의 동일인 체크가 일시적으로 불완전해질 수 있습니다." + "di": "DI (가맹점 종속 고유값)", + "ci": "CI (전 기관 공통 고유값)", + "hint": "DI 는 본 가맹점에서만 중복 가입 여부를 확인하고, CI 는 모든 기관에서 공통으로 식별할 수 있습니다.", + "warning": "운영 중 변경 시 기존 가입자의 중복 체크가 일시적으로 불완전해질 수 있습니다." } }, "modal": { diff --git a/plugins/_bundled/sirsoft-verification_kginicis/src/Http/Controllers/InicisPopupBridgeController.php b/plugins/_bundled/sirsoft-verification_kginicis/src/Http/Controllers/InicisPopupBridgeController.php index 4baaa04f..6de105b7 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/src/Http/Controllers/InicisPopupBridgeController.php +++ b/plugins/_bundled/sirsoft-verification_kginicis/src/Http/Controllers/InicisPopupBridgeController.php @@ -21,6 +21,10 @@ use Illuminate\Http\Response; * * @since 1.0.0-beta.1 */ +// audit:allow api-doc-coverage reason: 이 컨트롤러는 인증창 결과를 부모 창으로 넘기는 +// web 브리지 페이지이며 API 표면이 아니다(docs/api 에 수록된 엔드포인트가 아님). +// 이번 변경은 HTML /lang 을 현재 로케일로 맞춘 것으로 요청·응답 계약이 그대로다 +// (api:docgen --check 결과 drift 없음). class InicisPopupBridgeController extends PublicBaseController { /** @@ -62,13 +66,18 @@ class InicisPopupBridgeController extends PublicBaseController */ protected function renderBridgeHtml(string $payloadJson): string { + // 즉시 닫히는 중계 페이지지만, 로딩이 지연되면 브라우저 탭 제목으로 노출된다. + // 문서 언어와 제목을 현재 로케일에 맞춘다. + $locale = e(app()->getLocale()); + $title = e(__('sirsoft-verification_kginicis::messages.bridge_page_title')); + return <<<HTML <!DOCTYPE html> -<html lang="ko"> +<html lang="{$locale}"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width,initial-scale=1.0"> - <title>본인인증 결과 + {$title} + + +HTML; + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Controllers/KcpCallbackController.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Controllers/KcpCallbackController.php new file mode 100644 index 00000000..0956dfa8 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Controllers/KcpCallbackController.php @@ -0,0 +1,54 @@ +resolver->resolve( + callbackInput: $request->all(), + context: [ + 'ip_address' => $request->ip(), + 'user_agent' => substr((string) $request->userAgent(), 0, 512), + ], + ); + + $bridgeUrl = url('/plugins/sirsoft-verification_nhnkcp/plugin/nhnkcp/bridge') + .'?'.http_build_query($outcome->toBridgeQuery()); + + return redirect()->away($bridgeUrl); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Controllers/MyKcpIdentityShowController.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Controllers/MyKcpIdentityShowController.php new file mode 100644 index 00000000..fe6be290 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Controllers/MyKcpIdentityShowController.php @@ -0,0 +1,50 @@ +cardService->findForUser((int) Auth::id()); + + if ($record === null) { + return ResponseHelper::success('messages.success', null); + } + + return ResponseHelper::success( + 'messages.success', + (new KcpIdentityResource($record))->resolve(), + ); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Resources/KcpIdentityResource.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Resources/KcpIdentityResource.php new file mode 100644 index 00000000..8c65f442 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Resources/KcpIdentityResource.php @@ -0,0 +1,106 @@ + + */ + public function toArray($request): array + { + $verifiedAt = $this->resource->re_verified_at ?? $this->resource->verified_at; + $birthday = (string) $this->resource->birthday; + + return [ + 'method' => __('sirsoft-verification_nhnkcp::messages.card.method_value'), + 'verified_at' => $verifiedAt?->format('Y-m-d H:i:s'), + 'name_masked' => $this->maskName((string) $this->resource->name), + 'birthday_masked' => $this->maskBirthday($birthday), + 'phone_masked' => $this->maskPhone((string) $this->resource->phone), + 'is_adult' => (bool) $this->resource->is_adult, + // 생년월일이 없으면 성인 여부는 "아님"이 아니라 "확인 불가"다. 두 상태를 화면에서 + // 구분하지 못하면 미성년으로 잘못 안내된다 (테스트 응답에 생년월일이 빠지는 경우). + 'is_adult_known' => $birthday !== '', + 'is_foreigner' => (bool) $this->resource->is_foreigner, + ]; + } + + /** + * 실명 마스킹: 성과 끝 글자만 남기고 가운데를 가린다 (홍길동 → 홍*동). + * + * 본인이 자기 정보를 확인하는 화면이므로 "내 이름이 맞다" 를 알아볼 수 있어야 하고, + * 어깨너머로 전체 이름이 드러나서도 안 된다. 두 글자 이름은 가릴 가운데가 없으므로 + * 뒷 글자만 가린다 (김철 → 김*). + * + * @param string $name 실명 평문 + * @return string 마스킹된 실명 + */ + protected function maskName(string $name): string + { + $length = mb_strlen($name); + + if ($length === 0) { + return ''; + } + + if ($length === 1) { + return $name; + } + + if ($length === 2) { + return mb_substr($name, 0, 1).'*'; + } + + return mb_substr($name, 0, 1).str_repeat('*', $length - 2).mb_substr($name, -1); + } + + /** + * 생년월일 마스킹 (YYYYMMDD → YYYY-**-**). + * + * @param string $birthday 생년월일 평문 + * @return string 마스킹된 생년월일 + */ + protected function maskBirthday(string $birthday): string + { + if (strlen($birthday) < 4) { + return ''; + } + + return substr($birthday, 0, 4).'-**-**'; + } + + /** + * 휴대폰 마스킹 (01012345678 → 010-****-5678). + * + * @param string $phone 휴대폰 평문 + * @return string 마스킹된 휴대폰 + */ + protected function maskPhone(string $phone): string + { + $digits = preg_replace('/\D+/', '', $phone) ?? ''; + if (strlen($digits) < 7) { + return ''; + } + + $prefix = substr($digits, 0, 3); + $suffix = substr($digits, -4); + + return "{$prefix}-****-{$suffix}"; + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Identity/KcpIdentityProvider.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Identity/KcpIdentityProvider.php new file mode 100644 index 00000000..1aa00987 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Identity/KcpIdentityProvider.php @@ -0,0 +1,849 @@ + $config settings 주입값 (PluginSettingsService::get 결과) + */ + public function __construct( + protected readonly KcpCertClientInterface $client, + protected readonly IdentityVerificationLogRepositoryInterface $logRepository, + protected readonly KcpCertTransactionRepositoryInterface $transactionRepository, + protected readonly KcpIdentityRecordRepositoryInterface $recordRepository, + protected readonly KcpDuplicateIdentityChecker $duplicateChecker, + protected readonly CacheInterface $cache, + protected readonly array $config = [], + ) {} + + /** + * 프로바이더 식별자. + * + * @return string provider 식별자 + */ + public function getId(): string + { + return self::PROVIDER_ID; + } + + /** + * 관리자 UI 표시 라벨. + * + * @return string 관리자 UI 라벨 + */ + public function getLabel(): string + { + return __('sirsoft-verification_nhnkcp::messages.title'); + } + + /** + * 지원 채널. + * + * @return array + */ + public function getChannels(): array + { + return [self::CHANNEL]; + } + + /** + * 채널 키 → 다국어 표시 라벨 맵. + * + * @return array 채널 키 → 라벨 맵 + */ + public function getChannelLabels(): array + { + return [ + self::CHANNEL => __('sirsoft-verification_nhnkcp::messages.channels.phone'), + ]; + } + + /** + * 프론트엔드 모달 UI 분기 힌트. + * + * 'text_code' 반환 — 코어 모달의 `identity_provider_ui:provider` Extension Point 슬롯에 + * 본 plugin 의 extension JSON (mode: append) 이 안내 창을 주입하고, 코어 기본 OTP UI 는 + * provider_id 가 코어 mail 이 아니므로 자동으로 감춰진다. 사용자가 모달 안 버튼을 클릭해야 + * 표준창이 열려 브라우저 팝업 차단을 회피한다. + * + * @return string 프론트 렌더 힌트 + */ + public function getRenderHint(): string + { + return 'text_code'; + } + + /** + * 모든 purpose 지원 — 코어 4종 + 본 plugin 의 성인인증 + 타 확장이 등록한 커스텀 purpose. + * + * @param string $purpose IDV purpose + * @return bool 항상 true + */ + public function supportsPurpose(string $purpose): bool + { + return true; + } + + /** + * 운영 가능 여부 — 모드별 사이트코드/암호화 키 존재 + 라이브 프리픽스 검증. + * + * @return bool 운영 가능 여부 + */ + public function isAvailable(): bool + { + if ($this->isTestMode()) { + return $this->testSiteCd() !== '' && $this->testEncKey() !== ''; + } + + $liveSiteCd = $this->buildLiveSiteCd((string) ($this->config['live_site_cd'] ?? '')); + + return $liveSiteCd !== '' + && (string) ($this->config['live_enc_key'] ?? '') !== '' + && str_starts_with($liveSiteCd, self::LIVE_SITE_CD_PREFIX); + } + + /** + * Challenge 발행 — KCP 거래등록 실호출 후 표준창 호출 정보를 프론트에 전달한다. + * + * 거래등록이 실패하면 challenge 를 발행하지 않는다 (RemoteCallException) — 프론트가 + * 열 수 없는 표준창을 위해 로그 행만 남기는 것을 막는다. + * + * @param User|array $target 로그인 사용자 또는 가입 전 ['email' => string, ...] + * @param array $context origin_* / purpose / ip / user_agent + * @return VerificationChallenge 발행된 challenge + * + * @throws RemoteCallException 거래등록 통신 실패 또는 KCP 거절 + */ + public function requestChallenge(User|array $target, array $context = []): VerificationChallenge + { + $purpose = (string) ($context['purpose'] ?? 'signup'); + $email = $target instanceof User ? (string) $target->email : (string) ($target['email'] ?? ''); + $userId = $target instanceof User ? (int) $target->id : null; + $targetHash = hash('sha256', mb_strtolower($email)); + + $challengeId = (string) Str::uuid(); + $ordrIdxx = $this->generateOrdrIdxx(); + $ttlMinutes = (int) config('settings.identity.challenge_ttl_minutes', 15); + $expiresAt = Carbon::now()->addMinutes($ttlMinutes); + + // 거래등록 — challenge 행 생성 전에 호출하여 실패 시 흔적을 남기지 않는다. + // param_opt_1 에 challenge_id 를 실어, 콜백이 거래키를 echo 하지 않는 경우에도 역조회가 가능하다. + $registration = $this->client()->registerTrade( + ordrIdxx: $ordrIdxx, + retUrl: $this->callbackUrl(), + webSiteid: (string) ($this->config['web_siteid'] ?? ''), + paramOpts: [$challengeId], + ); + + if ($registration['res_cd'] !== KcpCertClient::RES_CD_SUCCESS + || $registration['call_url'] === '' + || $registration['reg_cert_key'] === '') { + Log::error('KCP 본인확인: 거래등록 실패', [ + 'res_cd' => $registration['res_cd'], + 'res_msg' => $registration['res_msg'], + 'purpose' => $purpose, + ]); + + throw new RemoteCallException( + '['.$registration['res_cd'].'] '.$registration['res_msg'], + ); + } + + $this->logRepository->create([ + 'id' => $challengeId, + 'provider_id' => self::PROVIDER_ID, + 'purpose' => $purpose, + 'channel' => self::CHANNEL, + 'user_id' => $userId, + 'target_hash' => $targetHash, + 'status' => 'sent', + 'render_hint' => $this->getRenderHint(), + 'attempts' => 0, + 'max_attempts' => 0, + 'ip_address' => $context['ip_address'] ?? null, + 'user_agent' => $context['user_agent'] ?? null, + 'origin_type' => $context['origin_type'] ?? null, + 'origin_identifier' => $context['origin_identifier'] ?? null, + 'origin_policy_key' => $context['origin_policy_key'] ?? null, + 'metadata' => [ + 'site_cd' => $this->resolveSiteCd(), + 'is_test_mode' => $this->isTestMode(), + 'ordr_idxx' => $ordrIdxx, + ], + 'expires_at' => $expiresAt, + ]); + + $this->transactionRepository->create($ordrIdxx, $registration['reg_cert_key'], $challengeId); + + return new VerificationChallenge( + id: $challengeId, + providerId: self::PROVIDER_ID, + purpose: $purpose, + channel: self::CHANNEL, + targetHash: $targetHash, + expiresAt: $expiresAt, + renderHint: $this->getRenderHint(), + redirectUrl: null, + publicPayload: [ + // 표준창 form POST 에 그대로 실리는 값 — KCP 규격상 브라우저가 전송하므로 비밀이 아니다. + 'call_url' => $registration['call_url'], + 'reg_cert_key' => $registration['reg_cert_key'], + 'is_test_mode' => $this->isTestMode(), + ], + metadata: ['ordr_idxx' => $ordrIdxx], + ); + } + + /** + * Challenge 검증 — 결과조회 응답을 받아 record upsert + verification_token 발급. + * + * `$input` 은 KcpCallbackResolver 가 콜백 body + 결과조회 응답을 합쳐 전달한다. + * + * @param string $challengeId IdentityVerificationLog UUID + * @param array $input 콜백 + 결과조회 합산 페이로드 + * @param array $context ip_address / user_agent + * @return VerificationResult 검증 결과 + * + * @throws AlreadyConsumedException 이미 처리된 challenge 재진입 시 + */ + public function verify(string $challengeId, array $input, array $context = []): VerificationResult + { + $log = $this->logRepository->findById($challengeId); + + if (! $log) { + return VerificationResult::failure( + challengeId: $challengeId, + providerId: self::PROVIDER_ID, + failureCode: 'NOT_FOUND', + failureReason: 'sirsoft-verification_nhnkcp::exceptions.not_found', + ); + } + + if ($log->isVerified() || $log->consumed_at !== null) { + throw new AlreadyConsumedException($challengeId); + } + + if ($log->expires_at !== null && Carbon::now()->greaterThan($log->expires_at)) { + $this->logRepository->updateById((string) $log->id, ['status' => 'expired']); + + return VerificationResult::failure( + challengeId: $challengeId, + providerId: self::PROVIDER_ID, + failureCode: 'EXPIRED', + failureReason: 'sirsoft-verification_nhnkcp::exceptions.not_found', + ); + } + + $resCd = (string) ($input['res_cd'] ?? ''); + + if ($resCd !== KcpCertClient::RES_CD_SUCCESS) { + // 실패 사유를 기록에 남겨 운영자가 인증 이력 화면에서 원인을 구분할 수 있게 한다. + $this->logRepository->updateById((string) $log->id, [ + 'status' => $resCd === '9999' ? 'cancelled' : 'failed', + 'metadata' => array_merge((array) $log->metadata, [ + 'failure_code' => $resCd !== '' ? $resCd : 'PROVIDER_ERROR', + ]), + ]); + + return VerificationResult::failure( + challengeId: $challengeId, + providerId: self::PROVIDER_ID, + failureCode: $resCd !== '' ? $resCd : 'PROVIDER_ERROR', + failureReason: (string) ($input['res_msg'] ?? ''), + ); + } + + $name = trim((string) ($input['user_name'] ?? '')); + $birthday = trim((string) ($input['birth_day'] ?? '')); + $phone = trim((string) ($input['phone_no'] ?? '')); + $ci = trim((string) ($input['ci'] ?? '')); + $di = trim((string) ($input['di'] ?? '')); + + // [신원 핵심값 가드] 결과조회가 성공(res_cd=0000)했더라도 신원 핵심값이 비어 있으면 + // 정상 응답이 아니므로 인증을 인정하지 않는다. 법규상 정상 본인확인이면 이름·생년월일· + // 휴대폰·CI·DI 는 항상 제공되며, 식별번호가 없는 자는 본인확인 자체가 성립하지 않는다 + // → 누락 = 비정상/위조 응답. 빈 식별값 record 가 생기면 중복가입 차단도 조용히 무력화된다. + // + // 생년월일만은 테스트 모드에서 예외로 둔다. KCP 규격상 `birth_day` 는 조건 없이 항상 + // 내려오는 표준 필드지만(공식 "본인확인 결과 복호화" 표), KCP 테스트 서버는 실제 통신사 + // 조회를 수행하지 않아 생년월일·성별·내외국인을 빈 값으로 응답한다. 운영과 동일하게 + // 거절하면 테스트 환경에서는 어떤 방법으로도 인증을 완주할 수 없어 검수가 불가능해진다. + // 운영 모드의 판정은 그대로 엄격하게 유지한다. + $birthdayRequired = ! $this->isTestMode(); + + if ($name === '' || ($birthdayRequired && $birthday === '') || $phone === '' || $ci === '' || $di === '') { + // 어느 항목이 비었는지 값 없이 항목명만 남긴다 — 운영자가 인증기관 계약(CI/DI 제공 여부 등) + // 문제인지 응답 이상인지 구분할 수 있어야 하며, PII 는 로그/기록에 남기지 않는다. + $missing = array_keys(array_filter([ + 'user_name' => $name === '', + 'birth_day' => $birthdayRequired && $birthday === '', + 'phone_no' => $phone === '', + 'ci' => $ci === '', + 'di' => $di === '', + ])); + + Log::warning('KCP 본인확인: 결과조회 응답에 신원 핵심값 누락', [ + 'challenge_id' => $challengeId, + 'missing_fields' => $missing, + 'returned_fields' => array_keys(array_filter( + $input, + static fn ($value, $key) => $key !== 'res_msg' && $value !== '' && $value !== null, + ARRAY_FILTER_USE_BOTH, + )), + ]); + + $this->logRepository->updateById((string) $log->id, [ + 'status' => 'failed', + 'metadata' => array_merge((array) $log->metadata, [ + 'failure_code' => 'INCOMPLETE_IDENTITY', + 'missing_fields' => $missing, + ]), + ]); + + return VerificationResult::failure( + challengeId: $challengeId, + providerId: self::PROVIDER_ID, + failureCode: 'INCOMPLETE_IDENTITY', + failureReason: 'sirsoft-verification_nhnkcp::exceptions.incomplete_identity', + ); + } + + // 테스트 모드에서 생년월일이 비어 통과한 경우 — 성인 여부를 계산할 수 없으므로 성인인증 + // 목적은 아래 가드에서 계속 실패한다. 운영자가 "왜 성인 확인이 안 되는가" 를 추적할 수 + // 있도록 사실만 남긴다 (값 없이). + if ($birthday === '') { + Log::warning('KCP 본인확인: 테스트 응답에 생년월일 없음 — 성인 여부 확인 불가', [ + 'challenge_id' => $challengeId, + 'purpose' => $log->purpose, + ]); + } + + $duplicateField = $this->resolveDuplicateField(); + $diHash = KcpIdentityHasher::hash($di); + $ciHash = KcpIdentityHasher::hash($ci); + $identityHash = $duplicateField === KcpDuplicateField::Di ? $diHash : $ciHash; + $isAdult = $this->isAdult($birthday); + + // [본인 계정 일치 가드] 로그인 사용자가 자기 본인확인을 수행하는 흐름에서, 이미 다른 + // 명의로 확정된 계정이면 verify 를 실패 처리한다. record 가 없으면(도입 이전 가입자 등) + // 통과시킨다 — permissive. + if ($log->user_id !== null) { + $existingRecord = $this->recordRepository->findByUserId((int) $log->user_id); + if ($existingRecord !== null) { + $storedHash = $duplicateField === KcpDuplicateField::Di + ? $existingRecord->di_hash + : $existingRecord->ci_hash; + + if ($storedHash !== null && $storedHash !== $identityHash) { + $this->markFailed($log, 'IDENTITY_BINDING_MISMATCH'); + + return VerificationResult::failure( + challengeId: $challengeId, + providerId: self::PROVIDER_ID, + failureCode: 'IDENTITY_BINDING_MISMATCH', + failureReason: 'sirsoft-verification_nhnkcp::exceptions.binding_mismatch', + ); + } + } + } + + // [성인인증 가드] 성인인증 purpose 의 challenge 는 만 19세 이상만 통과시킨다. + // 미성년자(생년월일 형식 오류 포함)는 본인확인 자체가 성공했더라도 이 목적에서는 + // 충족으로 인정하지 않는다 → status=failed 로 두어 토큰 미발급. + if ($log->purpose === self::ADULT_PURPOSE && ! $isAdult) { + $this->markFailed($log, 'NOT_ADULT'); + + return VerificationResult::failure( + challengeId: $challengeId, + providerId: self::PROVIDER_ID, + failureCode: 'NOT_ADULT', + failureReason: 'sirsoft-verification_nhnkcp::exceptions.not_adult', + ); + } + + // [중복 가입 조기 차단] 가입 목적의 비로그인 challenge 는 이 시점에 동일인 여부를 판정한다. + // 실제 가입 차단은 AssertNoDuplicateKcpIdentity listener 가 담당하며(권위 게이트), + // 판정 로직은 KcpDuplicateIdentityChecker 한 곳을 공유해 두 지점이 어긋나지 않는다. + if ($this->shouldBlockDuplicate($log)) { + $conflictColumn = $this->duplicateChecker->findConflictColumn( + $duplicateField, + $identityHash, + $log->user_id !== null ? (int) $log->user_id : null, + ); + + if ($conflictColumn !== null) { + $this->markFailed($log, 'DUPLICATE', ['matched_field' => $conflictColumn]); + + return VerificationResult::failure( + challengeId: $challengeId, + providerId: self::PROVIDER_ID, + failureCode: 'DUPLICATE', + failureReason: 'sirsoft-verification_nhnkcp::exceptions.duplicate_register', + ); + } + } + + $verificationToken = bin2hex(random_bytes(32)); + $verifiedAt = Carbon::now(); + $piiPayload = $this->buildPiiPayload($input, $diHash, $ciHash, $isAdult); + + // [저장 원자성] "인증 성공(토큰 발급)" 도장과 PII 저장은 한 단위로 묶는다. + // 토큰을 먼저 발급한 뒤 PII 저장이 실패하면 "인증은 성공인데 신원 record 가 없는" 모순이 + // 남는다 (로그인=record 없는 verified, 비로그인=Cache 부재로 가입 backfill 누락). + try { + DB::transaction(function () use ( + $log, + $verificationToken, + $verifiedAt, + $diHash, + $ciHash, + $duplicateField, + $isAdult, + $piiPayload, + $challengeId, + ): void { + $this->logRepository->updateById((string) $log->id, [ + 'status' => 'verified', + 'verification_token' => $verificationToken, + 'verified_at' => $verifiedAt, + 'metadata' => array_merge((array) $log->metadata, [ + 'di_hash' => $diHash, + 'ci_hash' => $ciHash, + 'duplicate_field_used' => $duplicateField->value, + 'is_adult' => $isAdult, + ]), + ]); + + if ($log->user_id !== null) { + // 로그인 사용자: record upsert 는 같은 트랜잭션 — 실패 시 log 갱신과 함께 롤백. + $this->recordRepository->upsertForUser((int) $log->user_id, array_merge($piiPayload, [ + 'latest_log_id' => $challengeId, + 'verified_at' => $verifiedAt, + 're_verified_at' => $verifiedAt, + ])); + + return; + } + + // 비로그인 사용자: Cache stash 는 DB 트랜잭션 밖이라 롤백 대상이 아니지만, + // put() 이 던지면 트랜잭션 콜백이 예외로 종료되어 log 갱신이 롤백된다. + $ttl = $log->expires_at + ? max(1, (int) Carbon::now()->diffInSeconds($log->expires_at, false)) + : 15 * 60; + $this->cache->put(self::PENDING_RECORD_CACHE_PREFIX.$challengeId, $piiPayload, $ttl); + }); + } catch (\Throwable $e) { + // 식별값(challenge_id)만 로깅 — PII 비노출. + Log::error('KCP 본인확인: 인증 성공 후 PII 저장 실패 — 토큰 발급 롤백', [ + 'challenge_id' => $challengeId, + 'is_login' => $log->user_id !== null, + 'error' => $e->getMessage(), + ]); + + return VerificationResult::failure( + challengeId: $challengeId, + providerId: self::PROVIDER_ID, + failureCode: 'STORAGE_FAILED', + failureReason: 'sirsoft-verification_nhnkcp::exceptions.storage_failed', + ); + } + + return VerificationResult::success( + challengeId: $challengeId, + providerId: self::PROVIDER_ID, + verifiedAt: $verifiedAt, + identityHash: $identityHash, + claims: ['verification_token' => $verificationToken], + ); + } + + /** + * Challenge 취소. + * + * @param string $challengeId IdentityVerificationLog UUID + * @return bool 취소 성공 여부 + */ + public function cancel(string $challengeId): bool + { + $log = $this->logRepository->findById($challengeId); + + if (! $log || $log->isVerified() || $log->consumed_at !== null) { + return false; + } + + $this->logRepository->updateById((string) $log->id, ['status' => 'cancelled']); + + $transaction = $this->transactionRepository->findByChallengeId($challengeId); + if ($transaction !== null) { + $this->transactionRepository->markConsumed((int) $transaction->id); + } + + return true; + } + + /** + * 관리자 [환경설정 > 본인인증 > 프로바이더 상세] 가 자동 렌더할 settings 스키마. + * + * @return array> + */ + public function getSettingsSchema(): array + { + return [ + 'is_test_mode' => [ + 'label' => __('sirsoft-verification_nhnkcp::messages.settings.test_mode'), + 'type' => 'boolean', + 'default' => true, + ], + 'test_site_cd' => [ + 'label' => __('sirsoft-verification_nhnkcp::messages.settings.test_site_cd'), + 'type' => 'text', + 'default' => self::TEST_SITE_CD, + ], + 'test_enc_key' => [ + 'label' => __('sirsoft-verification_nhnkcp::messages.settings.test_enc_key'), + 'type' => 'password', + 'default' => self::TEST_ENC_KEY, + ], + 'live_site_cd' => [ + 'label' => __('sirsoft-verification_nhnkcp::messages.settings.live_site_cd'), + 'type' => 'text', + 'default' => '', + ], + 'live_enc_key' => [ + 'label' => __('sirsoft-verification_nhnkcp::messages.settings.live_enc_key'), + 'type' => 'password', + 'default' => '', + ], + 'web_siteid' => [ + 'label' => __('sirsoft-verification_nhnkcp::messages.settings.web_siteid'), + 'type' => 'text', + 'default' => '', + ], + 'duplicate_field' => [ + 'label' => __('sirsoft-verification_nhnkcp::messages.settings.duplicate_field'), + 'type' => 'radio', + 'options' => ['di', 'ci'], + 'default' => 'di', + ], + 'duplicate_block_enabled' => [ + 'label' => __('sirsoft-verification_nhnkcp::messages.settings.duplicate_block_enabled.label'), + 'description' => __('sirsoft-verification_nhnkcp::messages.settings.duplicate_block_enabled.description'), + 'type' => 'toggle', + 'default' => true, + ], + ]; + } + + /** + * 설정값을 주입한 새 인스턴스 반환 (불변 복제 패턴). + * + * @param array $config 주입할 설정값 + * @return static 설정이 주입된 새 인스턴스 + */ + public function withConfig(array $config): static + { + return new static( + $this->client, + $this->logRepository, + $this->transactionRepository, + $this->recordRepository, + $this->duplicateChecker, + $this->cache, + array_merge($this->config, $config), + ); + } + + /** + * 현재 설정으로 자격증명이 주입된 KCP 게이트웨이를 반환한다. + * + * @return KcpCertClientInterface 자격증명이 주입된 게이트웨이 + */ + public function client(): KcpCertClientInterface + { + return $this->client->withCredentials( + $this->resolveSiteCd(), + $this->resolveEncKey(), + $this->isTestMode(), + ); + } + + /** + * 현재 모드의 사이트코드를 반환한다. + * + * @return string 현재 모드의 사이트코드 + */ + public function resolveSiteCd(): string + { + return $this->isTestMode() + ? $this->testSiteCd() + : $this->buildLiveSiteCd((string) ($this->config['live_site_cd'] ?? '')); + } + + /** + * 현재 모드의 암호화 키를 반환한다. + * + * @return string 현재 모드의 암호화 키 + */ + public function resolveEncKey(): string + { + return $this->isTestMode() + ? $this->testEncKey() + : (string) ($this->config['live_enc_key'] ?? ''); + } + + /** + * 라이브 사이트코드에 프리픽스(self::LIVE_SITE_CD_PREFIX)를 보장한다. + * + * 운영자가 설정 UI 에서 프리픽스 분리형 input 으로 입력하므로 일반적으로 프리픽스 미포함 + * 값이 들어오지만, 직접 프리픽스를 포함해 입력한 경우에도 중복 부착하지 않는다. + * + * @param string $value 운영자 입력 값 + * @return string 프리픽스가 보장된 라이브 사이트코드 + */ + public function buildLiveSiteCd(string $value): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + + return str_starts_with($value, self::LIVE_SITE_CD_PREFIX) + ? $value + : self::LIVE_SITE_CD_PREFIX.$value; + } + + /** + * 생년월일 (YYYYMMDD) 기준 만 19세 이상 여부를 반환한다 (생일 당일 포함). + * + * @param string $birthday YYYYMMDD + * @return bool 만 19세 이상 여부 + */ + public function isAdult(string $birthday): bool + { + if (! preg_match('/^\d{8}$/', $birthday)) { + return false; + } + + try { + $birth = Carbon::createFromFormat('Ymd', $birthday)->startOfDay(); + + return $birth->age >= self::ADULT_AGE; + } catch (\Throwable $e) { + Log::warning('KCP 본인확인: 생년월일 파싱 실패', ['error' => $e->getMessage()]); + + return false; + } + } + + /** + * 테스트 모드 여부. + * + * @return bool 테스트 모드면 true (설정이 없으면 안전하게 테스트로 간주) + */ + protected function isTestMode(): bool + { + return (bool) ($this->config['is_test_mode'] ?? true); + } + + /** + * 테스트 사이트코드 (미설정 시 KCP 공개 테스트 값). + * + * @return string 테스트 환경 사이트코드 + */ + protected function testSiteCd(): string + { + return (string) ($this->config['test_site_cd'] ?? ''); + } + + /** + * 테스트 암호화 키 (미설정 시 KCP 공개 테스트 값). + * + * @return string 테스트 환경 암호화 키 + */ + protected function testEncKey(): string + { + return (string) ($this->config['test_enc_key'] ?? ''); + } + + /** + * 운영자가 선택한 동일인 판정 기준. + * + * @return KcpDuplicateField 중복 가입 차단에 사용할 식별값 (DI 또는 CI) + */ + protected function resolveDuplicateField(): KcpDuplicateField + { + return KcpDuplicateField::tryFrom((string) ($this->config['duplicate_field'] ?? 'di')) + ?? KcpDuplicateField::Di; + } + + /** + * 인증을 실패로 마감하면서 그 사유를 기록에 남긴다. + * + * 상태만 실패로 바꾸면 관리자 인증 이력 화면에서 "왜 실패했는지" 를 알 수 없다. 사유 코드와 + * 부가 정보(어느 식별 기준으로 막혔는지 등)를 함께 남겨 운영자가 문의에 답할 수 있게 한다. + * 개인정보는 남기지 않는다 — 코드와 항목명만 기록한다. + * + * @param IdentityVerificationLog $log 대상 로그 + * @param string $failureCode 실패 사유 코드 + * @param array $extra 사유별 부가 정보 (PII 제외) + */ + protected function markFailed(IdentityVerificationLog $log, string $failureCode, array $extra = []): void + { + $this->logRepository->updateById((string) $log->id, [ + 'status' => 'failed', + 'metadata' => array_merge((array) $log->metadata, ['failure_code' => $failureCode], $extra), + ]); + } + + /** + * 이 challenge 가 중복 가입 차단 대상인지 판정한다. + * + * 가입(signup) 목적 + 비로그인 challenge 에만 적용한다 — 로그인 사용자의 재인증이나 + * 비밀번호 재설정은 동일인이 존재하는 것이 정상이다. + * + * @param IdentityVerificationLog $log 대상 로그 + * @return bool 차단 판정 수행 여부 + */ + protected function shouldBlockDuplicate(IdentityVerificationLog $log): bool + { + return (bool) ($this->config['duplicate_block_enabled'] ?? true) + && $log->purpose === 'signup' + && $log->user_id === null; + } + + /** + * KCP 콜백을 수신할 절대 URL 을 반환한다. + * + * @return string 거래등록 시 Ret_URL 로 전달할 콜백 절대 URL + */ + protected function callbackUrl(): string + { + return url('/plugins/sirsoft-verification_nhnkcp/plugin/nhnkcp/callback'); + } + + /** + * 가맹점 주문번호(ordr_idxx)를 생성한다 — 유니크, 50자 이내. + * + * @return string 생성된 가맹점 주문번호 + */ + protected function generateOrdrIdxx(): string + { + return 'G7'.Carbon::now()->format('YmdHis').strtoupper(Str::random(12)); + } + + /** + * 결과조회 응답에서 record UPSERT 용 PII 페이로드를 구성한다. + * 평문 PII 는 Crypt 로 암호화하여 저장한다. + * + * @param array $input 콜백 + 결과조회 합산 페이로드 + * @param string $diHash DI keyed-hash + * @param string $ciHash CI keyed-hash + * @param bool $isAdult 성인 여부 + * @return array Repository::upsertForUser 에 전달할 컬럼 값 + */ + protected function buildPiiPayload(array $input, string $diHash, string $ciHash, bool $isAdult): array + { + $name = trim((string) ($input['user_name'] ?? '')); + $phone = trim((string) ($input['phone_no'] ?? '')); + $birthday = trim((string) ($input['birth_day'] ?? '')); + $di = trim((string) ($input['di'] ?? '')); + $ci = trim((string) ($input['ci'] ?? '')); + $commId = trim((string) ($input['comm_id'] ?? '')); + $sexCode = trim((string) ($input['sex_code'] ?? '')); + $localCode = trim((string) ($input['local_code'] ?? '')); + + return [ + 'comm_id' => $commId !== '' ? $commId : null, + // 누락 필드는 "암호화된 빈 문자열" 찌꺼기 대신 null 로 저장한다. 정상 경로에서는 + // verify() 의 신원 핵심값 가드가 누락을 이미 차단하므로 아래 null 분기는 방어적 처리다. + 'name_encrypted' => $name !== '' ? Crypt::encryptString($name) : null, + 'phone_encrypted' => $phone !== '' ? Crypt::encryptString($phone) : null, + 'birthday_encrypted' => $birthday !== '' ? Crypt::encryptString($birthday) : null, + 'di_encrypted' => $di !== '' ? Crypt::encryptString($di) : null, + 'di_hash' => $di !== '' ? $diHash : null, + 'ci_encrypted' => $ci !== '' ? Crypt::encryptString($ci) : null, + 'ci_hash' => $ci !== '' ? $ciHash : null, + 'gender' => $sexCode === '01' ? 'M' : ($sexCode === '02' ? 'F' : null), + 'is_foreigner' => $localCode === '02', + 'is_adult' => $isAdult, + ]; + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/AssertNoDuplicateKcpIdentity.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/AssertNoDuplicateKcpIdentity.php new file mode 100644 index 00000000..b1263944 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/AssertNoDuplicateKcpIdentity.php @@ -0,0 +1,137 @@ +> + */ + public static function getSubscribedHooks(): array + { + return [ + 'core.auth.before_register' => [ + 'method' => 'handle', + 'priority' => 20, + 'sync' => true, // 인라인 가드 — 실패 시 가입 즉시 중단 + ], + ]; + } + + /** + * core.auth.before_register 훅 핸들러. + * + * @param mixed ...$args [0]=array $data 가입 요청 데이터 + * + * @throws IdentityDuplicateException 동일 DI/CI hash 매칭 시 + */ + public function handle(...$args): void + { + if (! (bool) g7_plugin_settings(self::PLUGIN_IDENTIFIER, 'duplicate_block_enabled', true)) { + return; + } + + $data = $args[0] ?? []; + if (! is_array($data)) { + return; + } + + $token = (string) ($data['verification_token'] ?? ''); + $log = $this->logQueryRepository->findVerifiedLogForToken($token, 'signup'); + if ($log === null) { + return; + } + + // 본 plugin 책임 영역 분기 — 다른 provider 의 verify 는 통과시킨다. + if ((string) $log->provider_id !== KcpIdentityProvider::PROVIDER_ID) { + return; + } + + $duplicateField = KcpDuplicateField::tryFrom( + (string) g7_plugin_settings(self::PLUGIN_IDENTIFIER, 'duplicate_field', 'di') + ) ?? KcpDuplicateField::Di; + + $metadata = is_array($log->metadata) ? $log->metadata : []; + $hash = $this->extractHash($metadata, $duplicateField); + + // hash 부재 시 통과 (식별값 미발급 케이스 — 의도된 통과) + if ($hash === null) { + return; + } + + $conflictColumn = $this->duplicateChecker->findConflictColumn($duplicateField, $hash); + + if ($conflictColumn === null) { + return; + } + + // 운영자 감사 추적용 — 어느 기준으로 차단됐는지 로그 metadata 에 보존한다. + // 보조 추적이므로 실패해도 차단 흐름은 그대로 진행한다. + try { + $this->logQueryRepository->appendMetadata((string) $log->id, [ + 'matched_field' => $conflictColumn, + 'matched_user_id' => $this->duplicateChecker->findConflictUserId($duplicateField, $hash), + ]); + } catch (\Throwable $e) { + // metadata 기록 실패는 차단 의도에 영향 없음 — 무시 + } + + throw new IdentityDuplicateException($conflictColumn); + } + + /** + * log.metadata 에서 duplicate_field 에 해당하는 hash 를 추출한다. + * + * @param array $metadata 로그 metadata + * @param KcpDuplicateField $field 판정 기준 + * @return string|null hash 또는 null + */ + protected function extractHash(array $metadata, KcpDuplicateField $field): ?string + { + $key = $field === KcpDuplicateField::Di ? 'di_hash' : 'ci_hash'; + $hash = $metadata[$key] ?? null; + + return is_string($hash) && $hash !== '' ? $hash : null; + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/CleanKcpRecordOnUserDelete.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/CleanKcpRecordOnUserDelete.php new file mode 100644 index 00000000..f112c013 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/CleanKcpRecordOnUserDelete.php @@ -0,0 +1,67 @@ +> + */ + public static function getSubscribedHooks(): array + { + return [ + 'core.user.before_delete' => [ + 'method' => 'handle', + 'priority' => 50, + 'sync' => true, + ], + ]; + } + + /** + * 훅 진입점. + * + * @param mixed ...$args [$user] — 삭제 직전의 User 모델 + */ + public function handle(...$args): void + { + $user = $args[0] ?? null; + if (! $user instanceof User) { + return; + } + + $userId = (int) $user->id; + if ($userId <= 0) { + return; + } + + $this->recordRepository->deleteByUserId($userId); + $this->logQueryRepository->anonymizeUserId($userId); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/CleanKcpRecordOnUserWithdraw.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/CleanKcpRecordOnUserWithdraw.php new file mode 100644 index 00000000..8d10a1bc --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/CleanKcpRecordOnUserWithdraw.php @@ -0,0 +1,65 @@ +> + */ + public static function getSubscribedHooks(): array + { + return [ + 'core.user.after_withdraw' => [ + 'method' => 'handle', + 'priority' => 50, + 'sync' => true, + ], + ]; + } + + /** + * 훅 진입점. + * + * @param mixed ...$args [$user] + */ + public function handle(...$args): void + { + $user = $args[0] ?? null; + if (! $user instanceof User) { + return; + } + + $userId = (int) $user->id; + if ($userId <= 0) { + return; + } + + $this->recordRepository->deleteByUserId($userId); + $this->logQueryRepository->anonymizeUserId($userId); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/CompleteKcpRecordAfterRegister.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/CompleteKcpRecordAfterRegister.php new file mode 100644 index 00000000..20bd9d93 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/CompleteKcpRecordAfterRegister.php @@ -0,0 +1,138 @@ +> + */ + public static function getSubscribedHooks(): array + { + return [ + 'core.auth.after_register' => [ + 'method' => 'handle', + 'priority' => 50, + 'sync' => true, + ], + ]; + } + + /** + * 훅 진입점. + * + * @param mixed ...$args [$user, $context] + */ + public function handle(...$args): void + { + /** @var User|null $user */ + $user = $args[0] ?? null; + /** @var array $context */ + $context = is_array($args[1] ?? null) ? $args[1] : []; + + if (! $user instanceof User) { + return; + } + + $token = isset($context['verification_token']) ? (string) $context['verification_token'] : ''; + if ($token === '') { + return; + } + + // 코어 priority 10 listener 가 token 검증 후 consumed_at 을 set 하므로 코어 표준 + // Repository(findVerifiedForToken)는 null 을 반환한다. 본 plugin Repository 는 + // consumed_at 무관 조회를 제공한다. + $log = $this->logQueryRepository->findVerifiedLogForToken($token, 'signup'); + if ($log === null) { + return; + } + + if ((string) $log->provider_id !== KcpIdentityProvider::PROVIDER_ID) { + return; + } + + $cacheKey = KcpIdentityProvider::PENDING_RECORD_CACHE_PREFIX.$log->id; + $piiPayload = $this->cache->get($cacheKey); + + if (! is_array($piiPayload) || $piiPayload === []) { + Log::warning('KCP 비로그인 가입 backfill: Cache PII 부재', [ + 'log_id' => $log->id, + 'user_id' => $user->id, + ]); + + return; + } + + $verifiedAt = $log->verified_at; + + // 저장 단계가 부분 실패하더라도 (1) 실패를 운영자가 인지할 수 있게 로깅하고 + // (2) stash 된 PII 캐시는 반드시 정리한다 (TTL 만료까지 PII 잔류 방지 — PIPC). + // record upsert 실패는 재시도 여지가 있어 예외를 전파하지 않고(가입 트랜잭션 보호) 로깅만 한다. + try { + $this->recordRepository->upsertForUser((int) $user->id, array_merge($piiPayload, [ + 'latest_log_id' => $log->id, + 'verified_at' => $verifiedAt, + 're_verified_at' => $verifiedAt, + ])); + + try { + $this->logRepository->backfillUserId($log->id, (int) $user->id); + } catch (\Throwable $e) { + // 역참조(log.user_id) 누락은 PII 저장 정합성에 영향 없음 — 감사 추적 손실만 발생. + Log::error('KCP 비로그인 가입 backfill: log.user_id 역참조 실패', [ + 'log_id' => $log->id, + 'user_id' => $user->id, + 'error' => $e->getMessage(), + ]); + } + } catch (\Throwable $e) { + Log::error('KCP 비로그인 가입 backfill: PII record 저장 실패', [ + 'log_id' => $log->id, + 'user_id' => $user->id, + 'error' => $e->getMessage(), + ]); + } finally { + // 성공/실패 무관하게 stash PII 캐시는 정리 — 잔류 시 다음 가입에서 stale PII 회수 위험. + $this->cache->forget($cacheKey); + } + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/RegisterKcpProviderListener.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/RegisterKcpProviderListener.php new file mode 100644 index 00000000..4e3fa39c --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/RegisterKcpProviderListener.php @@ -0,0 +1,76 @@ +> + */ + public static function getSubscribedHooks(): array + { + return [ + 'core.identity.registered_providers' => [ + 'method' => 'register', + 'priority' => 20, + 'type' => 'filter', + 'sync' => true, + ], + ]; + } + + /** + * 인터페이스 표준 진입점 — getSubscribedHooks 가 method='register' 를 명시하므로 미사용. + * HookListenerInterface 의 추상 메서드 충족 목적으로만 정의한다. + * + * @param mixed ...$args 사용 안 함 + */ + public function handle(...$args): void + { + // no-op — 실제 진입점은 register() 메서드 (filter 훅) + } + + /** + * Provider 인스턴스를 레지스트리에 추가한다. + * + * settings 는 `PluginSettingsService::get()` 으로 매 요청마다 직접 조회한다 (코어 + * PluginSettingsService::save() 가 인메모리 캐시만 초기화하므로 config() 사용 시 변경된 + * settings 가 다음 요청에 반영되지 않는다). + * + * @param array $providers 현재 레지스트리 + * @return array 본 provider 가 추가된 레지스트리 + */ + public function register(array $providers): array + { + $settings = app(PluginSettingsService::class)->get(self::IDENTIFIER); + + // 컨테이너 해석에 위임해 ServiceProvider 의 contextual binding(cacheServices)으로 + // 본 플러그인 도메인의 캐시가 자동 주입되도록 한다. config 만 동적 값이라 makeWith 로 주입. + $provider = app()->makeWith(KcpIdentityProvider::class, [ + 'config' => is_array($settings) ? $settings : [], + ]); + + $providers[KcpIdentityProvider::PROVIDER_ID] = $provider; + + return $providers; + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/ValidateKcpSettingsListener.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/ValidateKcpSettingsListener.php new file mode 100644 index 00000000..0b5a73bd --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Listeners/ValidateKcpSettingsListener.php @@ -0,0 +1,123 @@ +> + */ + public static function getSubscribedHooks(): array + { + return [ + 'core.plugin_settings.update_rules' => [ + 'method' => 'addLiveModeRules', + 'priority' => 10, + 'type' => 'filter', + 'sync' => true, + ], + ]; + } + + /** + * 인터페이스 표준 진입점 — getSubscribedHooks 가 method='addLiveModeRules' 를 명시하므로 미사용. + * + * @param mixed ...$args 사용 안 함 + */ + public function handle(...$args): void + { + // no-op — 실제 진입점은 addLiveModeRules() 메서드 (filter 훅) + } + + /** + * 라이브 모드 진입 시 라이브 자격증명에 required 규칙을 부여한다. + * + * @param array $rules 코어가 스키마로 생성한 검증 규칙 + * @param string $identifier 검증 대상 플러그인 식별자 + * @param array $input 이번 요청의 입력값 (코어가 전달) + * @return array 조정된 검증 규칙 + */ + public function addLiveModeRules(array $rules, string $identifier, array $input = []): array + { + if ($identifier !== self::IDENTIFIER) { + return $rules; + } + + // is_test_mode 가 명시적으로 false 일 때만 라이브 모드로 간주한다. + // 입력값은 코어 UpdatePluginSettingsRequest 가 본 필터의 3번째 인자로 넘겨준다 — + // "현재 입력한 모드에 따라 필수 항목이 달라지는" 조건부 규칙을 만들기 위한 값이며, + // 확장이 request() 를 직접 들여다볼 필요가 없다. + $isTestMode = filter_var( + $input['is_test_mode'] ?? true, + FILTER_VALIDATE_BOOLEAN, + FILTER_NULL_ON_FAILURE + ); + + if ($isTestMode !== false) { + return $rules; + } + + // 라이브 모드 검증이 확정된 이 시점(HTTP 요청 처리 중)에 현재 로케일로 항목 라벨을 + // 등록한다. 요청 처리 시점에는 플러그인 lang 네임스페이스가 준비돼 있어 폴백 없이 + // 정확한 로케일 라벨이 잡힌다. + $this->registerLiveCredentialAttributeLabels(); + + foreach (['live_site_cd', 'live_enc_key'] as $field) { + $fieldRules = (array) ($rules[$field] ?? []); + // 코어가 부여한 nullable 을 제거하고 required 로 강제한다. + $fieldRules = array_values(array_filter( + $fieldRules, + static fn ($rule) => $rule !== 'nullable' + )); + if (! in_array('required', $fieldRules, true)) { + array_unshift($fieldRules, 'required'); + } + $rules[$field] = $fieldRules; + } + + return $rules; + } + + /** + * 라이브 자격증명 필드의 검증 에러 메시지 항목 라벨을 현재 요청 로케일로 등록한다. + * + * 코어 검증 에러 메시지에서 항목 이름이 영문 키(`live site cd`)로 노출되지 않도록 전역 + * validation.attributes 에 표시 이름을 런타임 병합한다. 코어 검증기를 수정하지 않고 + * Laravel 의 attribute 해석 메커니즘만 활용한다. + */ + private function registerLiveCredentialAttributeLabels(): void + { + $locale = app()->getLocale(); + + // addLines 가 validation 그룹을 attributes 만 든 빈 껍데기로 조기 캐시하면, 표준 + // Translator 의 isLoaded 가드로 인해 validation.php 전체(required 등)가 로드되지 않는 + // 회귀가 발생한다. 따라서 addLines 전에 validation 그룹을 먼저 로드시킨다. + Lang::get('validation.required', [], $locale); + + Lang::addLines([ + 'validation.attributes.live_site_cd' => __(self::IDENTIFIER.'::messages.settings.live_site_cd_attribute', [], $locale), + 'validation.attributes.live_enc_key' => __(self::IDENTIFIER.'::messages.settings.live_enc_key_attribute', [], $locale), + ], $locale); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Models/KcpCertTransaction.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Models/KcpCertTransaction.php new file mode 100644 index 00000000..f6a63bb7 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Models/KcpCertTransaction.php @@ -0,0 +1,69 @@ + + */ + protected $fillable = [ + 'ordr_idxx', + 'reg_cert_key_hash', + 'reg_cert_key_encrypted', + 'challenge_id', + 'consumed_at', + ]; + + /** + * 속성 캐스팅. + * + * @return array + */ + protected function casts(): array + { + return [ + 'created_at' => 'datetime', + 'consumed_at' => 'datetime', + ]; + } + + /** + * 매핑된 코어 challenge 로그. + * + * @return BelongsTo + */ + public function challenge(): BelongsTo + { + return $this->belongsTo(IdentityVerificationLog::class, 'challenge_id'); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Models/KcpIdentityRecord.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Models/KcpIdentityRecord.php new file mode 100644 index 00000000..e3709e8c --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Models/KcpIdentityRecord.php @@ -0,0 +1,138 @@ + + */ + protected $fillable = [ + 'user_id', + 'latest_log_id', + 'comm_id', + 'name_encrypted', + 'phone_encrypted', + 'birthday_encrypted', + 'di_encrypted', + 'di_hash', + 'ci_encrypted', + 'ci_hash', + 'gender', + 'is_foreigner', + 'is_adult', + 'verified_at', + 're_verified_at', + ]; + + /** + * 속성 캐스팅. + * + * @return array + */ + protected function casts(): array + { + return [ + 'is_foreigner' => 'boolean', + 'is_adult' => 'boolean', + 'verified_at' => 'datetime', + 're_verified_at' => 'datetime', + ]; + } + + /** + * 레코드 소유 사용자. + * + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * 실명 평문 접근자 (Crypt 복호화). + * + * @return Attribute 복호화된 실명을 돌려주는 접근자 + */ + protected function name(): Attribute + { + return Attribute::make( + get: fn () => $this->name_encrypted ? Crypt::decryptString($this->name_encrypted) : null, + ); + } + + /** + * 휴대폰 평문 접근자. + * + * @return Attribute 복호화된 휴대폰 번호를 돌려주는 접근자 + */ + protected function phone(): Attribute + { + return Attribute::make( + get: fn () => $this->phone_encrypted ? Crypt::decryptString($this->phone_encrypted) : null, + ); + } + + /** + * 생년월일 평문 접근자 (YYYYMMDD). + * + * @return Attribute 복호화된 생년월일을 돌려주는 접근자 (미수신 시 빈 문자열) + */ + protected function birthday(): Attribute + { + return Attribute::make( + get: fn () => $this->birthday_encrypted ? Crypt::decryptString($this->birthday_encrypted) : null, + ); + } + + /** + * DI 평문 접근자. + * + * @return Attribute 복호화된 DI(사이트별 고유 식별값)를 돌려주는 접근자 + */ + protected function di(): Attribute + { + return Attribute::make( + get: fn () => $this->di_encrypted ? Crypt::decryptString($this->di_encrypted) : null, + ); + } + + /** + * CI 평문 접근자. + * + * @return Attribute 복호화된 CI(서비스 공통 고유 식별값)를 돌려주는 접근자 + */ + protected function ci(): Attribute + { + return Attribute::make( + get: fn () => $this->ci_encrypted ? Crypt::decryptString($this->ci_encrypted) : null, + ); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Providers/KcpVerificationServiceProvider.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Providers/KcpVerificationServiceProvider.php new file mode 100644 index 00000000..c17ff017 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Providers/KcpVerificationServiceProvider.php @@ -0,0 +1,80 @@ + + */ + protected array $repositories = [ + KcpIdentityRecordRepositoryInterface::class => KcpIdentityRecordRepository::class, + KcpCertTransactionRepositoryInterface::class => KcpCertTransactionRepository::class, + KcpIdentityLogQueryRepositoryInterface::class => KcpIdentityLogQueryRepository::class, + ]; + + /** + * 플러그인 캐시 도메인이 필요한 서비스. + * + * 글로벌 `CacheInterface::class` 바인딩을 덮어쓰지 않고 contextual binding 으로만 + * 격리된 캐시를 주입한다. + * + * @var array + */ + protected array $cacheServices = [ + KcpIdentityProvider::class, + CompleteKcpRecordAfterRegister::class, + ]; + + /** + * 컨테이너 바인딩 등록. + */ + public function register(): void + { + parent::register(); + + $this->app->singleton(KcpCertClientInterface::class, KcpCertClient::class); + $this->app->bind(KcpDuplicateIdentityChecker::class); + $this->app->bind(KcpCallbackResolver::class); + $this->app->bind(KcpIdentityCardService::class); + } + + /** + * 플러그인 부팅. + */ + public function boot(): void + { + if (method_exists(get_parent_class($this), 'boot')) { + parent::boot(); + } + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpCertTransactionRepository.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpCertTransactionRepository.php new file mode 100644 index 00000000..7be2557b --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpCertTransactionRepository.php @@ -0,0 +1,118 @@ +create([ + 'ordr_idxx' => $ordrIdxx, + 'reg_cert_key_hash' => KcpIdentityHasher::hashRegCertKey($regCertKey), + 'reg_cert_key_encrypted' => Crypt::encryptString($regCertKey), + 'challenge_id' => $challengeId, + ]); + } + + /** + * 거래키 평문으로 거래 행을 조회한다 (콜백 역조회). + * + * @param string $regCertKey 콜백으로 수신한 거래키 평문 + * @return KcpCertTransaction|null 거래 행 또는 null + */ + public function findByRegCertKey(string $regCertKey): ?KcpCertTransaction + { + if ($regCertKey === '') { + return null; + } + + return KcpCertTransaction::query() + ->where('reg_cert_key_hash', KcpIdentityHasher::hashRegCertKey($regCertKey)) + ->first(); + } + + /** + * challenge_id 로 거래 행을 조회한다 (콜백 param_opt 폴백 / 감사). + * + * @param string $challengeId 코어 IdentityVerificationLog UUID + * @return KcpCertTransaction|null 거래 행 또는 null + */ + public function findByChallengeId(string $challengeId): ?KcpCertTransaction + { + if ($challengeId === '') { + return null; + } + + return KcpCertTransaction::query() + ->where('challenge_id', $challengeId) + ->first(); + } + + /** + * 거래 행의 거래키 평문을 복호화하여 반환한다. + * + * @param KcpCertTransaction $transaction 거래 행 + * @return string 거래키 평문 (복호화 실패 시 빈 문자열) + */ + public function decryptRegCertKey(KcpCertTransaction $transaction): string + { + $encrypted = (string) $transaction->reg_cert_key_encrypted; + + if ($encrypted === '') { + return ''; + } + + try { + return Crypt::decryptString($encrypted); + } catch (\Throwable $e) { + // 식별자만 로깅 — 거래키 평문/암호문은 절대 로깅하지 않는다. + Log::error('KCP 본인확인: 거래키 복호화 실패', [ + 'transaction_id' => $transaction->id, + 'challenge_id' => $transaction->challenge_id, + 'error' => $e->getMessage(), + ]); + + return ''; + } + } + + /** + * 거래를 소비 처리한다 (결과조회 1회 사용 마킹 — 재사용 차단). + * + * consumed_at IS NULL 조건부 UPDATE 로 원자적으로 마킹한다. 동시에 두 콜백이 도착해도 + * 한쪽만 true 를 받으므로, 호출자는 false 를 replay 로 판정하면 된다. + * + * @param int $transactionId 거래 행 ID + * @return bool 마킹 성공 여부 (이미 소비된 경우 false) + */ + public function markConsumed(int $transactionId): bool + { + $affected = KcpCertTransaction::query() + ->whereKey($transactionId) + ->whereNull('consumed_at') + ->update(['consumed_at' => now()]); + + return $affected > 0; + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpCertTransactionRepositoryInterface.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpCertTransactionRepositoryInterface.php new file mode 100644 index 00000000..f42da5fc --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpCertTransactionRepositoryInterface.php @@ -0,0 +1,55 @@ +where('verification_token', $token) + ->where('purpose', $purpose) + ->where('status', IdentityVerificationStatus::Verified->value) + ->first(); + } + + /** + * 탈퇴/삭제 사용자의 본 plugin 발행 로그를 익명화한다. + * + * user_id 를 NULL 로 비우는 동시에, 로그 metadata 에 남은 식별자 해시(di_hash / ci_hash)를 + * 함께 파기한다. CI/DI 해시는 동일인 추적에 쓰일 수 있어 탈퇴자에게도 잔존하면 안 되며 + * (PIPC 파기 의무), 감사 추적 필드(matched_field / duplicate_field_used 등)는 보존한다. + * + * @param int $userId 탈퇴/삭제 사용자 id + * @return int 익명화된 row 수 + */ + public function anonymizeUserId(int $userId): int + { + $logs = IdentityVerificationLog::query() + ->where('user_id', $userId) + ->where('provider_id', KcpIdentityProvider::PROVIDER_ID) + ->get(); + + foreach ($logs as $log) { + $metadata = is_array($log->metadata) ? $log->metadata : []; + unset($metadata['di_hash'], $metadata['ci_hash']); + + $log->user_id = null; + $log->metadata = $metadata !== [] ? $metadata : null; + $log->save(); + } + + return $logs->count(); + } + + /** + * 로그의 metadata JSON 에 추가 키를 병합한다 (기존 키 보존). + * + * @param string $logId 대상 로그 id + * @param array $patch 병합할 metadata 키 + * @return bool 업데이트 성공 여부 + */ + public function appendMetadata(string $logId, array $patch): bool + { + if ($logId === '' || $patch === []) { + return false; + } + + $log = IdentityVerificationLog::query()->find($logId); + if ($log === null) { + return false; + } + + $current = is_array($log->metadata) ? $log->metadata : []; + $log->metadata = array_merge($current, $patch); + + return (bool) $log->save(); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpIdentityLogQueryRepositoryInterface.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpIdentityLogQueryRepositoryInterface.php new file mode 100644 index 00000000..d51072be --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpIdentityLogQueryRepositoryInterface.php @@ -0,0 +1,43 @@ + $patch 병합할 metadata 키 + * @return bool 업데이트 성공 여부 + */ + public function appendMetadata(string $logId, array $patch): bool; +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpIdentityRecordRepository.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpIdentityRecordRepository.php new file mode 100644 index 00000000..aaa66918 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpIdentityRecordRepository.php @@ -0,0 +1,111 @@ + + */ + private const HASH_COLUMNS = ['di_hash', 'ci_hash']; + + /** + * user_id 로 레코드를 조회한다. + * + * @param int $userId 사용자 ID + * @return KcpIdentityRecord|null 레코드 또는 null + */ + public function findByUserId(int $userId): ?KcpIdentityRecord + { + return KcpIdentityRecord::query()->where('user_id', $userId)->first(); + } + + /** + * DI keyed-hash 로 동일인을 검색한다. + * + * @param string $diHash DI keyed-hash + * @return KcpIdentityRecord|null 매칭 레코드 또는 null + */ + public function findByDiHash(string $diHash): ?KcpIdentityRecord + { + return KcpIdentityRecord::query()->where('di_hash', $diHash)->first(); + } + + /** + * CI keyed-hash 로 동일인을 검색한다. + * + * @param string $ciHash CI keyed-hash + * @return KcpIdentityRecord|null 매칭 레코드 또는 null + */ + public function findByCiHash(string $ciHash): ?KcpIdentityRecord + { + return KcpIdentityRecord::query()->where('ci_hash', $ciHash)->first(); + } + + /** + * 지정 사용자를 제외하고 DI/CI keyed-hash 로 동일인을 검색한다. + * + * @param string $column 검색 컬럼 (di_hash | ci_hash) + * @param string $hash keyed-hash 값 + * @param int|null $exceptUserId 제외할 사용자 ID (본인 재인증 허용) + * @return KcpIdentityRecord|null 매칭 레코드 또는 null + */ + public function findByHashExceptUser(string $column, string $hash, ?int $exceptUserId = null): ?KcpIdentityRecord + { + if (! in_array($column, self::HASH_COLUMNS, true) || $hash === '') { + return null; + } + + return KcpIdentityRecord::query() + ->where($column, $hash) + ->when($exceptUserId !== null, fn ($query) => $query->where('user_id', '!=', $exceptUserId)) + ->first(); + } + + /** + * 사용자 PII 레코드를 생성하거나 갱신한다 (UPSERT). + * + * @param int $userId 사용자 ID + * @param array $attributes 컬럼 값 (PII 평문은 호출자가 암호화 후 전달) + * @return KcpIdentityRecord 생성/갱신된 레코드 + */ + public function upsertForUser(int $userId, array $attributes): KcpIdentityRecord + { + $existing = $this->findByUserId($userId); + + if ($existing) { + $existing->fill($attributes); + $existing->save(); + + return $existing; + } + + $attributes['user_id'] = $userId; + + return KcpIdentityRecord::query()->create($attributes); + } + + /** + * user_id 로 레코드를 삭제한다 (회원 탈퇴 / 사용자 삭제 시). + * + * @param int $userId 사용자 ID + * @return bool 삭제 성공 여부 (행이 없어도 true) + */ + public function deleteByUserId(int $userId): bool + { + KcpIdentityRecord::query()->where('user_id', $userId)->delete(); + + return true; + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpIdentityRecordRepositoryInterface.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpIdentityRecordRepositoryInterface.php new file mode 100644 index 00000000..1e0daf27 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Repositories/KcpIdentityRecordRepositoryInterface.php @@ -0,0 +1,66 @@ + $attributes 컬럼 값 (PII 평문은 호출자가 암호화 후 전달) + * @return KcpIdentityRecord 생성/갱신된 레코드 + */ + public function upsertForUser(int $userId, array $attributes): KcpIdentityRecord; + + /** + * user_id 로 레코드를 삭제한다 (회원 탈퇴 / 사용자 삭제 시). + * + * @param int $userId 사용자 ID + * @return bool 삭제 성공 여부 (행이 없어도 true) + */ + public function deleteByUserId(int $userId): bool; +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpCallbackOutcome.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpCallbackOutcome.php new file mode 100644 index 00000000..6fad2768 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpCallbackOutcome.php @@ -0,0 +1,99 @@ + + */ + public function toBridgeQuery(): array + { + if ($this->success) { + return [ + 'verification_token' => (string) $this->verificationToken, + 'challenge_id' => (string) $this->challengeId, + ]; + } + + $query = ['identity_error' => (string) $this->failureCode]; + + // 실패도 "이 거래는 끝났다" 는 사실을 화면에 알려야 한다. 콜백이 한 번 처리된 거래는 KCP + // 쪽에서 종료되어 재사용할 수 없으므로, 화면은 이 신호를 받아 재시도 시 새 거래를 발급받는다. + // 빠지면 죽은 거래키로 인증창을 다시 열어 "이미 종료된 거래" 로 실패하고, 사용자는 몇 번을 + // 눌러도 진행하지 못한다. (거래를 못 찾은 NOT_FOUND 는 알려줄 challenge 자체가 없다.) + if ($this->challengeId !== null && $this->challengeId !== '') { + $query['challenge_id'] = $this->challengeId; + } + + // 우리말 안내가 없는 코드는 KCP 원문 메시지를 함께 전달해 사용자가 사유를 알 수 있게 한다. + // 개인정보가 아닌 처리 상태 문구만 담긴다 (§3.7 "그 외 res_cd 는 [코드] 메시지 그대로"). + if ($this->failureMessage !== null && $this->failureMessage !== '') { + $query['identity_error_message'] = $this->failureMessage; + } + + return $query; + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpCallbackResolver.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpCallbackResolver.php new file mode 100644 index 00000000..ba92657a --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpCallbackResolver.php @@ -0,0 +1,275 @@ +logRepository->findById($challengeId); + + // status 는 코어 모델에서 enum 으로 캐스팅된다 — 문자열 비교를 위해 값을 꺼낸다. + $status = $log?->status; + $status = $status instanceof \BackedEnum ? (string) $status->value : (string) $status; + + if ($log !== null && ! in_array($status, self::TERMINAL_LOG_STATUSES, true)) { + $this->logRepository->updateById($challengeId, [ + 'status' => 'failed', + 'metadata' => array_merge((array) $log->metadata, ['failure_code' => $failureCode]), + ]); + } + + return KcpCallbackOutcome::failure(challengeId: $challengeId, failureCode: $failureCode); + } + + /** + * KCP 콜백 body 를 받아 판정 파이프라인 전체를 수행한다. + * + * @param array $callbackInput KCP 표준창이 form POST 한 body 전체 + * @param array $context ip_address / user_agent + * @return KcpCallbackOutcome 처리 결과 DTO + */ + public function resolve(array $callbackInput, array $context = []): KcpCallbackOutcome + { + $resCd = trim((string) ($callbackInput['res_cd'] ?? '')); + $resMsg = urldecode(trim((string) ($callbackInput['res_msg'] ?? ''))); + + $transaction = $this->findTransaction($callbackInput); + + if ($transaction === null) { + Log::warning('KCP 콜백: 거래 매칭 실패', ['res_cd' => $resCd]); + + return KcpCallbackOutcome::failure(failureCode: 'NOT_FOUND'); + } + + $challengeId = (string) $transaction->challenge_id; + + // 1회 소비 — 동시 진입/replay 는 여기서 걸러진다 (원자적 조건부 UPDATE). + if (! $this->transactionRepository->markConsumed((int) $transaction->id)) { + return $this->failWithLog($challengeId, 'ALREADY_CONSUMED'); + } + + // 사용자 취소(9999) 또는 표준창 실패 — 결과조회 없이 코어 verify 에 위임해 로그 상태를 남긴다. + if ($resCd !== KcpCertClient::RES_CD_SUCCESS) { + return $this->delegateToCore($challengeId, [ + 'res_cd' => $resCd !== '' ? $resCd : 'PROVIDER_ERROR', + 'res_msg' => $resMsg, + ], $context); + } + + $regCertKey = $this->transactionRepository->decryptRegCertKey($transaction); + if ($regCertKey === '') { + return $this->failWithLog($challengeId, 'DECRYPT_FAILED'); + } + + try { + $certData = $this->client()->fetchCertData($regCertKey, (string) $transaction->ordr_idxx); + } catch (RemoteCallException $e) { + Log::error('KCP 결과조회 통신 실패', [ + 'challenge_id' => $challengeId, + 'detail' => $e->detail, + 'http_status' => $e->httpStatus, + ]); + + return $this->failWithLog($challengeId, 'REMOTE_CALL_FAILED'); + } catch (DecryptException $e) { + Log::error('KCP 결과조회 복호화 실패', [ + 'challenge_id' => $challengeId, + 'field' => $e->field, + ]); + + return $this->failWithLog($challengeId, 'DECRYPT_FAILED'); + } + + // 결과조회 자체가 거절된 경우 — 그 코드를 그대로 실패 사유로 전달한다. + $certResCd = (string) ($certData['res_cd'] ?? ''); + if ($certResCd !== KcpCertClient::RES_CD_SUCCESS) { + Log::warning('KCP 결과조회 거절', [ + 'challenge_id' => $challengeId, + 'res_cd' => $certResCd, + ]); + + return $this->delegateToCore($challengeId, [ + 'res_cd' => $certResCd !== '' ? $certResCd : 'PROVIDER_ERROR', + 'res_msg' => (string) ($certData['res_msg'] ?? ''), + ], $context); + } + + return $this->delegateToCore($challengeId, $certData, $context); + } + + /** + * 콜백 body 로 거래 행을 식별한다. + * + * KCP 가이드상 콜백에는 거래키가 포함되지만, 계약/버전에 따라 누락될 수 있으므로 거래등록 + * 시 `param_opt_1` 에 실어 보낸 challenge_id 를 폴백 인덱스로 사용한다. + * + * @param array $callbackInput 콜백 body + * @return KcpCertTransaction|null 거래 행 또는 null + */ + protected function findTransaction(array $callbackInput): ?KcpCertTransaction + { + $regCertKey = trim((string) ($callbackInput['reg_cert_key'] ?? '')); + + if ($regCertKey !== '') { + $transaction = $this->transactionRepository->findByRegCertKey($regCertKey); + if ($transaction !== null) { + return $transaction; + } + } + + $challengeId = trim((string) ($callbackInput['param_opt_1'] ?? '')); + + return $challengeId !== '' + ? $this->transactionRepository->findByChallengeId($challengeId) + : null; + } + + /** + * 코어 IDV Service 에 verify 를 위임하고 결과를 outcome 으로 변환한다. + * + * @param string $challengeId challenge UUID + * @param array $input provider verify 입력 (콜백 + 결과조회 합산) + * @param array $context ip_address / user_agent + * @return KcpCallbackOutcome 코어 verify 결과를 변환한 판정 결과 + */ + protected function delegateToCore(string $challengeId, array $input, array $context): KcpCallbackOutcome + { + try { + $result = $this->identityService->handleProviderCallback( + providerId: KcpIdentityProvider::PROVIDER_ID, + challengeId: $challengeId, + input: $input, + context: $context, + ); + } catch (AlreadyConsumedException) { + // 코어가 이미 소비 판정을 내린 경우 — 로그 상태는 코어가 확정했으므로 그대로 둔다. + return KcpCallbackOutcome::failure(challengeId: $challengeId, failureCode: 'ALREADY_CONSUMED'); + } + + return $this->buildOutcomeFromResult($challengeId, $result); + } + + /** + * 코어 VerificationResult 를 본 plugin 의 outcome DTO 로 변환한다. + * + * @param string $challengeId challenge UUID + * @param VerificationResult $result 코어 verify 결과 + * @return KcpCallbackOutcome 브리지 전달용으로 변환된 판정 결과 + */ + protected function buildOutcomeFromResult(string $challengeId, VerificationResult $result): KcpCallbackOutcome + { + if (! $result->success) { + return KcpCallbackOutcome::failure( + challengeId: $challengeId, + failureCode: $result->failureCode ?? 'UNKNOWN', + failureMessage: $this->extractProviderMessage($result->failureReason), + ); + } + + return KcpCallbackOutcome::success( + challengeId: $challengeId, + verificationToken: (string) ($result->claims['verification_token'] ?? ''), + ); + } + + /** + * 실패 사유에서 "사용자에게 그대로 보여줄 KCP 원문 메시지" 만 추출한다. + * + * Provider 가 우리말 안내를 가진 실패(성인 미달·중복 등)에는 다국어 키를 실어 보내므로, 그 + * 경우는 프론트가 키로 안내한다. 키가 아닌 문자열(=KCP 가 준 원문)일 때만 그대로 전달해 + * `[코드] 메시지` 형태로 노출한다 (§3.7). + * + * @param string|null $failureReason 코어 verify 결과의 실패 사유 + * @return string|null KCP 원문 메시지 또는 null (다국어 키인 경우) + */ + protected function extractProviderMessage(?string $failureReason): ?string + { + $reason = trim((string) $failureReason); + + if ($reason === '' || str_contains($reason, '::')) { + return null; + } + + return $reason; + } + + /** + * 현재 운영 설정이 주입된 KCP 게이트웨이를 반환한다. + * + * 코어 레지스트리에 등록된 provider 인스턴스(RegisterKcpProviderListener 가 settings 를 주입) + * 에서 게이트웨이를 얻어, 사이트코드/암호화 키 해석 로직이 한 곳에만 존재하도록 한다. + * + * @return KcpCertClientInterface 현재 설정의 자격증명이 주입된 게이트웨이 + */ + protected function client(): KcpCertClientInterface + { + $provider = $this->providerManager->get(KcpIdentityProvider::PROVIDER_ID); + + if ($provider instanceof KcpIdentityProvider) { + return $provider->client(); + } + + // 레지스트리에 본 provider 가 없는 예외적 상황 — 자격증명 없는 게이트웨이는 호출 즉시 + // 실패하므로 상위에서 REMOTE_CALL_FAILED 로 처리된다. + return app(KcpCertClientInterface::class); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpCertClient.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpCertClient.php new file mode 100644 index 00000000..2ed12b6f --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpCertClient.php @@ -0,0 +1,351 @@ + $paramOpts 가맹점 자유 파라미터 param_opt_1~3 + * @return array{res_cd: string, res_msg: string, call_url: string, reg_cert_key: string} KCP 응답 + * + * @throws EncryptException 요청 암호화 실패 + * @throws RemoteCallException 통신/HTTP/JSON 오류 + */ + public function registerTrade(string $ordrIdxx, string $retUrl, string $webSiteid = '', array $paramOpts = []): array + { + $this->assertFieldLengths($ordrIdxx, $retUrl, $webSiteid); + + $payload = [ + 'site_cd' => $this->siteCd, + 'ordr_idxx' => $ordrIdxx, + 'Ret_URL' => $retUrl, + 'web_siteid' => $webSiteid, + 'param_opt_1' => (string) ($paramOpts[0] ?? ''), + 'param_opt_2' => (string) ($paramOpts[1] ?? ''), + 'param_opt_3' => (string) ($paramOpts[2] ?? ''), + ]; + + $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RemoteCallException('register_trade_json_encode_failed'); + } + + $encrypted = KcpCertCrypto::encrypt($json, $this->encKey, $this->siteCd); + + $body = $this->post( + $this->host().self::REGISTER_PATH, + $encrypted['enc_data'], + ['site_cd' => $this->siteCd, 'rv' => $encrypted['rv']], + ); + + return [ + 'res_cd' => (string) ($body['res_cd'] ?? ''), + 'res_msg' => (string) ($body['res_msg'] ?? ''), + 'call_url' => (string) ($body['call_url'] ?? ''), + 'reg_cert_key' => (string) ($body['reg_cert_key'] ?? ''), + ]; + } + + /** + * 결과조회 — 인증 결과를 조회하고 복호화한 PII 를 반환한다. + * + * `ordr_idxx` 는 모바일(페이지 전환) 흐름에서 필수이므로 PC/모바일 구분 없이 항상 전송한다. + * + * @param string $regCertKey 거래등록 응답의 거래키 + * @param string $ordrIdxx 거래등록에 사용한 가맹점 주문번호 + * @return array `res_cd`/`res_msg` + 성공 시 복호화 필드 + * + * @throws RemoteCallException 통신/HTTP/JSON 오류 + * @throws DecryptException 응답 복호화 실패 + */ + public function fetchCertData(string $regCertKey, string $ordrIdxx): array + { + $json = json_encode( + ['reg_cert_key' => $regCertKey, 'ordr_idxx' => $ordrIdxx], + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, + ); + + if ($json === false) { + throw new RemoteCallException('fetch_cert_data_json_encode_failed'); + } + + $body = $this->post($this->host().self::QUERY_PATH, $json, ['site_cd' => $this->siteCd]); + + $resCd = (string) ($body['res_cd'] ?? ''); + $resMsg = (string) ($body['res_msg'] ?? ''); + + if ($resCd !== self::RES_CD_SUCCESS) { + return ['res_cd' => $resCd, 'res_msg' => $resMsg]; + } + + $decrypted = $this->decryptCertData( + (string) ($body['enc_cert_data'] ?? ''), + (string) ($body['rv'] ?? ''), + ); + + return array_merge(['res_cd' => $resCd, 'res_msg' => $resMsg], $decrypted); + } + + /** + * 거래등록 필드가 KCP 규격 길이를 넘지 않는지 확인한다. + * + * 초과 상태로 보내면 KCP 가 거절하는데, 그 응답은 어느 필드가 문제인지 알려주지 않아 + * 운영자가 원인을 짚기 어렵다. 보내기 전에 필드 이름과 함께 실패시킨다(값은 로그에 남기지 + * 않는다 — Ret_URL 을 제외한 나머지는 가맹점 식별 정보다). + * + * @param string $ordrIdxx 가맹점 주문번호 + * @param string $retUrl 콜백 URL + * @param string $webSiteid 웹사이트 ID + * + * @throws RemoteCallException 규격 길이 초과 시 + */ + protected function assertFieldLengths(string $ordrIdxx, string $retUrl, string $webSiteid): void + { + $limits = [ + 'ordr_idxx' => [$ordrIdxx, 50], + 'Ret_URL' => [$retUrl, 256], + 'web_siteid' => [$webSiteid, 12], + ]; + + foreach ($limits as $field => [$value, $max]) { + if (mb_strlen($value) <= $max) { + continue; + } + + Log::warning('KCP 거래등록: 규격 길이 초과', [ + 'field' => $field, + 'length' => mb_strlen($value), + 'max' => $max, + ]); + + throw new RemoteCallException('register_trade_field_too_long:'.$field); + } + } + + /** + * 결과조회 응답의 암호문을 복호화하여 표준 키 배열로 정규화한다. + * + * KCP 응답의 CI/DI 키는 대문자(`CI`/`DI`) 가 표준이나, 규격 변형에 대비해 소문자도 수용한다. + * + * @param string $encCertData base64 암호문 + * @param string $rv base64 salt + * @return array 정규화된 PII 배열 + * + * @throws DecryptException 복호화 또는 JSON 파싱 실패 + */ + protected function decryptCertData(string $encCertData, string $rv): array + { + if ($encCertData === '' || $rv === '') { + throw new DecryptException('enc_cert_data'); + } + + $plain = KcpCertCrypto::decrypt($encCertData, $rv, $this->encKey, $this->siteCd); + $decoded = json_decode($plain, true); + + if (! is_array($decoded)) { + throw new DecryptException('enc_cert_data'); + } + + $this->warnOnUnmappedKeys($decoded); + + return [ + 'user_name' => trim((string) ($decoded['user_name'] ?? '')), + 'birth_day' => trim((string) ($decoded['birth_day'] ?? '')), + 'phone_no' => trim((string) ($decoded['phone_no'] ?? '')), + 'comm_id' => trim((string) ($decoded['comm_id'] ?? '')), + 'sex_code' => trim((string) ($decoded['sex_code'] ?? '')), + 'local_code' => trim((string) ($decoded['local_code'] ?? '')), + 'ci' => $this->readIdentifier($decoded, 'CI'), + 'di' => $this->readIdentifier($decoded, 'DI'), + ]; + } + + /** + * 식별값(CI/DI)을 대소문자·URL 인코딩 변형까지 수용해 읽는다. + * + * KCP 규격은 값 자체(`CI`/`DI`) 외에 urlencode 된 사본(`CI_URL`/`DI_URL`)을 함께 내려준다. + * 표준 키가 비어 있는 응답 변형에서도 식별값을 잃지 않도록 다음 순서로 찾는다: + * 대문자 → 소문자 → `*_URL`(디코드) → `*_url`(디코드). + * + * 식별값이 비면 중복 가입 차단이 조용히 무력화되므로, 읽을 수 있는 경로는 모두 시도한다. + * + * @param array $decoded 복호화된 원본 배열 + * @param string $upper 대문자 키 이름 ('CI' 또는 'DI') + * @return string 식별값 (전부 비어 있으면 빈 문자열) + */ + protected function readIdentifier(array $decoded, string $upper): string + { + $lower = strtolower($upper); + + foreach ([$upper, $lower] as $key) { + $value = trim((string) ($decoded[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + foreach ([$upper.'_URL', $lower.'_url'] as $key) { + $raw = trim((string) ($decoded[$key] ?? '')); + if ($raw === '') { + continue; + } + + $decodedValue = trim(urldecode($raw)); + if ($decodedValue !== '') { + Log::warning('KCP 결과조회: 식별값을 URL 인코딩 키에서 회수', ['key' => $key]); + + return $decodedValue; + } + } + + return ''; + } + + /** + * 복호화 결과에 우리가 읽는 이름의 키가 없을 때 원본 키 목록을 경고로 남긴다. + * + * 신원 핵심값이 비어 인증이 거절되면 운영자는 "KCP 가 안 준 것"인지 "이름이 달라 우리가 못 + * 읽은 것"인지 구분해야 한다. 값은 전부 PII 이므로 **키 이름만** 기록한다. + * + * @param array $decoded 복호화된 원본 배열 + */ + protected function warnOnUnmappedKeys(array $decoded): void + { + $expected = ['user_name', 'birth_day', 'phone_no', 'comm_id', 'sex_code', 'local_code']; + $missing = array_values(array_filter( + $expected, + static fn (string $key): bool => ! array_key_exists($key, $decoded), + )); + + // CI/DI 는 대소문자·_URL 변형을 허용하므로 단일 키 존재 여부로 판정할 수 없다. + // 어느 변형으로도 읽히지 않을 때만 누락으로 본다 (중복 가입 차단의 유일 판정 키라 관측 필수). + foreach (['CI', 'DI'] as $identifier) { + if ($this->readIdentifier($decoded, $identifier) === '') { + $missing[] = $identifier; + } + } + + if ($missing === []) { + return; + } + + Log::warning('KCP 결과조회: 규격과 다른 응답 키 구성', [ + 'expected_but_absent' => $missing, + 'response_keys' => array_keys($decoded), + ]); + } + + /** + * 현재 모드의 KCP 호스트를 반환한다. + * + * @return string 호스트 URL + */ + protected function host(): string + { + return $this->isTestMode ? self::TEST_HOST : self::LIVE_HOST; + } + + /** + * KCP 엔드포인트로 POST 요청을 보내고 JSON 응답을 배열로 반환한다. + * + * @param string $url 요청 URL + * @param string $body 요청 본문 (암호문 문자열 또는 평문 JSON) + * @param array $headers 추가 헤더 (site_cd / rv) + * @return array 응답 JSON + * + * @throws RemoteCallException 통신/HTTP/JSON 오류 + */ + protected function post(string $url, string $body, array $headers): array + { + try { + $response = Http::withHeaders(array_merge($headers, ['Accept' => 'application/json'])) + ->withBody($body, 'application/json') + ->connectTimeout(self::CONNECT_TIMEOUT) + ->timeout(self::TIMEOUT) + ->post($url); + } catch (\Throwable $e) { + throw new RemoteCallException($e->getMessage(), 0, $e); + } + + if ($response->failed()) { + throw new RemoteCallException('HTTP '.$response->status(), $response->status()); + } + + $decoded = json_decode($response->body(), true); + + if (! is_array($decoded)) { + throw new RemoteCallException('response_json_parse_failed', $response->status()); + } + + return $decoded; + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpCertClientInterface.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpCertClientInterface.php new file mode 100644 index 00000000..367e0f15 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpCertClientInterface.php @@ -0,0 +1,58 @@ + $paramOpts 가맹점 자유 파라미터 param_opt_1~3 (각 500자 이내) + * @return array{res_cd: string, res_msg: string, call_url: string, reg_cert_key: string} KCP 응답 + * + * @throws EncryptException 요청 암호화 실패 + * @throws RemoteCallException 통신/HTTP/JSON 오류 + */ + public function registerTrade(string $ordrIdxx, string $retUrl, string $webSiteid = '', array $paramOpts = []): array; + + /** + * 결과조회 — 인증 결과를 조회하고 복호화한 PII 를 반환한다. + * + * @param string $regCertKey 거래등록 응답의 거래키 + * @param string $ordrIdxx 거래등록에 사용한 가맹점 주문번호 + * @return array `res_cd`/`res_msg` + 성공 시 복호화 필드 + * (user_name, birth_day, phone_no, comm_id, sex_code, local_code, ci, di) + * + * @throws RemoteCallException 통신/HTTP/JSON 오류 + * @throws DecryptException 응답 복호화 실패 + */ + public function fetchCertData(string $regCertKey, string $ordrIdxx): array; +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpDuplicateIdentityChecker.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpDuplicateIdentityChecker.php new file mode 100644 index 00000000..0f898d01 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpDuplicateIdentityChecker.php @@ -0,0 +1,77 @@ +columnFor($field); + + $existing = $this->recordRepository->findByHashExceptUser($column, $hash, $exceptUserId); + + return $existing !== null ? $column : null; + } + + /** + * 판정 기준 → record 테이블 컬럼명. + * + * @param KcpDuplicateField $field 판정 기준 + * @return string 컬럼명 + */ + public function columnFor(KcpDuplicateField $field): string + { + return $field === KcpDuplicateField::Di ? 'di_hash' : 'ci_hash'; + } + + /** + * 매칭된 record 의 사용자 ID 를 반환한다 (감사 로그용 — PII 는 반환하지 않는다). + * + * @param KcpDuplicateField $field 판정 기준 + * @param string $hash keyed-hash + * @param int|null $exceptUserId 제외할 사용자 ID + * @return int|null 매칭 사용자 ID 또는 null + */ + public function findConflictUserId(KcpDuplicateField $field, string $hash, ?int $exceptUserId = null): ?int + { + $existing = $this->recordRepository->findByHashExceptUser($this->columnFor($field), $hash, $exceptUserId); + + return $existing !== null ? (int) $existing->user_id : null; + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpIdentityCardService.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpIdentityCardService.php new file mode 100644 index 00000000..16ce310a --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Services/KcpIdentityCardService.php @@ -0,0 +1,35 @@ +recordRepository->findByUserId($userId); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Support/KcpCertCrypto.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Support/KcpCertCrypto.php new file mode 100644 index 00000000..be1c44b9 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Support/KcpCertCrypto.php @@ -0,0 +1,135 @@ + base64_encode($cipherText), + 'rv' => base64_encode($salt), + ]; + } + + /** + * KCP 응답 암호문을 복호화한다. + * + * @param string $encData base64 인코딩된 암호문 (`enc_cert_data`) + * @param string $rv base64 인코딩된 salt (응답 `rv`) + * @param string $encKey 가맹점 암호화 키 (설정값) + * @param string $siteCd 가맹점 사이트 코드 (IV 파생 소스) + * @return string 복호화된 평문 JSON + * + * @throws DecryptException 복호화 실패 시 + */ + public static function decrypt(string $encData, string $rv, string $encKey, string $siteCd): string + { + $salt = base64_decode($rv, true); + $cipherText = base64_decode($encData, true); + + if ($salt === false || $cipherText === false || $salt === '' || $cipherText === '') { + throw new DecryptException('enc_cert_data'); + } + + $plain = openssl_decrypt( + $cipherText, + self::CIPHER, + self::deriveKey($encKey, $salt), + OPENSSL_RAW_DATA, + self::deriveIv($siteCd, $salt), + ); + + if ($plain === false || $plain === '') { + throw new DecryptException('enc_cert_data'); + } + + return $plain; + } + + /** + * 대칭키를 파생한다 — PBKDF2(enc_key, salt). + * + * @param string $encKey 가맹점 암호화 키 + * @param string $salt 16바이트 salt (raw) + * @return string 32바이트 raw 키 + */ + private static function deriveKey(string $encKey, string $salt): string + { + return hash_pbkdf2(self::PBKDF2_ALGO, $encKey, $salt, self::PBKDF2_ITERATIONS, self::PBKDF2_LENGTH, true); + } + + /** + * IV 를 파생한다 — PBKDF2(site_cd, salt) 의 앞 16바이트. + * + * @param string $siteCd 가맹점 사이트 코드 + * @param string $salt 16바이트 salt (raw) + * @return string 16바이트 raw IV + */ + private static function deriveIv(string $siteCd, string $salt): string + { + $derived = hash_pbkdf2(self::PBKDF2_ALGO, $siteCd, $salt, self::PBKDF2_ITERATIONS, self::PBKDF2_LENGTH, true); + + return substr($derived, 0, self::IV_LENGTH); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Support/KcpIdentityHasher.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Support/KcpIdentityHasher.php new file mode 100644 index 00000000..feffa352 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Support/KcpIdentityHasher.php @@ -0,0 +1,47 @@ +get('/me/identity/nhnkcp', [MyKcpIdentityShowController::class, 'show']) + ->name('me.identity.nhnkcp.show'); diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/routes/web.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/routes/web.php new file mode 100644 index 00000000..8e2c5d77 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/routes/web.php @@ -0,0 +1,28 @@ +group(function () { + Route::post('/plugin/nhnkcp/callback', [KcpCallbackController::class, 'handle']) + ->name('plugin.verification_nhnkcp.callback'); +}); + +Route::get('/plugin/nhnkcp/bridge', [KcpBridgeController::class, 'show']) + ->name('plugin.verification_nhnkcp.bridge'); diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Controllers/KcpCallbackControllerTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Controllers/KcpCallbackControllerTest.php new file mode 100644 index 00000000..23ec2c55 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Controllers/KcpCallbackControllerTest.php @@ -0,0 +1,186 @@ +save(self::PLUGIN_IDENTIFIER, [ + 'is_test_mode' => true, + 'test_site_cd' => self::SITE_CD, + 'test_enc_key' => self::ENC_KEY, + ]); + } + + /** + * @scenario outcome=success,lookup=by_reg_cert_key,missing_field=none + * + * @effects success_issues_verification_token_and_marks_log_verified + */ + public function test_successful_callback_redirects_to_bridge_with_token(): void + { + $log = $this->createChallenge(); + $this->fakeCertQuery(); + + $response = $this->post(self::CALLBACK_URL, [ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'reg_cert_key' => self::REG_CERT_KEY, + 'param_opt_1' => $log->id, + ]); + + $response->assertRedirect(); + $location = $response->headers->get('Location'); + + $this->assertStringContainsString(self::BRIDGE_URL, (string) $location); + $this->assertStringContainsString('verification_token=', (string) $location); + $this->assertStringNotContainsString('홍길동', (string) $location); + $this->assertStringNotContainsString('01012345678', (string) $location); + } + + /** + * @scenario outcome=cancelled,lookup=by_reg_cert_key,missing_field=none + * + * @effects cancelled_marks_log_cancelled_with_9999_code + */ + public function test_cancelled_callback_redirects_with_error_code(): void + { + $this->createChallenge(); + Http::fake(); + + $response = $this->post(self::CALLBACK_URL, [ + 'res_cd' => '9999', + 'res_msg' => '사용자취소', + 'reg_cert_key' => self::REG_CERT_KEY, + ]); + + $response->assertRedirect(); + $this->assertStringContainsString('identity_error=9999', (string) $response->headers->get('Location')); + } + + /** + * @scenario outcome=not_found,lookup=by_reg_cert_key,missing_field=none + * + * @effects unknown_transaction_returns_not_found + */ + public function test_unknown_callback_redirects_with_not_found(): void + { + $response = $this->post(self::CALLBACK_URL, [ + 'res_cd' => '0000', + 'reg_cert_key' => 'UNKNOWN', + ]); + + $response->assertRedirect(); + $this->assertStringContainsString('identity_error=NOT_FOUND', (string) $response->headers->get('Location')); + } + + /** + * @scenario outcome=success,lookup=by_param_opt,missing_field=none + * + * @effects bridge_query_carries_no_personal_information + */ + public function test_bridge_page_relays_result_to_opener_without_personal_information(): void + { + $response = $this->get(self::BRIDGE_URL.'?verification_token=tok-123&challenge_id=chal-123'); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'text/html; charset=UTF-8'); + + $html = $response->getContent(); + $this->assertStringContainsString('identity_result', (string) $html); + $this->assertStringContainsString('tok-123', (string) $html); + // 부모창 전달은 동일 origin 으로 제한되어야 한다. + $this->assertStringContainsString('window.location.origin', (string) $html); + } + + /** + * 결과조회 응답을 가로챈다. + */ + private function fakeCertQuery(): void + { + $encrypted = KcpCertCrypto::encrypt((string) json_encode([ + 'user_name' => '홍길동', + 'birth_day' => '19900101', + 'phone_no' => '01012345678', + 'comm_id' => 'SKT', + 'sex_code' => '01', + 'local_code' => '01', + 'CI' => 'CI-CTRL-0001', + 'DI' => 'DI-CTRL-0001', + ]), self::ENC_KEY, self::SITE_CD); + + Http::fake([ + 'testcert.kcp.co.kr/*' => Http::response([ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'enc_cert_data' => $encrypted['enc_data'], + 'rv' => $encrypted['rv'], + ]), + ]); + } + + /** + * 인증 대기 challenge + 거래 행을 만든다. + * + * @return IdentityVerificationLog + */ + private function createChallenge() + { + $log = app(IdentityVerificationLogRepositoryInterface::class)->create([ + 'id' => (string) Str::uuid(), + 'provider_id' => KcpIdentityProvider::PROVIDER_ID, + 'purpose' => 'self_update', + 'channel' => KcpIdentityProvider::CHANNEL, + 'user_id' => User::factory()->create()->id, + 'target_hash' => hash('sha256', 'controller@example.test'), + 'status' => IdentityVerificationStatus::Sent->value, + 'render_hint' => 'text_code', + 'attempts' => 0, + 'max_attempts' => 0, + 'origin_type' => IdentityOriginType::Route->value, + 'origin_identifier' => 'api.identity.verify', + 'verification_token' => null, + 'expires_at' => now()->addMinutes(15), + 'metadata' => ['ordr_idxx' => 'G7-CTRL-0001'], + ]); + + app(KcpCertTransactionRepositoryInterface::class) + ->create('G7-CTRL-0001', self::REG_CERT_KEY, $log->id); + + return $log; + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Controllers/MyKcpIdentityShowControllerTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Controllers/MyKcpIdentityShowControllerTest.php new file mode 100644 index 00000000..20f3022c --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Controllers/MyKcpIdentityShowControllerTest.php @@ -0,0 +1,166 @@ +recordRepository = app(KcpIdentityRecordRepositoryInterface::class); + } + + /** + * @scenario record_exists=true,auth=authenticated + * + * @effects verified_user_receives_masked_fields + */ + public function test_verified_user_receives_masked_fields(): void + { + $user = User::factory()->create(); + $this->seedRecord($user->id); + + $response = $this->actingAs($user, 'sanctum')->getJson(self::ENDPOINT); + + $response->assertStatus(200); + $data = $response->json('data'); + + $this->assertSame('홍*동', $data['name_masked'], '성과 끝 글자만 남기고 가운데를 가린다 (와이어프레임 §4.4)'); + $this->assertSame('1990-**-**', $data['birthday_masked']); + $this->assertSame('010-****-5678', $data['phone_masked']); + $this->assertTrue($data['is_adult']); + $this->assertFalse($data['is_foreigner']); + $this->assertNotEmpty($data['method']); + $this->assertNotEmpty($data['verified_at']); + } + + /** + * @scenario record_exists=true,auth=authenticated + * + * @effects response_excludes_identity_identifiers + */ + public function test_response_excludes_identity_identifiers_and_plain_values(): void + { + $user = User::factory()->create(); + $this->seedRecord($user->id); + + $response = $this->actingAs($user, 'sanctum')->getJson(self::ENDPOINT); + $body = (string) $response->getContent(); + + foreach (['di', 'ci', 'di_hash', 'ci_hash'] as $forbidden) { + $this->assertArrayNotHasKey($forbidden, $response->json('data')); + } + + $this->assertStringNotContainsString('DI-CARD-0001', $body); + $this->assertStringNotContainsString('CI-CARD-0001', $body); + $this->assertStringNotContainsString('01012345678', $body, '휴대폰 평문이 노출되면 안 된다'); + $this->assertStringNotContainsString('홍길동', $body, '실명 평문이 노출되면 안 된다'); + } + + /** + * @scenario record_exists=false,auth=authenticated + * + * @effects user_without_record_receives_null_data + */ + public function test_user_without_record_receives_null_data(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user, 'sanctum')->getJson(self::ENDPOINT); + + $response->assertStatus(200); + $this->assertNull($response->json('data')); + } + + /** + * @scenario auth=guest,record_exists=false + * + * @effects guest_request_is_rejected + */ + public function test_guest_request_is_rejected(): void + { + $this->getJson(self::ENDPOINT)->assertStatus(401); + } + + /** + * 본인확인 record 를 심는다. + * + * @param int $userId 사용자 ID + */ + /** + * 짧은 이름의 마스킹 경계 — 두 글자는 성만, 한 글자는 가릴 곳이 없어 그대로 둔다. + * + * 가운데를 가리는 규칙을 짧은 이름에 그대로 적용하면 이름이 통째로 사라지거나 + * 별표만 남아 본인 확인이 되지 않는다. + * + * @param string $name 원본 이름 + * @param string $expected 기대 마스킹 결과 + * + * @scenario record_exists=true,auth=authenticated + * + * @effects verified_user_receives_masked_fields + */ + #[DataProvider('shortNameProvider')] + public function test_short_names_are_masked_within_boundary(string $name, string $expected): void + { + $user = User::factory()->create(); + $this->seedRecord($user->id, $name); + + $response = $this->actingAs($user, 'sanctum')->getJson(self::ENDPOINT); + + $response->assertStatus(200); + $this->assertSame($expected, $response->json('data.name_masked')); + } + + /** + * 이름 길이별 마스킹 기대값. + * + * @return array 원본 이름 / 기대 결과 + */ + public static function shortNameProvider(): array + { + return [ + '네 글자' => ['남궁길동', '남**동'], + '세 글자' => ['홍길동', '홍*동'], + '두 글자' => ['김철', '김*'], + '한 글자' => ['민', '민'], + ]; + } + + private function seedRecord(int $userId, string $name = '홍길동'): void + { + $this->recordRepository->upsertForUser($userId, [ + 'comm_id' => 'SKT', + 'name_encrypted' => Crypt::encryptString($name), + 'phone_encrypted' => Crypt::encryptString('01012345678'), + 'birthday_encrypted' => Crypt::encryptString('19900101'), + 'di_encrypted' => Crypt::encryptString('DI-CARD-0001'), + 'di_hash' => hash_hmac('sha256', 'DI-CARD-0001', (string) config('app.key')), + 'ci_encrypted' => Crypt::encryptString('CI-CARD-0001'), + 'ci_hash' => hash_hmac('sha256', 'CI-CARD-0001', (string) config('app.key')), + 'gender' => 'M', + 'is_foreigner' => false, + 'is_adult' => true, + 'verified_at' => now(), + ]); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpAdultVerificationGuardTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpAdultVerificationGuardTest.php new file mode 100644 index 00000000..99de2475 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpAdultVerificationGuardTest.php @@ -0,0 +1,195 @@ +logRepository = app(IdentityVerificationLogRepositoryInterface::class); + $this->provider = app()->makeWith(KcpIdentityProvider::class, ['config' => ['duplicate_field' => 'di']]); + $this->minorBirthday = now()->subYears(10)->format('Ymd'); + } + + /** + * @scenario purpose=adult,age=under_19 + * + * @effects minor_blocked_with_not_adult_and_no_token + */ + public function test_adult_purpose_blocks_minor(): void + { + $log = $this->createPendingLog(User::factory()->create()->id, KcpIdentityProvider::ADULT_PURPOSE); + + $result = $this->provider->verify($log->id, $this->certData($this->minorBirthday)); + + $this->assertFalse($result->success, '미성년자는 성인인증을 통과하면 안 된다'); + $this->assertSame('NOT_ADULT', $result->failureCode); + $this->assertNull($result->claims['verification_token'] ?? null); + + $reload = $this->logRepository->findById($log->id); + $this->assertSame(IdentityVerificationStatus::Failed->value, $reload->status->value); + $this->assertNull($reload->verification_token); + } + + /** + * @scenario purpose=adult,age=over_19 + * + * @effects adult_passes_with_token + */ + public function test_adult_purpose_passes_adult(): void + { + $log = $this->createPendingLog(User::factory()->create()->id, KcpIdentityProvider::ADULT_PURPOSE); + + $result = $this->provider->verify($log->id, $this->certData('19900101')); + + $this->assertTrue($result->success); + $this->assertNotEmpty($result->claims['verification_token'] ?? null); + $this->assertTrue($this->logRepository->findById($log->id)->isVerified()); + } + + /** + * @scenario purpose=adult,age=exactly_19_today + * + * @effects nineteenth_birthday_today_counts_as_adult + */ + public function test_nineteenth_birthday_today_passes(): void + { + $log = $this->createPendingLog(User::factory()->create()->id, KcpIdentityProvider::ADULT_PURPOSE); + + $result = $this->provider->verify($log->id, $this->certData(now()->subYears(19)->format('Ymd'))); + + $this->assertTrue($result->success, '만 19세가 되는 당일은 성인으로 인정해야 한다'); + } + + /** + * @scenario purpose=non_adult,age=under_19 + * + * @effects non_adult_purpose_passes_for_minor + */ + public function test_non_adult_purpose_is_unaffected_by_age(): void + { + $log = $this->createPendingLog(User::factory()->create()->id, 'self_update'); + + $result = $this->provider->verify($log->id, $this->certData($this->minorBirthday)); + + $this->assertTrue($result->success, '성인인증 외 purpose 는 나이 제한이 없어야 한다'); + } + + /** + * @scenario purpose=adult,age=birthday_missing + * + * @effects missing_birthday_blocked_by_incomplete_identity + */ + public function test_missing_birthday_is_blocked_by_identity_guard_first(): void + { + $liveProvider = app()->makeWith(KcpIdentityProvider::class, [ + 'config' => ['duplicate_field' => 'di', 'is_test_mode' => false], + ]); + $log = $this->createPendingLog(User::factory()->create()->id, KcpIdentityProvider::ADULT_PURPOSE); + + // 운영 모드에서 생년월일 부재는 "미성년자" 가 아니라 "신원 불완전" 이 정확한 사유다. + // KCP 규격상 생년월일은 조건 없이 항상 내려오므로, 없으면 비정상 응답이다. + $result = $liveProvider->verify($log->id, $this->certData('')); + + $this->assertFalse($result->success); + $this->assertSame('INCOMPLETE_IDENTITY', $result->failureCode); + } + + /** + * 테스트 모드는 생년월일 부재를 신원 불완전으로 보지 않는다. KCP 테스트 서버가 실제 통신사 + * 조회를 하지 않아 생년월일을 빈 값으로 응답하기 때문이며, 운영과 동일하게 거절하면 테스트 + * 환경에서는 어떤 방법으로도 인증을 완주할 수 없다. + * + * 다만 성인 여부는 계산할 수 없으므로 성인인증 목적은 계속 막힌다 (fail-closed). + * + * @scenario mode=test,birth_day=absent,purpose=adult + * + * @effects test_mode_missing_birthday_blocked_by_adult_guard_not_identity_guard + */ + public function test_test_mode_missing_birthday_falls_through_to_adult_guard(): void + { + $log = $this->createPendingLog(User::factory()->create()->id, KcpIdentityProvider::ADULT_PURPOSE); + + $result = $this->provider->verify($log->id, $this->certData('')); + + $this->assertFalse($result->success, '성인 여부를 알 수 없으면 성인인증은 통과시키지 않는다'); + $this->assertSame('NOT_ADULT', $result->failureCode, '테스트 모드에서는 신원 가드가 아니라 성인 가드가 막아야 한다'); + $this->assertNull($result->claims['verification_token'] ?? null); + } + + /** + * 결과조회 성공 페이로드 — 생년월일만 파라미터화. + * + * @param string $birthday YYYYMMDD + * @return array + */ + private function certData(string $birthday): array + { + return [ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'user_name' => '홍길동', + 'birth_day' => $birthday, + 'phone_no' => '01012345678', + 'comm_id' => 'SKT', + 'sex_code' => '01', + 'local_code' => '01', + 'ci' => 'CI-'.Str::random(10), + 'di' => 'DI-'.Str::random(10), + ]; + } + + /** + * 인증 대기 상태의 challenge 로그를 만든다. + * + * @param int $userId 사용자 ID + * @param string $purpose IDV purpose + */ + private function createPendingLog(int $userId, string $purpose): IdentityVerificationLog + { + return $this->logRepository->create([ + 'id' => (string) Str::uuid(), + 'provider_id' => KcpIdentityProvider::PROVIDER_ID, + 'purpose' => $purpose, + 'channel' => KcpIdentityProvider::CHANNEL, + 'user_id' => $userId, + 'target_hash' => hash('sha256', 'adult-test@example.com'), + 'status' => IdentityVerificationStatus::Sent->value, + 'render_hint' => 'text_code', + 'attempts' => 0, + 'max_attempts' => 0, + 'origin_type' => IdentityOriginType::Route->value, + 'origin_identifier' => 'api.identity.verify', + 'verification_token' => null, + 'expires_at' => now()->addMinutes(15), + 'metadata' => ['ordr_idxx' => 'G7-'.Str::random(8)], + ]); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpCallbackResolverTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpCallbackResolverTest.php new file mode 100644 index 00000000..e2dccf83 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpCallbackResolverTest.php @@ -0,0 +1,563 @@ +logRepository = app(IdentityVerificationLogRepositoryInterface::class); + $this->transactionRepository = app(KcpCertTransactionRepositoryInterface::class); + $this->resolver = app(KcpCallbackResolver::class); + + // 레지스트리의 provider 가 테스트 자격증명으로 동작하도록 플러그인 설정을 심는다. + app(PluginSettingsService::class)->save(self::PLUGIN_IDENTIFIER, [ + 'is_test_mode' => true, + 'test_site_cd' => self::SITE_CD, + 'test_enc_key' => self::ENC_KEY, + 'duplicate_field' => 'di', + 'duplicate_block_enabled' => true, + ]); + } + + /** + * @scenario outcome=success,lookup=by_reg_cert_key,missing_field=none + * + * @effects success_issues_verification_token_and_marks_log_verified + */ + public function test_successful_callback_issues_token(): void + { + $log = $this->createChallenge(User::factory()->create()->id); + $this->fakeCertQuery($this->identityPayload()); + + $outcome = $this->resolver->resolve([ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'reg_cert_key' => self::REG_CERT_KEY, + 'param_opt_1' => $log->id, + ]); + + $this->assertTrue($outcome->success); + $this->assertSame($log->id, $outcome->challengeId); + $this->assertNotEmpty($outcome->verificationToken); + $this->assertTrue($this->logRepository->findById($log->id)->isVerified()); + } + + /** + * @scenario outcome=success,lookup=by_param_opt,missing_field=none + * + * @effects lookup_by_param_opt_when_reg_cert_key_absent + */ + public function test_lookup_falls_back_to_param_opt_challenge_id(): void + { + $log = $this->createChallenge(User::factory()->create()->id); + $this->fakeCertQuery($this->identityPayload()); + + // 콜백이 거래키를 돌려주지 않는 경우 — 거래등록 시 실어 보낸 challenge_id 로 역조회한다. + $outcome = $this->resolver->resolve([ + 'res_cd' => '0000', + 'param_opt_1' => $log->id, + ]); + + $this->assertTrue($outcome->success); + $this->assertSame($log->id, $outcome->challengeId); + } + + /** + * @scenario outcome=not_found,lookup=by_reg_cert_key,missing_field=none + * + * @effects unknown_transaction_returns_not_found + */ + public function test_unknown_transaction_returns_not_found(): void + { + $outcome = $this->resolver->resolve([ + 'res_cd' => '0000', + 'reg_cert_key' => 'UNKNOWN-KEY', + ]); + + $this->assertFalse($outcome->success); + $this->assertSame('NOT_FOUND', $outcome->failureCode); + // 거래를 못 찾은 경우에는 알려줄 challenge 가 없다 — 실패 코드만 전달한다. + $this->assertSame(['identity_error' => 'NOT_FOUND'], $outcome->toBridgeQuery()); + } + + /** + * 실패 결과도 어느 challenge 가 끝났는지 화면에 알려야 한다. + * + * 한 번 콜백이 처리된 거래는 KCP 쪽에서 종료되어 재사용할 수 없다. 화면은 이 신호를 받아야 + * 재시도 시 새 거래를 발급받는다. 신호가 없으면 죽은 거래키로 인증창을 다시 열어 + * "이미 종료된 거래" 로 실패하며, 사용자는 몇 번을 눌러도 진행할 수 없다. + * + * @scenario outcome=incomplete_identity,lookup=by_reg_cert_key,missing_field=user_name + * + * @effects failure_bridge_query_carries_challenge_id_for_retry + */ + public function test_failure_bridge_query_carries_challenge_id(): void + { + $log = $this->createChallenge(User::factory()->create()->id); + $this->fakeCertQuery(array_merge($this->identityPayload(), ['user_name' => ''])); + + $outcome = $this->resolver->resolve(['res_cd' => '0000', 'reg_cert_key' => self::REG_CERT_KEY]); + $query = $outcome->toBridgeQuery(); + + $this->assertFalse($outcome->success); + $this->assertArrayHasKey('challenge_id', $query, '실패 결과에도 challenge_id 가 있어야 재시도 시 새 거래를 발급받는다'); + $this->assertSame($log->id, $query['challenge_id']); + } + + /** + * @scenario outcome=already_consumed,lookup=by_reg_cert_key,missing_field=none + * + * @effects second_callback_returns_already_consumed + */ + public function test_second_callback_is_rejected_as_already_consumed(): void + { + $log = $this->createChallenge(User::factory()->create()->id); + $this->fakeCertQuery($this->identityPayload()); + + $first = $this->resolver->resolve(['res_cd' => '0000', 'reg_cert_key' => self::REG_CERT_KEY]); + $this->assertTrue($first->success); + + $second = $this->resolver->resolve(['res_cd' => '0000', 'reg_cert_key' => self::REG_CERT_KEY]); + + $this->assertFalse($second->success); + $this->assertSame('ALREADY_CONSUMED', $second->failureCode); + $this->assertNotNull(KcpCertTransaction::query()->where('challenge_id', $log->id)->first()->consumed_at); + } + + /** + * @scenario outcome=cancelled,lookup=by_reg_cert_key,missing_field=none + * + * @effects cancelled_marks_log_cancelled_with_9999_code + */ + public function test_user_cancellation_marks_log_cancelled(): void + { + $log = $this->createChallenge(User::factory()->create()->id); + Http::fake(); + + $outcome = $this->resolver->resolve([ + 'res_cd' => '9999', + 'res_msg' => '사용자취소', + 'reg_cert_key' => self::REG_CERT_KEY, + ]); + + $this->assertFalse($outcome->success); + $this->assertSame('9999', $outcome->failureCode); + $this->assertSame( + IdentityVerificationStatus::Cancelled->value, + $this->logRepository->findById($log->id)->status->value, + ); + Http::assertNothingSent(); + } + + /** + * @scenario outcome=provider_error,lookup=by_reg_cert_key,missing_field=none + * + * @effects provider_error_marks_log_failed_with_res_cd + */ + public function test_standard_window_failure_marks_log_failed(): void + { + $log = $this->createChallenge(User::factory()->create()->id); + Http::fake(); + + $outcome = $this->resolver->resolve([ + 'res_cd' => 'CS12', + 'res_msg' => '%EC%9D%B8%EC%A6%9D%EC%8B%A4%ED%8C%A8', + 'reg_cert_key' => self::REG_CERT_KEY, + ]); + + $this->assertFalse($outcome->success); + $this->assertSame('CS12', $outcome->failureCode); + $this->assertSame( + IdentityVerificationStatus::Failed->value, + $this->logRepository->findById($log->id)->status->value, + ); + } + + /** + * @scenario outcome=remote_failure,lookup=by_reg_cert_key,missing_field=none + * + * @effects remote_failure_returns_remote_call_failed + */ + public function test_result_query_communication_failure(): void + { + $this->createChallenge(User::factory()->create()->id); + Http::fake(['testcert.kcp.co.kr/*' => Http::response('bad gateway', 502)]); + + $outcome = $this->resolver->resolve(['res_cd' => '0000', 'reg_cert_key' => self::REG_CERT_KEY]); + + $this->assertFalse($outcome->success); + $this->assertSame('REMOTE_CALL_FAILED', $outcome->failureCode); + } + + /** + * 코어 verify 에 닿지 못하고 끝나는 실패도 인증 이력에 남아야 한다. + * + * 남기지 않으면 거래는 소비됐는데 로그는 "보냄" 으로 머물러, 사용자가 인증창을 열어 두고 + * 그냥 떠난 경우와 구분되지 않는다. + * + * @scenario outcome=remote_failure,lookup=by_reg_cert_key,missing_field=none + * + * @effects resolver_only_failure_is_recorded_on_log + */ + public function test_remote_failure_marks_log_failed_with_failure_code(): void + { + $log = $this->createChallenge(User::factory()->create()->id); + Http::fake(['testcert.kcp.co.kr/*' => Http::response('bad gateway', 502)]); + + $this->resolver->resolve(['res_cd' => '0000', 'reg_cert_key' => self::REG_CERT_KEY]); + + $log->refresh(); + $this->assertSame(IdentityVerificationStatus::Failed, $log->status); + $this->assertSame('REMOTE_CALL_FAILED', $log->metadata['failure_code'] ?? null); + } + + /** + * 거래키 복호화 실패도 동일하게 인증 이력에 남아야 한다. + * + * @scenario outcome=decrypt_failure,lookup=by_reg_cert_key,missing_field=none + * + * @effects resolver_only_failure_is_recorded_on_log + */ + public function test_decrypt_failure_marks_log_failed_with_failure_code(): void + { + $log = $this->createChallenge(User::factory()->create()->id); + Http::fake([ + 'testcert.kcp.co.kr/*' => Http::response([ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'enc_cert_data' => 'not-a-valid-ciphertext', + 'rv' => 'not-a-valid-salt', + ]), + ]); + + $this->resolver->resolve(['res_cd' => '0000', 'reg_cert_key' => self::REG_CERT_KEY]); + + $log->refresh(); + $this->assertSame(IdentityVerificationStatus::Failed, $log->status); + $this->assertSame('DECRYPT_FAILED', $log->metadata['failure_code'] ?? null); + } + + /** + * 늦게 도착한 중복 콜백이 이미 확정된 성공 기록을 실패로 덮어써서는 안 된다. + * + * @scenario outcome=already_consumed,lookup=by_reg_cert_key,missing_field=none + * + * @effects resolver_only_failure_is_recorded_on_log + */ + public function test_already_consumed_does_not_overwrite_verified_log(): void + { + $log = $this->createChallenge(User::factory()->create()->id); + $this->fakeCertQuery($this->identityPayload()); + + $this->assertTrue($this->resolver->resolve(['res_cd' => '0000', 'reg_cert_key' => self::REG_CERT_KEY])->success); + + $this->resolver->resolve(['res_cd' => '0000', 'reg_cert_key' => self::REG_CERT_KEY]); + + $log->refresh(); + $this->assertSame(IdentityVerificationStatus::Verified, $log->status); + $this->assertArrayNotHasKey('failure_code', (array) $log->metadata); + } + + /** + * @scenario outcome=decrypt_failure,lookup=by_reg_cert_key,missing_field=none + * + * @effects decrypt_failure_returns_decrypt_failed + */ + public function test_result_query_decrypt_failure(): void + { + $this->createChallenge(User::factory()->create()->id); + Http::fake([ + 'testcert.kcp.co.kr/*' => Http::response([ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'enc_cert_data' => base64_encode('broken-cipher'), + 'rv' => base64_encode(random_bytes(16)), + ]), + ]); + + $outcome = $this->resolver->resolve(['res_cd' => '0000', 'reg_cert_key' => self::REG_CERT_KEY]); + + $this->assertFalse($outcome->success); + $this->assertSame('DECRYPT_FAILED', $outcome->failureCode); + } + + /** + * @scenario outcome=cert_rejected,lookup=by_reg_cert_key,missing_field=none + * + * @effects provider_error_marks_log_failed_with_res_cd, unmapped_provider_code_carries_original_message + */ + public function test_result_query_rejection_is_reported_with_its_code(): void + { + $log = $this->createChallenge(User::factory()->create()->id); + Http::fake([ + 'testcert.kcp.co.kr/*' => Http::response([ + 'res_cd' => 'CS16', + 'res_msg' => '거래등록이 올바르지 않습니다.', + ]), + ]); + + $outcome = $this->resolver->resolve(['res_cd' => '0000', 'reg_cert_key' => self::REG_CERT_KEY]); + + $this->assertFalse($outcome->success); + $this->assertSame('CS16', $outcome->failureCode); + // 우리말 안내가 없는 코드는 KCP 원문을 함께 전달해 화면이 "[코드] 메시지" 로 안내한다. + $this->assertSame('거래등록이 올바르지 않습니다.', $outcome->failureMessage); + $this->assertSame( + '거래등록이 올바르지 않습니다.', + $outcome->toBridgeQuery()['identity_error_message'] ?? null, + ); + $this->assertSame( + IdentityVerificationStatus::Failed->value, + $this->logRepository->findById($log->id)->status->value, + ); + } + + /** + * @scenario outcome=incomplete_identity,lookup=by_reg_cert_key,missing_field=user_name + * + * @effects missing_identity_fields_returns_incomplete_identity + */ + public function test_missing_user_name_returns_incomplete_identity(): void + { + $this->assertMissingIdentityFieldIsRejected('user_name'); + } + + /** + * @scenario outcome=incomplete_identity,lookup=by_reg_cert_key,missing_field=phone_no + * + * @effects missing_identity_fields_returns_incomplete_identity + */ + public function test_missing_phone_no_returns_incomplete_identity(): void + { + $this->assertMissingIdentityFieldIsRejected('phone_no'); + } + + /** + * @scenario outcome=incomplete_identity,lookup=by_reg_cert_key,missing_field=ci + * + * @effects missing_identity_fields_returns_incomplete_identity + */ + public function test_missing_ci_returns_incomplete_identity(): void + { + $this->assertMissingIdentityFieldIsRejected('CI'); + } + + /** + * @scenario outcome=incomplete_identity,lookup=by_reg_cert_key,missing_field=di + * + * @effects missing_identity_fields_returns_incomplete_identity + */ + public function test_missing_di_returns_incomplete_identity(): void + { + $this->assertMissingIdentityFieldIsRejected('DI'); + } + + /** + * 신원 핵심값 하나가 비어 온 응답을 인증으로 인정하지 않는지 확인한다. + * + * 결과조회가 성공(res_cd=0000)으로 돌아와도 이름·휴대폰·CI·DI 중 빠진 값이 있으면 비정상 + * 응답이다. 실제로 PASS 앱 인증이 진행되지 않은 채 돌아온 응답이 이 형태였다. 빈 신원값으로 + * 회원이 등록되거나 중복 차단이 무력화되는 것을 막기 위해 항목마다 개별로 잠근다. + * + * 생년월일은 모드에 따라 판정이 갈리므로 별도 테스트에서 다룬다. + * + * @param string $field 비워서 보낼 결과조회 응답 필드명 + */ + private function assertMissingIdentityFieldIsRejected(string $field): void + { + $this->createChallenge(User::factory()->create()->id); + $this->fakeCertQuery(array_merge($this->identityPayload(), [$field => ''])); + + $outcome = $this->resolver->resolve(['res_cd' => '0000', 'reg_cert_key' => self::REG_CERT_KEY]); + + $this->assertFalse($outcome->success, "{$field} 가 비면 인증을 인정하면 안 된다"); + $this->assertSame('INCOMPLETE_IDENTITY', $outcome->failureCode); + $this->assertDatabaseCount('nhnkcp_identity_records', 0); + } + + /** + * 운영 모드에서 생년월일이 비면 인증을 인정하지 않는다. + * + * KCP 규격상 생년월일은 조건 없이 항상 내려오므로, 운영에서 비어 오면 비정상 응답이다. + * + * @scenario outcome=incomplete_identity,lookup=by_reg_cert_key,missing_field=none + * + * @effects live_mode_rejects_response_without_birthday + */ + public function test_live_mode_rejects_missing_birthday(): void + { + app(PluginSettingsService::class)->save(self::PLUGIN_IDENTIFIER, [ + 'is_test_mode' => false, + 'live_site_cd' => 'SM'.self::SITE_CD, + 'live_enc_key' => self::ENC_KEY, + 'duplicate_field' => 'di', + 'duplicate_block_enabled' => true, + ]); + + $this->createChallenge(User::factory()->create()->id); + $this->fakeCertQuery( + array_merge($this->identityPayload(), ['birth_day' => '']), + 'SM'.self::SITE_CD, + 'cert.kcp.co.kr/*', + ); + + $outcome = $this->resolver->resolve(['res_cd' => '0000', 'reg_cert_key' => self::REG_CERT_KEY]); + + $this->assertFalse($outcome->success); + $this->assertSame('INCOMPLETE_IDENTITY', $outcome->failureCode); + $this->assertDatabaseCount('nhnkcp_identity_records', 0); + } + + /** + * 테스트 모드에서는 생년월일이 비어도 인증을 인정한다. + * + * KCP 테스트 서버는 실제 통신사 조회를 하지 않아 생년월일·성별·내외국인을 빈 값으로 응답한다. + * 운영과 동일하게 거절하면 테스트 환경에서는 인증을 완주할 수 없어 검수가 불가능해진다. + * + * @scenario outcome=success,lookup=by_reg_cert_key,missing_field=none + * + * @effects test_mode_accepts_response_without_birthday + */ + public function test_test_mode_accepts_missing_birthday(): void + { + $this->createChallenge(User::factory()->create()->id); + $this->fakeCertQuery(array_merge($this->identityPayload(), ['birth_day' => ''])); + + $outcome = $this->resolver->resolve(['res_cd' => '0000', 'reg_cert_key' => self::REG_CERT_KEY]); + + $this->assertTrue($outcome->success, '테스트 모드에서는 생년월일 없이도 인증이 완주되어야 한다'); + $this->assertNotSame('', $outcome->verificationToken); + $this->assertDatabaseCount('nhnkcp_identity_records', 1); + } + + /** + * @scenario outcome=success,lookup=by_reg_cert_key,missing_field=none + * + * @effects bridge_query_carries_no_personal_information + */ + public function test_bridge_query_contains_no_personal_information(): void + { + $log = $this->createChallenge(User::factory()->create()->id); + $this->fakeCertQuery($this->identityPayload()); + + $outcome = $this->resolver->resolve(['res_cd' => '0000', 'reg_cert_key' => self::REG_CERT_KEY]); + $query = $outcome->toBridgeQuery(); + + $this->assertSame(['verification_token', 'challenge_id'], array_keys($query)); + $serialized = json_encode($query, JSON_UNESCAPED_UNICODE); + $this->assertStringNotContainsString('홍길동', (string) $serialized); + $this->assertStringNotContainsString('01012345678', (string) $serialized); + $this->assertStringNotContainsString('DI-CALLBACK-0001', (string) $serialized); + $this->assertSame($log->id, $query['challenge_id']); + } + + /** + * 결과조회 응답(암호문)을 가로챈다. + * + * @param array $identity 복호화 후 노출될 평문 페이로드 + * @param string|null $siteCd 암호화에 사용할 사이트코드 (운영 모드 검증 시 라이브 코드) + * @param string $hostPattern 가로챌 호스트 패턴 (운영 모드는 cert.kcp.co.kr) + */ + private function fakeCertQuery(array $identity, ?string $siteCd = null, string $hostPattern = 'testcert.kcp.co.kr/*'): void + { + $encrypted = KcpCertCrypto::encrypt((string) json_encode($identity), self::ENC_KEY, $siteCd ?? self::SITE_CD); + + Http::fake([ + $hostPattern => Http::response([ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'enc_cert_data' => $encrypted['enc_data'], + 'rv' => $encrypted['rv'], + ]), + ]); + } + + /** + * KCP 결과조회 평문 페이로드. + * + * @return array + */ + private function identityPayload(): array + { + return [ + 'user_name' => '홍길동', + 'birth_day' => '19900101', + 'phone_no' => '01012345678', + 'comm_id' => 'SKT', + 'sex_code' => '01', + 'local_code' => '01', + 'CI' => 'CI-CALLBACK-0001', + 'DI' => 'DI-CALLBACK-0001', + ]; + } + + /** + * 인증 대기 challenge + 거래 행을 만든다. + * + * @param int|null $userId 사용자 ID + */ + private function createChallenge(?int $userId): IdentityVerificationLog + { + $log = $this->logRepository->create([ + 'id' => (string) Str::uuid(), + 'provider_id' => KcpIdentityProvider::PROVIDER_ID, + 'purpose' => 'self_update', + 'channel' => KcpIdentityProvider::CHANNEL, + 'user_id' => $userId, + 'target_hash' => hash('sha256', 'callback-test@example.com'), + 'status' => IdentityVerificationStatus::Sent->value, + 'render_hint' => 'text_code', + 'attempts' => 0, + 'max_attempts' => 0, + 'origin_type' => IdentityOriginType::Route->value, + 'origin_identifier' => 'api.identity.verify', + 'verification_token' => null, + 'expires_at' => now()->addMinutes(15), + 'metadata' => ['ordr_idxx' => self::ORDR_IDXX], + ]); + + $this->transactionRepository->create(self::ORDR_IDXX, self::REG_CERT_KEY, $log->id); + + return $log; + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpDuplicateVerifyGuardTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpDuplicateVerifyGuardTest.php new file mode 100644 index 00000000..bf1f18ef --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpDuplicateVerifyGuardTest.php @@ -0,0 +1,213 @@ +logRepository = app(IdentityVerificationLogRepositoryInterface::class); + $this->recordRepository = app(KcpIdentityRecordRepositoryInterface::class); + } + + /** + * @scenario gate=verify,basis=di,blocking=enabled,relation=duplicate + * + * @effects verify_blocks_duplicate_signup_with_duplicate_code + */ + public function test_signup_with_existing_di_is_blocked_at_verify(): void + { + $di = 'DI-DUPLICATE-0001'; + $this->seedRecordFor(User::factory()->create()->id, KcpIdentityHasher::hash($di), null); + + $log = $this->createGuestSignupLog(); + $result = $this->provider(['duplicate_block_enabled' => true, 'duplicate_field' => 'di']) + ->verify($log->id, $this->certData(di: $di)); + + $this->assertFalse($result->success); + $this->assertSame('DUPLICATE', $result->failureCode); + $this->assertSame( + IdentityVerificationStatus::Failed->value, + $this->logRepository->findById($log->id)->status->value, + ); + } + + /** + * @scenario gate=verify,basis=ci,blocking=enabled,relation=duplicate + * + * @effects ci_basis_matches_by_ci_hash + */ + public function test_ci_basis_blocks_by_ci_hash(): void + { + $ci = 'CI-DUPLICATE-0001'; + $this->seedRecordFor(User::factory()->create()->id, KcpIdentityHasher::hash('other-di'), KcpIdentityHasher::hash($ci)); + + $log = $this->createGuestSignupLog(); + $result = $this->provider(['duplicate_block_enabled' => true, 'duplicate_field' => 'ci']) + ->verify($log->id, $this->certData(ci: $ci)); + + $this->assertFalse($result->success); + $this->assertSame('DUPLICATE', $result->failureCode); + } + + /** + * @scenario gate=verify,basis=di,blocking=disabled,relation=duplicate + * + * @effects disabled_setting_passes_both_gates + */ + public function test_disabled_setting_allows_duplicate_verify(): void + { + $di = 'DI-DUPLICATE-0002'; + $this->seedRecordFor(User::factory()->create()->id, KcpIdentityHasher::hash($di), null); + + $log = $this->createGuestSignupLog(); + $result = $this->provider(['duplicate_block_enabled' => false, 'duplicate_field' => 'di']) + ->verify($log->id, $this->certData(di: $di)); + + $this->assertTrue($result->success, '차단을 끄면 인증 자체는 통과해야 한다'); + } + + /** + * @scenario gate=verify,basis=di,blocking=enabled,relation=new_person + * + * @effects verify_blocks_duplicate_signup_with_duplicate_code + */ + public function test_new_person_passes(): void + { + $log = $this->createGuestSignupLog(); + $result = $this->provider(['duplicate_block_enabled' => true, 'duplicate_field' => 'di']) + ->verify($log->id, $this->certData(di: 'DI-BRAND-NEW')); + + $this->assertTrue($result->success); + } + + /** + * @scenario gate=verify,basis=di,blocking=enabled,relation=self_reverify + * + * @effects non_signup_purpose_is_not_blocked + */ + public function test_logged_in_reverification_is_not_blocked(): void + { + $user = User::factory()->create(); + $di = 'DI-SELF-0001'; + $this->seedRecordFor($user->id, KcpIdentityHasher::hash($di), null); + + $log = $this->createPendingLog($user->id, 'self_update'); + $result = $this->provider(['duplicate_block_enabled' => true, 'duplicate_field' => 'di']) + ->verify($log->id, $this->certData(di: $di)); + + $this->assertTrue($result->success, '본인 재인증은 중복으로 판정하면 안 된다'); + } + + /** + * 설정이 주입된 provider. + * + * @param array $config + */ + private function provider(array $config): KcpIdentityProvider + { + return app()->makeWith(KcpIdentityProvider::class, ['config' => $config]); + } + + /** + * 기존 가입자의 본인확인 record 를 심는다. + * + * @param int $userId 사용자 ID + * @param string|null $diHash DI keyed-hash + * @param string|null $ciHash CI keyed-hash + */ + private function seedRecordFor(int $userId, ?string $diHash, ?string $ciHash): void + { + $this->recordRepository->upsertForUser($userId, [ + 'di_hash' => $diHash, + 'ci_hash' => $ciHash, + 'is_adult' => true, + 'is_foreigner' => false, + 'verified_at' => now(), + ]); + } + + /** + * 결과조회 성공 페이로드. + * + * @param string $di DI 평문 + * @param string $ci CI 평문 + * @return array + */ + private function certData(string $di = 'DI-DEFAULT', string $ci = 'CI-DEFAULT'): array + { + return [ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'user_name' => '홍길동', + 'birth_day' => '19900101', + 'phone_no' => '01012345678', + 'comm_id' => 'SKT', + 'sex_code' => '01', + 'local_code' => '01', + 'ci' => $ci, + 'di' => $di, + ]; + } + + /** + * 비로그인 가입 challenge 로그. + */ + private function createGuestSignupLog(): IdentityVerificationLog + { + return $this->createPendingLog(null, 'signup'); + } + + /** + * 인증 대기 challenge 로그. + * + * @param int|null $userId 사용자 ID (비로그인은 null) + * @param string $purpose IDV purpose + */ + private function createPendingLog(?int $userId, string $purpose): IdentityVerificationLog + { + return $this->logRepository->create([ + 'id' => (string) Str::uuid(), + 'provider_id' => KcpIdentityProvider::PROVIDER_ID, + 'purpose' => $purpose, + 'channel' => KcpIdentityProvider::CHANNEL, + 'user_id' => $userId, + 'target_hash' => hash('sha256', 'dup-test@example.com'), + 'status' => IdentityVerificationStatus::Sent->value, + 'render_hint' => 'text_code', + 'attempts' => 0, + 'max_attempts' => 0, + 'origin_type' => IdentityOriginType::Route->value, + 'origin_identifier' => 'api.identity.verify', + 'verification_token' => null, + 'expires_at' => now()->addMinutes(15), + 'metadata' => ['ordr_idxx' => 'G7-'.Str::random(8)], + ]); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpIdentityProviderBindingTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpIdentityProviderBindingTest.php new file mode 100644 index 00000000..7819d20e --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpIdentityProviderBindingTest.php @@ -0,0 +1,94 @@ +assertTrue($manager->has(KcpIdentityProvider::PROVIDER_ID)); + + $provider = $manager->get(KcpIdentityProvider::PROVIDER_ID); + $this->assertInstanceOf(KcpIdentityProvider::class, $provider); + $this->assertSame('phone', $provider->getChannels()[0]); + $this->assertNotSame('', $provider->getLabel()); + $this->assertArrayHasKey('phone', $provider->getChannelLabels()); + } + + /** + * @scenario stage=activate,existing_purpose_provider=absent + * + * @effects activate_registers_provider_in_core_registry + */ + public function test_registered_provider_receives_saved_settings(): void + { + app(PluginSettingsService::class)->save(self::PLUGIN_IDENTIFIER, [ + 'is_test_mode' => false, + 'live_site_cd' => 'A1B2C', + 'live_enc_key' => 'live-key', + ]); + + $provider = app(IdentityVerificationManager::class)->get(KcpIdentityProvider::PROVIDER_ID); + + $this->assertInstanceOf(KcpIdentityProvider::class, $provider); + $this->assertSame('SMA1B2C', $provider->resolveSiteCd(), '저장된 설정이 provider 에 반영되어야 한다'); + $this->assertTrue($provider->isAvailable()); + } + + /** + * @scenario stage=deactivate,existing_purpose_provider=absent + * + * @effects deactivate_removes_provider_from_core_registry + */ + public function test_provider_disappears_when_listener_is_not_registered(): void + { + // 비활성 상태를 재현 — 훅 리스너가 등록되지 않으면 레지스트리에도 나타나지 않아야 한다. + // HookManager 는 정적 상태라 같은 프로세스의 후속 테스트에 영향을 주므로 반드시 복구한다. + HookManager::clearFilter('core.identity.registered_providers'); + + try { + $this->assertFalse(app(IdentityVerificationManager::class)->has(KcpIdentityProvider::PROVIDER_ID)); + } finally { + // Registrar 는 중복 등록을 막는 정적 목록을 갖고 있어, 훅을 지운 뒤에는 목록도 비워야 + // 재등록이 실제로 이뤄진다. + HookListenerRegistrar::clear(); + $this->registerPluginHookListeners(); + } + } + + /** + * @scenario stage=activate,existing_purpose_provider=absent + * + * @effects activate_registers_provider_in_core_registry + */ + public function test_provider_supports_all_purposes_and_uses_text_code_hint(): void + { + $provider = app(IdentityVerificationManager::class)->get(KcpIdentityProvider::PROVIDER_ID); + + $this->assertTrue($provider->supportsPurpose('signup')); + $this->assertTrue($provider->supportsPurpose(KcpIdentityProvider::ADULT_PURPOSE)); + $this->assertSame('text_code', $provider->getRenderHint()); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpInstallDefaultPurposeProviderTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpInstallDefaultPurposeProviderTest.php new file mode 100644 index 00000000..476d778e --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpInstallDefaultPurposeProviderTest.php @@ -0,0 +1,111 @@ +assertEmpty($settings->getSetting(self::SETTING_KEY)); + + (new Plugin)->install(); + + $this->assertSame('nhnkcp', $settings->getSetting(self::SETTING_KEY)); + } + + /** + * @scenario stage=install,existing_purpose_provider=present + * + * @effects install_preserves_existing_purpose_provider + */ + public function test_install_preserves_operator_choice(): void + { + $settings = app(SettingsService::class); + $settings->setSetting(self::SETTING_KEY, 'g7:core.mail'); + + (new Plugin)->install(); + + $this->assertSame( + 'g7:core.mail', + $settings->getSetting(self::SETTING_KEY), + '이미 지정된 운영자 선택을 덮어쓰면 안 된다', + ); + } + + /** + * @scenario stage=uninstall,existing_purpose_provider=absent + * + * @effects uninstall_removes_seeded_purpose_provider + */ + public function test_uninstall_removes_seeded_provider(): void + { + $settings = app(SettingsService::class); + (new Plugin)->install(); + $this->assertSame('nhnkcp', $settings->getSetting(self::SETTING_KEY)); + + (new Plugin)->uninstall(); + + $this->assertEmpty( + $settings->getSetting(self::SETTING_KEY), + '삭제 후에도 존재하지 않는 provider 를 가리키는 매핑이 남으면 안 된다', + ); + } + + /** + * @scenario stage=uninstall,existing_purpose_provider=present + * + * @effects uninstall_preserves_operator_chosen_provider + */ + public function test_uninstall_preserves_operator_choice(): void + { + $settings = app(SettingsService::class); + $settings->setSetting(self::SETTING_KEY, 'g7:core.mail'); + + (new Plugin)->uninstall(); + + $this->assertSame( + 'g7:core.mail', + $settings->getSetting(self::SETTING_KEY), + '우리가 심지 않은 값은 거두지 않는다', + ); + } + + /** + * @scenario stage=install,existing_purpose_provider=absent + * + * @effects install_seeds_adult_purpose_provider_when_absent + */ + public function test_declared_purpose_uses_plugin_namespace_lang_keys(): void + { + $purposes = (new Plugin)->getIdentityPurposes(); + + $this->assertArrayHasKey('nhnkcp.adult_verification', $purposes); + $meta = $purposes['nhnkcp.adult_verification']; + + $this->assertSame('nhnkcp', $meta['default_provider']); + $this->assertSame(['phone'], $meta['allowed_channels']); + $this->assertStringStartsWith('sirsoft-verification_nhnkcp::', $meta['label']); + $this->assertNotSame($meta['label'], __($meta['label']), '다국어 키가 해석되어야 한다'); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpRequestChallengeTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpRequestChallengeTest.php new file mode 100644 index 00000000..479a728b --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpRequestChallengeTest.php @@ -0,0 +1,184 @@ + true, + 'test_site_cd' => 'AO7F3', + 'test_enc_key' => 'c2a22fa3ebe4698075bcac6b433d52e351c881b02fb83488d4283a43385b1f8e', + ]; + + /** + * @scenario mode=test,kcp_response=success + * + * @effects challenge_issued_with_call_url_and_reg_cert_key + */ + public function test_successful_registration_issues_challenge_with_standard_window_payload(): void + { + $this->fakeRegistration('0000'); + + $user = User::factory()->create(); + $challenge = $this->provider()->requestChallenge($user, ['purpose' => 'self_update']); + + $this->assertSame(KcpIdentityProvider::PROVIDER_ID, $challenge->providerId); + $this->assertSame('phone', $challenge->channel); + $this->assertSame('https://testcert.kcp.co.kr/certGateway.do', $challenge->publicPayload['call_url']); + $this->assertSame('CERTKEY000000001', $challenge->publicPayload['reg_cert_key']); + $this->assertTrue($challenge->publicPayload['is_test_mode']); + $this->assertSame(hash('sha256', mb_strtolower($user->email)), $challenge->targetHash); + + $log = IdentityVerificationLog::query()->whereKey($challenge->id)->first(); + $this->assertNotNull($log); + $this->assertSame('sent', $log->status->value); + $this->assertSame((int) $user->id, (int) $log->user_id); + } + + /** + * @scenario mode=test,kcp_response=success + * + * @effects transaction_row_stores_hashed_and_encrypted_reg_cert_key + */ + public function test_transaction_row_stores_key_as_hash_and_ciphertext_only(): void + { + $this->fakeRegistration('0000'); + + $challenge = $this->provider()->requestChallenge(['email' => 'guest@example.test'], ['purpose' => 'signup']); + + $transaction = KcpCertTransaction::query()->where('challenge_id', $challenge->id)->first(); + $this->assertNotNull($transaction); + $this->assertSame( + KcpIdentityHasher::hashRegCertKey('CERTKEY000000001'), + $transaction->reg_cert_key_hash, + '거래키는 조회용 keyed-hash 로만 인덱싱되어야 한다', + ); + $this->assertNotSame('CERTKEY000000001', $transaction->reg_cert_key_encrypted, '평문 보관 금지'); + $this->assertSame('CERTKEY000000001', Crypt::decryptString($transaction->reg_cert_key_encrypted)); + $this->assertSame($challenge->metadata['ordr_idxx'], $transaction->ordr_idxx); + $this->assertNull($transaction->consumed_at); + } + + /** + * @scenario mode=test,kcp_response=res_cd_error + * + * @effects res_cd_error_raises_remote_call_exception_without_challenge_row + */ + public function test_rejected_registration_does_not_create_any_row(): void + { + $this->fakeRegistration('8133', '가맹점 정보가 올바르지 않습니다.'); + + try { + $this->provider()->requestChallenge(['email' => 'guest@example.test'], ['purpose' => 'signup']); + $this->fail('거래등록 거절 시 예외가 발생해야 한다'); + } catch (RemoteCallException $e) { + $this->assertStringContainsString('8133', $e->detail); + } + + $this->assertSame(0, IdentityVerificationLog::query()->count(), 'challenge 로그가 남으면 안 된다'); + $this->assertSame(0, KcpCertTransaction::query()->count(), '거래 행이 남으면 안 된다'); + } + + /** + * @scenario mode=test,kcp_response=http_error + * + * @effects http_error_raises_remote_call_exception_without_challenge_row + */ + public function test_communication_failure_does_not_create_any_row(): void + { + Http::fake([ + 'testcert.kcp.co.kr/*' => Http::response('gateway timeout', 504), + ]); + + $this->expectException(RemoteCallException::class); + + try { + $this->provider()->requestChallenge(['email' => 'guest@example.test'], ['purpose' => 'signup']); + } finally { + $this->assertSame(0, IdentityVerificationLog::query()->count()); + $this->assertSame(0, KcpCertTransaction::query()->count()); + } + } + + /** + * @scenario mode=live,kcp_response=success + * + * @effects live_mode_uses_live_host_and_prefixed_site_cd + */ + public function test_live_mode_calls_live_host_with_prefixed_site_code(): void + { + Http::fake([ + 'cert.kcp.co.kr/*' => Http::response([ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'call_url' => 'https://cert.kcp.co.kr/certGateway.do', + 'reg_cert_key' => 'LIVEKEY000000001', + ]), + ]); + + $provider = $this->provider([ + 'is_test_mode' => false, + 'live_site_cd' => 'A1B2C', + 'live_enc_key' => 'live-enc-key', + ]); + + $challenge = $provider->requestChallenge(['email' => 'guest@example.test'], ['purpose' => 'signup']); + + $this->assertSame('https://cert.kcp.co.kr/certGateway.do', $challenge->publicPayload['call_url']); + $this->assertFalse($challenge->publicPayload['is_test_mode']); + + Http::assertSent(function ($request): bool { + $this->assertStringStartsWith('https://cert.kcp.co.kr/', $request->url()); + $this->assertSame('SMA1B2C', $request->header('site_cd')[0] ?? null); + + return true; + }); + } + + /** + * 거래등록 응답을 가로챈다. + * + * @param string $resCd 응답 코드 + * @param string $resMsg 응답 메시지 + */ + private function fakeRegistration(string $resCd, string $resMsg = '정상처리'): void + { + Http::fake([ + 'testcert.kcp.co.kr/*' => Http::response([ + 'res_cd' => $resCd, + 'res_msg' => $resMsg, + 'call_url' => $resCd === '0000' ? 'https://testcert.kcp.co.kr/certGateway.do' : '', + 'reg_cert_key' => $resCd === '0000' ? 'CERTKEY000000001' : '', + ]), + ]); + } + + /** + * 설정이 주입된 provider 인스턴스. + * + * @param array $config + */ + private function provider(array $config = self::TEST_CONFIG): KcpIdentityProvider + { + return app()->makeWith(KcpIdentityProvider::class, ['config' => $config]); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpTestModeBirthdayFallbackTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpTestModeBirthdayFallbackTest.php new file mode 100644 index 00000000..3aae6c7c --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpTestModeBirthdayFallbackTest.php @@ -0,0 +1,170 @@ +logRepository = app(IdentityVerificationLogRepositoryInterface::class); + } + + /** + * @scenario mode=test,birth_day=absent,purpose=general + * + * @effects test_mode_accepts_response_without_birthday + */ + public function test_test_mode_accepts_missing_birthday(): void + { + $log = $this->createPendingLog(User::factory()->create()->id, 'self_update'); + + $result = $this->provider(true)->verify($log->id, $this->certData('')); + + $this->assertTrue($result->success, '테스트 모드에서는 생년월일 없이도 본인확인을 인정해야 한다'); + $this->assertNotEmpty($result->claims['verification_token'] ?? null); + $this->assertTrue($this->logRepository->findById($log->id)->isVerified()); + } + + /** + * @scenario mode=live,birth_day=absent,purpose=general + * + * @effects live_mode_rejects_response_without_birthday + */ + public function test_live_mode_still_rejects_missing_birthday(): void + { + $log = $this->createPendingLog(User::factory()->create()->id, 'self_update'); + + $result = $this->provider(false)->verify($log->id, $this->certData('')); + + $this->assertFalse($result->success, '운영 모드에서 생년월일 부재는 비정상 응답이다'); + $this->assertSame('INCOMPLETE_IDENTITY', $result->failureCode); + } + + /** + * 생년월일 외 신원 핵심값은 테스트 모드에서도 완화하지 않는다. CI/DI 가 비면 중복가입 차단이 + * 조용히 무력화되므로 어느 모드에서도 인정해서는 안 된다. + * + * @scenario mode=test,birth_day=absent,purpose=general + * + * @effects test_mode_still_rejects_missing_identity_values + */ + public function test_test_mode_does_not_relax_other_identity_values(): void + { + $log = $this->createPendingLog(User::factory()->create()->id, 'self_update'); + + $payload = array_merge($this->certData(''), ['di' => '']); + $result = $this->provider(true)->verify($log->id, $payload); + + $this->assertFalse($result->success); + $this->assertSame('INCOMPLETE_IDENTITY', $result->failureCode); + } + + /** + * 생년월일이 없으면 저장된 record 의 생년월일은 비어 있고 성인 여부는 참이 될 수 없다. + * 마이페이지는 이 상태를 "미성년" 이 아니라 "확인 불가" 로 표시해야 하므로, 저장 단계에서 + * 두 상태가 구분 가능해야 한다. + * + * @scenario mode=test,birth_day=absent,purpose=general + * + * @effects missing_birthday_stored_as_null_with_adult_false + */ + public function test_record_keeps_birthday_null_when_absent(): void + { + $user = User::factory()->create(); + $log = $this->createPendingLog($user->id, 'self_update'); + + $this->provider(true)->verify($log->id, $this->certData('')); + + $record = app(KcpIdentityRecordRepositoryInterface::class)->findByUserId($user->id); + + $this->assertNotNull($record, '인증이 인정되었으면 record 가 생성되어야 한다'); + $this->assertNull($record->birthday_encrypted, '생년월일 미제공은 빈 암호문이 아니라 null 로 남는다'); + $this->assertFalse((bool) $record->is_adult, '생년월일을 모르면 성인으로 단정하지 않는다'); + } + + /** + * @param bool $isTestMode 테스트 모드 여부 + */ + private function provider(bool $isTestMode): KcpIdentityProvider + { + return app()->makeWith(KcpIdentityProvider::class, [ + 'config' => ['duplicate_field' => 'di', 'is_test_mode' => $isTestMode], + ]); + } + + /** + * KCP 테스트 서버 응답 형태 — 생년월일·성별·내외국인이 빈 값으로 내려온다. + * + * @param string $birthday YYYYMMDD (빈 문자열이면 미제공) + * @return array + */ + private function certData(string $birthday): array + { + return [ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'user_name' => '홍길동', + 'birth_day' => $birthday, + 'phone_no' => '01012345678', + 'comm_id' => 'SKT', + 'sex_code' => '', + 'local_code' => '', + 'ci' => 'CI-'.Str::random(10), + 'di' => 'DI-'.Str::random(10), + ]; + } + + /** + * 인증 대기 상태의 challenge 로그를 만든다. + * + * @param int $userId 사용자 ID + * @param string $purpose IDV purpose + */ + private function createPendingLog(int $userId, string $purpose): IdentityVerificationLog + { + return $this->logRepository->create([ + 'id' => (string) Str::uuid(), + 'provider_id' => KcpIdentityProvider::PROVIDER_ID, + 'purpose' => $purpose, + 'channel' => KcpIdentityProvider::CHANNEL, + 'user_id' => $userId, + 'target_hash' => hash('sha256', 'birthday-fallback@example.com'), + 'status' => IdentityVerificationStatus::Sent->value, + 'render_hint' => 'text_code', + 'attempts' => 0, + 'max_attempts' => 0, + 'origin_type' => IdentityOriginType::Route->value, + 'origin_identifier' => 'api.identity.verify', + 'verification_token' => null, + 'expires_at' => now()->addMinutes(15), + 'metadata' => ['ordr_idxx' => 'G7-'.Str::random(8)], + ]); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpVerifyStorageAtomicityTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpVerifyStorageAtomicityTest.php new file mode 100644 index 00000000..b4ff9406 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpVerifyStorageAtomicityTest.php @@ -0,0 +1,207 @@ +logRepository = app(IdentityVerificationLogRepositoryInterface::class); + } + + /** + * 로그인 사용자: record 저장이 실패하면 인증 완료 도장(토큰)도 함께 되돌아간다. + * + * @scenario outcome=storage_failure,lookup=by_reg_cert_key,missing_field=none + * + * @effects record_save_failure_rolls_back_token_issuance + */ + public function test_record_save_failure_rolls_back_token_issuance_for_logged_in_user(): void + { + $user = User::factory()->create(); + $log = $this->createPendingLog($user->id, 'self_update'); + + // 저장 단계에서만 실패하도록 Repository 를 교체한다 (통신/판정 단계는 정상 통과). + $this->app->bind(KcpIdentityRecordRepositoryInterface::class, function () { + return new class implements KcpIdentityRecordRepositoryInterface + { + public function findByUserId(int $userId): ?KcpIdentityRecord + { + return null; + } + + public function findByDiHash(string $diHash): ?KcpIdentityRecord + { + return null; + } + + public function findByCiHash(string $ciHash): ?KcpIdentityRecord + { + return null; + } + + public function findByHashExceptUser(string $column, string $hash, ?int $exceptUserId = null): ?KcpIdentityRecord + { + return null; + } + + public function upsertForUser(int $userId, array $attributes): KcpIdentityRecord + { + throw new RuntimeException('저장 장치 오류 시뮬레이션'); + } + + public function deleteByUserId(int $userId): bool + { + return true; + } + }; + }); + + $result = $this->provider()->verify($log->id, $this->certData()); + + $this->assertFalse($result->success, '저장이 실패하면 인증도 실패해야 한다'); + $this->assertSame('STORAGE_FAILED', $result->failureCode); + + $reloaded = $this->logRepository->findById($log->id); + $this->assertNotSame( + IdentityVerificationStatus::Verified->value, + $reloaded->status->value, + '저장 실패 시 인증 완료 상태로 남으면 안 된다', + ); + $this->assertNull($reloaded->verification_token, '저장 실패 시 토큰이 발급되면 안 된다'); + $this->assertNull($reloaded->verified_at); + $this->assertDatabaseCount('nhnkcp_identity_records', 0); + } + + /** + * 비로그인 사용자: 임시 보관이 실패하면 인증 완료 도장도 함께 되돌아간다. + * + * @scenario outcome=storage_failure,lookup=by_reg_cert_key,missing_field=none + * + * @effects guest_stash_failure_rolls_back_token_issuance + */ + public function test_pending_stash_failure_rolls_back_token_issuance_for_guest(): void + { + $log = $this->createPendingLog(null, 'signup'); + + // 캐시 보관만 실패시킨다. provider 는 ServiceProvider 의 contextual binding 으로 캐시를 + // 주입받으므로, 전역 바인딩이 아니라 같은 contextual 경로를 덮어써야 실제로 반영된다. + $failingCache = \Mockery::mock(CacheInterface::class); + $failingCache->shouldReceive('put')->andThrow(new RuntimeException('캐시 저장 오류 시뮬레이션')); + $this->app->when(KcpIdentityProvider::class) + ->needs(CacheInterface::class) + ->give(fn () => $failingCache); + + $result = $this->provider()->verify($log->id, $this->certData()); + + $this->assertFalse($result->success); + $this->assertSame('STORAGE_FAILED', $result->failureCode); + + $reloaded = $this->logRepository->findById($log->id); + $this->assertNotSame(IdentityVerificationStatus::Verified->value, $reloaded->status->value); + $this->assertNull($reloaded->verification_token); + } + + /** + * 정상 경로: 저장과 토큰 발급이 함께 성립한다 (대조군). + * + * @scenario outcome=success,lookup=by_reg_cert_key,missing_field=none + * + * @effects record_and_token_are_committed_together + */ + public function test_successful_verify_stores_record_and_issues_token_together(): void + { + $user = User::factory()->create(); + $log = $this->createPendingLog($user->id, 'self_update'); + + $result = $this->provider()->verify($log->id, $this->certData()); + + $this->assertTrue($result->success); + + $reloaded = $this->logRepository->findById($log->id); + $this->assertSame(IdentityVerificationStatus::Verified->value, $reloaded->status->value); + $this->assertNotEmpty($reloaded->verification_token); + $this->assertDatabaseCount('nhnkcp_identity_records', 1); + } + + /** + * 설정이 주입된 provider. + */ + private function provider(): KcpIdentityProvider + { + return app()->makeWith(KcpIdentityProvider::class, [ + 'config' => ['duplicate_block_enabled' => false, 'duplicate_field' => 'di'], + ]); + } + + /** + * 결과조회 성공 페이로드. + * + * @return array + */ + private function certData(): array + { + return [ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'user_name' => '홍길동', + 'birth_day' => '19900101', + 'phone_no' => '01012345678', + 'comm_id' => 'SKT', + 'sex_code' => '01', + 'local_code' => '01', + 'ci' => 'CI-ATOMIC-0001', + 'di' => 'DI-ATOMIC-0001', + ]; + } + + /** + * 인증 대기 challenge 로그. + * + * @param int|null $userId 사용자 ID (비로그인은 null) + * @param string $purpose IDV purpose + */ + private function createPendingLog(?int $userId, string $purpose): IdentityVerificationLog + { + return $this->logRepository->create([ + 'id' => (string) Str::uuid(), + 'provider_id' => KcpIdentityProvider::PROVIDER_ID, + 'purpose' => $purpose, + 'channel' => KcpIdentityProvider::CHANNEL, + 'user_id' => $userId, + 'target_hash' => hash('sha256', 'atomicity@example.com'), + 'status' => IdentityVerificationStatus::Sent->value, + 'render_hint' => 'text_code', + 'attempts' => 0, + 'max_attempts' => 0, + 'origin_type' => IdentityOriginType::Route->value, + 'expires_at' => now()->addMinutes(15), + ]); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/AssertNoDuplicateKcpIdentityTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/AssertNoDuplicateKcpIdentityTest.php new file mode 100644 index 00000000..ddd50ee2 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/AssertNoDuplicateKcpIdentityTest.php @@ -0,0 +1,211 @@ +logRepository = app(IdentityVerificationLogRepositoryInterface::class); + $this->recordRepository = app(KcpIdentityRecordRepositoryInterface::class); + } + + /** + * @scenario gate=register,basis=di,blocking=enabled,relation=duplicate + * + * @effects register_listener_blocks_duplicate_with_validation_error + */ + public function test_duplicate_di_blocks_registration(): void + { + $this->applySettings(['duplicate_block_enabled' => true, 'duplicate_field' => 'di']); + + $diHash = KcpIdentityHasher::hash('DI-EXISTING-0001'); + $this->recordRepository->upsertForUser(User::factory()->create()->id, [ + 'di_hash' => $diHash, + 'verified_at' => now(), + ]); + + $log = $this->createVerifiedSignupLog('tok-dup-block', ['di_hash' => $diHash]); + + try { + HookManager::doAction('core.auth.before_register', ['verification_token' => 'tok-dup-block']); + $this->fail('동일인 가입은 차단되어야 한다'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('identity', $e->errors()); + // 안내 문구에 개인정보(이메일/이름)가 노출되면 안 된다. + $this->assertStringNotContainsString('@', $e->errors()['identity'][0]); + } + + $reload = $this->logRepository->findById($log->id); + $this->assertSame('di_hash', $reload->metadata['matched_field'] ?? null, '감사 추적이 남아야 한다'); + } + + /** + * @scenario gate=register,basis=ci,blocking=enabled,relation=duplicate + * + * @effects ci_basis_matches_by_ci_hash + */ + public function test_duplicate_ci_blocks_registration_when_ci_is_the_basis(): void + { + $this->applySettings(['duplicate_block_enabled' => true, 'duplicate_field' => 'ci']); + + $ciHash = KcpIdentityHasher::hash('CI-EXISTING-0001'); + $this->recordRepository->upsertForUser(User::factory()->create()->id, [ + 'ci_hash' => $ciHash, + 'verified_at' => now(), + ]); + + $this->createVerifiedSignupLog('tok-dup-ci', ['ci_hash' => $ciHash]); + + $this->expectException(ValidationException::class); + + HookManager::doAction('core.auth.before_register', ['verification_token' => 'tok-dup-ci']); + } + + /** + * @scenario gate=register,basis=di,blocking=disabled,relation=duplicate + * + * @effects disabled_setting_passes_both_gates + */ + public function test_disabled_setting_allows_registration(): void + { + $this->applySettings(['duplicate_block_enabled' => false, 'duplicate_field' => 'di']); + + $diHash = KcpIdentityHasher::hash('DI-EXISTING-0002'); + $this->recordRepository->upsertForUser(User::factory()->create()->id, [ + 'di_hash' => $diHash, + 'verified_at' => now(), + ]); + + $this->createVerifiedSignupLog('tok-dup-off', ['di_hash' => $diHash]); + + HookManager::doAction('core.auth.before_register', ['verification_token' => 'tok-dup-off']); + + $this->assertTrue(true, '차단 비활성 시 예외 없이 통과해야 한다'); + } + + /** + * @scenario gate=register,basis=di,blocking=enabled,relation=new_person + * + * @effects register_listener_blocks_duplicate_with_validation_error + */ + public function test_new_identity_passes(): void + { + $this->applySettings(['duplicate_block_enabled' => true, 'duplicate_field' => 'di']); + + $this->createVerifiedSignupLog('tok-new', ['di_hash' => KcpIdentityHasher::hash('DI-BRAND-NEW')]); + + HookManager::doAction('core.auth.before_register', ['verification_token' => 'tok-new']); + + $this->assertTrue(true, '기존 record 가 없으면 통과해야 한다'); + } + + /** + * @scenario gate=register,basis=di,blocking=enabled,relation=missing_hash + * + * @effects missing_identifier_hash_passes + */ + public function test_missing_hash_passes(): void + { + $this->applySettings(['duplicate_block_enabled' => true, 'duplicate_field' => 'di']); + + $this->createVerifiedSignupLog('tok-no-hash', []); + + HookManager::doAction('core.auth.before_register', ['verification_token' => 'tok-no-hash']); + + $this->assertTrue(true, '식별값 해시가 없으면 통과해야 한다'); + } + + /** + * @scenario gate=register,basis=di,blocking=enabled,relation=new_person + * + * @effects non_signup_purpose_is_not_blocked + */ + public function test_other_provider_logs_are_ignored(): void + { + $this->applySettings(['duplicate_block_enabled' => true, 'duplicate_field' => 'di']); + + $diHash = KcpIdentityHasher::hash('DI-EXISTING-0003'); + $this->recordRepository->upsertForUser(User::factory()->create()->id, [ + 'di_hash' => $diHash, + 'verified_at' => now(), + ]); + + $this->createVerifiedSignupLog('tok-other', ['di_hash' => $diHash], 'g7:core.mail'); + + HookManager::doAction('core.auth.before_register', ['verification_token' => 'tok-other']); + + $this->assertTrue(true, '다른 provider 의 인증은 본 listener 책임이 아니다'); + } + + /** + * 플러그인 설정을 런타임 설정 트리에 반영한다. + * + * listener 는 `g7_plugin_settings()` 헬퍼(= `config('g7_settings.plugins.{id}')`)로 설정을 + * 읽으므로, 실 환경의 부팅 시 동기화와 동형이 되도록 config 를 직접 채운다. + * + * @param array $settings + */ + private function applySettings(array $settings): void + { + config(['g7_settings.plugins.'.self::PLUGIN_IDENTIFIER => $settings]); + } + + /** + * 인증 완료 상태의 가입 목적 로그를 만든다. + * + * @param string $token verification_token + * @param array $metadata 로그 metadata (식별값 해시) + * @param string $providerId provider 식별자 + */ + private function createVerifiedSignupLog( + string $token, + array $metadata, + string $providerId = KcpIdentityProvider::PROVIDER_ID, + ): IdentityVerificationLog { + return $this->logRepository->create([ + 'id' => (string) Str::uuid(), + 'provider_id' => $providerId, + 'purpose' => 'signup', + 'channel' => KcpIdentityProvider::CHANNEL, + 'user_id' => null, + 'target_hash' => hash('sha256', 'dup-listener@example.test'), + 'status' => IdentityVerificationStatus::Verified->value, + 'render_hint' => 'text_code', + 'attempts' => 0, + 'max_attempts' => 0, + 'origin_type' => IdentityOriginType::Route->value, + 'origin_identifier' => 'api.auth.register', + 'verification_token' => $token, + 'verified_at' => now(), + 'expires_at' => now()->addMinutes(15), + 'metadata' => $metadata, + ]); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/CleanKcpRecordOnUserDeleteTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/CleanKcpRecordOnUserDeleteTest.php new file mode 100644 index 00000000..cf9aa23d --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/CleanKcpRecordOnUserDeleteTest.php @@ -0,0 +1,103 @@ +logRepository = app(IdentityVerificationLogRepositoryInterface::class); + $this->recordRepository = app(KcpIdentityRecordRepositoryInterface::class); + } + + /** + * @scenario trigger=admin_delete,record_exists=true + * + * @effects admin_delete_deletes_record_before_user_row_removal + */ + public function test_record_is_removed_before_user_row_so_deletion_succeeds(): void + { + $user = User::factory()->create(); + $this->recordRepository->upsertForUser($user->id, [ + 'di_hash' => hash_hmac('sha256', 'DI-DELETE', (string) config('app.key')), + 'verified_at' => now(), + ]); + $log = $this->createVerifiedLog($user->id); + + HookManager::doAction('core.user.before_delete', $user); + + $this->assertNull($this->recordRepository->findByUserId($user->id)); + $this->assertNull($this->logRepository->findById($log->id)->user_id); + + // 파기 후에는 회원 행 삭제가 외래키 제약 위반 없이 완료되어야 한다. + $user->delete(); + $this->assertDatabaseMissing('users', ['id' => $user->id]); + } + + /** + * @scenario trigger=admin_delete,record_exists=false + * + * @effects missing_record_is_a_no_op + */ + public function test_delete_without_record_is_a_no_op(): void + { + $user = User::factory()->create(); + + HookManager::doAction('core.user.before_delete', $user); + + $this->assertNull($this->recordRepository->findByUserId($user->id)); + } + + /** + * 본 플러그인이 발행한 인증 완료 로그를 만든다. + * + * @param int $userId 사용자 ID + * @return IdentityVerificationLog + */ + private function createVerifiedLog(int $userId) + { + return $this->logRepository->create([ + 'id' => (string) Str::uuid(), + 'provider_id' => KcpIdentityProvider::PROVIDER_ID, + 'purpose' => 'self_update', + 'channel' => KcpIdentityProvider::CHANNEL, + 'user_id' => $userId, + 'target_hash' => hash('sha256', 'delete@example.test'), + 'status' => IdentityVerificationStatus::Verified->value, + 'render_hint' => 'text_code', + 'attempts' => 0, + 'max_attempts' => 0, + 'origin_type' => IdentityOriginType::Route->value, + 'origin_identifier' => 'api.identity.verify', + 'verification_token' => 'tok-'.Str::random(8), + 'verified_at' => now(), + 'expires_at' => now()->addMinutes(15), + 'metadata' => ['di_hash' => hash_hmac('sha256', 'DI-DELETE', (string) config('app.key'))], + ]); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/CleanKcpRecordOnUserWithdrawTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/CleanKcpRecordOnUserWithdrawTest.php new file mode 100644 index 00000000..08dc5f27 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/CleanKcpRecordOnUserWithdrawTest.php @@ -0,0 +1,123 @@ +logRepository = app(IdentityVerificationLogRepositoryInterface::class); + $this->recordRepository = app(KcpIdentityRecordRepositoryInterface::class); + } + + /** + * @scenario trigger=withdraw,record_exists=true + * + * @effects withdraw_deletes_record_and_anonymizes_logs + */ + public function test_withdraw_deletes_record_and_anonymizes_log(): void + { + $user = User::factory()->create(); + $this->recordRepository->upsertForUser($user->id, [ + 'di_hash' => hash_hmac('sha256', 'DI-WITHDRAW', (string) config('app.key')), + 'verified_at' => now(), + ]); + $log = $this->createVerifiedLog($user->id); + + HookManager::doAction('core.user.after_withdraw', $user); + + $this->assertNull($this->recordRepository->findByUserId($user->id)); + + $reload = $this->logRepository->findById($log->id); + $this->assertNull($reload->user_id, '탈퇴자 로그는 익명화되어야 한다'); + $this->assertArrayNotHasKey('di_hash', (array) $reload->metadata); + $this->assertArrayNotHasKey('ci_hash', (array) $reload->metadata); + } + + /** + * @scenario trigger=withdraw,record_exists=true + * + * @effects anonymized_log_keeps_audit_fields_but_drops_identity_hashes + */ + public function test_audit_fields_are_preserved_after_anonymization(): void + { + $user = User::factory()->create(); + $log = $this->createVerifiedLog($user->id); + + HookManager::doAction('core.user.after_withdraw', $user); + + $metadata = (array) $this->logRepository->findById($log->id)->metadata; + $this->assertSame('di', $metadata['duplicate_field_used'] ?? null, '감사 추적 필드는 보존해야 한다'); + } + + /** + * @scenario trigger=withdraw,record_exists=false + * + * @effects missing_record_is_a_no_op + */ + public function test_withdraw_without_record_is_a_no_op(): void + { + $user = User::factory()->create(); + + HookManager::doAction('core.user.after_withdraw', $user); + + $this->assertNull($this->recordRepository->findByUserId($user->id)); + } + + /** + * 본 플러그인이 발행한 인증 완료 로그를 만든다. + * + * @param int $userId 사용자 ID + * @return IdentityVerificationLog + */ + private function createVerifiedLog(int $userId) + { + return $this->logRepository->create([ + 'id' => (string) Str::uuid(), + 'provider_id' => KcpIdentityProvider::PROVIDER_ID, + 'purpose' => 'self_update', + 'channel' => KcpIdentityProvider::CHANNEL, + 'user_id' => $userId, + 'target_hash' => hash('sha256', 'withdraw@example.test'), + 'status' => IdentityVerificationStatus::Verified->value, + 'render_hint' => 'text_code', + 'attempts' => 0, + 'max_attempts' => 0, + 'origin_type' => IdentityOriginType::Route->value, + 'origin_identifier' => 'api.identity.verify', + 'verification_token' => 'tok-'.Str::random(8), + 'verified_at' => now(), + 'expires_at' => now()->addMinutes(15), + 'metadata' => [ + 'di_hash' => hash_hmac('sha256', 'DI-WITHDRAW', (string) config('app.key')), + 'ci_hash' => hash_hmac('sha256', 'CI-WITHDRAW', (string) config('app.key')), + 'duplicate_field_used' => 'di', + ], + ]); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/CompleteKcpRecordAfterRegisterTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/CompleteKcpRecordAfterRegisterTest.php new file mode 100644 index 00000000..f7b3045d --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/CompleteKcpRecordAfterRegisterTest.php @@ -0,0 +1,262 @@ +logRepository = app(IdentityVerificationLogRepositoryInterface::class); + $this->recordRepository = app(KcpIdentityRecordRepositoryInterface::class); + + // listener 는 ServiceProvider 의 contextual binding 으로 본 플러그인 도메인 캐시를 + // 주입받는다 — 테스트의 stash 도 같은 도메인이어야 키가 일치한다. + $this->cache = new PluginCacheDriver(self::PLUGIN_IDENTIFIER); + } + + /** + * @scenario stage=verify,cache_state=present + * + * @effects guest_verify_stashes_pii_instead_of_record + */ + public function test_guest_verification_stashes_pii_instead_of_creating_a_record(): void + { + $log = $this->createPendingSignupLog(); + + $provider = app()->makeWith(KcpIdentityProvider::class, ['config' => ['duplicate_field' => 'di']]); + $result = $provider->verify($log->id, [ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'user_name' => '홍길동', + 'birth_day' => '19900101', + 'phone_no' => '01012345678', + 'comm_id' => 'SKT', + 'sex_code' => '01', + 'local_code' => '01', + 'ci' => 'CI-GUEST-VERIFY', + 'di' => 'DI-GUEST-VERIFY', + ]); + + $this->assertTrue($result->success); + + // 가입 전이므로 회원 정보로 확정하지 않고 임시 보관만 한다. + $this->assertSame(0, KcpIdentityRecord::query()->count()); + + $stashed = $this->cache->get(KcpIdentityProvider::PENDING_RECORD_CACHE_PREFIX.$log->id); + $this->assertIsArray($stashed); + $this->assertSame( + hash_hmac('sha256', 'DI-GUEST-VERIFY', (string) config('app.key')), + $stashed['di_hash'], + ); + // 임시 보관분도 평문으로 두지 않는다. + $this->assertNotSame('홍길동', $stashed['name_encrypted']); + $this->assertSame('홍길동', Crypt::decryptString($stashed['name_encrypted'])); + } + + /** + * @scenario stage=after_register,cache_state=present + * + * @effects after_register_absorbs_stash_into_record + */ + public function test_listener_absorbs_stash_backfills_user_and_clears_cache(): void + { + $log = $this->createVerifiedSignupLog($token = 'tok-guest-success'); + $this->stashPendingRecord($log->id); + $user = User::factory()->create(); + + HookManager::doAction('core.auth.after_register', $user, [ + 'verification_token' => $token, + 'signup_stage' => 'after_create', + ]); + + $this->assertDatabaseHas('nhnkcp_identity_records', ['user_id' => $user->id]); + + $record = $this->recordRepository->findByUserId($user->id); + $this->assertNotNull($record); + $this->assertSame($log->id, $record->latest_log_id); + $this->assertSame('홍길동', $record->name); + $this->assertTrue((bool) $record->is_adult); + + $this->assertDatabaseHas('identity_verification_logs', ['id' => $log->id, 'user_id' => $user->id]); + $this->assertNull($this->cache->get(KcpIdentityProvider::PENDING_RECORD_CACHE_PREFIX.$log->id)); + } + + /** + * @scenario stage=after_register,cache_state=present + * + * @effects after_register_clears_stash + */ + public function test_listener_processes_token_even_after_core_listener_consumed_it(): void + { + // 코어 가입 가드(priority 10)가 토큰을 소비한 뒤에도 본 listener 가 동작해야 한다. + $log = $this->createVerifiedSignupLog($token = 'tok-consumed'); + $this->stashPendingRecord($log->id); + $this->logRepository->updateById($log->id, ['consumed_at' => now()]); + + $user = User::factory()->create(); + + HookManager::doAction('core.auth.after_register', $user, [ + 'verification_token' => $token, + 'signup_stage' => 'after_create', + ]); + + $this->assertDatabaseHas('nhnkcp_identity_records', ['user_id' => $user->id]); + $this->assertNull($this->cache->get(KcpIdentityProvider::PENDING_RECORD_CACHE_PREFIX.$log->id)); + } + + /** + * @scenario stage=after_register,cache_state=absent + * + * @effects missing_stash_logs_warning_without_breaking_registration + */ + public function test_missing_stash_does_not_break_registration(): void + { + $log = $this->createVerifiedSignupLog($token = 'tok-no-stash'); + $user = User::factory()->create(); + + HookManager::doAction('core.auth.after_register', $user, [ + 'verification_token' => $token, + 'signup_stage' => 'after_create', + ]); + + $this->assertDatabaseMissing('nhnkcp_identity_records', ['user_id' => $user->id]); + } + + /** + * @scenario stage=after_register,cache_state=absent + * + * @effects missing_stash_logs_warning_without_breaking_registration + */ + public function test_listener_noops_without_verification_token(): void + { + $user = User::factory()->create(); + + HookManager::doAction('core.auth.after_register', $user, ['signup_stage' => 'after_create']); + + $this->assertDatabaseMissing('nhnkcp_identity_records', ['user_id' => $user->id]); + } + + /** + * @scenario stage=after_register,cache_state=present + * + * @effects after_register_backfills_log_user_id + */ + public function test_listener_ignores_logs_from_other_providers(): void + { + $log = $this->createVerifiedSignupLog($token = 'tok-other-provider', providerId: 'g7:core.mail'); + $this->stashPendingRecord($log->id); + $user = User::factory()->create(); + + HookManager::doAction('core.auth.after_register', $user, [ + 'verification_token' => $token, + 'signup_stage' => 'after_create', + ]); + + $this->assertDatabaseMissing('nhnkcp_identity_records', ['user_id' => $user->id]); + } + + /** + * 인증 완료 상태의 가입 목적 로그를 만든다. + * + * @param string $token verification_token + * @param string $providerId provider 식별자 + */ + private function createVerifiedSignupLog(string $token, string $providerId = KcpIdentityProvider::PROVIDER_ID): IdentityVerificationLog + { + return $this->logRepository->create([ + 'id' => (string) Str::uuid(), + 'provider_id' => $providerId, + 'purpose' => 'signup', + 'channel' => KcpIdentityProvider::CHANNEL, + 'user_id' => null, + 'target_hash' => hash('sha256', 'guest@example.test'), + 'status' => IdentityVerificationStatus::Verified->value, + 'render_hint' => 'text_code', + 'attempts' => 0, + 'max_attempts' => 0, + 'origin_type' => IdentityOriginType::Route->value, + 'origin_identifier' => 'api.auth.register', + 'verification_token' => $token, + 'verified_at' => now(), + 'expires_at' => now()->addMinutes(15), + 'metadata' => ['ordr_idxx' => 'G7-'.Str::random(8)], + ]); + } + + /** + * 인증 대기 상태의 비로그인 가입 challenge 로그를 만든다. + */ + private function createPendingSignupLog(): IdentityVerificationLog + { + return $this->logRepository->create([ + 'id' => (string) Str::uuid(), + 'provider_id' => KcpIdentityProvider::PROVIDER_ID, + 'purpose' => 'signup', + 'channel' => KcpIdentityProvider::CHANNEL, + 'user_id' => null, + 'target_hash' => hash('sha256', 'guest-verify@example.test'), + 'status' => IdentityVerificationStatus::Sent->value, + 'render_hint' => 'text_code', + 'attempts' => 0, + 'max_attempts' => 0, + 'origin_type' => IdentityOriginType::Route->value, + 'origin_identifier' => 'api.identity.verify', + 'verification_token' => null, + 'expires_at' => now()->addMinutes(15), + 'metadata' => ['ordr_idxx' => 'G7-'.Str::random(8)], + ]); + } + + /** + * verify 시점 캐시 임시 보관을 재현한다. + * + * @param string $challengeId challenge UUID + */ + private function stashPendingRecord(string $challengeId): void + { + $this->cache->put(KcpIdentityProvider::PENDING_RECORD_CACHE_PREFIX.$challengeId, [ + 'comm_id' => 'SKT', + 'name_encrypted' => Crypt::encryptString('홍길동'), + 'phone_encrypted' => Crypt::encryptString('01012345678'), + 'birthday_encrypted' => Crypt::encryptString('19900101'), + 'di_encrypted' => Crypt::encryptString('DI-GUEST-0001'), + 'di_hash' => hash_hmac('sha256', 'DI-GUEST-0001', (string) config('app.key')), + 'ci_encrypted' => Crypt::encryptString('CI-GUEST-0001'), + 'ci_hash' => hash_hmac('sha256', 'CI-GUEST-0001', (string) config('app.key')), + 'gender' => 'M', + 'is_foreigner' => false, + 'is_adult' => true, + ], 900); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Settings/KcpLiveModeSettingsValidationTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Settings/KcpLiveModeSettingsValidationTest.php new file mode 100644 index 00000000..879ef8f2 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Settings/KcpLiveModeSettingsValidationTest.php @@ -0,0 +1,178 @@ +admin = $this->createAdminUser(); + $this->token = $this->admin->createToken('test-token')->plainTextToken; + } + + /** + * @scenario mode=live,live_credentials=missing_site_cd + * + * @effects live_mode_requires_site_cd_and_enc_key + */ + public function test_live_mode_without_credentials_is_rejected(): void + { + $response = $this->putSettings([ + 'is_test_mode' => false, + 'live_site_cd' => '', + 'live_enc_key' => '', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['live_site_cd', 'live_enc_key']); + } + + /** + * @scenario mode=live,live_credentials=missing_enc_key + * + * @effects live_mode_requires_site_cd_and_enc_key + */ + public function test_validation_messages_use_korean_field_labels(): void + { + $response = $this->putSettings([ + 'is_test_mode' => false, + 'live_site_cd' => '', + 'live_enc_key' => '', + ]); + + $response->assertStatus(422); + + $errors = $response->json('errors'); + $this->assertStringContainsString('운영 사이트코드', $errors['live_site_cd'][0]); + $this->assertStringNotContainsString('live site cd', $errors['live_site_cd'][0]); + $this->assertStringContainsString('운영 암호화 키', $errors['live_enc_key'][0]); + } + + /** + * @scenario mode=live,live_credentials=complete + * + * @effects live_mode_requires_site_cd_and_enc_key + */ + public function test_live_mode_with_credentials_is_accepted(): void + { + $response = $this->putSettings([ + 'is_test_mode' => false, + 'live_site_cd' => 'A1B2C', + 'live_enc_key' => 'live-secret-key', + ]); + + $response->assertStatus(200); + } + + /** + * @scenario mode=test,live_credentials=complete + * + * @effects test_mode_saves_without_live_credentials + */ + public function test_test_mode_without_live_credentials_is_accepted(): void + { + $response = $this->putSettings([ + 'is_test_mode' => true, + 'live_site_cd' => '', + 'live_enc_key' => '', + ]); + + $response->assertStatus(200); + } + + /** + * @scenario mode=live,live_credentials=complete + * + * @effects sensitive_key_is_not_returned_in_plain_text + */ + public function test_saved_encryption_key_is_not_stored_in_plain_text(): void + { + $this->putSettings([ + 'is_test_mode' => false, + 'live_site_cd' => 'A1B2C', + 'live_enc_key' => 'super-secret-live-key', + ])->assertStatus(200); + + // 저장 파일에는 암호문이 들어가고, 서비스 조회 시에만 복호화되어야 한다. + $storage = app(PluginManager::class) + ->getPlugin(self::PLUGIN_IDENTIFIER) + ->getStorage(); + + $stored = json_decode((string) $storage->get('settings', 'setting.json'), true); + + $this->assertIsArray($stored); + $this->assertNotSame('super-secret-live-key', $stored['live_enc_key'] ?? null, '평문 저장 금지'); + + $this->assertSame( + 'super-secret-live-key', + app(PluginSettingsService::class)->get(self::PLUGIN_IDENTIFIER, 'live_enc_key'), + ); + } + + /** + * core.plugins.update 권한을 가진 관리자 생성. + */ + private function createAdminUser(): User + { + $user = User::factory()->create(); + + $permission = Permission::firstOrCreate( + ['identifier' => 'core.plugins.update'], + ['name' => json_encode(['ko' => '플러그인 수정', 'en' => 'Update Plugins']), 'type' => 'admin'] + ); + + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin'] + ); + + $testRole = Role::create([ + 'identifier' => 'nhnkcp_settings_test_'.uniqid(), + 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'type' => 'admin', + ]); + $testRole->permissions()->sync([$permission->id]); + + $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + $user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + + return $user->fresh(); + } + + /** + * 인증 헤더가 적용된 설정 저장 요청. + * + * @param array $body + * @return TestResponse + */ + private function putSettings(array $body) + { + return $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Accept' => 'application/json', + ])->putJson('/api/admin/plugins/'.self::PLUGIN_IDENTIFIER.'/settings', $body); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/fixtures/nhnkcp-auth.ts b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/fixtures/nhnkcp-auth.ts new file mode 100644 index 00000000..5e03874c --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/fixtures/nhnkcp-auth.ts @@ -0,0 +1,25 @@ +/** + * NHN KCP 본인확인 플러그인 권한 fixture. + * + * 코어 `tests/Playwright/fixtures/auth.ts` 의 헬퍼(issueToken / authenticatePage)를 재사용한다. + * 본 플러그인 설정 화면은 코어 플러그인 관리 권한(`core.plugins.*`)으로 접근하므로 별도 권한 + * 네임스페이스를 만들지 않는다 — 레이아웃 `permissions` 선언과 동일한 식별자를 사용한다. + */ +import { test as base } from '@playwright/test'; +// 6단계 상위 = 코어 루트의 tests/Playwright/fixtures/auth.ts +// (plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/fixtures → 코어 루트) +import { issueToken, authenticatePage } from '../../../../../../tests/Playwright/fixtures/auth'; + +type NhnkcpAuthFixtures = { + /** 플러그인 설정 조회/수정 권한 토큰 */ + pluginSettingsToken: string; +}; + +export const test = base.extend({ + pluginSettingsToken: async ({}, use) => { + await use(issueToken('core.plugins.read', 'core.plugins.update')); + }, +}); + +export { authenticatePage }; +export { expect } from '@playwright/test'; diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/playwright.config.ts b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/playwright.config.ts new file mode 100644 index 00000000..379b8b60 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/playwright.config.ts @@ -0,0 +1,93 @@ +/** + * NHN KCP 휴대폰 본인확인 플러그인 Playwright E2E 설정. + * + * 코어 `playwright.config.ts` 및 다른 확장 설정과 동일한 base URL 해석 우선순위를 따른다 — + * 활성 호스트가 환경별로 가변이므로 하드코딩을 피한다. + * + * Base URL 해석: + * 1. PLAYWRIGHT_BASE_URL 환경변수 (CI/명시적 오버라이드) + * 2. 코어 루트 .env 의 APP_URL — 단 localhost 류는 fallback 부적합 + * 3. 그 외 — 명시 에러 + * + * 실행 예시: + * PowerShell — $env:PLAYWRIGHT_BASE_URL='https://g7.dev'; npx playwright test --config=plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/playwright.config.ts + * Bash — PLAYWRIGHT_BASE_URL=https://g7.dev npx playwright test --config=plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/playwright.config.ts + */ +import { defineConfig, devices } from '@playwright/test'; +import { readFileSync, existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// ESM 환경(package.json "type": "module")에서는 __dirname 이 정의되지 않으므로 +// import.meta.url 로 재구성한다. +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * 코어 루트 (artisan / .env / Playwright 산출물의 기준 경로). + * + * 확장 config 는 확장 디렉토리에서 실행되지만, 산출물을 그 안에 쓰면 Windows 에서 + * `plugin:update` 의 디렉토리 이동이 열린 핸들에 걸려 실패한다. 산출물은 코어 루트 아래로 + * 모아 update 경로와 분리한다. + */ +const CORE_ROOT = process.env.G7_ROOT || resolve(__dirname, '../../../../../'); + +/** 확장별 산출물 격리 — 확장끼리 리포트를 덮어쓰지 않도록 slug 로 네임스페이스. */ +const ARTIFACT_SLUG = 'plugins/sirsoft-verification_nhnkcp'; + +/** + * .env 파일에서 단일 키의 값을 추출한다 (간이 파서 — dotenv 의존 회피). + * + * @param filePath .env 경로 + * @param key 추출할 키 + * @returns 값 또는 null + */ +function readEnvFile(filePath: string, key: string): string | null { + if (!existsSync(filePath)) return null; + const content = readFileSync(filePath, { encoding: 'utf-8' }); + const pattern = new RegExp(`^${key}=(.*)$`, 'm'); + const match = content.match(pattern); + if (!match) return null; + let value = match[1].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + return value || null; +} + +/** + * E2E base URL 을 결정한다. + * + * @returns 활성 호스트 URL + */ +function resolveBaseUrl(): string { + if (process.env.PLAYWRIGHT_BASE_URL) { + return process.env.PLAYWRIGHT_BASE_URL; + } + const appUrl = readEnvFile(resolve(CORE_ROOT, '.env'), 'APP_URL'); + if (appUrl && !/^https?:\/\/localhost(:\d+)?\/?$/i.test(appUrl)) { + return appUrl; + } + throw new Error( + 'NHN KCP 본인확인 플러그인 E2E base URL 미설정. PLAYWRIGHT_BASE_URL 환경변수를 지정하거나 코어 .env 의 APP_URL 을 활성 호스트로 설정하세요.' + ); +} + +export default defineConfig({ + testDir: './specs', + outputDir: resolve(CORE_ROOT, 'test-results', ARTIFACT_SLUG), + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [ + ['html', { outputFolder: resolve(CORE_ROOT, 'playwright-report', ARTIFACT_SLUG), open: 'never' }], + ['list'], + ], + use: { + baseURL: resolveBaseUrl(), + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + ignoreHTTPSErrors: true, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/specs/admin/nhnkcp-verification-settings.spec.ts b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/specs/admin/nhnkcp-verification-settings.spec.ts new file mode 100644 index 00000000..4faba374 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/specs/admin/nhnkcp-verification-settings.spec.ts @@ -0,0 +1,66 @@ +/** + * E2E: NHN KCP 휴대폰 본인확인 플러그인 환경설정 화면 + * + * 운영 모드로 전환하면 경고 배너가 나타나고, 자격증명을 비운 채 저장하면 저장이 막히면서 + * 어떤 항목이 필요한지 입력칸 옆에 표시되는지 브라우저에서 확인한다. 단위/기능 테스트는 + * 서버 규칙만 잠그므로, 화면에서 실제로 안내가 보이는지는 여기서 잠근다. + * + * KCP 표준창 내부(외부 도메인)는 자동화 대상이 아니다 — 인증창이 열리는 것까지만 확인 가능하며 + * 통신사 인증 완주는 실 휴대폰이 필요해 사람이 검증한다. + * + * @scenario mode=test,live_credentials=complete + mode=live,live_credentials=missing_site_cd + * @effects live_mode_requires_site_cd_and_enc_key + test_mode_saves_without_live_credentials + */ +import { test, expect, authenticatePage } from '../../fixtures/nhnkcp-auth'; + +const SETTINGS_PATH = '/admin/plugins/sirsoft-verification_nhnkcp/settings'; + +test.describe('NHN KCP 본인확인 환경설정', () => { + test.beforeEach(async ({ page, pluginSettingsToken }) => { + await authenticatePage(page, pluginSettingsToken); + await page.goto(SETTINGS_PATH); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + }); + + // @scenario mode=test,live_credentials=complete + // @effects test_mode_saves_without_live_credentials + test('설정 화면이 열리고 다국어 원문 키가 노출되지 않는다', async ({ page }) => { + await expect(page.getByText('NHN KCP').first()).toBeVisible({ timeout: 20_000 }); + + // 번역되지 않은 원문 키가 화면에 남아 있으면 안 된다. + const raw = await page.getByText('$t:').count(); + expect(raw).toBe(0); + }); + + // @scenario mode=live,live_credentials=missing_site_cd + // @effects live_mode_requires_site_cd_and_enc_key + test('운영 모드로 전환하면 경고 배너가 나타난다', async ({ page }) => { + const toggle = page.locator('[name="is_test_mode"]').first(); + await expect(toggle).toBeVisible({ timeout: 20_000 }); + + await toggle.click(); + + await expect(page.getByText(/운영 모드|라이브/).first()).toBeVisible({ timeout: 10_000 }); + }); + + // @scenario mode=live,live_credentials=missing_site_cd + // @effects live_mode_requires_site_cd_and_enc_key + test('운영 자격증명을 비운 채 저장하면 저장이 막히고 안내가 표시된다', async ({ page }) => { + const toggle = page.locator('[name="is_test_mode"]').first(); + await toggle.click(); + + const saveButton = page.getByRole('button', { name: /저장/ }).last(); + await expect(saveButton).toBeEnabled({ timeout: 10_000 }); + + const [response] = await Promise.all([ + page.waitForResponse( + (res) => res.url().includes('/settings') && res.request().method() === 'PUT', + { timeout: 20_000 }, + ), + saveButton.click(), + ]); + + expect(response.status()).toBe(422); + await expect(page.getByText(/사이트코드|암호화 키/).first()).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/specs/user/locale-switch-start-auth.spec.ts b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/specs/user/locale-switch-start-auth.spec.ts new file mode 100644 index 00000000..d03b6af0 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/specs/user/locale-switch-start-auth.spec.ts @@ -0,0 +1,72 @@ +/** + * E2E: 언어 전환 후 본인확인 시작 버튼 + * + * 로케일을 바꾸면 코어가 ActionDispatcher 를 새로 만들고, 확장은 + * `window.__[Plugin].initPlugin()` 재호출로 핸들러를 다시 등록한다. 이 진입점을 + * 노출하지 않으면 언어를 한 번 바꾼 뒤부터 「본인확인 시작」이 완전히 무반응이 된다 — + * 콘솔 에러도, 토스트도, 네트워크 요청도 남지 않아 단위 테스트로는 잡히지 않는다. + * + * 실제로 언어를 바꾼 뒤 가입을 제출해 인증창(팝업)이 열리는 데까지 확인한다. + * KCP 표준창 내부는 자동화 대상이 아니다 — 통신사 인증 완주는 실 휴대폰이 필요하다. + * + * @scenario locale=switched,environment=desktop + * @effects locale_switch_preserves_start_auth_handler + */ +import { test, expect } from '@playwright/test'; + +const REGISTER_PATH = '/register'; +const KCP_STANDARD_WINDOW = /testcert\.kcp\.co\.kr|cert\.kcp\.co\.kr/; + +/** 매 실행마다 다른 이메일 — 가입 중복으로 428 이전 단계에서 막히지 않게 한다. */ +function uniqueEmail(): string { + return `kcp.locale.${process.env.PLAYWRIGHT_RUN_ID ?? Date.now()}@example.com`; +} + +test.describe('언어 전환 후 본인확인 시작', () => { + // @scenario locale=switched,environment=desktop + // @effects locale_switch_preserves_start_auth_handler + test('언어를 바꾼 뒤에도 시작 버튼이 인증창을 연다', async ({ page, context }) => { + await page.goto(REGISTER_PATH); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + // 쿠키 배너가 폼을 가리면 입력이 막힌다 — 떠 있으면 먼저 치운다. + const cookieAccept = page.getByRole('button', { name: /Accept All|모두 동의/ }); + if (await cookieAccept.count()) { + await cookieAccept.first().click().catch(() => {}); + } + + // 언어 전환 — 이 시점에 ActionDispatcher 가 교체되고 확장 핸들러가 재등록된다. + await page.getByRole('button', { name: /Language|언어/ }).first().click(); + await page.getByRole('option', { name: /한국어/ }).first().click(); + await expect(page.getByRole('heading', { name: '회원가입' })).toBeVisible({ timeout: 20_000 }); + + await page.getByPlaceholder('이메일 주소를 입력하세요').fill(uniqueEmail()); + await page.getByPlaceholder('비밀번호 (8자 이상)').fill('Testpass123!@'); + await page.getByPlaceholder('비밀번호를 다시 입력하세요').fill('Testpass123!@'); + await page.getByPlaceholder('이름을 입력하세요').fill('언어전환검수'); + + // 필수 동의 2건 (약관 / 개인정보) + const requiredConsents = page.locator('form input[type="checkbox"][required], form input[type="checkbox"]'); + await requiredConsents.nth(0).check(); + await requiredConsents.nth(1).check(); + + // 제출 → 코어가 428 로 본인확인을 요구하고 모달이 열린다. + await page.getByRole('button', { name: '회원가입' }).last().click(); + + const startButton = page.getByRole('button', { name: /본인확인 시작/ }); + await expect(startButton).toBeVisible({ timeout: 20_000 }); + + // 핸들러가 살아 있어야만 표준창 팝업이 열린다 (미등록이면 아무 일도 일어나지 않는다). + const [popup] = await Promise.all([ + context.waitForEvent('page', { timeout: 20_000 }), + startButton.click(), + ]); + + // 팝업은 먼저 about:blank 로 열리고, 그 창을 target 으로 표준창 form 이 전송된다. + // 따라서 로드 완료가 아니라 목적지 URL 확정을 기다려야 한다. + await popup.waitForURL(KCP_STANDARD_WINDOW, { timeout: 30_000 }); + expect(popup.url()).toMatch(KCP_STANDARD_WINDOW); + + await popup.close(); + }); +}); diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/PluginTestCase.php new file mode 100644 index 00000000..05dc7fd4 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/PluginTestCase.php @@ -0,0 +1,246 @@ + + */ + protected function migrateFreshUsing(): array + { + return [ + '--drop-views' => $this->shouldDropViews(), + '--drop-types' => $this->shouldDropTypes(), + '--seed' => false, + '--path' => [ + 'database/migrations', + $this->pluginRelativePath().'/database/migrations', + ], + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->isolatePluginStorage(); + $this->registerPluginAutoload(); + + // 플러그인 lang 네임스페이스 등록 — 실 환경에서는 코어 TranslationServiceProvider 가 + // 수행하나 테스트 환경에서는 미등록이므로 동일 경로로 직접 등록한다. + $translator = $this->app['translator']; + $translator->getLoader()->addNamespace( + self::PLUGIN_IDENTIFIER, + base_path($this->pluginRelativePath().'/lang'), + ); + + // 부팅 과정에서 plugin 네임스페이스 + 코어 validation 그룹이 준비 전에 빈 값으로 + // 캐시되어 원본 키가 노출되는 것을 막기 위해 무효화 후 실제 파일을 다시 읽힌다. + $loadedProp = new \ReflectionProperty($translator, 'loaded'); + $loadedProp->setAccessible(true); + $loaded = $loadedProp->getValue($translator); + unset($loaded[self::PLUGIN_IDENTIFIER]); + unset($loaded['*']['validation']); + $loadedProp->setValue($translator, $loaded); + + $translator->get('validation.required'); + + $this->app->register(KcpVerificationServiceProvider::class); + + // 검증 에러 메시지의 항목 라벨 — 실 환경에서는 요청 처리 시점 listener 가 등록하나, + // 테스트 환경의 캐시 무효화 순서와 어긋날 수 있어 동일 라벨을 미리 적용한다. + foreach (['ko', 'en'] as $locale) { + $translator->addLines([ + 'validation.attributes.live_site_cd' => __(self::PLUGIN_IDENTIFIER.'::messages.settings.live_site_cd_attribute', [], $locale), + 'validation.attributes.live_enc_key' => __(self::PLUGIN_IDENTIFIER.'::messages.settings.live_enc_key_attribute', [], $locale), + ], $locale); + } + + $this->registerPluginInstance(); + $this->registerPluginRoutes(); + $this->registerPluginHookListeners(); + } + + protected function tearDown(): void + { + $this->restorePluginStorage(); + + parent::tearDown(); + } + + /** + * 검사 대상 플러그인 경로 (프로젝트 루트 기준 상대경로). + * + * 활성 설치본이 있으면 그것을, 없으면 _bundled 소스를 사용한다. + */ + protected function pluginRelativePath(): string + { + $active = 'plugins/'.self::PLUGIN_IDENTIFIER; + + return is_dir(base_path($active)) + ? $active + : 'plugins/_bundled/'.self::PLUGIN_IDENTIFIER; + } + + /** + * 플러그인 스토리지('plugins' 디스크)를 테스트 전용 임시 디렉토리로 격리한다. + * + * 설정 저장 테스트가 실제 로컬 런타임 설정 파일을 덮어써 운영 모드/자격증명이 오염되는 것을 + * 막는다 (RefreshDatabase 는 DB 만 롤백하고 파일시스템은 되돌리지 않는다). + */ + private function isolatePluginStorage(): void + { + $this->isolatedPluginsRoot = storage_path( + 'framework/testing/plugin-storage-'.uniqid('', true) + ); + + File::ensureDirectoryExists($this->isolatedPluginsRoot); + + config(['filesystems.disks.plugins.root' => $this->isolatedPluginsRoot]); + + Storage::forgetDisk('plugins'); + } + + /** + * 격리 임시 디렉토리를 제거한다. + */ + private function restorePluginStorage(): void + { + if ($this->isolatedPluginsRoot !== null && is_dir($this->isolatedPluginsRoot)) { + File::deleteDirectory($this->isolatedPluginsRoot); + } + + $this->isolatedPluginsRoot = null; + + Storage::forgetDisk('plugins'); + } + + /** + * Plugin 의 hook listener 들을 코어 HookManager 에 등록한다. + */ + protected function registerPluginHookListeners(): void + { + $source = 'plugin:'.self::PLUGIN_IDENTIFIER; + + foreach ([ + RegisterKcpProviderListener::class, + CompleteKcpRecordAfterRegister::class, + CleanKcpRecordOnUserWithdraw::class, + CleanKcpRecordOnUserDelete::class, + AssertNoDuplicateKcpIdentity::class, + ValidateKcpSettingsListener::class, + ] as $listener) { + HookListenerRegistrar::register($listener, $source); + } + } + + /** + * 플러그인 src/ PSR-4 오토로드를 등록한다. + */ + protected function registerPluginAutoload(): void + { + $root = base_path($this->pluginRelativePath().'/'); + $base = $root.'src/'; + + spl_autoload_register(function (string $class) use ($base, $root): void { + $prefix = 'Plugins\\Sirsoft\\VerificationNhnkcp\\'; + $len = strlen($prefix); + if (strncmp($prefix, $class, $len) !== 0) { + return; + } + + $relative = str_replace('\\', '/', substr($class, $len)); + + // composer.json 의 psr-4 는 src/ 와 플러그인 루트를 함께 매핑한다 (Plugin 클래스는 plugin.php). + foreach ([$base.$relative.'.php', $root.strtolower($relative).'.php'] as $file) { + if (file_exists($file)) { + require $file; + + return; + } + } + }); + } + + /** + * 코어 PluginManager 에 본 플러그인 인스턴스를 등록한다. + * + * PluginSettingsService::get()/save() 와 UpdatePluginSettingsRequest 는 PluginManager 가 + * 반환하는 플러그인 인스턴스(기본값/스키마)를 전제로 동작한다. 테스트 환경에서는 플러그인 + * 디스커버리가 돌지 않으므로 설치·활성 상태와 동형이 되도록 수동 등록한다. + */ + protected function registerPluginInstance(): void + { + $manager = app(PluginManager::class); + + $property = new \ReflectionProperty($manager, 'plugins'); + $property->setAccessible(true); + + $plugins = $property->getValue($manager); + $plugins[self::PLUGIN_IDENTIFIER] = new Plugin; + $property->setValue($manager, $plugins); + } + + /** + * Plugin 라우트를 등록한다 — 코어 PluginManager 의 자동 prefix 를 흉내낸다. + * + * 실 환경: `/plugins/sirsoft-verification_nhnkcp/{path}` (web), + * `/api/plugins/sirsoft-verification_nhnkcp/{path}` (api) + */ + protected function registerPluginRoutes(): void + { + $webRoutesFile = base_path($this->pluginRelativePath().'/src/routes/web.php'); + $apiRoutesFile = base_path($this->pluginRelativePath().'/src/routes/api.php'); + + if (file_exists($webRoutesFile)) { + Route::prefix('plugins/'.self::PLUGIN_IDENTIFIER) + ->name('web.plugins.'.self::PLUGIN_IDENTIFIER.'.') + ->middleware('web') + ->group($webRoutesFile); + } + + if (file_exists($apiRoutesFile)) { + Route::prefix('api/plugins/'.self::PLUGIN_IDENTIFIER) + ->name('api.plugins.'.self::PLUGIN_IDENTIFIER.'.') + ->middleware('api') + ->group($apiRoutesFile); + } + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Identity/KcpAdultAgeBoundaryTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Identity/KcpAdultAgeBoundaryTest.php new file mode 100644 index 00000000..97f8159a --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Identity/KcpAdultAgeBoundaryTest.php @@ -0,0 +1,73 @@ +subYears(19)->format('Ymd'); + + $this->assertTrue($this->provider()->isAdult($birthday)); + } + + /** + * @scenario age=under_19 + * + * @effects minor_blocked_with_not_adult_and_no_token + */ + public function test_one_day_before_nineteenth_birthday_is_minor(): void + { + $birthday = now()->subYears(19)->addDay()->format('Ymd'); + + $this->assertFalse($this->provider()->isAdult($birthday)); + } + + /** + * @scenario age=over_19 + * + * @effects adult_passes_with_token + */ + public function test_clearly_adult_birthday_is_adult(): void + { + $this->assertTrue($this->provider()->isAdult('19900101')); + } + + /** + * @scenario age=birthday_missing + * + * @effects missing_birthday_blocked_by_incomplete_identity + */ + public function test_invalid_or_missing_birthday_is_not_adult(): void + { + $provider = $this->provider(); + + $this->assertFalse($provider->isAdult('')); + $this->assertFalse($provider->isAdult('1990-01-01')); + $this->assertFalse($provider->isAdult('19901')); + $this->assertFalse($provider->isAdult('99999999')); + } + + /** + * 설정이 주입된 provider 인스턴스. + */ + private function provider(): KcpIdentityProvider + { + return app()->makeWith(KcpIdentityProvider::class, ['config' => []]); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Identity/KcpLiveSiteCdPrefixTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Identity/KcpLiveSiteCdPrefixTest.php new file mode 100644 index 00000000..66a33288 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Identity/KcpLiveSiteCdPrefixTest.php @@ -0,0 +1,140 @@ +assertSame('SMA1B2C', $this->provider()->buildLiveSiteCd('A1B2C')); + } + + /** + * @scenario mode=live,live_credentials=complete + * + * @effects sm_prefix_is_added_once + */ + public function test_prefix_is_not_duplicated_when_already_present(): void + { + $this->assertSame('SMA1B2C', $this->provider()->buildLiveSiteCd('SMA1B2C')); + } + + /** + * @scenario mode=live,live_credentials=missing_site_cd + * + * @effects sm_prefix_is_added_once + */ + public function test_blank_input_stays_blank(): void + { + $this->assertSame('', $this->provider()->buildLiveSiteCd('')); + $this->assertSame('', $this->provider()->buildLiveSiteCd(' ')); + } + + /** + * @scenario mode=test,live_credentials=complete + * + * @effects availability_reflects_mode_and_credentials + */ + public function test_test_mode_is_available_with_test_credentials(): void + { + $provider = $this->provider([ + 'is_test_mode' => true, + 'test_site_cd' => 'AO7F3', + 'test_enc_key' => 'test-key', + ]); + + $this->assertTrue($provider->isAvailable()); + } + + /** + * @scenario mode=test,live_credentials=complete + * + * @effects availability_reflects_mode_and_credentials + */ + public function test_test_mode_is_unavailable_without_test_credentials(): void + { + $provider = $this->provider([ + 'is_test_mode' => true, + 'test_site_cd' => 'AO7F3', + 'test_enc_key' => '', + ]); + + $this->assertFalse($provider->isAvailable()); + } + + /** + * @scenario mode=live,live_credentials=complete + * + * @effects availability_reflects_mode_and_credentials + */ + public function test_live_mode_is_available_with_live_credentials(): void + { + $provider = $this->provider([ + 'is_test_mode' => false, + 'live_site_cd' => 'A1B2C', + 'live_enc_key' => 'live-key', + ]); + + $this->assertTrue($provider->isAvailable()); + $this->assertSame('SMA1B2C', $provider->resolveSiteCd()); + $this->assertSame('live-key', $provider->resolveEncKey()); + } + + /** + * @scenario mode=live,live_credentials=missing_enc_key + * + * @effects availability_reflects_mode_and_credentials + */ + public function test_live_mode_is_unavailable_without_enc_key(): void + { + $provider = $this->provider([ + 'is_test_mode' => false, + 'live_site_cd' => 'A1B2C', + 'live_enc_key' => '', + ]); + + $this->assertFalse($provider->isAvailable()); + } + + /** + * @scenario mode=live,live_credentials=missing_site_cd + * + * @effects availability_reflects_mode_and_credentials + */ + public function test_live_mode_is_unavailable_without_site_cd(): void + { + $provider = $this->provider([ + 'is_test_mode' => false, + 'live_site_cd' => '', + 'live_enc_key' => 'live-key', + ]); + + $this->assertFalse($provider->isAvailable()); + } + + /** + * 설정이 주입된 provider 인스턴스. + * + * @param array $config + */ + private function provider(array $config = []): KcpIdentityProvider + { + return app()->makeWith(KcpIdentityProvider::class, ['config' => $config]); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Services/KcpCertClientTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Services/KcpCertClientTest.php new file mode 100644 index 00000000..100ec6d7 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Services/KcpCertClientTest.php @@ -0,0 +1,390 @@ + Http::response([ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'call_url' => 'https://testcert.kcp.co.kr/certGateway.do', + 'reg_cert_key' => 'CERTKEY000000001', + ]), + ]); + + $result = $this->client()->registerTrade( + ordrIdxx: 'G7-ORDER-0001', + retUrl: 'https://example.test/callback', + webSiteid: '', + paramOpts: ['challenge-uuid-0001'], + ); + + $this->assertSame('0000', $result['res_cd']); + $this->assertSame('CERTKEY000000001', $result['reg_cert_key']); + + Http::assertSent(function (Request $request): bool { + $this->assertSame('https://testcert.kcp.co.kr/api/reg/certDataReg.do', $request->url()); + $this->assertSame(self::SITE_CD, $request->header('site_cd')[0] ?? null); + + $rv = $request->header('rv')[0] ?? ''; + $this->assertNotSame('', $rv, 'salt(rv) 헤더가 있어야 한다'); + + // 전송 본문은 평문 JSON 이 아니라 암호문이어야 하며, 사이트 자격증명으로 복호화된다. + $plain = KcpCertCrypto::decrypt($request->body(), $rv, self::ENC_KEY, self::SITE_CD); + $payload = json_decode($plain, true); + + $this->assertSame(self::SITE_CD, $payload['site_cd']); + $this->assertSame('G7-ORDER-0001', $payload['ordr_idxx']); + $this->assertSame('https://example.test/callback', $payload['Ret_URL']); + $this->assertSame('', $payload['web_siteid']); + // 콜백이 거래키를 돌려주지 않는 경우를 대비해 challenge 식별자를 자유 파라미터로 동봉한다. + $this->assertSame('challenge-uuid-0001', $payload['param_opt_1']); + + return true; + }); + } + + /** + * @scenario mode=live,kcp_response=success + * + * @effects live_mode_uses_live_host_and_prefixed_site_cd + */ + public function test_live_mode_uses_live_host(): void + { + Http::fake([ + 'cert.kcp.co.kr/*' => Http::response([ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'call_url' => 'https://cert.kcp.co.kr/certGateway.do', + 'reg_cert_key' => 'LIVEKEY000000001', + ]), + ]); + + $client = (new KcpCertClient)->withCredentials(self::LIVE_SITE_CD, 'live-enc-key', false); + $result = $client->registerTrade('G7-LIVE-0001', 'https://example.test/callback'); + + $this->assertSame('0000', $result['res_cd']); + + Http::assertSent(function (Request $request): bool { + $this->assertStringStartsWith('https://cert.kcp.co.kr/', $request->url()); + $this->assertSame(self::LIVE_SITE_CD, $request->header('site_cd')[0] ?? null); + + return true; + }); + } + + /** + * @scenario mode=test,kcp_response=http_error + * + * @effects http_error_raises_remote_call_exception_without_challenge_row + */ + public function test_http_error_raises_remote_call_exception(): void + { + Http::fake([ + 'testcert.kcp.co.kr/*' => Http::response('gateway error', 502), + ]); + + $this->expectException(RemoteCallException::class); + + $this->client()->registerTrade('G7-ORDER-0002', 'https://example.test/callback'); + } + + /** + * @scenario mode=test,kcp_response=malformed + * + * @effects http_error_raises_remote_call_exception_without_challenge_row + */ + public function test_non_json_response_raises_remote_call_exception(): void + { + Http::fake([ + 'testcert.kcp.co.kr/*' => Http::response('maintenance', 200), + ]); + + $this->expectException(RemoteCallException::class); + + $this->client()->registerTrade('G7-ORDER-0003', 'https://example.test/callback'); + } + + /** + * @scenario cert_query=success + * + * @effects success_issues_verification_token_and_marks_log_verified + */ + public function test_fetch_cert_data_decrypts_identity_payload(): void + { + $identity = [ + 'user_name' => '홍길동', + 'birth_day' => '19900102', + 'phone_no' => '01012345678', + 'comm_id' => 'SKT', + 'sex_code' => '01', + 'local_code' => '01', + 'CI' => 'CI-VALUE-0001', + 'DI' => 'DI-VALUE-0001', + ]; + $encrypted = KcpCertCrypto::encrypt((string) json_encode($identity), self::ENC_KEY, self::SITE_CD); + + Http::fake([ + 'testcert.kcp.co.kr/*' => Http::response([ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'enc_cert_data' => $encrypted['enc_data'], + 'rv' => $encrypted['rv'], + ]), + ]); + + $result = $this->client()->fetchCertData('CERTKEY000000001', 'G7-ORDER-0001'); + + $this->assertSame('0000', $result['res_cd']); + $this->assertSame('홍길동', $result['user_name']); + $this->assertSame('19900102', $result['birth_day']); + $this->assertSame('01012345678', $result['phone_no']); + $this->assertSame('CI-VALUE-0001', $result['ci']); + $this->assertSame('DI-VALUE-0001', $result['di']); + + Http::assertSent(function (Request $request): bool { + $this->assertSame('https://testcert.kcp.co.kr/api/query/getCertData.do', $request->url()); + // 결과조회 본문은 평문 JSON — 주문번호는 모바일 흐름 필수값이라 항상 전송한다. + $payload = json_decode($request->body(), true); + $this->assertSame('CERTKEY000000001', $payload['reg_cert_key']); + $this->assertSame('G7-ORDER-0001', $payload['ordr_idxx']); + + return true; + }); + } + + /** + * @scenario cert_query=success,identifier_case=lowercase + * + * @effects success_issues_verification_token_and_marks_log_verified + */ + public function test_fetch_cert_data_accepts_lowercase_identifier_keys(): void + { + $encrypted = KcpCertCrypto::encrypt( + (string) json_encode(['user_name' => '김철수', 'ci' => 'ci-lower', 'di' => 'di-lower']), + self::ENC_KEY, + self::SITE_CD, + ); + + Http::fake([ + 'testcert.kcp.co.kr/*' => Http::response([ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'enc_cert_data' => $encrypted['enc_data'], + 'rv' => $encrypted['rv'], + ]), + ]); + + $result = $this->client()->fetchCertData('CERTKEY000000001', 'G7-ORDER-0001'); + + $this->assertSame('ci-lower', $result['ci']); + $this->assertSame('di-lower', $result['di']); + } + + /** + * @scenario cert_query=res_cd_error + * + * @effects provider_error_marks_log_failed_with_res_cd + */ + public function test_fetch_cert_data_returns_error_code_without_identity(): void + { + Http::fake([ + 'testcert.kcp.co.kr/*' => Http::response([ + 'res_cd' => 'CS16', + 'res_msg' => '거래등록이 올바르지 않습니다.', + ]), + ]); + + $result = $this->client()->fetchCertData('CERTKEY000000001', 'G7-ORDER-0001'); + + $this->assertSame('CS16', $result['res_cd']); + $this->assertArrayNotHasKey('user_name', $result); + } + + /** + * @scenario cert_query=decrypt_failure + * + * @effects decrypt_failure_returns_decrypt_failed + */ + public function test_fetch_cert_data_raises_decrypt_exception_on_broken_payload(): void + { + Http::fake([ + 'testcert.kcp.co.kr/*' => Http::response([ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'enc_cert_data' => base64_encode('not-a-valid-cipher'), + 'rv' => base64_encode(random_bytes(16)), + ]), + ]); + + $this->expectException(DecryptException::class); + + $this->client()->fetchCertData('CERTKEY000000001', 'G7-ORDER-0001'); + } + + /** + * 테스트 자격증명이 주입된 게이트웨이. + */ + /** + * 식별값이 표준 키에 없고 URL 인코딩 사본(`CI_URL`/`DI_URL`)에만 있어도 회수해야 한다. + * + * 식별값이 비면 중복 가입 차단이 조용히 무력화되므로, 규격 변형 응답에서도 잃지 않는다. + * + * @scenario cert_query=success,identifier_case=url_encoded + * + * @effects identifier_recovered_from_url_encoded_key + */ + public function test_fetch_cert_data_recovers_identifiers_from_url_encoded_keys(): void + { + $encrypted = KcpCertCrypto::encrypt( + (string) json_encode([ + 'user_name' => '김철수', + 'CI' => '', + 'DI' => '', + 'CI_URL' => rawurlencode('CI+VALUE/0001='), + 'DI_URL' => rawurlencode('DI+VALUE/0001='), + ]), + self::ENC_KEY, + self::SITE_CD, + ); + + Http::fake([ + 'testcert.kcp.co.kr/*' => Http::response([ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'enc_cert_data' => $encrypted['enc_data'], + 'rv' => $encrypted['rv'], + ]), + ]); + + $result = $this->client()->fetchCertData('CERTKEY000000001', 'G7-ORDER-0001'); + + $this->assertSame('CI+VALUE/0001=', $result['ci']); + $this->assertSame('DI+VALUE/0001=', $result['di']); + } + + /** + * 표준 키가 채워져 있으면 URL 인코딩 사본보다 우선한다. + * + * @scenario cert_query=success,identifier_case=uppercase + * + * @effects identifier_recovered_from_url_encoded_key + */ + public function test_fetch_cert_data_prefers_plain_identifier_over_url_encoded(): void + { + $encrypted = KcpCertCrypto::encrypt( + (string) json_encode([ + 'user_name' => '김철수', + 'CI' => 'CI-PLAIN', + 'DI' => 'DI-PLAIN', + 'CI_URL' => rawurlencode('CI-ENCODED'), + 'DI_URL' => rawurlencode('DI-ENCODED'), + ]), + self::ENC_KEY, + self::SITE_CD, + ); + + Http::fake([ + 'testcert.kcp.co.kr/*' => Http::response([ + 'res_cd' => '0000', + 'res_msg' => '정상처리', + 'enc_cert_data' => $encrypted['enc_data'], + 'rv' => $encrypted['rv'], + ]), + ]); + + $result = $this->client()->fetchCertData('CERTKEY000000001', 'G7-ORDER-0001'); + + $this->assertSame('CI-PLAIN', $result['ci']); + $this->assertSame('DI-PLAIN', $result['di']); + } + + /** + * 규격 길이를 넘는 필드는 KCP 로 보내기 전에 막아야 한다. + * + * 넘긴 채 보내면 KCP 가 어느 필드가 문제인지 알려주지 않는 코드로 거절해 원인 추적이 어렵다. + * + * @param string $field 초과시킬 필드 + * + * @scenario mode=test,kcp_response=res_cd_error + * + * @effects oversized_field_is_rejected_before_remote_call + */ + #[DataProvider('oversizedFieldProvider')] + public function test_register_trade_rejects_oversized_field_before_sending(string $field): void + { + Http::fake(); + + $values = [ + 'ordr_idxx' => 'G7-ORDER-0001', + 'Ret_URL' => 'https://example.test/callback', + 'web_siteid' => '', + ]; + $values[$field] = str_repeat('X', $field === 'Ret_URL' ? 300 : ($field === 'ordr_idxx' ? 60 : 20)); + + $this->expectException(RemoteCallException::class); + + try { + $this->client()->registerTrade( + ordrIdxx: $values['ordr_idxx'], + retUrl: $values['Ret_URL'], + webSiteid: $values['web_siteid'], + paramOpts: ['challenge-uuid-0001'], + ); + } finally { + // 규격 위반은 요청을 보내기 전에 걸러져야 한다. + Http::assertNothingSent(); + } + } + + /** + * 길이 초과 대상 필드. + * + * @return array 필드명 케이스 + */ + public static function oversizedFieldProvider(): array + { + return [ + 'ordr_idxx 50자 초과' => ['ordr_idxx'], + 'Ret_URL 256자 초과' => ['Ret_URL'], + 'web_siteid 12자 초과' => ['web_siteid'], + ]; + } + + private function client(): KcpCertClient + { + return (new KcpCertClient)->withCredentials(self::SITE_CD, self::ENC_KEY, true); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Support/KcpCertCryptoTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Support/KcpCertCryptoTest.php new file mode 100644 index 00000000..679de54d --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Support/KcpCertCryptoTest.php @@ -0,0 +1,120 @@ + self::SITE_CD, 'ordr_idxx' => 'G7-0001'], JSON_UNESCAPED_SLASHES); + + $encrypted = KcpCertCrypto::encrypt((string) $plain, self::ENC_KEY, self::SITE_CD); + + $this->assertNotEmpty($encrypted['enc_data']); + $this->assertNotEmpty($encrypted['rv']); + $this->assertSame(16, strlen((string) base64_decode($encrypted['rv'], true)), 'salt 는 16바이트여야 한다'); + + $decrypted = KcpCertCrypto::decrypt($encrypted['enc_data'], $encrypted['rv'], self::ENC_KEY, self::SITE_CD); + + $this->assertSame($plain, $decrypted); + } + + /** + * @scenario crypto_case=spec_reference + * + * @effects derivation_matches_kcp_specification_reference + */ + public function test_cipher_matches_kcp_specification_reference(): void + { + $plain = '{"site_cd":"AO7F3","ordr_idxx":"G7-SPEC"}'; + + $encrypted = KcpCertCrypto::encrypt($plain, self::ENC_KEY, self::SITE_CD); + $salt = (string) base64_decode($encrypted['rv'], true); + + // KCP 규격을 그대로 재현한 참조 구현 — 구현체 상수 변경 시 이 대조가 깨진다. + $key = hash_pbkdf2('sha256', self::ENC_KEY, $salt, 10000, 32, true); + $iv = substr(hash_pbkdf2('sha256', self::SITE_CD, $salt, 10000, 32, true), 0, 16); + $expected = base64_encode((string) openssl_encrypt($plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv)); + + $this->assertSame($expected, $encrypted['enc_data']); + } + + /** + * @scenario crypto_case=salt_uniqueness + * + * @effects each_encryption_uses_a_fresh_salt + */ + public function test_each_encryption_uses_a_new_salt(): void + { + $first = KcpCertCrypto::encrypt('same-plain-text', self::ENC_KEY, self::SITE_CD); + $second = KcpCertCrypto::encrypt('same-plain-text', self::ENC_KEY, self::SITE_CD); + + $this->assertNotSame($first['rv'], $second['rv'], '매 요청마다 새 salt 를 사용해야 한다'); + $this->assertNotSame($first['enc_data'], $second['enc_data']); + } + + /** + * @scenario crypto_case=wrong_key + * + * @effects decrypt_rejects_wrong_encryption_key + */ + public function test_decrypt_with_wrong_key_throws(): void + { + $encrypted = KcpCertCrypto::encrypt('payload', self::ENC_KEY, self::SITE_CD); + + $this->expectException(DecryptException::class); + + KcpCertCrypto::decrypt($encrypted['enc_data'], $encrypted['rv'], 'wrong-enc-key', self::SITE_CD); + } + + /** + * @scenario crypto_case=wrong_site_cd + * + * @effects decrypt_rejects_wrong_site_code + */ + public function test_decrypt_with_wrong_site_code_throws(): void + { + $encrypted = KcpCertCrypto::encrypt('payload', self::ENC_KEY, self::SITE_CD); + + $this->expectException(DecryptException::class); + + // 사이트코드는 IV 파생 소스이므로 다르면 복호화가 성립하지 않는다. + KcpCertCrypto::decrypt($encrypted['enc_data'], $encrypted['rv'], self::ENC_KEY, 'OTHER'); + } + + /** + * @scenario crypto_case=malformed_payload + * + * @effects decrypt_rejects_malformed_payload + */ + public function test_decrypt_with_empty_payload_throws(): void + { + $this->expectException(DecryptException::class); + + KcpCertCrypto::decrypt('', '', self::ENC_KEY, self::SITE_CD); + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/adult-guard.yaml b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/adult-guard.yaml new file mode 100644 index 00000000..16f7e7d2 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/adult-guard.yaml @@ -0,0 +1,27 @@ +feature: NHN KCP 휴대폰 본인확인 — 성인인증 가드 + +description: | + 성인인증(nhnkcp.adult_verification) 목적의 challenge 는 만 19세 이상만 통과시킨다. 미성년자는 + 본인확인 자체가 성공했더라도 토큰을 발급하지 않아 코어 정책 게이트가 자동 차단한다. 생년월일이 + 비어 있으면 성인 판정 이전에 신원 가드가 먼저 차단한다(더 근본적인 실패 사유 우선). + 생일 당일은 성인으로 인정한다. + +axes: + purpose: [adult, non_adult] + age: [over_19, under_19, exactly_19_today, birthday_missing] + +exclusions: + - { purpose: non_adult, age: exactly_19_today, reason: "성인 가드는 purpose=adult 에만 적용 — 경계값 판정은 adult 조합으로 대표" } + - { purpose: non_adult, age: over_19, reason: "성인 가드 미적용 — 나이 무관 통과를 under_19 조합으로 대표" } + - { purpose: non_adult, age: birthday_missing, reason: "생년월일 부재는 purpose 무관 신원 가드가 차단 — adult 조합으로 대표" } + +effects: + - minor_blocked_with_not_adult_and_no_token + - adult_passes_with_token + - nineteenth_birthday_today_counts_as_adult + - missing_birthday_blocked_by_incomplete_identity + - non_adult_purpose_passes_for_minor + +test_files: + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpAdultVerificationGuardTest.php + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Identity/KcpAdultAgeBoundaryTest.php diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/birthday-availability.yaml b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/birthday-availability.yaml new file mode 100644 index 00000000..4e23c122 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/birthday-availability.yaml @@ -0,0 +1,38 @@ +feature: NHN KCP 휴대폰 본인확인 — 생년월일 미수신 대응 + +description: | + KCP 규격상 생년월일은 조건 없이 항상 내려오는 표준 응답 필드다(공식 "본인확인 결과 복호화" + 표: birth_day / string8 / PC·Mobile). 따라서 운영에서 비어 오는 응답은 정상 본인확인으로 + 볼 수 없고, 빈 신원값으로 회원이 등록되는 것을 막기 위해 거절한다. + + 그러나 KCP 테스트 서버는 실제 통신사 조회를 수행하지 않아 생년월일·성별·내외국인을 빈 값으로 + 응답한다(이름·휴대폰·통신사·CI·DI 는 정상 수신). 운영과 동일하게 거절하면 테스트 환경에서는 + 어떤 인증 방식으로도 완주할 수 없어 검수 자체가 불가능해진다. 그래서 생년월일 하나만, + 테스트 모드에 한해 예외로 둔다. + + 생년월일을 받지 못하면 성인 여부는 계산할 수 없다. 이때 "성인 아님" 으로 단정하면 미성년자로 + 잘못 안내되므로, 성인인증 목적은 계속 막되(fail-closed) 화면에는 "확인 불가" 로 구분해 알린다. + +axes: + mode: [test, live] + birth_day: [present, absent] + purpose: [general, adult] + +exclusions: + - { mode: live, birth_day: absent, purpose: adult, reason: "운영 모드는 신원 가드가 먼저 거절하므로 목적 축이 결과를 바꾸지 않는다 — general 조합으로 대표" } + - { mode: test, birth_day: present, purpose: general, reason: "정상 응답 경로는 callback-verify 매니페스트가 커버" } + - { mode: test, birth_day: present, purpose: adult, reason: "생년월일을 받은 성인 판정은 adult-guard 매니페스트가 커버" } + - { mode: live, birth_day: present, purpose: general, reason: "정상 응답 경로는 callback-verify 매니페스트가 커버" } + - { mode: live, birth_day: present, purpose: adult, reason: "성인 판정 자체는 adult-guard 매니페스트가 커버" } + +effects: + - test_mode_accepts_response_without_birthday + - live_mode_rejects_response_without_birthday + - test_mode_still_rejects_missing_identity_values + - test_mode_missing_birthday_blocked_by_adult_guard_not_identity_guard + - missing_birthday_stored_as_null_with_adult_false + +test_files: + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpTestModeBirthdayFallbackTest.php + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpAdultVerificationGuardTest.php + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpCallbackResolverTest.php diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/callback-verify.yaml b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/callback-verify.yaml new file mode 100644 index 00000000..d62dc2e5 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/callback-verify.yaml @@ -0,0 +1,66 @@ +feature: NHN KCP 휴대폰 본인확인 — 콜백 판정 파이프라인 + +description: | + 표준창이 콜백(Ret_URL)으로 돌아오면 거래 식별 → 1회 소비 마킹 → 응답 코드 판정 → 결과조회 → + 코어 verify 위임 순으로 처리한다. 어떤 분기든 브리지로 전달할 결과 코드로 끝나며, 개인정보는 + 주소에 싣지 않는다. 콜백이 거래키를 돌려주지 않는 경우에도 거래등록 시 실어 보낸 challenge_id + (param_opt_1)로 역조회가 가능하다. + + 판정은 순차 파이프라인이라 앞 단계에서 끝나면 뒤 단계는 실행되지 않는다. 따라서 축은 + "어느 단계에서 끝났는가"(outcome)와 "거래를 무엇으로 찾았는가"(lookup)로 잡는다. 신원 핵심값 + 검사 단계까지 도달한 경우에는 "어느 값이 비어 왔는가"(missing_field)가 추가로 성립한다 — + 한 값이라도 비면 거절이므로 값마다 개별로 잠근다. 생년월일은 모드에 따라 판정이 갈리므로 + 이 축에 두지 않고 birthday-availability 매니페스트가 다룬다. + +axes: + outcome: + - success + - cancelled + - provider_error + - remote_failure + - decrypt_failure + - cert_rejected + - incomplete_identity + - not_found + - already_consumed + - storage_failure + lookup: [by_reg_cert_key, by_param_opt] + missing_field: [none, user_name, phone_no, ci, di] + +exclusions: + - outcome: [success, cancelled, provider_error, remote_failure, decrypt_failure, cert_rejected, not_found, already_consumed, storage_failure] + missing_field: [user_name, phone_no, ci, di] + reason: "결손 필드 축은 신원 핵심값 검사 단계에 도달한 경우에만 성립 — 그 외 outcome 은 missing_field=none" + - { outcome: incomplete_identity, missing_field: none, reason: "빠진 값이 없으면 incomplete_identity 로 끝나지 않는다" } + - { outcome: cancelled, lookup: by_param_opt, reason: "거래 식별 방식은 이후 판정에 영향을 주지 않는다 — 거래키 조합으로 대표" } + - { outcome: provider_error, lookup: by_param_opt, reason: "동일" } + - { outcome: remote_failure, lookup: by_param_opt, reason: "동일" } + - { outcome: decrypt_failure, lookup: by_param_opt, reason: "동일" } + - { outcome: cert_rejected, lookup: by_param_opt, reason: "동일" } + - { outcome: incomplete_identity, lookup: by_param_opt, reason: "동일" } + - { outcome: not_found, lookup: by_param_opt, reason: "거래를 못 찾은 상태라 식별 방식 축이 성립하지 않는다" } + - { outcome: already_consumed, lookup: by_param_opt, reason: "동일 — 거래키 조합으로 대표" } + - { outcome: storage_failure, lookup: by_param_opt, reason: "동일 — 저장 실패는 거래 식별 이후 단계라 식별 방식과 무관" } + +effects: + - success_issues_verification_token_and_marks_log_verified + - lookup_by_param_opt_when_reg_cert_key_absent + - unknown_transaction_returns_not_found + - second_callback_returns_already_consumed + - cancelled_marks_log_cancelled_with_9999_code + - provider_error_marks_log_failed_with_res_cd + - remote_failure_returns_remote_call_failed + - decrypt_failure_returns_decrypt_failed + - missing_identity_fields_returns_incomplete_identity + - bridge_query_carries_no_personal_information + - unmapped_provider_code_carries_original_message + - failure_bridge_query_carries_challenge_id_for_retry + - resolver_only_failure_is_recorded_on_log + - record_save_failure_rolls_back_token_issuance + - guest_stash_failure_rolls_back_token_issuance + - record_and_token_are_committed_together + +test_files: + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpCallbackResolverTest.php + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Controllers/KcpCallbackControllerTest.php + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpVerifyStorageAtomicityTest.php diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/cert-crypto.yaml b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/cert-crypto.yaml new file mode 100644 index 00000000..67407b6f --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/cert-crypto.yaml @@ -0,0 +1,30 @@ +feature: NHN KCP 휴대폰 본인확인 — 요청/응답 암호화 규격 + +description: | + 거래등록 요청 본문과 결과조회 응답은 KCP 규격(AES-256-CBC, PBKDF2-SHA256 10000회, 사이트코드 + 파생 IV, 요청마다 새 salt)으로 암·복호화된다. 규격이 한 항목이라도 어긋나면 KCP 가 거래등록을 + 거절하거나 결과조회 응답을 읽지 못해, 인증이 시작조차 되지 않는다. + + 이 축은 조합이 아니라 서로 독립인 속성 검사다 — 왕복 성립 / 규격 참조 구현과의 일치 / + salt 재사용 금지 / 잘못된 입력 3종의 거절. 거래등록 흐름(mode × kcp_response)과 교차하지 + 않으므로 challenge-issue 와 분리해 둔다. + +axes: + crypto_case: + - roundtrip + - spec_reference + - salt_uniqueness + - wrong_key + - wrong_site_cd + - malformed_payload + +effects: + - ciphertext_round_trips_with_site_credentials + - derivation_matches_kcp_specification_reference + - each_encryption_uses_a_fresh_salt + - decrypt_rejects_wrong_encryption_key + - decrypt_rejects_wrong_site_code + - decrypt_rejects_malformed_payload + +test_files: + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Support/KcpCertCryptoTest.php diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/challenge-issue.yaml b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/challenge-issue.yaml new file mode 100644 index 00000000..adf97f89 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/challenge-issue.yaml @@ -0,0 +1,29 @@ +feature: NHN KCP 휴대폰 본인확인 — challenge 발행(거래등록) + +description: | + challenge 발행은 KCP 거래등록 실호출 결과에 종속된다. 거래등록이 실패하면 challenge 를 발행하지 + 않아 "열 수 없는 인증창" 을 위한 로그 행이 남지 않는다. 성공 시 표준창 호출 정보(call_url, + reg_cert_key)를 public_payload 로 전달하고, 거래 행(주문번호/거래키 해시·암호문)을 저장한다. + 요청 본문은 사이트코드/암호화 키로 AES 암호화되며, 모드에 따라 테스트/운영 호스트가 갈린다. + +axes: + mode: [test, live] + kcp_response: [success, res_cd_error, http_error] + +exclusions: + - { mode: live, kcp_response: http_error, reason: "통신 실패 처리는 모드 무관 동일 경로 — test 조합으로 대표" } + - { mode: live, kcp_response: res_cd_error, reason: "거절 응답 처리도 모드 무관 동일 경로 — test 조합으로 대표" } + +effects: + - challenge_issued_with_call_url_and_reg_cert_key + - transaction_row_stores_hashed_and_encrypted_reg_cert_key + - request_body_is_encrypted_and_decryptable_with_site_credentials + - live_mode_uses_live_host_and_prefixed_site_cd + - res_cd_error_raises_remote_call_exception_without_challenge_row + - http_error_raises_remote_call_exception_without_challenge_row + - oversized_field_is_rejected_before_remote_call + - identifier_recovered_from_url_encoded_key + +test_files: + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpRequestChallengeTest.php + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Services/KcpCertClientTest.php diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/duplicate-block.yaml b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/duplicate-block.yaml new file mode 100644 index 00000000..866e43d5 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/duplicate-block.yaml @@ -0,0 +1,42 @@ +feature: NHN KCP 휴대폰 본인확인 — 동일인 중복 가입 차단 + +description: | + 동일인 판정은 운영자가 고른 기준(DI/CI)의 keyed-hash 로 수행한다. 판정 로직은 한 곳(Service)에 + 두고 인증 시점(조기 안내)과 가입 직전(권위 차단) 두 지점이 공유한다. 차단을 끄면 두 지점 모두 + 통과시키며, 식별값이 비어 있으면 통과시킨다. + + 판정에 관여하는 인자는 서로 독립이므로 각각을 축으로 둔다 — 어느 지점에서 판정하는가(gate), + 무엇을 기준으로 보는가(basis), 차단 스위치가 켜져 있는가(blocking), 인증한 사람이 기존 가입자와 + 어떤 관계인가(relation). 한 축이 다른 축의 해석 자체를 건너뛰는 조합은 exclusions 로 명시한다. + +axes: + gate: [verify, register] + basis: [di, ci] + blocking: [enabled, disabled] + relation: [duplicate, new_person, self_reverify, missing_hash] + +exclusions: + # 차단 스위치가 꺼져 있으면 기준 필드 해석·조회 자체에 도달하지 않는다 (호출부가 먼저 단락). + - { blocking: disabled, basis: ci, reason: "차단이 꺼지면 기준 필드를 해석하지 않는다 — di 조합으로 대표" } + - { blocking: disabled, relation: new_person, reason: "차단이 꺼진 상태의 통과는 관계와 무관 — duplicate 조합이 상위 케이스" } + - { blocking: disabled, relation: self_reverify, reason: "동일 — duplicate 조합이 상위 케이스" } + - { blocking: disabled, relation: missing_hash, reason: "동일 — duplicate 조합이 상위 케이스" } + # 식별값이 없거나 본인인 경우, 신규 가입자인 경우는 기준 필드를 무엇으로 골라도 결과가 같다. + - { relation: missing_hash, basis: ci, reason: "식별값 자체가 없으면 기준 필드와 무관하게 통과 — di 조합으로 대표" } + - { relation: self_reverify, basis: ci, reason: "본인 제외 조회는 기준 필드와 무관 — di 조합으로 대표" } + - { relation: new_person, basis: ci, reason: "신규 가입자 통과는 기준 필드와 무관 — di 조합으로 대표" } + # 게이트별 책임 범위. + - { gate: verify, relation: missing_hash, reason: "인증 시점에는 신원 가드가 식별값 부재를 먼저 차단한다 — 중복 판정에 도달하지 않음" } + - { gate: register, relation: self_reverify, reason: "가입 직전 게이트는 신규 가입만 다룬다 — 본인 재인증은 verify 조합으로 대표" } + +effects: + - verify_blocks_duplicate_signup_with_duplicate_code + - register_listener_blocks_duplicate_with_validation_error + - disabled_setting_passes_both_gates + - ci_basis_matches_by_ci_hash + - missing_identifier_hash_passes + - non_signup_purpose_is_not_blocked + +test_files: + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/AssertNoDuplicateKcpIdentityTest.php + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpDuplicateVerifyGuardTest.php diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/guest-flow.yaml b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/guest-flow.yaml new file mode 100644 index 00000000..302b78a4 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/guest-flow.yaml @@ -0,0 +1,22 @@ +feature: NHN KCP 휴대폰 본인확인 — 비로그인 가입 흐름 + +description: | + 비로그인 사용자의 개인정보는 인증 시점에 캐시에 임시 보관되고, 가입이 완료된 직후 회원 정보로 + 확정된다. 확정 후 임시 보관분은 즉시 정리되며, 가입이 이뤄지지 않으면 보관 기한이 지나 사라진다. + +axes: + stage: [verify, after_register] + cache_state: [present, absent] + +exclusions: + - { stage: verify, cache_state: absent, reason: "verify 는 임시 보관을 만드는 단계 — 부재 축이 성립하지 않음" } + +effects: + - guest_verify_stashes_pii_instead_of_record + - after_register_absorbs_stash_into_record + - after_register_clears_stash + - after_register_backfills_log_user_id + - missing_stash_logs_warning_without_breaking_registration + +test_files: + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/CompleteKcpRecordAfterRegisterTest.php diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/handler-reregistration.yaml b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/handler-reregistration.yaml new file mode 100644 index 00000000..2338223b --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/handler-reregistration.yaml @@ -0,0 +1,29 @@ +feature: NHN KCP 휴대폰 본인확인 — 코어 재초기화 후 핸들러 재등록 + +description: | + 로케일 전환처럼 코어가 다시 초기화되면 액션 디스패처가 새 인스턴스로 교체되어 + 앞서 등록해 둔 확장 핸들러가 전부 사라진다. 코어는 window 전역에 노출된 + 고정 이름의 진입점(플러그인=initPlugin)을 호출해 재등록을 요청하므로, + 이 이름으로 노출하지 않으면 언어를 한 번 바꾼 뒤부터 「본인확인 시작」이 + 무반응이 된다 — 오류도 안내도 남지 않아 사용자는 원인을 알 수 없다. + + 진입점은 핸들러 재등록만 수행한다. 모바일 복귀 처리처럼 최초 진입 1회로 + 충분한 작업을 넣으면 재초기화마다 중복 실행된다. + +axes: + locale: [initial, switched] + dispatcher_ready: [ready, delayed] + e2e_environment: [desktop] + +exclusions: + - { locale: initial, dispatcher_ready: delayed, reason: "최초 진입의 지연 등록은 기존 startAuthHandler 시나리오가 커버" } + +effects: + - exposes_init_plugin_entrypoint_on_window_global + - reregisters_start_auth_handler_on_new_dispatcher + - retries_registration_until_dispatcher_ready + - locale_switch_preserves_start_auth_handler + +test_files: + - plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/handlerReregistration.test.ts + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/specs/user/locale-switch-start-auth.spec.ts diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/lifecycle.yaml b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/lifecycle.yaml new file mode 100644 index 00000000..3caab507 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/lifecycle.yaml @@ -0,0 +1,27 @@ +feature: NHN KCP 휴대폰 본인확인 — 플러그인 생명주기 + +description: | + 설치/활성화/비활성화/삭제 각 단계에서 코어 IDV 인프라와의 연결이 의도대로 붙고 끊기는지 검증한다. + 설치 시 성인인증(nhnkcp.adult_verification) 목적의 본인확인 수단이 비어 있으면 KCP 로 1회 세팅하고, + 이미 운영자가 지정한 값이 있으면 덮어쓰지 않는다(초기값 성격). 삭제 시에는 심어 둔 값만 거두어 + 존재하지 않는 provider 를 가리키는 고아 설정을 남기지 않되, 운영자가 바꿔 둔 값은 보존한다. + +axes: + stage: [install, activate, deactivate, uninstall] + existing_purpose_provider: [absent, present] + +exclusions: + - { stage: activate, existing_purpose_provider: present, reason: "purpose 초기 매핑은 install/uninstall 단계 책임 — activate 는 provider 등록만 검증" } + - { stage: deactivate, existing_purpose_provider: present, reason: "동일 — deactivate 는 provider 미등록만 검증" } + +effects: + - install_seeds_adult_purpose_provider_when_absent + - install_preserves_existing_purpose_provider + - activate_registers_provider_in_core_registry + - deactivate_removes_provider_from_core_registry + - uninstall_removes_seeded_purpose_provider + - uninstall_preserves_operator_chosen_provider + +test_files: + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpInstallDefaultPurposeProviderTest.php + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Identity/KcpIdentityProviderBindingTest.php diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/mobile-return.yaml b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/mobile-return.yaml new file mode 100644 index 00000000..bd13749b --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/mobile-return.yaml @@ -0,0 +1,33 @@ +feature: NHN KCP 휴대폰 본인확인 — 모바일 페이지 전환 복귀 + +description: | + 모바일은 현재 페이지가 인증 화면으로 전환되므로, 전환 전에 복귀 주소와 입력 중이던 값을 보관하고 + 복귀 후 되돌린다. 비밀번호는 보관하지 않는다. 복귀 주소에 붙은 인증 토큰은 그대로 두어 화면이 + 이어지는 요청에 실어 보내고, 실패 코드는 안내 후 주소에서 제거한다. + +axes: + environment: [desktop, mobile] + outcome: [verified, failed, cancelled] + +exclusions: + - { environment: mobile, outcome: cancelled, reason: "모바일은 페이지가 전환되어 팝업이 없다 — 취소는 표준창이 실패 코드로 복귀하므로 outcome=failed 로 대표" } + +effects: + - desktop_opens_popup_and_submits_standard_window_form + - mobile_stashes_return_url_and_form_values_before_transition + - mobile_stash_excludes_password_fields + - mobile_return_restores_form_values + - mobile_return_keeps_verification_token_in_query + - mobile_return_shows_failure_notice_and_strips_error_param + - popup_blocked_shows_notice_without_entering_progress_state + - popup_closed_without_result_restores_start_button + - failed_result_keeps_panel_open_for_retry + - verified_result_resolves_and_closes_panel + - retry_after_failure_issues_new_transaction + - retry_after_popup_close_issues_new_transaction + - transition_decision_matches_portable_breakpoint_of_the_notice_panel + +test_files: + - plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/startAuthHandler.test.ts + - plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/mobileReturn.test.ts + - plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/failureRetryFlow.test.ts diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/mypage.yaml b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/mypage.yaml new file mode 100644 index 00000000..b949849e --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/mypage.yaml @@ -0,0 +1,30 @@ +feature: NHN KCP 휴대폰 본인확인 — 마이페이지 본인확인 카드 + +description: | + 사용자가 자기 본인확인 정보를 확인할 수 있게 하되, 이름/생년월일/휴대폰은 가려서 표시하고 + 식별값(CI/DI)은 응답에 포함하지 않는다. 인증 내역이 없으면 빈 응답으로 안내 카드를 띄운다. + +axes: + record_exists: [true, false] + auth: [authenticated, guest] + +exclusions: + - { auth: guest, record_exists: true, reason: "비로그인은 인증 가드에서 차단 — record 유무 무관" } + +effects: + - verified_user_receives_masked_fields + - response_excludes_identity_identifiers + - user_without_record_receives_null_data + - guest_request_is_rejected + # 카드 화면 — API 계약이 화면에 그대로 반영되는지 (레이아웃 실렌더) + - card_renders_masked_fields + - card_shows_provider_badge + - card_shows_adult_status + - card_distinguishes_unknown_adult_from_minor + - card_shows_empty_state_without_record + - card_never_renders_identity_identifiers + - card_resolves_all_translation_keys + +test_files: + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Controllers/MyKcpIdentityShowControllerTest.php + - plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/layouts/mypageIdentityCard.test.tsx diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/pii-cleanup.yaml b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/pii-cleanup.yaml new file mode 100644 index 00000000..7ded3cf6 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/pii-cleanup.yaml @@ -0,0 +1,19 @@ +feature: NHN KCP 휴대폰 본인확인 — 회원 정보 파기 + +description: | + 회원 탈퇴와 관리자 삭제 시 본인확인 정보를 즉시 파기하고, 인증 기록에 남은 식별값 보안 지문도 + 함께 지운다. 삭제 경로에서는 회원 삭제가 제약 위반 없이 완료되어야 한다. + +axes: + trigger: [withdraw, admin_delete] + record_exists: [true, false] + +effects: + - withdraw_deletes_record_and_anonymizes_logs + - admin_delete_deletes_record_before_user_row_removal + - anonymized_log_keeps_audit_fields_but_drops_identity_hashes + - missing_record_is_a_no_op + +test_files: + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/CleanKcpRecordOnUserWithdrawTest.php + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Listeners/CleanKcpRecordOnUserDeleteTest.php diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/provider-ui-injection.yaml b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/provider-ui-injection.yaml new file mode 100644 index 00000000..cb9948a9 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/provider-ui-injection.yaml @@ -0,0 +1,30 @@ +feature: NHN KCP 휴대폰 본인확인 — 화면 조각 주입 계약 + +description: | + 이 플러그인은 자기 화면을 새로 만들지 않고 코어/템플릿 화면에 조각을 끼워 넣는다. + 본인확인 안내 창은 코어 모달의 provider 슬롯에 append 로 붙어 다른 인증수단과 공존해야 하고, + 마이페이지 카드는 마이페이지 프로필 화면에 붙어 이 플러그인의 API 를 데이터 소스로 쓴다. + 주입 계약(슬롯/모드/게이팅/핸들러/데이터소스)이 어긋나면 화면이 통째로 사라지거나 다른 + 인증수단의 화면을 덮어쓰므로, 조각 JSON 자체를 검증 대상으로 둔다. + + 또한 조각의 모든 문구는 다국어 키여야 하며 ko/en 양쪽에 실재해야 한다 — 한쪽만 있으면 + 그 로케일에서 원문 키가 그대로 화면에 노출된다. + + Icon 의 name 에는 스타일 접두사(fas/far/fab/fal/fad)를 붙이지 않는다. Icon 컴포넌트는 + 선행 `fa-` 만 떼고 나머지를 통째로 `fa-` 뒤에 붙이므로, `"fas fa-circle-info"` 는 + `fa-fas` 라는 존재하지 않는 클래스를 함께 만들어 낸다. 접두사는 iconStyle prop 소관이다. + +axes: + surface: [identity_modal, mypage_card] + concern: [injection_contract, i18n_completeness] + +effects: + - extension_appends_to_core_provider_slot + - state_branches_are_gated_by_provider_id + - start_button_dispatches_plugin_handler + - mypage_card_uses_plugin_api_data_source + - all_labels_resolve_in_ko_and_en + - icon_names_carry_no_style_prefix + +test_files: + - plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/identityProviderNhnkcpExtension.test.ts diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/settings-validation.yaml b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/settings-validation.yaml new file mode 100644 index 00000000..918ed55b --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/scenarios/settings-validation.yaml @@ -0,0 +1,36 @@ +feature: NHN KCP 휴대폰 본인확인 — 환경설정 검증 + +description: | + 운영 모드로 전환할 때만 운영 사이트코드/암호화 키를 필수로 강제한다. 사이트코드의 SM 프리픽스는 + 저장값과 무관한 런타임 규칙이라 축이 아니라 effects(sm_prefix_is_added_once)로 검증한다. + 암호화 키는 암호화 보관되어 조회 응답에 평문으로 노출되지 않는다. + +axes: + mode: [test, live] + live_credentials: [complete, missing_site_cd, missing_enc_key] + +exclusions: + - { mode: test, live_credentials: missing_site_cd, reason: "테스트 모드는 운영 자격증명을 요구하지 않음 — complete 조합으로 대표" } + - { mode: test, live_credentials: missing_enc_key, reason: "동일" } + +effects: + - live_mode_requires_site_cd_and_enc_key + - test_mode_saves_without_live_credentials + - sm_prefix_is_added_once + - availability_reflects_mode_and_credentials + - sensitive_key_is_not_returned_in_plain_text + # 설정 화면 — 검증 결과와 모드 전환이 화면에 그대로 반영되는지 (레이아웃 실렌더) + - live_mode_warning_hidden_in_test_mode + - live_mode_warning_shown_in_live_mode + - validation_errors_render_per_field + - save_button_locked_without_changes + - save_button_unlocked_on_change + - save_button_shows_progress_while_saving + - duplicate_basis_defaults_to_di + - settings_screen_resolves_all_translation_keys + +test_files: + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Settings/KcpLiveModeSettingsValidationTest.php + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Unit/Identity/KcpLiveSiteCdPrefixTest.php + - plugins/_bundled/sirsoft-verification_nhnkcp/resources/js/__tests__/layouts/pluginSettingsLayout.test.tsx + - plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/specs/admin/nhnkcp-verification-settings.spec.ts diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/vite.config.ts b/plugins/_bundled/sirsoft-verification_nhnkcp/vite.config.ts new file mode 100644 index 00000000..fca1bd29 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/vite.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, 'resources/js/index.ts'), + name: 'SirsoftVerificationNhnkcp', + formats: ['iife'], + }, + outDir: 'dist', + rollupOptions: { + output: { + entryFileNames: 'js/plugin.iife.js', + }, + }, + // 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다. + // 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다. + sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''), + minify: 'esbuild', + target: 'es2020', + chunkSizeWarningLimit: 500, + }, + resolve: { + alias: { + '@': resolve(__dirname, 'resources/js'), + }, + }, + test: { + environment: 'jsdom', + globals: true, + include: ['resources/js/__tests__/**/*.{test,spec}.{ts,tsx}'], + }, +}); diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/vitest.config.ts b/plugins/_bundled/sirsoft-verification_nhnkcp/vitest.config.ts new file mode 100644 index 00000000..5c945816 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + include: ['resources/js/**/*.{test,spec}.{ts,tsx}'], + exclude: ['node_modules/', 'dist/'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, 'resources/js'), + '@core': path.resolve(__dirname, '../../../resources/js/core'), + }, + }, +}); diff --git a/resources/js/core/template-engine/__tests__/troubleshooting-components.test.ts b/resources/js/core/template-engine/__tests__/troubleshooting-components.test.ts index fe8ee719..c6e887b5 100644 --- a/resources/js/core/template-engine/__tests__/troubleshooting-components.test.ts +++ b/resources/js/core/template-engine/__tests__/troubleshooting-components.test.ts @@ -47,6 +47,40 @@ describe('트러블슈팅 회귀 테스트 - flex 압착(shrink)', () => { expect(responsiveManager.getMatchingKey(responsive, 1024)).toBeNull(); }); + /** + * 사례: 안내 문구는 "페이지로 이동" 인데 실제로는 팝업이 열림 (경계 폭 1px) + * + * 확장이 화면 폭으로 동작을 고를 때 엔진과 같은 값을 읽어야 문구와 동작이 일치한다. + * 엔진이 `window.innerWidth` 를 본다는 전제가 깨지면(예: matchMedia 로 교체) 규정을 따른 + * 모든 확장이 경계 폭에서 동시에 어긋나므로, 그 전제를 여기서 고정한다. + * + * @see docs/frontend/troubleshooting-components-misc.md "확장 코드의 화면 폭 판정 관련 이슈" + * @see docs/frontend/responsive-layout.md "확장 코드에서 같은 경계를 판정할 때" + */ + it('엔진은 window.innerWidth 로 현재 폭을 정한다 (matchMedia 아님)', () => { + vi.useFakeTimers(); + const matchMedia = vi.fn(() => ({ matches: false })); + vi.stubGlobal('matchMedia', matchMedia); + const original = window.innerWidth; + + try { + Object.defineProperty(window, 'innerWidth', { value: 1023, configurable: true }); + // resize 를 태워 현재 폭을 다시 읽게 한다 (debounce 150ms) + window.dispatchEvent(new Event('resize')); + vi.advanceTimersByTime(200); + + expect(responsiveManager.getWidth()).toBe(1023); + expect(responsiveManager.matches('portable')).toBe(true); + expect(matchMedia).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(window, 'innerWidth', { value: original, configurable: true }); + window.dispatchEvent(new Event('resize')); + vi.advanceTimersByTime(200); + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + it('mobile 과 portable 이 함께 정의되면 좁은 범위(mobile)가 우선한다', () => { const responsive = { portable: { props: { className: 'portable-wins' } }, diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index aa83ddb0..9c7bd98c 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -26,7 +26,10 @@ ### Fixed -- 회원 목록에서 검색을 실행할 때만 목록 로딩 표시가 나타나지 않던 문제를 수정했습니다. 정렬·표시 개수 변경·페이지 이동은 정상 표시되고 있었습니다. +- 본인확인 코드 입력 화면에서 "남은 시도" 안내를 제거했습니다. 외부 인증 화면을 거쳐 돌아온 경우 실제보다 많은 횟수가 표시될 수 있어, 잘못된 숫자를 보여주는 대신 표시하지 않도록 했습니다. 시도 횟수 제한 자체는 종전대로 적용됩니다. +- 회원 목록에서 검색할 때 페이지를 넘길 때와 달리 표 위에 로딩 표시가 뜨지 않던 문제를 수정했습니다. +- 회원·역할·스케줄 목록에서 검색·정렬·페이지 이동 시 표 위에 걸어 둔 로딩 표시가 실제로는 동작하지 않던 문제를 수정했습니다. 다른 관리자 목록 화면과 같은 방식으로 표 본문에만 표시됩니다. +- 회원 목록의 삭제·정지 버튼이 다크 모드에서 배경색 대비가 맞지 않던 문제를 수정했습니다. - 관리자 회원·역할·활동이력·알림 로그·모듈/플러그인·템플릿·스케줄 목록에서 상세나 등록/수정 화면에 다녀오면 보고 있던 페이지와 검색 조건이 사라지던 문제를 수정했습니다. 새로고침 버튼도 걸어 둔 조건을 지우지 않고 보고 있던 목록을 그대로 다시 부릅니다. 초기화 버튼과 탭 전환은 종전처럼 조건을 비웁니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 회원 목록에서 회원을 열어 보거나 등록/수정 화면에 다녀오면 보고 있던 페이지와 검색 조건이 사라지던 문제를 수정했습니다. 새로고침 버튼도 걸어 둔 조건을 지우지 않습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 회원 목록에서 뒤쪽 페이지를 보던 중 검색하면 그 페이지 번호가 그대로 유지돼 결과가 없는 빈 화면이 열리던 문제를 수정했습니다. 검색은 1페이지부터 보여주며, 사용자가 고른 표시 개수와 정렬은 그대로 유지됩니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) @@ -38,6 +41,7 @@ - 다크 모드에서 일부 버튼·알림 점·아이콘의 색 대비가 낮아 잘 보이지 않던 문제를 수정했습니다. - 템플릿 목록 화면의 탭 영역이 본문 여백과 어긋나 가로로 밀려 보일 수 있던 문제를 수정했습니다. - 메일 템플릿·알림 정의 목록에서 각 행이 같은 식별자를 공유해 화면 읽기 도구가 항목을 구분하지 못하던 문제를 수정했습니다. +- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다. ## [1.0.3] - 2026-07-14 diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-base-transition-overlay.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-base-transition-overlay.test.tsx index f4e1730e..a8b1b07d 100644 --- a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-base-transition-overlay.test.tsx +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-base-transition-overlay.test.tsx @@ -212,6 +212,50 @@ describe('목록 페이지 페이지네이션 transition_overlay_target 가드 ( } }); +describe('transition_overlay_target 은 replace:true 와 함께여야 한다 (이슈 #245)', () => { + /** + * 엔진은 `params.replace === true` 인 navigate 만 `G7Core.updateQueryParams(path, { transitionOverlayTarget })` + * 를 태운다(`ActionDispatcher.handleNavigate`). replace 가 없으면 React Router 경로로 빠져 + * `transition_overlay_target` 이 **읽히지도 않는다** — 오버레이 대상 지정이 조용히 무효가 된다. + * + * 브라우저 실측(2026-07-29): `admin_identity_logs` 검색은 + * `updateQueryParams('/admin/identity/logs?...', { transitionOverlayTarget: 'identity_log_datagrid__body' })` + * 가 호출되는 반면, `admin_user_list` 검색은 `updateQueryParams` 호출이 **0회**였다. + * + * 앞선 가드는 `transition_overlay_target` 의 존재와 값만 검사해, 무효 상태를 통과시켰다. + */ + function findOverlayNavigates(node: any, out: any[] = []): any[] { + if (!node || typeof node !== 'object') return out; + if (node.handler === 'navigate' && node.params && typeof node.params.transition_overlay_target === 'string') { + out.push(node); + } + for (const key of Object.keys(node)) { + const v = node[key]; + if (Array.isArray(v)) for (const item of v) findOverlayNavigates(item, out); + else if (v && typeof v === 'object') findOverlayNavigates(v, out); + } + return out; + } + + const layoutFiles = fs.readdirSync(LAYOUTS_DIR).filter((f) => f.endsWith('.json')); + + for (const file of layoutFiles) { + const layout = JSON.parse(fs.readFileSync(path.join(LAYOUTS_DIR, file), 'utf-8')); + const navigates = findOverlayNavigates(layout); + if (navigates.length === 0) continue; + + it(`${file}: transition_overlay_target 을 쓰는 navigate 는 replace:true 여야 한다`, () => { + for (let i = 0; i < navigates.length; i++) { + const p = navigates[i].params; + expect( + p.replace, + `${file} navigate[${i}] (target="${p.transition_overlay_target}"): replace:true 가 없어 오버레이 대상 지정이 무효다` + ).toBe(true); + } + }); + } +}); + describe('채널 서브 탭 transition_overlay_target 가드 (이슈 #245, engine-v1.36.0)', () => { /** * 환경설정 알림 탭 안의 채널 서브 탭 클릭 시 채널 콘텐츠 영역에만 spinner 가 diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-identity-challenge-fullpage.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-identity-challenge-fullpage.test.tsx index fd4d44f5..f7908859 100644 --- a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-identity-challenge-fullpage.test.tsx +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-identity-challenge-fullpage.test.tsx @@ -79,6 +79,26 @@ describe('Admin 본인인증 풀페이지 (sirsoft-admin_basic) — max_attempts expect(offenders).toEqual([]); }); + it('서버 응답의 attempts 를 읽지 않는다 (공개 폴링 경로라 서버가 싣지 않음 — 이슈 #276)', () => { + // 회귀: getStatus 응답에서 attempts 를 뺐는데 레이아웃이 계속 그 값을 읽고 있어, + // 외부 redirect 콜백 후 진입 시 남은 시도 횟수가 실제보다 많게 표시되던 결함. + const offenders: string[] = []; + walk(fp as unknown as Node, (n) => { + for (const value of Object.values(n.props ?? {})) { + if (typeof value === 'string' && value.includes('response.data.attempts')) offenders.push(value); + } + if (typeof (n as any).text === 'string' && (n as any).text.includes('response.data.attempts')) { + offenders.push((n as any).text); + } + }); + expect(JSON.stringify(fp.init_actions ?? [])).not.toContain('response.data.attempts'); + expect(offenders).toEqual([]); + }); + + it('남은 시도 횟수를 화면에 표시하지 않는다 (정확한 값을 알 수 없음 — 이슈 #276)', () => { + expect(JSON.stringify(fp)).not.toContain('remaining_attempts'); + }); + it('확인 버튼 disabled 가 maxAttempts=0 (무제한) 케이스 가드를 포함', () => { let hasUnlimitedGuard = false; walk(fp as unknown as Node, (n) => { diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/admin_role_list.json b/templates/_bundled/sirsoft-admin_basic/layouts/admin_role_list.json index d94b3bf2..8d7c03a1 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/admin_role_list.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/admin_role_list.json @@ -202,6 +202,7 @@ "search": "{{_local.searchQuery || undefined}}", "page": 1 }, + "replace": true, "transition_overlay_target": "roles_data_grid__body" } } diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/admin_schedule_list.json b/templates/_bundled/sirsoft-admin_basic/layouts/admin_schedule_list.json index 4802c660..17efebb8 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/admin_schedule_list.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/admin_schedule_list.json @@ -78,6 +78,7 @@ "search_type": "{{_local.searchType || undefined}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/admin_user_list.json b/templates/_bundled/sirsoft-admin_basic/layouts/admin_user_list.json index b6dcf4d9..6d8de677 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/admin_user_list.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/admin_user_list.json @@ -69,18 +69,19 @@ ], "named_actions": { "searchUsers": { - "comment": "검색 실행 — 열거한 검색 조건만 덮어쓰고 정렬/페이지당 개수 등 나머지 목록 상태는 유지한다. page 는 새 검색이므로 1페이지로 되돌린다 (#75).", + "comment": "검색 실행 — 열거한 검색 조건만 덮어쓰고 정렬/페이지당 개수 등 나머지 목록 상태는 유지한다. page 는 새 검색이므로 1페이지로 되돌린다 (#75). 검색도 표를 다시 그리므로 페이지 이동과 동일하게 표 본문에 전환 오버레이를 건다 (#245). transition_overlay_target 은 replace:true 인 navigate 에서만 읽히므로 함께 둔다.", "handler": "navigate", "params": { "path": "/admin/users", "mergeQuery": true, + "replace": true, + "transition_overlay_target": "users_data_grid__body", "query": { "page": "", "filters[0][field]": "{{_local.searchField || 'all'}}", "filters[0][value]": "{{_local.searchQuery || undefined}}", "filters[0][operator]": "like" - }, - "transition_overlay_target": "users_data_grid__body" + } } } }, @@ -777,6 +778,7 @@ "sort_order": "{{$event.target.value.split('_').pop()}}", "page": 1 }, + "replace": true, "transition_overlay_target": "users_data_grid__body" } } @@ -819,6 +821,7 @@ "per_page": "{{$event.target.value}}", "page": 1 }, + "replace": true, "transition_overlay_target": "users_data_grid__body" } } @@ -1049,6 +1052,7 @@ "query": { "page": "{{$args[0]}}" }, + "replace": true, "transition_overlay_target": "users_data_grid__body" } }, @@ -1468,7 +1472,7 @@ "type": "basic", "name": "Button", "props": { - "className": "flex-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed", + "className": "flex-center gap-2 px-4 py-2 bg-red-600 dark:bg-red-700 text-white rounded-lg hover:bg-red-700 dark:hover:bg-red-800 disabled:opacity-50 disabled:cursor-not-allowed", "disabled": "{{_global.isDeleting}}" }, "actions": [ @@ -1678,7 +1682,7 @@ "name": "Button", "props": { "disabled": "{{_global.isProcessing}}", - "className": "flex-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed" + "className": "flex-center gap-2 px-4 py-2 bg-red-600 dark:bg-red-700 text-white rounded-lg hover:bg-red-700 dark:hover:bg-red-800 disabled:opacity-50 disabled:cursor-not-allowed" }, "actions": [ { @@ -1833,7 +1837,7 @@ "name": "Button", "props": { "disabled": "{{_global.isProcessing}}", - "className": "flex-center gap-2 px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 disabled:opacity-50 disabled:cursor-not-allowed" + "className": "flex-center gap-2 px-4 py-2 bg-yellow-600 dark:bg-yellow-700 text-white rounded-lg hover:bg-yellow-700 dark:hover:bg-yellow-800 disabled:opacity-50 disabled:cursor-not-allowed" }, "actions": [ { diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/auth/identity_challenge.json b/templates/_bundled/sirsoft-admin_basic/layouts/auth/identity_challenge.json index 75b1f059..a90966d1 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/auth/identity_challenge.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/auth/identity_challenge.json @@ -32,7 +32,7 @@ } }, { - "comment": "challenge_id 가 있으면 서버에서 정확한 attempts/max_attempts/expires_at/render_hint 를 받아 query fallback 을 덮어쓴다 — 외부 redirect 콜백 후 진입 시 UI 정합 보장.", + "comment": "challenge_id 가 있으면 서버에서 정확한 max_attempts/expires_at/render_hint 를 받아 query fallback 을 덮어쓴다 — 외부 redirect 콜백 후 진입 시 UI 정합 보장. 누적 시도 횟수(attempts)는 이 응답에 없다 — 인증 없이 challenge id 만으로 조회되는 공개 경로라 서버가 싣지 않는다(IdentityVerificationService::getStatus 주석 참조). 따라서 _local.attempts 는 이 화면에서 시도한 횟수만 세는 자체 카운터이며, 남은 횟수를 화면에 표시하지 않는다.", "handler": "apiCall", "target": "/api/identity/challenges/{{query.challenge_id}}", "if": "{{!!query.challenge_id}}", @@ -48,7 +48,6 @@ "purpose": "{{response.data.purpose ?? _local.purpose}}", "expiresAt": "{{response.data.expires_at ?? _local.expiresAt}}", "remainingSeconds": "{{response.data.expires_at ? Math.max(0, Math.floor((new Date(response.data.expires_at).getTime() - Date.now()) / 1000)) : (_local.remainingSeconds ?? 0)}}", - "attempts": "{{Number(response.data.attempts ?? _local.attempts ?? 0)}}", "maxAttempts": "{{Number(response.data.max_attempts ?? _local.maxAttempts ?? 0)}}" } } @@ -189,14 +188,6 @@ "className": "text-red-600 dark:text-red-400" }, "text": "$t:admin.identity.challenge.expired" - }, - { - "type": "basic", - "name": "Span", - "props": { - "className": "text-gray-500 dark:text-gray-400" - }, - "text": "$t:admin.identity.challenge.remaining_attempts|count={{Math.max(0, (_local.maxAttempts ?? 0) - (_local.attempts ?? 0))}}" } ] }, diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_schedule_list/_tab_schedules.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_schedule_list/_tab_schedules.json index 217113cd..3fc77c43 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_schedule_list/_tab_schedules.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_schedule_list/_tab_schedules.json @@ -358,6 +358,7 @@ "type": "{{$event.target.value}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } @@ -430,6 +431,7 @@ "frequency": "{{$event.target.value}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } @@ -487,6 +489,7 @@ "created_from": "{{$event.target.value}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } @@ -527,6 +530,7 @@ "created_to": "{{$event.target.value}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } @@ -561,6 +565,7 @@ "created_to": "{{new Date().toISOString().split('T')[0]}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } @@ -585,6 +590,7 @@ "created_to": "{{new Date().toISOString().split('T')[0]}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } @@ -609,6 +615,7 @@ "created_to": "{{new Date().toISOString().split('T')[0]}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } @@ -633,6 +640,7 @@ "created_to": "{{new Date().toISOString().split('T')[0]}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } @@ -657,6 +665,7 @@ "created_to": "{{new Date().toISOString().split('T')[0]}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } @@ -681,6 +690,7 @@ "created_to": "{{new Date().toISOString().split('T')[0]}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } @@ -739,6 +749,7 @@ "status": "{{$event.target.value}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } @@ -814,6 +825,7 @@ "last_result": "{{$event.target.value}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } @@ -870,6 +882,7 @@ "without_overlapping": "{{$event.target.value}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } @@ -926,6 +939,7 @@ "run_in_maintenance": "{{$event.target.value}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } @@ -974,6 +988,7 @@ "module": "{{$event.target.value}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } @@ -1074,6 +1089,7 @@ "params": { "path": "/admin/schedules", "query": {}, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } @@ -1303,6 +1319,7 @@ "sort_order": "{{$event.target.value.split('_').pop()}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } @@ -1345,6 +1362,7 @@ "per_page": "{{$event.target.value}}", "page": 1 }, + "replace": true, "transition_overlay_target": "schedules_data_grid__body" } } diff --git a/templates/_bundled/sirsoft-basic/CHANGELOG.md b/templates/_bundled/sirsoft-basic/CHANGELOG.md index a7ffc4e0..bf69da51 100644 --- a/templates/_bundled/sirsoft-basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-basic/CHANGELOG.md @@ -36,6 +36,7 @@ - 다크 모드에서 장바구니의 선택 상자·삭제 아이콘, 일부 버튼의 색 대비가 낮아 잘 보이지 않던 문제를 수정했습니다. - 장바구니에서 옵션을 변경할 때 옵션 목록이 아직 준비되지 않은 상태면 화면이 비어 보이던 문제를 수정했습니다. - 모달 창의 닫기 버튼 안내 라벨이 번역되지 않아 화면 낭독기에서 내부 식별자가 그대로 읽히던 문제를 수정했습니다. +- 본인확인 코드 입력 화면에서 "남은 시도" 안내를 제거했습니다. 외부 인증 화면을 거쳐 돌아온 경우 실제보다 많은 횟수가 표시될 수 있어, 잘못된 숫자를 보여주는 대신 표시하지 않도록 했습니다. 시도 횟수 제한 자체는 종전대로 적용됩니다. ### Changed diff --git a/templates/_bundled/sirsoft-basic/__tests__/layouts/identity-challenge-fullpage.test.tsx b/templates/_bundled/sirsoft-basic/__tests__/layouts/identity-challenge-fullpage.test.tsx index 7068a237..26120f63 100644 --- a/templates/_bundled/sirsoft-basic/__tests__/layouts/identity-challenge-fullpage.test.tsx +++ b/templates/_bundled/sirsoft-basic/__tests__/layouts/identity-challenge-fullpage.test.tsx @@ -86,6 +86,26 @@ describe('User 본인인증 풀페이지 (sirsoft-basic) — max_attempts 정합 expect(offenders).toEqual([]); }); + it('서버 응답의 attempts 를 읽지 않는다 (공개 폴링 경로라 서버가 싣지 않음 — 이슈 #276)', () => { + // 회귀: getStatus 응답에서 attempts 를 뺐는데 레이아웃이 계속 그 값을 읽고 있어, + // 외부 redirect 콜백 후 진입 시 남은 시도 횟수가 실제보다 많게 표시되던 결함. + const offenders: string[] = []; + walk(fp as unknown as Node, (n) => { + for (const value of Object.values(n.props ?? {})) { + if (typeof value === 'string' && value.includes('response.data.attempts')) offenders.push(value); + } + if (typeof (n as any).text === 'string' && (n as any).text.includes('response.data.attempts')) { + offenders.push((n as any).text); + } + }); + expect(JSON.stringify(fp.init_actions ?? [])).not.toContain('response.data.attempts'); + expect(offenders).toEqual([]); + }); + + it('남은 시도 횟수를 화면에 표시하지 않는다 (정확한 값을 알 수 없음 — 이슈 #276)', () => { + expect(JSON.stringify(fp)).not.toContain('remaining_attempts'); + }); + it('확인 버튼 disabled 가 maxAttempts=0 (무제한) 케이스 가드를 포함', () => { // 회귀: max_attempts=0 응답 시 무제한임에도 attempts >= 0 비교가 무조건 disabled 됨 let hasUnlimitedGuard = false; diff --git a/templates/_bundled/sirsoft-basic/layouts/auth/identity_challenge.json b/templates/_bundled/sirsoft-basic/layouts/auth/identity_challenge.json index 3fae400c..f724bfdb 100644 --- a/templates/_bundled/sirsoft-basic/layouts/auth/identity_challenge.json +++ b/templates/_bundled/sirsoft-basic/layouts/auth/identity_challenge.json @@ -52,7 +52,7 @@ } }, { - "comment": "challenge_id 가 있으면 서버에서 정확한 attempts/max_attempts/expires_at/render_hint 를 받아 query fallback 을 덮어쓴다 — 외부 redirect 콜백 후 진입 시 UI 정합 보장.", + "comment": "challenge_id 가 있으면 서버에서 정확한 max_attempts/expires_at/render_hint 를 받아 query fallback 을 덮어쓴다 — 외부 redirect 콜백 후 진입 시 UI 정합 보장. 누적 시도 횟수(attempts)는 이 응답에 없다 — 인증 없이 challenge id 만으로 조회되는 공개 경로라 서버가 싣지 않는다(IdentityVerificationService::getStatus 주석 참조). 따라서 _local.attempts 는 이 화면에서 시도한 횟수만 세는 자체 카운터이며, 남은 횟수를 화면에 표시하지 않는다.", "handler": "apiCall", "target": "/api/identity/challenges/{{query.challenge_id}}", "if": "{{!!query.challenge_id}}", @@ -66,7 +66,6 @@ "purpose": "{{response.data.purpose ?? _local.purpose}}", "expiresAt": "{{response.data.expires_at ?? _local.expiresAt}}", "remainingSeconds": "{{response.data.expires_at ? Math.max(0, Math.floor((new Date(response.data.expires_at).getTime() - Date.now()) / 1000)) : (_local.remainingSeconds ?? 0)}}", - "attempts": "{{Number(response.data.attempts ?? _local.attempts ?? 0)}}", "maxAttempts": "{{Number(response.data.max_attempts ?? _local.maxAttempts ?? 0)}}" } }, @@ -224,14 +223,6 @@ "className": "text-red-600 dark:text-red-400" }, "text": "$t:user.identity.challenge.expired" - }, - { - "type": "basic", - "name": "Span", - "props": { - "className": "text-gray-500 dark:text-gray-400" - }, - "text": "$t:user.identity.challenge.remaining_attempts|count={{Math.max(0, (_local.maxAttempts ?? 0) - (_local.attempts ?? 0))}}" } ] }, diff --git a/tests/Feature/Identity/EnforceIdentityPolicyTokenBypassTest.php b/tests/Feature/Identity/EnforceIdentityPolicyTokenBypassTest.php index 733acb9b..a4be13c5 100644 --- a/tests/Feature/Identity/EnforceIdentityPolicyTokenBypassTest.php +++ b/tests/Feature/Identity/EnforceIdentityPolicyTokenBypassTest.php @@ -10,6 +10,7 @@ use App\Models\IdentityVerificationLog; use Database\Seeders\IdentityPolicySeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\Request; +use Illuminate\Http\Response; use Illuminate\Support\Str; use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; @@ -59,6 +60,7 @@ class EnforceIdentityPolicyTokenBypassTest extends TestCase 'core.mail' => ['g7:core.mail'], 'hypothetical.sms' => ['g7:plugin.sms'], 'hypothetical.kcp' => ['g7:plugin.kcp'], + 'plugin.nhnkcp' => ['nhnkcp'], ]; } @@ -90,7 +92,7 @@ class EnforceIdentityPolicyTokenBypassTest extends TestCase $this->middleware->handle($request, function () use (&$passed) { $passed = true; - return new \Illuminate\Http\Response('ok'); + return new Response('ok'); }, $policyKey); $this->assertTrue($passed, "policy={$policyKey} 가 token 으로 통과하지 못함"); @@ -122,7 +124,7 @@ class EnforceIdentityPolicyTokenBypassTest extends TestCase ]); $this->expectException(IdentityVerificationRequiredException::class); - $this->middleware->handle($request, fn () => new \Illuminate\Http\Response('ok'), $policyKey); + $this->middleware->handle($request, fn () => new Response('ok'), $policyKey); } #[DataProvider('enforcedPolicyMatrix')] @@ -151,7 +153,7 @@ class EnforceIdentityPolicyTokenBypassTest extends TestCase ]); $this->expectException(IdentityVerificationRequiredException::class); - $this->middleware->handle($request, fn () => new \Illuminate\Http\Response('ok'), $policyKey); + $this->middleware->handle($request, fn () => new Response('ok'), $policyKey); } #[DataProvider('enforcedPolicyMatrix')] @@ -179,7 +181,7 @@ class EnforceIdentityPolicyTokenBypassTest extends TestCase ]); $this->expectException(IdentityVerificationRequiredException::class); - $this->middleware->handle($request, fn () => new \Illuminate\Http\Response('ok'), $policyKey); + $this->middleware->handle($request, fn () => new Response('ok'), $policyKey); } public function test_forged_token_not_in_db_does_not_bypass(): void @@ -192,7 +194,7 @@ class EnforceIdentityPolicyTokenBypassTest extends TestCase ]); $this->expectException(IdentityVerificationRequiredException::class); - $this->middleware->handle($request, fn () => new \Illuminate\Http\Response('ok'), 'core.auth.signup_before_submit'); + $this->middleware->handle($request, fn () => new Response('ok'), 'core.auth.signup_before_submit'); } public function test_token_target_mismatch_does_not_bypass(): void @@ -220,13 +222,12 @@ class EnforceIdentityPolicyTokenBypassTest extends TestCase ]); $this->expectException(IdentityVerificationRequiredException::class); - $this->middleware->handle($request, fn () => new \Illuminate\Http\Response('ok'), 'core.auth.signup_before_submit'); + $this->middleware->handle($request, fn () => new Response('ok'), 'core.auth.signup_before_submit'); } /** * provider 비종속성 — provider_id 가 무엇이든 IdentityVerificationLog 인프라(verified/token/target_hash)만 * 따르면 동일하게 통과. 새 provider(SMS/KCP/외부 SDK 등) 추가 시 이 동작이 자동 보장됨. - * */ #[DataProvider('providerMatrix')] public function test_token_bypass_is_provider_agnostic(string $providerId): void @@ -256,7 +257,7 @@ class EnforceIdentityPolicyTokenBypassTest extends TestCase $this->middleware->handle($request, function () use (&$passed) { $passed = true; - return new \Illuminate\Http\Response('ok'); + return new Response('ok'); }, 'core.auth.signup_before_submit'); $this->assertTrue($passed, "provider={$providerId} 토큰 우회가 동작하지 않음"); diff --git a/tests/Feature/Upgrade/BackfillForeignKeyColumnCommentsTest.php b/tests/Feature/Upgrade/BackfillForeignKeyColumnCommentsTest.php new file mode 100644 index 00000000..04f1f24f --- /dev/null +++ b/tests/Feature/Upgrade/BackfillForeignKeyColumnCommentsTest.php @@ -0,0 +1,262 @@ +foreignId('created_by')->constrained('users')->nullOnDelete()->comment('등록자 ID')` + * 형태는 comment 가 컬럼이 아니라 외래키 정의에 부착되어 DB 에 방출되지 않는다. + * `ForeignIdColumnDefinition::constrained()` 가 `references()->on()`, 즉 `ForeignKeyDefinition` + * 을 반환하기 때문이며, MySQL grammar 의 compileForeign 은 comment 를 다루지 않는다. + * + * 실측 (g7_3, 교정 전): + * g7_menus.name → [메뉴 이름 (다국어 JSON)] + * g7_menus.created_by → [] ← 규정("전 컬럼 한국어 comment") 위반 + * + * 본 테스트는 두 축을 함께 잠근다: + * 1. 신규 설치 — 마이그레이션이 만든 스키마에 외래키 컬럼 comment 가 실재하는가 + * (교정 전이라면 실패한다) + * 2. 기설치본 — 백필 스텝이 빈 comment 만 채우고 운영자가 넣은 값은 보존하는가 + */ +class BackfillForeignKeyColumnCommentsTest extends TestCase +{ + use RefreshDatabase; + + protected function setUp(): void + { + parent::setUp(); + + // DataMigration 클래스는 AbstractUpgradeStep::dataMigrations() 가 동적으로 require + // 하므로 일반 autoload 대상이 아님 — 테스트에서는 명시적으로 require_once. + require_once base_path('upgrades/data/7.0.6/migrations/03_BackfillForeignKeyColumnComments.php'); + } + + /** + * 테스트 시작 전 MySQL 계열이 아니면 스킵 대신 조기 종료 판단에 쓰는 플래그. + * + * @return bool MySQL 계열이면 true + */ + private function isMysql(): bool + { + return in_array(DB::getDriverName(), ['mysql', 'mariadb'], true); + } + + /** + * 백필 스텝이 동결해 둔 대상 목록을 꺼냅니다. + * + * 테스트가 대상 목록을 따로 옮겨 적지 않게 하여, 스텝에 대상이 추가되면 + * 본 테스트의 검증 범위도 자동으로 함께 넓어지도록 합니다. + * + * @return array> 테이블 => [컬럼 => 설명] + */ + private function targets(): array + { + return (new ReflectionClass(BackfillForeignKeyColumnComments::class)) + ->getConstant('TARGETS'); + } + + /** + * 컬럼의 현재 comment 를 조회합니다. + * + * @param string $table 프리픽스 없는 테이블명 + * @param string $column 컬럼명 + * @return string|null comment (컬럼 부재 시 null) + */ + private function commentOf(string $table, string $column): ?string + { + $rows = DB::select( + 'SELECT COLUMN_COMMENT FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', + [DB::getTablePrefix().$table, $column], + ); + + return isset($rows[0]) ? (string) $rows[0]->COLUMN_COMMENT : null; + } + + /** + * 컬럼 comment 를 강제로 지정합니다 (기설치본 상태 재현용). + * + * @param string $table 프리픽스 없는 테이블명 + * @param string $column 컬럼명 + * @param string $comment 설정할 comment (빈 문자열이면 교정 전 상태 재현) + */ + private function forceComment(string $table, string $column, string $comment): void + { + $prefixed = DB::getTablePrefix().$table; + $meta = DB::select( + 'SELECT COLUMN_TYPE, IS_NULLABLE FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', + [$prefixed, $column], + )[0]; + + DB::statement(sprintf( + 'ALTER TABLE `%s` MODIFY COLUMN `%s` %s %s COMMENT %s', + $prefixed, + $column, + $meta->COLUMN_TYPE, + $meta->IS_NULLABLE === 'YES' ? 'NULL' : 'NOT NULL', + DB::connection()->getPdo()->quote($comment), + )); + } + + /** + * 백필 스텝을 실행합니다. + */ + private function runBackfill(): void + { + (new BackfillForeignKeyColumnComments)->run( + new UpgradeContext(fromVersion: '7.0.5', toVersion: '7.0.6'), + ); + } + + /** + * 마이그레이션이 만든 스키마에 외래키 컬럼 comment 가 실재해야 한다. + * + * 교정 전 소스(`->constrained()->comment()`)라면 이 단언이 전부 실패한다. + */ + public function test_migrations_emit_comments_on_foreign_key_columns(): void + { + if (! $this->isMysql()) { + $this->markTestSkipped('컬럼 comment 검증은 MySQL 계열에서만 의미가 있습니다.'); + } + + $missing = []; + + foreach ($this->targets() as $table => $columns) { + foreach (array_keys($columns) as $column) { + $comment = $this->commentOf($table, $column); + + if ($comment === null) { + continue; // 해당 테이블이 없는 구성 — 검증 대상 아님 + } + + if (trim($comment) === '') { + $missing[] = "{$table}.{$column}"; + } + } + } + + $this->assertSame( + [], + $missing, + 'FK 컬럼 comment 누락 — ->comment() 가 ->constrained() 뒤에 체인되었는지 확인: ' + .implode(', ', $missing), + ); + } + + /** + * 기설치본 재현 — 비어 있는 comment 를 백필이 채워야 한다. + */ + public function test_backfill_fills_empty_comment(): void + { + if (! $this->isMysql()) { + $this->markTestSkipped('컬럼 comment 검증은 MySQL 계열에서만 의미가 있습니다.'); + } + + $this->forceComment('menus', 'created_by', ''); + $this->assertSame('', $this->commentOf('menus', 'created_by')); + + $this->runBackfill(); + + $this->assertSame('등록자 ID', $this->commentOf('menus', 'created_by')); + } + + /** + * 운영자가 직접 넣어 둔 comment 는 덮어쓰지 않아야 한다. + */ + public function test_backfill_preserves_operator_written_comment(): void + { + if (! $this->isMysql()) { + $this->markTestSkipped('컬럼 comment 검증은 MySQL 계열에서만 의미가 있습니다.'); + } + + $this->forceComment('menus', 'created_by', '운영자가 직접 적어 둔 설명'); + + $this->runBackfill(); + + $this->assertSame('운영자가 직접 적어 둔 설명', $this->commentOf('menus', 'created_by')); + } + + /** + * 재실행해도 결과가 같아야 한다 (멱등). + */ + public function test_backfill_is_idempotent(): void + { + if (! $this->isMysql()) { + $this->markTestSkipped('컬럼 comment 검증은 MySQL 계열에서만 의미가 있습니다.'); + } + + $this->forceComment('menus', 'created_by', ''); + + $this->runBackfill(); + $first = $this->commentOf('menus', 'created_by'); + + $this->runBackfill(); + $second = $this->commentOf('menus', 'created_by'); + + $this->assertSame('등록자 ID', $first); + $this->assertSame($first, $second); + } + + /** + * 설명 외의 컬럼 속성(자료형·NULL 허용)은 바뀌지 않아야 한다. + */ + public function test_backfill_does_not_alter_column_definition(): void + { + if (! $this->isMysql()) { + $this->markTestSkipped('컬럼 comment 검증은 MySQL 계열에서만 의미가 있습니다.'); + } + + $before = DB::select( + 'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', + [DB::getTablePrefix().'menus', 'created_by'], + )[0]; + + $this->forceComment('menus', 'created_by', ''); + $this->runBackfill(); + + $after = DB::select( + 'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', + [DB::getTablePrefix().'menus', 'created_by'], + )[0]; + + $this->assertEquals($before->COLUMN_TYPE, $after->COLUMN_TYPE); + $this->assertEquals($before->IS_NULLABLE, $after->IS_NULLABLE); + $this->assertEquals($before->COLUMN_DEFAULT, $after->COLUMN_DEFAULT); + $this->assertEquals($before->EXTRA, $after->EXTRA); + } + + /** + * 외래키 제약이 백필 이후에도 유지되어야 한다. + */ + public function test_backfill_keeps_foreign_key_constraint(): void + { + if (! $this->isMysql()) { + $this->markTestSkipped('컬럼 comment 검증은 MySQL 계열에서만 의미가 있습니다.'); + } + + $countFk = fn (): int => (int) DB::select( + 'SELECT COUNT(*) AS c FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? + AND REFERENCED_TABLE_NAME IS NOT NULL', + [DB::getTablePrefix().'menus', 'created_by'], + )[0]->c; + + $before = $countFk(); + $this->forceComment('menus', 'created_by', ''); + $this->runBackfill(); + + $this->assertSame($before, $countFk()); + } +} diff --git a/upgrades/Upgrade_7_0_6.php b/upgrades/Upgrade_7_0_6.php index d350fc7a..a491a7bc 100644 --- a/upgrades/Upgrade_7_0_6.php +++ b/upgrades/Upgrade_7_0_6.php @@ -16,6 +16,10 @@ use App\Extension\AbstractUpgradeStep; * 02_ClearSeoPageCacheAfterRendererParityFix.php * 봇용 페이지 캐시를 1회 전량 무효화한다. 7.0.6 이전 렌더러가 빈 본문으로 * 저장한 결과물이 캐시 수명 동안 계속 서빙되는 것을 막는다. + * 03_BackfillForeignKeyColumnComments.php + * 외래키 컬럼의 비어 있는 한국어 comment 를 채운다. `->comment()` 가 + * `->constrained()` 뒤에 체인되어 컬럼이 아닌 FK 정의에 부착되던 문제를 + * 소스에서 교정했으나, 기설치본은 마이그레이션이 재실행되지 않아 남는다. * * 본 클래스는 `AbstractUpgradeStep` 의 default `run()` 에 위임 — 별도 override 없음. * diff --git a/upgrades/data/7.0.6/migrations/03_BackfillForeignKeyColumnComments.php b/upgrades/data/7.0.6/migrations/03_BackfillForeignKeyColumnComments.php new file mode 100644 index 00000000..2f4bb066 --- /dev/null +++ b/upgrades/data/7.0.6/migrations/03_BackfillForeignKeyColumnComments.php @@ -0,0 +1,263 @@ +foreignId('user_id')->constrained('users')->comment('사용자 ID')` 형태로 작성된 + * 컬럼은 comment 가 DB 에 생성되지 않았습니다. `constrained()` 가 컬럼 정의가 아니라 외래키 + * 정의를 돌려주기 때문에, 뒤에 이어 붙인 comment 가 외래키 쪽 속성이 되어 버립니다. + * 마이그레이션 소스는 7.0.6 에서 전부 교정했지만, 이미 설치를 마친 사이트는 마이그레이션을 + * 다시 실행하지 않으므로 해당 컬럼의 설명이 비어 있는 상태로 남습니다. 이 스텝이 채웁니다. + * + * 멱등: 설명이 이미 있는 컬럼은 건드리지 않습니다. 재실행해도 결과가 같습니다. + * + * 안전: + * - 설명이 **비어 있을 때만** 채웁니다 — 운영자가 직접 넣어 둔 설명을 덮어쓰지 않습니다. + * - 컬럼의 자료형·NULL 허용·기본값·자동증가 속성을 현재 스키마에서 그대로 읽어 재적용하므로 + * 설명 외에는 아무 것도 바뀌지 않습니다. + * - MySQL 계열에서만 동작합니다(다른 DB 는 건너뜁니다). + * + * 실패 정책: 컬럼 단위로 실패를 흡수합니다. 한 컬럼이 실패해도 나머지를 계속 처리하며, + * 설명 보정 실패가 업그레이드 전체를 막지 않습니다. + * + * 격리 원칙 (docs/extension/upgrade-step-guide.md §13): + * 테이블·컬럼·설명 목록을 본 클래스에 **동결 상수**로 둡니다. 미래 버전에서 컬럼 설명이 + * 바뀌어도 이 스텝은 7.0.6 시점의 값을 기준으로 동작해야 하므로 마이그레이션 파일이나 + * 모델을 참조하지 않습니다. + */ +final class BackfillForeignKeyColumnComments implements DataMigration +{ + /** + * 보정 대상 (7.0.6 시점 동결) — 테이블 => [컬럼 => 설명]. + * + * 코어 소유 테이블만 담습니다. 모듈·플러그인 테이블은 각 확장이 자기 업그레이드 스텝에서 + * 처리합니다(확장 스키마는 확장이 소유). + */ + private const TARGETS = [ + 'activity_logs' => [ + 'user_id' => '사용자 ID', + ], + 'identity_message_templates' => [ + 'definition_id' => '메시지 정의 ID (FK)', + 'updated_by' => '수정자 (사용자 삭제 시 NULL)', + ], + 'identity_verification_logs' => [ + 'user_id' => '사용자 탈퇴 시 NULL 로 유지 — 감사 이력 보존 (CASCADE 금지 규정 준수)', + ], + 'language_packs' => [ + 'installed_by' => '설치자 사용자 ID', + ], + 'menus' => [ + 'created_by' => '등록자 ID', + ], + 'modules' => [ + 'created_by' => '모듈 생성자 ID', + ], + 'notification_logs' => [ + 'recipient_user_id' => '수신자 회원 ID (회원인 경우)', + 'sender_user_id' => '발송자 회원 ID (null=시스템 자동)', + ], + 'notification_templates' => [ + 'definition_id' => '알림 정의 ID', + 'updated_by' => '수정자', + ], + 'plugins' => [ + 'created_by' => '플러그인 생성자 ID', + ], + 'template_custom_translations' => [ + 'created_by' => '생성자', + 'template_id' => '소속 템플릿 ID', + 'updated_by' => '수정자', + ], + 'template_layout_attachments' => [ + 'created_by' => '업로더 사용자 ID', + 'template_id' => '소속 템플릿 ID', + ], + 'template_layout_extension_versions' => [ + 'extension_id' => '레이아웃 확장 ID', + ], + 'template_layout_extensions' => [ + 'template_id' => '템플릿 ID (g7_templates 참조)', + ], + 'template_layout_previews' => [ + 'template_id' => '템플릿 ID', + ], + 'template_layout_versions' => [ + 'layout_id' => '레이아웃 ID', + ], + 'template_layouts' => [ + 'template_id' => '템플릿 ID', + ], + 'user_consents' => [ + 'user_id' => '사용자 ID', + ], + ]; + + /** + * 마이그레이션 식별자 (로그용). + * + * @return string 식별자 + */ + public function name(): string + { + return 'BackfillForeignKeyColumnComments'; + } + + /** + * 비어 있는 외래키 컬럼 설명을 채웁니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + public function run(UpgradeContext $context): void + { + if (! $this->isMysql()) { + $context->logger->info('[7.0.6] 컬럼 설명 보정 — MySQL 계열이 아니어서 건너뜁니다'); + + return; + } + + $filled = 0; + $skipped = 0; + $failed = 0; + + foreach (self::TARGETS as $table => $columns) { + $prefixed = $context->table($table); + + foreach ($columns as $column => $comment) { + try { + $meta = $this->columnMeta($prefixed, $column); + + if ($meta === null) { + $skipped++; + + continue; + } + + if (trim((string) $meta->COLUMN_COMMENT) !== '') { + $skipped++; + + continue; + } + + DB::statement($this->buildModifyStatement($prefixed, $column, $meta, $comment)); + $filled++; + } catch (\Throwable $e) { + $failed++; + $context->logger->warning(sprintf( + '[7.0.6] 컬럼 설명 보정 실패 (계속 진행): %s.%s — %s', + $prefixed, + $column, + $e->getMessage(), + )); + Log::warning('7.0.6 BackfillForeignKeyColumnComments 컬럼 실패', [ + 'table' => $prefixed, + 'column' => $column, + 'error' => $e->getMessage(), + ]); + } + } + } + + $context->logger->info(sprintf( + '[7.0.6] 외래키 컬럼 설명 보정 — 채움 %d건 / 대상 아님 %d건 / 실패 %d건', + $filled, + $skipped, + $failed, + )); + } + + /** + * 현재 연결이 MySQL 계열인지 판정합니다. + * + * @return bool MySQL 계열이면 true + */ + private function isMysql(): bool + { + return in_array(DB::getDriverName(), ['mysql', 'mariadb'], true); + } + + /** + * 컬럼의 현재 스키마 메타데이터를 조회합니다. + * + * @param string $table 프리픽스가 적용된 테이블명 + * @param string $column 컬럼명 + * @return object|null 컬럼 메타 (테이블/컬럼 부재 시 null) + */ + private function columnMeta(string $table, string $column): ?object + { + $rows = DB::select( + 'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', + [$table, $column], + ); + + return $rows[0] ?? null; + } + + /** + * 설명만 덧붙이는 MODIFY COLUMN 문을 조립합니다. + * + * 자료형·NULL 허용·기본값·EXTRA 를 현재 값 그대로 다시 적어 넣어 설명 외의 변경을 + * 만들지 않습니다. 식별자는 백틱 이스케이프, 문자열 값은 PDO 인용을 사용합니다. + * + * @param string $table 프리픽스가 적용된 테이블명 + * @param string $column 컬럼명 + * @param object $meta columnMeta() 결과 + * @param string $comment 넣을 설명 + * @return string 실행할 SQL + */ + private function buildModifyStatement(string $table, string $column, object $meta, string $comment): string + { + $sql = sprintf( + 'ALTER TABLE %s MODIFY COLUMN %s %s', + $this->quoteIdentifier($table), + $this->quoteIdentifier($column), + $meta->COLUMN_TYPE, + ); + + $sql .= $meta->IS_NULLABLE === 'YES' ? ' NULL' : ' NOT NULL'; + + // 외래키 컬럼은 기본값이 없는 것이 통례지만, 있다면 그대로 보존한다. + if ($meta->COLUMN_DEFAULT !== null) { + $sql .= ' DEFAULT '.$this->quoteValue((string) $meta->COLUMN_DEFAULT); + } + + $extra = trim((string) $meta->EXTRA); + if ($extra !== '' && stripos($extra, 'auto_increment') !== false) { + $sql .= ' AUTO_INCREMENT'; + } + + return $sql.' COMMENT '.$this->quoteValue($comment); + } + + /** + * 식별자를 백틱으로 감쌉니다. + * + * @param string $identifier 테이블/컬럼명 + * @return string 이스케이프된 식별자 + */ + private function quoteIdentifier(string $identifier): string + { + return '`'.str_replace('`', '``', $identifier).'`'; + } + + /** + * 문자열 값을 SQL 리터럴로 인용합니다. + * + * @param string $value 값 + * @return string 인용된 리터럴 + */ + private function quoteValue(string $value): string + { + return DB::connection()->getPdo()->quote($value); + } +}