*/ protected array $providers = []; /** * 확장(모듈/플러그인) 이 선언한 purpose 레지스트리. * * `AbstractModule::getIdentityPurposes()` / `AbstractPlugin::getIdentityPurposes()` * 반환값을 `ModuleManager` / `PluginManager` 가 부팅 시 여기에 병합합니다. * DB 에 저장되지 않는 **코드 계약** 입니다. * * @var array> */ protected array $declaredPurposes = []; /** * 코어 기본 purpose 목록. * * `signup` / `password_reset` / `self_update` / `sensitive_action` / `login` — 이 5종은 * 코어가 계약으로 보장하며 `MailIdentityProvider` 가 모두 지원합니다. * * `IdentityVerificationPurpose` 에 case 를 추가하면 이 레지스트리에도 반드시 등록해야 * 합니다. 등록하지 않으면 `getAllPurposes()` 에서 빠져 관리자가 그 목적의 메시지 템플릿· * 정책을 만들 수 없고, `hasPurpose()` 도 false 가 됩니다. * * @var array> */ protected array $corePurposes = [ IdentityVerificationPurpose::Signup->value => [ 'label' => 'identity.purposes.signup.label', 'description' => 'identity.purposes.signup.description', 'default_provider' => null, 'allowed_channels' => [IdentityVerificationChannel::Email->value], 'source_type' => IdentityPolicySourceType::Core->value, 'source_identifier' => 'core', ], IdentityVerificationPurpose::PasswordReset->value => [ 'label' => 'identity.purposes.password_reset.label', 'description' => 'identity.purposes.password_reset.description', 'default_provider' => null, 'allowed_channels' => [IdentityVerificationChannel::Email->value], 'source_type' => IdentityPolicySourceType::Core->value, 'source_identifier' => 'core', ], IdentityVerificationPurpose::SelfUpdate->value => [ 'label' => 'identity.purposes.self_update.label', 'description' => 'identity.purposes.self_update.description', 'default_provider' => null, 'allowed_channels' => [IdentityVerificationChannel::Email->value], 'source_type' => IdentityPolicySourceType::Core->value, 'source_identifier' => 'core', ], IdentityVerificationPurpose::SensitiveAction->value => [ 'label' => 'identity.purposes.sensitive_action.label', 'description' => 'identity.purposes.sensitive_action.description', 'default_provider' => null, 'allowed_channels' => [IdentityVerificationChannel::Email->value], 'source_type' => IdentityPolicySourceType::Core->value, 'source_identifier' => 'core', ], IdentityVerificationPurpose::Login->value => [ 'label' => 'identity.purposes.login.label', 'description' => 'identity.purposes.login.description', 'default_provider' => null, 'allowed_channels' => [IdentityVerificationChannel::Email->value], 'source_type' => IdentityPolicySourceType::Core->value, 'source_identifier' => 'core', ], ]; /** * 기본 프로바이더 id (설정에 의해 덮어쓰기 가능). */ protected string $defaultId = 'g7:core.mail'; /** * 프로바이더를 등록합니다. * * @param IdentityVerificationInterface $provider IDV 프로바이더 인스턴스 */ public function register(IdentityVerificationInterface $provider): void { $this->providers[$provider->getId()] = $provider; } /** * 프로바이더 등록을 해제합니다. * * @param string $id 프로바이더 식별자 (예: g7:core.mail) */ public function unregister(string $id): void { unset($this->providers[$id]); } /** * 특정 id 의 프로바이더가 등록되어 있는지 확인합니다. * * @param string $id 프로바이더 식별자 * @return bool 등록 여부 */ public function has(string $id): bool { return isset($this->all()[$id]); } /** * 특정 id 의 프로바이더를 반환합니다. * * @param string $id 프로바이더 식별자 * @return IdentityVerificationInterface 등록된 provider * * @throws InvalidArgumentException 프로바이더 미등록 시 */ public function get(string $id): IdentityVerificationInterface { $providers = $this->all(); if (! isset($providers[$id])) { throw new InvalidArgumentException("Identity verification provider not found: {$id}"); } return $providers[$id]; } /** * 등록된 전체 프로바이더 목록 (필터 훅 통과 후). * * @return array */ public function all(): array { $merged = HookManager::applyFilters('core.identity.registered_providers', $this->providers); if (! is_array($merged)) { return $this->providers; } $valid = []; foreach ($merged as $key => $provider) { if ($provider instanceof IdentityVerificationInterface) { $valid[$provider->getId()] = $provider; } } return $valid; } /** * 기본 프로바이더를 반환합니다. * * 우선순위: settings.identity.default_provider → 코어 기본 (g7:core.mail) → 등록된 첫 provider. * * @return IdentityVerificationInterface 기본 provider * * @throws InvalidArgumentException 등록된 provider 가 하나도 없을 때 */ public function default(): IdentityVerificationInterface { $configured = (string) config('settings.identity.default_provider', $this->defaultId); $providers = $this->all(); if (isset($providers[$configured])) { return $providers[$configured]; } if (isset($providers[$this->defaultId])) { return $providers[$this->defaultId]; } if (empty($providers)) { throw new InvalidArgumentException('No identity verification provider is registered.'); } return $providers[array_key_first($providers)]; } /** * 특정 purpose 에 사용할 프로바이더를 해석합니다. * * 해석 순서: * 0. 명시 $providerId 가 등록 + supportsPurpose 통과 → 사용 (호출자 명시 우선) * 1. `settings.identity.purpose_providers.{purpose}` 에 명시적 지정 + 해당 프로바이더가 purpose 지원 → 사용 * 2. 기본 프로바이더가 purpose 지원 → 사용 * 3. 등록된 프로바이더 중 purpose 지원하는 첫 번째 → 사용 * 4. 없으면 mail (코어 기본) 반환 — mail 은 모든 purpose 지원 계약 * * 정책의 provider_id 가 우선되므로 (IdentityPolicyService::resolveRenderHint 참조), 본 메서드는 * 정책에 provider_id 가 명시되지 않은 경우의 fallback 으로 사용됩니다. * * $providerId 가 등록되지 않았거나 purpose 미지원이면 silent fallback (1~4 단계 진행). * 운영자가 정책에 명시한 provider 가 비활성화/미설치된 상태에서도 가입 흐름이 중단되지 않도록 한다. * * @param string $purpose IDV 목적 (signup, password_reset, sensitive_action 등) * @param string|null $providerId 호출자 명시 provider id (Mode A controller 요청 / Mode B 정책) * @return IdentityVerificationInterface 해석된 provider */ public function resolveForPurpose(string $purpose, ?string $providerId = null): IdentityVerificationInterface { $providers = $this->all(); if ($providerId !== null && $providerId !== '' && isset($providers[$providerId]) && $providers[$providerId]->supportsPurpose($purpose)) { return $providers[$providerId]; } $explicitId = (string) config("settings.identity.purpose_providers.{$purpose}", ''); if ($explicitId !== '' && isset($providers[$explicitId]) && $providers[$explicitId]->supportsPurpose($purpose)) { return $providers[$explicitId]; } $default = $this->default(); if ($default->supportsPurpose($purpose)) { return $default; } foreach ($providers as $provider) { if ($provider->supportsPurpose($purpose)) { return $provider; } } if (isset($providers[$this->defaultId])) { return $providers[$this->defaultId]; } throw new InvalidArgumentException("No identity verification provider supports purpose: {$purpose}"); } /** * 확장(모듈/플러그인) 이 선언한 purpose 들을 레지스트리에 등록합니다. * * `ModuleManager::bootModules()` / `PluginManager::bootPlugins()` 가 * 활성화된 확장의 `getIdentityPurposes()` 결과를 순회하며 호출합니다. * * 같은 key 로 중복 등록되면 나중 호출이 이전 값을 덮어씁니다 * (확장 로드 순서 결정성 보장은 ExtensionManager 책임). * * @param array> $purposes key => metadata 매핑 * @param string|null $sourceType 'module' | 'plugin' | 'admin' (미명시 시 'admin' 으로 마킹) * @param string|null $sourceIdentifier source 식별자 (module/plugin id; 미명시 시 'admin') */ public function registerDeclaredPurposes(array $purposes, ?string $sourceType = null, ?string $sourceIdentifier = null): void { $resolvedType = $sourceType ?? 'admin'; $resolvedIdentifier = $sourceIdentifier ?? 'admin'; foreach ($purposes as $key => $meta) { if (! is_string($key) || $key === '' || ! is_array($meta)) { continue; } // legacy `label_key` / `description_key` 명명을 표준 `label` / `description` 으로 정규화. // controller 의 resolvePurposeText 는 `label` / `description` 만 인식하므로, 미정규화 // meta 가 등록되면 응답에서 라벨이 raw 키로 노출되는 회귀 발생. if (! isset($meta['label']) && isset($meta['label_key'])) { $meta['label'] = $meta['label_key']; } if (! isset($meta['description']) && isset($meta['description_key'])) { $meta['description'] = $meta['description_key']; } $meta['source_type'] = $resolvedType; $meta['source_identifier'] = $resolvedIdentifier; $this->declaredPurposes[$key] = $meta; } } /** * 확장이 선언한 purpose 를 한 개 등록합니다. * * @param string $key purpose 식별자 * @param array $meta label/description/allowed_channels 등 메타데이터 * @param string|null $sourceType 'module' | 'plugin' | 'admin' (미명시 시 'admin') * @param string|null $sourceIdentifier source 식별자 (미명시 시 'admin') */ public function registerPurpose(string $key, array $meta, ?string $sourceType = null, ?string $sourceIdentifier = null): void { $meta['source_type'] = $sourceType ?? 'admin'; $meta['source_identifier'] = $sourceIdentifier ?? 'admin'; $this->declaredPurposes[$key] = $meta; } /** * 등록된 전체 purpose 목록을 반환합니다. * * 병합 순서: 코어 기본 4종 → 확장 getter 선언분 → `core.identity.purposes` filter 훅. * 같은 key 가 충돌하면 나중 소스가 이전을 덮어씁니다 (filter 훅이 최종 결정권). * * @return array> */ public function getAllPurposes(): array { $merged = $this->corePurposes; foreach ($this->declaredPurposes as $key => $meta) { $merged[$key] = $meta; } $filtered = HookManager::applyFilters('core.identity.purposes', $merged); if (! is_array($filtered)) { return $merged; } return $filtered; } /** * 특정 purpose 가 등록되어 있는지 확인합니다 (코어·확장·filter 훅 모두 포함). * * @param string $key purpose 식별자 * @return bool 존재 여부 */ public function hasPurpose(string $key): bool { $all = $this->getAllPurposes(); return isset($all[$key]); } }