diff --git a/AGENTS.md b/AGENTS.md
index b1135ab7..7ccefc71 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -47,7 +47,7 @@
| [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... |
| [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
-### 프론트엔드 [frontend/](docs/frontend/) (50개)
+### 프론트엔드 [frontend/](docs/frontend/) (47개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
@@ -95,9 +95,6 @@
| [tailwind-safelist.md](docs/frontend/tailwind-safelist.md) | Tailwind Safelist 가이드 | Tailwind는 빌드 시 사용된 클래스만 CSS에 포함 |
| [template-development.md](docs/frontend/template-development.md) | 템플릿 개발 가이드라인 | 디렉토리: templates/[vendor-template]/ (예: sirsoft-admin_basic) |
| [template-handlers.md](docs/frontend/template-handlers.md) | 템플릿 전용 핸들러 | setLocale: 앱 언어 변경 — 엔진 빌트인 (ActionDispatcher) |
-| [components.md](docs/frontend/templates/sirsoft-admin_basic/components.md) | sirsoft-admin_basic 컴포넌트 | Basic 37개: HTML 래핑 (Div, Button, Input, Select, Form, A, ... |
-| [handlers.md](docs/frontend/templates/sirsoft-admin_basic/handlers.md) | sirsoft-admin_basic 핸들러 | setLocale: 앱 언어 변경 (locale 파라미터) |
-| [layouts.md](docs/frontend/templates/sirsoft-admin_basic/layouts.md) | sirsoft-admin_basic 레이아웃 | 베이스: _admin_base.json (사이드바 + 헤더 + 콘텐츠 슬롯) |
| [components.md](docs/frontend/templates/sirsoft-basic/components.md) | sirsoft-basic 컴포넌트 | Basic 26개: HTML 래핑 (Div, Button, Input, Select, Form, A, ... |
| [handlers.md](docs/frontend/templates/sirsoft-basic/handlers.md) | sirsoft-basic 핸들러 | setTheme/initTheme: 다크/라이트 모드 전환 (admin과 동일 키 공유) |
| [layouts.md](docs/frontend/templates/sirsoft-basic/layouts.md) | sirsoft-basic 레이아웃 | 베이스: _user_base.json (헤더 + 푸터 + 모바일 네비 + 콘텐츠 슬롯) |
@@ -180,6 +177,23 @@
| `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 |
+### 확장 개발자 문서 (9개 확장, 자동 스캔)
+
+> 확장을 수정하기 전에 읽는 문서. 설계 의도 · 디렉토리 지도 · 확장점(발행/구독 훅) · 수정 시 동반 의무 · 금지 패턴을 담는다. `php artisan ext:docgen` 이 실측 부분을 유지하며, 이 표는 `{modules,plugins,templates}/_bundled/*/docs/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
+
+| 확장 | 유형 | 에이전트 가이드 | 문서 목차 | 실측 집계 |
+|------|------|----------------|----------|----------|
+| `sirsoft-board` | 모듈 | [AGENTS.md](modules/_bundled/sirsoft-board/AGENTS.md) | [docs/](modules/_bundled/sirsoft-board/docs/README.md) | 훅 90 · 라우트 80 · 모델 9 · 레이아웃 46 |
+| `sirsoft-gdpr` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-gdpr/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-gdpr/docs/README.md) | 훅 2 · 라우트 15 · 모델 3 · 레이아웃 4 |
+| `sirsoft-pay_kginicis` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_kginicis/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_kginicis/docs/README.md) | 훅 6 · 라우트 35 · 모델 0 · 레이아웃 1 |
+| `sirsoft-pay_nhnkcp` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_nhnkcp/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/README.md) | 훅 8 · 라우트 16 · 모델 0 · 레이아웃 1 |
+| `sirsoft-pay_nicepayments` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_nicepayments/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_nicepayments/docs/README.md) | 훅 5 · 라우트 15 · 모델 0 · 레이아웃 1 |
+| `sirsoft-tosspayments` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-tosspayments/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-tosspayments/docs/README.md) | 훅 4 · 라우트 4 · 모델 0 · 레이아웃 1 |
+| `sirsoft-verification_kginicis` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-verification_kginicis/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-verification_kginicis/docs/README.md) | 훅 3 · 라우트 2 · 모델 2 · 레이아웃 1 |
+| `sirsoft-verification_nhnkcp` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-verification_nhnkcp/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md) | 훅 0 · 라우트 2 · 모델 2 · 레이아웃 1 |
+| `sirsoft-admin_basic` | 템플릿 | [AGENTS.md](templates/_bundled/sirsoft-admin_basic/AGENTS.md) | [docs/](templates/_bundled/sirsoft-admin_basic/docs/README.md) | 훅 0 · 라우트 29 · 모델 0 · 레이아웃 145 |
+
+
---
diff --git a/README.ko.md b/README.ko.md
index 70098fc3..28e085cf 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -1,13 +1,9 @@
English | 한국어
-
-
-
+# 그누보드7
-
- 모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS
- A modern, extensible CMS platform built with Laravel + React
-
+**모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS**
+A modern, extensible CMS platform built with Laravel + React
diff --git a/README.md b/README.md
index 907e300d..e6ded2ed 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,9 @@
English | 한국어
-
-
-
+# Gnuboard7
-
- A modern, extensible CMS platform built with Laravel + React
- The next generation of Gnuboard — Korea's most widely used open-source CMS
-
+**A modern, extensible CMS platform built with Laravel + React**
+The next generation of Gnuboard — Korea's most widely used open-source CMS
diff --git a/app/Support/ExtensionDoc/ExtensionDocScaffolder.php b/app/Support/ExtensionDoc/ExtensionDocScaffolder.php
index f0c6b9ec..b84d3e2f 100644
--- a/app/Support/ExtensionDoc/ExtensionDocScaffolder.php
+++ b/app/Support/ExtensionDoc/ExtensionDocScaffolder.php
@@ -149,6 +149,11 @@ class ExtensionDocScaffolder
'blocks' => [
'레이아웃 목록' => 'layouts',
'라우트 매핑' => 'layout-map',
+ // 템플릿의 `extensions/{module-identifier}/*.json` 은 그 모듈/플러그인이
+ // 발행한 레이아웃 확장 조각을 이 템플릿이 오버라이드한 것이다(모듈/플러그인
+ // 쪽 `resources/extensions/` 와는 반대 방향 — 발행이 아니라 대체). 모듈/플러그인
+ // 문서의 `docs/extension-points.md` 는 템플릿에 존재하지 않아 자리가 없었다.
+ '확장 오버라이드' => 'template-overrides',
],
],
'docs/handlers.md' => [
@@ -538,6 +543,7 @@ class ExtensionDocScaffolder
'hooks-subscribed' => $this->renderHooksSubscribed($ctx),
'listeners' => $this->renderListeners($ctx),
'layout-extensions' => $this->renderLayoutExtensions($ctx),
+ 'template-overrides' => $this->renderLayoutExtensions($ctx),
'middleware' => $this->renderMiddleware($ctx),
'channels' => $this->renderChannels($ctx),
'schedules' => $this->renderSchedules($ctx),
@@ -963,6 +969,7 @@ class ExtensionDocScaffolder
['제공 컴포넌트', (string) $frontend['components']['total'].'개', $this->docLink($ctx, 'docs/components.md', '제공 컴포넌트')],
['레이아웃', (string) count($frontend['layouts']).'개', $this->docLink($ctx, 'docs/layouts.md', '레이아웃 목록')],
['전용 핸들러', (string) count($frontend['handlers']['names']).'개', $this->docLink($ctx, 'docs/handlers.md', '템플릿 전용 핸들러')],
+ ['확장 오버라이드', (string) count($frontend['layoutExtensions']).'개', $this->docLink($ctx, 'docs/layouts.md', '확장 오버라이드')],
];
}
@@ -1250,23 +1257,37 @@ class ExtensionDocScaffolder
{
$files = $ctx['frontend']['layoutExtensions'];
$declared = $ctx['surface']['values']['getLayoutExtensions'] ?? [];
+ // 템플릿의 `extensions/{module-identifier}/*.json` 은 그 모듈/플러그인이 발행한
+ // 조각을 이 템플릿이 대체하는 것이지, 모듈/플러그인처럼 밖으로 발행하는 것이 아니다.
+ $isTemplate = ($ctx['record']['type'] ?? null) === ExtensionInventory::TYPE_TEMPLATE;
if ($files === [] && ! is_array($declared)) {
- return $this->none('레이아웃 확장이 없습니다.');
+ return $this->none($isTemplate ? '오버라이드하는 레이아웃 확장 조각이 없습니다.' : '레이아웃 확장이 없습니다.');
}
if ($files === [] && $declared === []) {
- return $this->none('레이아웃 확장이 없습니다.');
+ return $this->none($isTemplate ? '오버라이드하는 레이아웃 확장 조각이 없습니다.' : '레이아웃 확장이 없습니다.');
}
+ $known = array_flip($files);
+
$rows = [];
foreach ($files as $file) {
- $rows[] = [$this->code($file), '다른 확장/템플릿 레이아웃에 주입되는 조각'];
+ $rows[] = [$this->code($file), $isTemplate ? '모듈/플러그인 확장 조각을 대체하는 오버라이드' : '다른 확장/템플릿 레이아웃에 주입되는 조각'];
}
+ // `getLayoutExtensions()` 기본 구현(AbstractModule/AbstractPlugin)은 위 파일 목록과
+ // 같은 디렉토리를 glob() 한 절대경로라 100% 중복이다. 확장-상대 경로로 정규화한 뒤
+ // 이미 실린 파일은 건너뛰고, 확장이 오버라이드해 다른 대상을 선언한 경우만 싣는다.
if (is_array($declared)) {
foreach ($declared as $key => $value) {
- $rows[] = [$this->code(is_string($key) ? $key : (string) $value), '`getLayoutExtensions()` 선언'];
+ $raw = is_string($key) ? $key : (is_string($value) ? $value : (string) $value);
+ $rel = $this->relativeToExtension($ctx, $raw);
+ if (isset($known[$rel])) {
+ continue;
+ }
+ $known[$rel] = true;
+ $rows[] = [$this->code($rel), '`getLayoutExtensions()` 선언'];
}
}
@@ -1372,7 +1393,11 @@ class ExtensionDocScaffolder
$rows = [];
foreach ($definitions as $key => $definition) {
- $name = is_string($key) ? $key : (is_array($definition) ? ($definition['key'] ?? $definition['event'] ?? '-') : (string) $definition);
+ // getNotificationDefinitions() 의 표준 계약(AbstractModule/AbstractPlugin 소비처인
+ // NotificationSyncHelper 기준)은 리스트 배열 + 각 원소의 'type' 키다. 'key'/'event'
+ // 는 어떤 확장도 쓰지 않아, 문자열 키가 아닌 한(list 배열이면 전부 정수 키) 이 표는
+ // 항상 '-' 만 찍고 있었다.
+ $name = is_string($key) ? $key : (is_array($definition) ? ($definition['type'] ?? $definition['key'] ?? $definition['event'] ?? '-') : (string) $definition);
$channels = is_array($definition) ? ($definition['channels'] ?? null) : null;
$rows[] = [
@@ -1590,7 +1615,7 @@ class ExtensionDocScaffolder
$extra[] = '기본값 파일: '.$this->code($this->relativeToExtension($ctx, $defaultsPath));
}
if (is_string($layout) && $layout !== '') {
- $extra[] = '설정 화면 레이아웃: '.$this->code($layout);
+ $extra[] = '설정 화면 레이아웃: '.$this->code($this->relativeToExtension($ctx, $layout));
}
if ($extra !== []) {
@@ -1624,7 +1649,7 @@ class ExtensionDocScaffolder
$layout = $ctx['surface']['values']['getSettingsLayout'] ?? null;
if (is_string($layout) && $layout !== '') {
- return '관리자 설정 화면이 있습니다 (레이아웃: '.$this->code($layout).'). 설정 항목은 화면에서 확인하세요.'
+ return '관리자 설정 화면이 있습니다 (레이아웃: '.$this->code($this->relativeToExtension($ctx, $layout)).'). 설정 항목은 화면에서 확인하세요.'
.(is_string($route) && $route !== '' ? ' 경로: '.$this->code($route) : '');
}
diff --git a/docs/README.md b/docs/README.md
index 26b47788..afc294e7 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -10,7 +10,7 @@
| 카테고리 | 문서 수 | 링크 상태 |
|----------|---------|----------|
| [백엔드](backend/) | 37개 | 정상 |
-| [프론트엔드](frontend/) | 51개 | 정상 |
+| [프론트엔드](frontend/) | 49개 | 정상 |
| [확장 시스템](extension/) | 32개 | 정상 |
| 공통 | 20개 | 정상 |
| [AI 도구](ai-tools/) | - | 정상 |
@@ -43,7 +43,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
| 4 | [레이아웃 JSON - 상속](frontend/layout-json-inheritance.md) | extends: 베이스 레이아웃 상속 (type: "slot" 위치에 삽입) |
| 5 | [컴포넌트 개발 규칙](frontend/components.md) | HTML 태그 직접 사용 금지 |
| 6 | [컴포넌트 Props 레퍼런스](frontend/component-props.md) | - |
-| 7 | [sirsoft-admin_basic 컴포넌트](frontend/templates/sirsoft-admin_basic/components.md) | Basic (37개), Composite (66개), Layout (8개) |
+| 7 | [sirsoft-basic 컴포넌트](frontend/templates/sirsoft-basic/components.md) | Basic (26개), Composite 등 |
| 8 | [데이터 바인딩 및 표현식](frontend/data-binding.md) | API 데이터: {{user.name}}, URL 파라미터: {{route.id}} |
| 9 | [데이터 바인딩 - 다국어 처리](frontend/data-binding-i18n.md) | - |
| 10 | [액션 핸들러 가이드](frontend/actions.md) | 구조: type 또는 event(이벤트), handler(핸들러명), params(옵션) |
@@ -52,6 +52,8 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
| 13 | [데이터 소스](frontend/data-sources.md) | data_sources 배열에 API 정의: id, endpoint, method |
| 14 | [다크 모드 지원](frontend/dark-mode.md) | Tailwind dark: variant 사용 |
+sirsoft-admin_basic 컴포넌트 문서는 확장이 소유합니다 — [templates/_bundled/sirsoft-admin_basic/docs/components.md](../templates/_bundled/sirsoft-admin_basic/docs/components.md) 를 참고하세요.
+
### 컨트롤러 작성
| 순서 | 문서 | TL;DR 핵심 |
@@ -167,7 +169,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
| [user-overrides.md](backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) |
| [validation.md](backend/validation.md) | 검증 (Validation) |
-### 프론트엔드 (51개)
+### 프론트엔드 (49개)
| 문서 | 제목 |
|------|------|
@@ -216,9 +218,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
| [tailwind-safelist.md](frontend/tailwind-safelist.md) | Tailwind Safelist 가이드 |
| [template-development.md](frontend/template-development.md) | 템플릿 개발 가이드라인 |
| [template-handlers.md](frontend/template-handlers.md) | 템플릿 전용 핸들러 |
-| [components.md](frontend/components.md) | sirsoft-admin_basic 컴포넌트 |
-| [handlers.md](frontend/handlers.md) | sirsoft-admin_basic 핸들러 |
-| [layouts.md](frontend/layouts.md) | sirsoft-admin_basic 레이아웃 |
+| [README.md](frontend/README.md) | 템플릿별 컴포넌트·핸들러·레이아웃 문서 |
| [components.md](frontend/components.md) | sirsoft-basic 컴포넌트 |
| [handlers.md](frontend/handlers.md) | sirsoft-basic 핸들러 |
| [layouts.md](frontend/layouts.md) | sirsoft-basic 레이아웃 |
diff --git a/docs/ai-tools/agents/src/prompts/template.ts b/docs/ai-tools/agents/src/prompts/template.ts
index 0907cb93..8ff98cd4 100644
--- a/docs/ai-tools/agents/src/prompts/template.ts
+++ b/docs/ai-tools/agents/src/prompts/template.ts
@@ -173,7 +173,7 @@ global.window = {
- docs/frontend/template-development.md
- docs/extension/template-basics.md
- docs/extension/template-commands.md
-- docs/frontend/templates/sirsoft-admin_basic/components.md
+- templates/_bundled/sirsoft-admin_basic/docs/components.md
- docs/frontend/templates/sirsoft-basic/components.md
## 테스트 실행
diff --git a/docs/frontend/README.md b/docs/frontend/README.md
index 75f4fba0..c84822c6 100644
--- a/docs/frontend/README.md
+++ b/docs/frontend/README.md
@@ -40,7 +40,6 @@
| 템플릿 식별자 | 컴포넌트 | 핸들러 | 레이아웃 |
|--------------|---------|--------|--------|
-| `sirsoft-admin_basic` | [components.md](templates/sirsoft-admin_basic/components.md) | [handlers.md](templates/sirsoft-admin_basic/handlers.md) | [layouts.md](templates/sirsoft-admin_basic/layouts.md) |
| `sirsoft-basic` | [components.md](templates/sirsoft-basic/components.md) | [handlers.md](templates/sirsoft-basic/handlers.md) | [layouts.md](templates/sirsoft-basic/layouts.md) |
### 컴포넌트 개발
diff --git a/docs/frontend/component-props-composite.md b/docs/frontend/component-props-composite.md
index 888e855a..c77d3056 100644
--- a/docs/frontend/component-props-composite.md
+++ b/docs/frontend/component-props-composite.md
@@ -1,6 +1,6 @@
# 컴포넌트 Props 레퍼런스 - Composite
-> **관련 문서**: [컴포넌트 Props (Basic/DataGrid/Modal)](component-props.md) | [컴포넌트 개발 규칙](components.md) | [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md)
+> **관련 문서**: [컴포넌트 Props (Basic/DataGrid/Modal)](component-props.md) | [컴포넌트 개발 규칙](components.md) | [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
---
@@ -671,7 +671,7 @@ G7에서는 콘텐츠의 렌더링 모드를 DB의 `*_mode` 컬럼(`'text'` / `'
- [컴포넌트 Props (Basic/DataGrid/Modal)](component-props.md)
- [컴포넌트 개발 규칙](components.md)
- [컴포넌트 고급 기능](components-advanced.md)
-- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md)
+- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
- [액션 핸들러 - 커스텀 콜백](actions.md#커스텀-이벤트-event-필드)
- [보안 가이드 - HTML 렌더링](security.md#htmlcontent--htmleditor-html-렌더링이-필요한-경우)
- [에디터 컴포넌트](editors.md)
diff --git a/docs/frontend/component-props.md b/docs/frontend/component-props.md
index ab9c010d..15005a30 100644
--- a/docs/frontend/component-props.md
+++ b/docs/frontend/component-props.md
@@ -1144,7 +1144,7 @@ id prop 사용: scrollIntoView 등 DOM selector로 접근해야 할 때 필수
- [컴포넌트 개발 규칙](components.md) - basic, composite, layout 컴포넌트
- [레이아웃 JSON 스키마](layout-json.md) - 컴포넌트를 레이아웃 JSON에서 사용하는 방법
-- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) - Admin 컴포넌트 목록 (111개)
+- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) - Admin 컴포넌트 목록
- [sirsoft-basic 컴포넌트](templates/sirsoft-basic/components.md) - User 컴포넌트 목록 (58개)
- [에디터 컴포넌트](editors.md) - HtmlEditor, CodeEditor 상세 가이드
- [데이터 바인딩](data-binding.md) - `{{}}` 표현식, `$t:` 다국어
diff --git a/docs/frontend/components-types.md b/docs/frontend/components-types.md
index 961daf02..fc3bd2d0 100644
--- a/docs/frontend/components-types.md
+++ b/docs/frontend/components-types.md
@@ -1,7 +1,7 @@
# 컴포넌트 타입별 개발 규칙
> **메인 문서**: [components.md](components.md)
-> **관련 문서**: [layout-json-components.md](layout-json-components.md) | [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md)
+> **관련 문서**: [layout-json-components.md](layout-json-components.md) | [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
---
@@ -452,5 +452,5 @@ export const Container: React.FC = ({ children }) => (
- [컴포넌트 개발 규칙 인덱스](components.md)
- [컴포넌트 패턴](components-patterns.md)
- [컴포넌트 고급 기능](components-advanced.md)
-- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md)
+- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
- [sirsoft-basic 컴포넌트](templates/sirsoft-basic/components.md)
diff --git a/docs/frontend/components.md b/docs/frontend/components.md
index ad643981..bace9ebb 100644
--- a/docs/frontend/components.md
+++ b/docs/frontend/components.md
@@ -29,11 +29,13 @@
| 템플릿 식별자 | 컴포넌트 | 핸들러 | 레이아웃 |
|--------------|---------|--------|--------|
-| `sirsoft-admin_basic` | [components.md](templates/sirsoft-admin_basic/components.md) | [handlers.md](templates/sirsoft-admin_basic/handlers.md) | [layouts.md](templates/sirsoft-admin_basic/layouts.md) |
| `sirsoft-basic` | [components.md](templates/sirsoft-basic/components.md) | [handlers.md](templates/sirsoft-basic/handlers.md) | [layouts.md](templates/sirsoft-basic/layouts.md) |
+> `sirsoft-admin_basic` 은 문서를 그 템플릿이 직접 소유합니다 — [templates/_bundled/sirsoft-admin_basic/docs/](../../templates/_bundled/sirsoft-admin_basic/docs/README.md).
+> 템플릿별 문서 위치는 [templates/README.md](templates/README.md) 를 따릅니다.
+
---
## 목차
@@ -153,7 +155,7 @@ import { Icon, IconName } from '../basic/Icon';
- [g7core-api.md](g7core-api.md) - G7Core 전역 API 레퍼런스
- [레이아웃 JSON 스키마](./layout-json.md) - 컴포넌트를 레이아웃 JSON에서 사용하는 방법
- [데이터 바인딩](./data-binding.md) - props에서 데이터 바인딩 사용법
-- [sirsoft-admin_basic 컴포넌트](./templates/sirsoft-admin_basic/components.md) - Admin 컴포넌트 목록 (111개)
+- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) - Admin 컴포넌트 목록
- [sirsoft-basic 컴포넌트](./templates/sirsoft-basic/components.md) - User 컴포넌트 목록 (58개)
- [다크 모드](./dark-mode.md) - 컴포넌트 다크 모드 지원 가이드
- [상태 관리](./state-management.md) - 전역/로컬 상태 관리 및 동기화 패턴
diff --git a/docs/frontend/dark-mode.md b/docs/frontend/dark-mode.md
index 6b6b3cf0..a6237de1 100644
--- a/docs/frontend/dark-mode.md
+++ b/docs/frontend/dark-mode.md
@@ -25,7 +25,7 @@
| `sirsoft-admin_basic` | 미선언 | ThemeToggle 존재, dark: variant 동작 |
| `sirsoft-basic` | `true` | template.json에 선언, 완전 지원 |
-> 상세 컴포넌트 목록: [sirsoft-admin_basic](templates/sirsoft-admin_basic/components.md), [sirsoft-basic](templates/sirsoft-basic/components.md)
+> 상세 컴포넌트 목록: [sirsoft-admin_basic](../../templates/_bundled/sirsoft-admin_basic/docs/components.md), [sirsoft-basic](templates/sirsoft-basic/components.md)
---
diff --git a/docs/frontend/editors.md b/docs/frontend/editors.md
index 82eaea03..d31a14a9 100644
--- a/docs/frontend/editors.md
+++ b/docs/frontend/editors.md
@@ -366,7 +366,7 @@ HtmlEditor는 내부적으로 **DOMPurify**를 사용하여 HTML을 정화합니
## 관련 문서
- [컴포넌트 Props 레퍼런스](component-props.md) - Select, Input, Button Props
-- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) - Admin 컴포넌트 목록
+- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) - Admin 컴포넌트 목록
- [레이아웃 JSON](layout-json.md) - 레이아웃 JSON 스키마
- [데이터 바인딩](data-binding.md) - {{}} 표현식, $t: 다국어
- [상태 관리](state-management.md) - _local, setState
diff --git a/docs/frontend/responsive-layout.md b/docs/frontend/responsive-layout.md
index bbccfe3c..bfb6a302 100644
--- a/docs/frontend/responsive-layout.md
+++ b/docs/frontend/responsive-layout.md
@@ -23,7 +23,7 @@
| `sirsoft-admin_basic` | 미선언 | 데스크톱 중심, Tailwind responsive 클래스는 동작 |
| `sirsoft-basic` | `true` | MobileNav 포함, portable preset 지원 |
-> 상세 컴포넌트 목록: [sirsoft-admin_basic](templates/sirsoft-admin_basic/components.md), [sirsoft-basic](templates/sirsoft-basic/components.md)
+> 상세 컴포넌트 목록: [sirsoft-admin_basic](../../templates/_bundled/sirsoft-admin_basic/docs/components.md), [sirsoft-basic](templates/sirsoft-basic/components.md)
---
diff --git a/docs/frontend/template-handlers.md b/docs/frontend/template-handlers.md
index f3a0c278..b6e9e466 100644
--- a/docs/frontend/template-handlers.md
+++ b/docs/frontend/template-handlers.md
@@ -22,11 +22,13 @@
| 템플릿 식별자 | 핸들러 문서 | TL;DR 핵심 |
|--------------|-----------|----------|
-| `sirsoft-admin_basic` | [handlers.md](templates/sirsoft-admin_basic/handlers.md) | setLocale: 앱 언어 변경 (locale 파라미터) |
| `sirsoft-basic` | [handlers.md](templates/sirsoft-basic/handlers.md) | setTheme/initTheme: 다크/라이트 모드 전환 (admin과 동일 키 공유) |
+> `sirsoft-admin_basic` 은 문서를 그 템플릿이 직접 소유합니다 — [templates/_bundled/sirsoft-admin_basic/docs/](../../templates/_bundled/sirsoft-admin_basic/docs/README.md).
+> 템플릿별 문서 위치는 [templates/README.md](templates/README.md) 를 따릅니다.
+
---
## 엔진 빌트인 핸들러
@@ -55,5 +57,5 @@
- [액션 핸들러 개요](actions-handlers.md)
- [템플릿 개발 가이드](template-development.md)
- [다크 모드 지원](dark-mode.md)
-- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md)
+- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
- [sirsoft-basic 컴포넌트](templates/sirsoft-basic/components.md)
diff --git a/docs/frontend/templates/README.md b/docs/frontend/templates/README.md
new file mode 100644
index 00000000..b46d7ef4
--- /dev/null
+++ b/docs/frontend/templates/README.md
@@ -0,0 +1,13 @@
+# 템플릿별 컴포넌트·핸들러·레이아웃 문서
+
+번들 템플릿의 컴포넌트/핸들러/레이아웃 상세 문서는 그 템플릿이 소유합니다(#601). 어느
+템플릿이 어디에 문서를 갖는지는 아래 표를 따릅니다 — 이관이 끝난 템플릿은 코어에 사본을
+남기지 않습니다.
+
+| 템플릿 | 상태 | 문서 위치 |
+|---|---|---|
+| `sirsoft-admin_basic` | 이관 완료 | [templates/_bundled/sirsoft-admin_basic/docs/](../../../templates/_bundled/sirsoft-admin_basic/docs/README.md) |
+| `sirsoft-basic` | 이관 대기 (S3) | [components.md](sirsoft-basic/components.md) · [handlers.md](sirsoft-basic/handlers.md) · [layouts.md](sirsoft-basic/layouts.md) |
+
+이관 배경·문서 체계 전반은 [extension-documentation.md](../../extension/extension-documentation.md)
+를 참고하세요.
diff --git a/docs/frontend/templates/sirsoft-admin_basic/handlers.md b/docs/frontend/templates/sirsoft-admin_basic/handlers.md
deleted file mode 100644
index a9aa7d89..00000000
--- a/docs/frontend/templates/sirsoft-admin_basic/handlers.md
+++ /dev/null
@@ -1,445 +0,0 @@
-# sirsoft-admin_basic 핸들러
-
-> **템플릿 식별자**: `sirsoft-admin_basic` (type: admin)
-> **관련 문서**: [액션 핸들러 개요](../../actions-handlers.md) | [컴포넌트](./components.md) | [레이아웃](./layouts.md)
-
----
-
-## TL;DR (5초 요약)
-
-```text
-1. setLocale: 앱 언어 변경 (locale 파라미터)
-2. setTheme/initTheme: 다크/라이트 모드 전환 및 초기화
-3. scrollToSection: 특정 섹션으로 스크롤 이동 (offset 지원)
-4. initMenuFromUrl: URL 기반 메뉴 활성 상태 초기화
-5. filterVisibility 4종: 필터 패널 가시성 저장/토글/초기화
-6. multilingualTag 3종: 다국어 태그 저장/취소/업데이트
-```
-
----
-
-## 목차
-
-1. [setLocale](#setlocale)
-2. [setTheme / initTheme](#settheme--inittheme)
-3. [scrollToSection](#scrolltosection)
-4. [initMenuFromUrl](#initmenuefromurl)
-5. [필터 가시성 핸들러](#필터-가시성-핸들러)
-6. [다국어 태그 핸들러](#다국어-태그-핸들러)
-7. [핸들러 소스 파일 매핑](#핸들러-소스-파일-매핑)
-
----
-
-## setLocale
-
-앱 언어를 변경합니다. 번역 파일을 다시 로드하고 UI를 갱신합니다.
-
-**소스**: `src/handlers/setLocaleHandler.ts`
-
-```json
-{
- "type": "click",
- "handler": "setLocale",
- "params": {
- "locale": "en"
- }
-}
-```
-
-### params
-
-| 필드 | 타입 | 필수 | 설명 |
-|------|------|------|------|
-| `locale` | string | ✅ | 변경할 로케일 코드 (예: `"ko"`, `"en"`, `"ja"`) |
-
-### 동작
-
-```text
-1. 로케일 변경 → 번역 파일 다시 로드
-2. _global.locale 자동 업데이트
-3. 모든 $t: 표현식 재평가
-```
-
-### 사용 예시
-
-```json
-{
- "id": "lang_en_btn",
- "type": "basic",
- "name": "Button",
- "props": {
- "text": "English"
- },
- "actions": [
- {
- "type": "click",
- "handler": "setLocale",
- "params": {
- "locale": "en"
- }
- }
- ]
-}
-```
-
----
-
-## setTheme / initTheme
-
-### setTheme
-
-다크/라이트 모드를 전환합니다.
-
-**소스**: `src/handlers/setThemeHandler.ts`
-
-```json
-{
- "type": "click",
- "handler": "setTheme",
- "params": {
- "theme": "dark"
- }
-}
-```
-
-### setTheme params
-
-| 필드 | 타입 | 필수 | 설명 |
-|------|------|------|------|
-| `theme` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) |
-
-### 동작
-
-```text
-1. localStorage에 테마 설정 저장
-2. document.documentElement에 class 적용 (dark/light)
-3. Tailwind dark: variant 활성화/비활성화
-```
-
-### initTheme
-
-앱 시작 시 저장된 테마 설정을 적용합니다. 주로 `init_actions`에서 사용합니다.
-
-```json
-{
- "init_actions": [
- {
- "handler": "initTheme"
- }
- ]
-}
-```
-
-params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원합니다.
-
-### 사용 예시
-
-```json
-{
- "id": "theme_toggle",
- "type": "composite",
- "name": "ThemeToggle",
- "actions": [
- {
- "type": "click",
- "handler": "setTheme",
- "params": {
- "theme": "{{_global.theme === 'dark' ? 'light' : 'dark'}}"
- }
- }
- ]
-}
-```
-
----
-
-## scrollToSection
-
-특정 섹션으로 스크롤합니다. 오프셋 지원에 특화되어 있습니다.
-
-**소스**: `src/handlers/scrollToSectionHandler.ts`
-
-```json
-{
- "type": "click",
- "handler": "scrollToSection",
- "params": {
- "selector": "#features",
- "offset": -80
- }
-}
-```
-
-### params
-
-| 필드 | 타입 | 필수 | 기본값 | 설명 |
-|------|------|------|--------|------|
-| `selector` | string | ✅ | - | CSS 선택자 (예: `"#section-id"`, `".class-name"`) |
-| `offset` | number | ❌ | `0` | 스크롤 오프셋 (음수: 위로, 양수: 아래로). 고정 헤더 높이 보상에 사용 |
-
-### 동작
-
-```text
-1. document.querySelector(selector)로 대상 요소 검색
-2. 요소의 위치 계산 + offset 적용
-3. window.scrollTo({ top, behavior: 'smooth' })로 부드러운 스크롤
-```
-
-### 사용 예시
-
-```json
-{
- "id": "nav_features",
- "type": "basic",
- "name": "A",
- "props": {
- "text": "$t:common.features",
- "className": "cursor-pointer"
- },
- "actions": [
- {
- "type": "click",
- "handler": "scrollToSection",
- "params": {
- "selector": "#features",
- "offset": -80
- }
- }
- ]
-}
-```
-
----
-
-## initMenuFromUrl
-
-현재 URL을 기반으로 사이드바/네비게이션 메뉴의 활성 상태를 초기화합니다. 주로 관리자 템플릿의 `init_actions`에서 사용합니다.
-
-**소스**: `src/handlers/initMenuFromUrlHandler.ts`
-
-```json
-{
- "init_actions": [
- {
- "handler": "initMenuFromUrl"
- }
- ]
-}
-```
-
-### params
-
-없음. 현재 URL 경로를 메뉴 항목과 매칭하여 활성 메뉴를 자동 설정합니다.
-
-### 동작
-
-```text
-1. 현재 URL 경로 (window.location.pathname) 추출
-2. 사이드바 메뉴 데이터에서 URL 매칭
-3. 매칭된 메뉴 항목의 is_active 상태 설정
-4. 부모 메뉴도 자동으로 펼침 상태 설정
-```
-
-### 사용 예시 (_admin_base.json)
-
-```json
-{
- "init_actions": [
- { "handler": "initTheme" },
- { "handler": "initMenuFromUrl" }
- ]
-}
-```
-
----
-
-## 필터 가시성 핸들러
-
-목록 화면에서 필터 패널의 가시성(표시/숨김)을 관리합니다. localStorage에 상태를 저장하여 새로고침 후에도 유지합니다.
-
-**소스**: `src/handlers/filterVisibilityHandler.ts`
-
-### initFilterVisibility
-
-저장된 필터 가시성 상태를 `_local`에 복원합니다.
-
-```json
-{
- "init_actions": [
- {
- "handler": "initFilterVisibility"
- }
- ]
-}
-```
-
-### saveFilterVisibility
-
-현재 필터 가시성 상태를 localStorage에 저장합니다.
-
-```json
-{
- "handler": "saveFilterVisibility",
- "params": {
- "filters": "{{_local.filterVisibility}}"
- }
-}
-```
-
-### toggleFilterVisibility
-
-특정 필터 키의 가시성을 토글합니다.
-
-```json
-{
- "type": "click",
- "handler": "toggleFilterVisibility",
- "params": {
- "key": "advancedFilters"
- }
-}
-```
-
-### resetFilterVisibility
-
-모든 필터 가시성을 초기 상태로 리셋합니다.
-
-```json
-{
- "type": "click",
- "handler": "resetFilterVisibility"
-}
-```
-
-### 핸들러 params 요약
-
-| 핸들러 | params | 설명 |
-|--------|--------|------|
-| `initFilterVisibility` | 없음 | localStorage → `_local` 복원 |
-| `saveFilterVisibility` | `{ filters }` | `_local` → localStorage 저장 |
-| `toggleFilterVisibility` | `{ key }` | 특정 키 토글 |
-| `resetFilterVisibility` | 없음 | 전체 초기화 |
-
-### 사용 예시 (목록 페이지)
-
-```json
-{
- "init_actions": [
- { "handler": "initFilterVisibility" }
- ],
- "components": [
- {
- "id": "filter_toggle_btn",
- "type": "basic",
- "name": "Button",
- "props": {
- "text": "$t:common.toggle_filters"
- },
- "actions": [
- {
- "type": "click",
- "handler": "toggleFilterVisibility",
- "params": { "key": "advancedFilters" }
- }
- ]
- },
- {
- "id": "filter_section",
- "type": "basic",
- "name": "Div",
- "if": "{{_local.filterVisibility?.advancedFilters}}",
- "children": [
- { "comment": "필터 컴포넌트들" }
- ]
- }
- ]
-}
-```
-
----
-
-## 다국어 태그 핸들러
-
-다국어 입력 컴포넌트(MultilingualInput)에서 사용하는 태그 관리 핸들러입니다.
-
-**소스**: `src/handlers/multilingualTagHandler.ts`
-
-### saveMultilingualTag
-
-다국어 태그를 저장합니다.
-
-```json
-{
- "handler": "saveMultilingualTag",
- "params": {
- "field": "tags",
- "locale": "{{_global.locale}}"
- }
-}
-```
-
-### cancelMultilingualTag
-
-다국어 태그 편집을 취소합니다.
-
-```json
-{
- "handler": "cancelMultilingualTag"
-}
-```
-
-### updateMultilingualTagValue
-
-다국어 태그 값을 업데이트합니다.
-
-```json
-{
- "handler": "updateMultilingualTagValue",
- "params": {
- "field": "tags",
- "locale": "ko",
- "value": "{{$event.target.value}}"
- }
-}
-```
-
-### 태그 핸들러 params 요약
-
-| 핸들러 | params | 설명 |
-|--------|--------|------|
-| `saveMultilingualTag` | `{ field, locale }` | 태그 저장 |
-| `cancelMultilingualTag` | 없음 | 편집 취소 |
-| `updateMultilingualTagValue` | `{ field, locale, value }` | 값 업데이트 |
-
----
-
-## 핸들러 소스 파일 매핑
-
-| 핸들러명 | 소스 파일 | 등록 함수 |
-|---------|----------|----------|
-| `setLocale` | `src/handlers/setLocaleHandler.ts` | `setLocaleHandler` |
-| `setTheme`, `initTheme` | `src/handlers/setThemeHandler.ts` | `initTheme` |
-| `scrollToSection` | `src/handlers/scrollToSectionHandler.ts` | `scrollToSectionHandler` |
-| `initMenuFromUrl` | `src/handlers/initMenuFromUrlHandler.ts` | `initMenuFromUrlHandler` |
-| `initFilterVisibility`, `saveFilterVisibility`, `toggleFilterVisibility`, `resetFilterVisibility` | `src/handlers/filterVisibilityHandler.ts` | `initFilterVisibilityHandler` |
-| `saveMultilingualTag`, `cancelMultilingualTag`, `updateMultilingualTagValue` | `src/handlers/multilingualTagHandler.ts` | `saveMultilingualTagHandler` |
-
----
-
-## 주의사항
-
-```text
-이 핸들러들은 sirsoft-admin_basic 템플릿에서만 등록됨 (다른 템플릿에서 미지원 가능)
-범용 핸들러(navigate, apiCall, setState 등)와 달리 템플릿 의존적
-✅ 커스텀 핸들러이므로 template.json의 핸들러 등록 확인 필요
-✅ 범용 핸들러는 actions-handlers.md 참조
-```
-
----
-
-## 관련 문서
-
-- [액션 핸들러 개요](../../actions-handlers.md)
-- [sirsoft-admin_basic 컴포넌트](./components.md)
-- [sirsoft-admin_basic 레이아웃](./layouts.md)
-- [sirsoft-basic 핸들러](../sirsoft-basic/handlers.md)
diff --git a/docs/frontend/templates/sirsoft-admin_basic/layouts.md b/docs/frontend/templates/sirsoft-admin_basic/layouts.md
deleted file mode 100644
index 46a49173..00000000
--- a/docs/frontend/templates/sirsoft-admin_basic/layouts.md
+++ /dev/null
@@ -1,368 +0,0 @@
-# sirsoft-admin_basic 레이아웃
-
-> **템플릿 식별자**: `sirsoft-admin_basic` (type: admin)
-> **관련 문서**: [컴포넌트](./components.md) | [핸들러](./handlers.md) | [레이아웃 JSON 스키마](../../layout-json.md)
-
----
-
-## TL;DR (5초 요약)
-
-```text
-1. 베이스: _admin_base.json (사이드바 + 헤더 + 콘텐츠 슬롯)
-2. 코어 페이지 17개: 대시보드, 사용자, 역할, 설정, 확장 관리 등
-3. Partial 70개+: 탭, 모달, 패널, 필터 등
-4. 에러 페이지 6개: 401, 403, 404, 500, 503, maintenance
-5. 패턴: 목록(DataGrid+Pagination), 상세(Card), 폼(Form+FormField), 설정(Tab)
-```
-
----
-
-## 목차
-
-1. [페이지 맵 (트리 구조)](#페이지-맵-트리-구조)
-2. [카테고리별 가이드](#카테고리별-가이드)
- - [목록 페이지 패턴](#목록-페이지-패턴)
- - [상세 페이지 패턴](#상세-페이지-패턴)
- - [폼 페이지 패턴](#폼-페이지-패턴)
- - [설정 페이지 패턴](#설정-페이지-패턴)
- - [확장 관리 패턴](#확장-관리-패턴)
- - [에러 페이지 패턴](#에러-페이지-패턴)
-
----
-
-## 페이지 맵 (트리 구조)
-
-```text
-_admin_base.json (베이스 레이아웃)
-│
-├── admin_dashboard.json (대시보드)
-├── admin_login.json (로그인 — _admin_base 미상속)
-│
-├── 사용자 관리
-│ ├── admin_user_list.json (목록)
-│ ├── admin_user_form.json (생성/수정)
-│ └── admin_user_detail.json (상세)
-│
-├── 역할 관리
-│ ├── admin_role_list.json (목록)
-│ │ └── partials/admin_role_list/_modal_delete.json
-│ └── admin_role_form.json (생성/수정)
-│
-├── 메뉴 관리
-│ └── admin_menu_list.json (3패널 레이아웃)
-│ └── partials/admin_menu_list/
-│ ├── _panel_menu_list.json (좌측: 메뉴 트리)
-│ ├── _panel_form.json (중앙: 편집 폼)
-│ ├── _panel_detail.json (중앙: 상세 보기)
-│ ├── _panel_view.json (우측: 미리보기)
-│ └── _modal_delete.json
-│
-├── 환경 설정
-│ └── admin_settings.json (탭 네비게이션)
-│ └── partials/admin_settings/
-│ ├── _tab_general.json (일반)
-│ ├── _tab_mail.json (메일 발송 SMTP 설정)
-│ ├── _tab_notification_definitions.json (알림 정의)
-│ ├── _tab_security.json (보안)
-│ ├── _tab_upload.json (업로드)
-│ ├── _tab_drivers.json (드라이버)
-│ ├── _tab_seo.json (SEO)
-│ ├── _tab_advanced.json (고급)
-│ ├── _tab_info.json (시스템 정보)
-│ ├── _modal_cache_delete.json
-│ ├── _modal_core_changelog.json
-│ ├── _modal_core_update_guide.json
-│ ├── _modal_core_update_result.json
-│ ├── _modal_notification_template_form.json
-│ ├── _modal_notification_template_preview.json
-│ └── _modal_password_confirm.json
-│
-├── 모듈 관리
-│ └── admin_module_list.json
-│ └── partials/admin_module_list/
-│ ├── _modal_detail.json
-│ ├── _modal_install.json
-│ ├── _modal_manual_install.json
-│ ├── _modal_uninstall.json
-│ ├── _modal_update.json
-│ ├── _modal_deactivate_warning.json
-│ ├── _modal_force_activate.json
-│ ├── _modal_force_deactivate.json
-│ ├── _modal_extension_license.json
-│ └── _modal_refresh_layouts.json
-│
-├── 플러그인 관리
-│ └── admin_plugin_list.json
-│ └── partials/admin_plugin_list/
-│ ├── (모듈과 동일 구조 — 10개 모달)
-│ └── ...
-│
-├── 템플릿 관리
-│ ├── admin_template_list.json
-│ │ └── partials/admin_template_list/
-│ │ ├── _tab_admin.json (Admin 템플릿 탭)
-│ │ ├── _tab_user.json (User 템플릿 탭)
-│ │ ├── _modal_detail.json
-│ │ ├── _modal_install.json
-│ │ ├── _modal_manual_install.json
-│ │ ├── _modal_uninstall.json
-│ │ ├── _modal_update.json
-│ │ ├── _modal_activate.json
-│ │ ├── _modal_deactivate.json
-│ │ ├── _modal_force_activate.json
-│ │ ├── _modal_extension_license.json
-│ │ └── _modal_refresh_layouts.json
-│ └── admin_template_layout_edit.json (레이아웃 편집기)
-│ └── partials/admin_template_layout_edit/_modal_version_history.json
-│
-├── 스케줄 관리
-│ └── admin_schedule_list.json
-│ └── partials/admin_schedule_list/
-│ ├── _tab_schedules.json
-│ ├── _modal_form.json
-│ ├── _modal_delete.json
-│ ├── _modal_duplicate.json
-│ ├── _modal_history.json
-│ └── _modal_run.json
-│
-├── 메일 발송 로그
-│ └── admin_mail_send_log_list.json
-│ └── partials/admin_mail_send_log_list/
-│ ├── _partial_datagrid.json
-│ └── _partial_filter.json
-│
-├── 공통 Partial
-│ ├── partials/_modal_changelog.json
-│ └── partials/_modal_license.json
-│
-├── 에러 페이지
-│ └── errors/
-│ ├── 401.json (인증 필요)
-│ ├── 403.json (접근 거부)
-│ ├── 404.json (페이지 없음)
-│ ├── 500.json (서버 오류)
-│ ├── 503.json (서비스 불가)
-│ └── maintenance.json (점검 중)
-│
-├── 오버라이드
-│ └── overrides/sirsoft-sample/index.json
-│
-└── 테스트
- └── template_partial_test.json
- └── partials/template_partial_test/
- ├── _content_section.json
- ├── _header_section.json
- └── _info_card.json
-```
-
----
-
-## 카테고리별 가이드
-
-### 목록 페이지 패턴
-
-**대표**: `admin_user_list.json`, `admin_role_list.json`
-
-**구성**:
-```text
-extends: _admin_base
-slots.content:
- └── PageHeader (제목, 액션 버튼)
- └── FilterGroup (선택적)
- └── DataGrid (columns, data, pagination)
- └── Pagination
-```
-
-**data_sources**:
-```json
-{
- "id": "users",
- "endpoint": "/api/admin/users",
- "method": "GET",
- "auto_fetch": true,
- "params": { "page": "{{_local.page ?? 1}}", "per_page": "{{_local.per_page ?? 15}}" }
-}
-```
-
-**핸들러 패턴**:
-- `apiCall` — 삭제, 상태 변경
-- `navigate` — 상세/수정 페이지 이동
-- `setState` — 필터, 페이지네이션 상태
-
-**Partial 구조**: 모달 (삭제 확인, 상세 보기 등)
-
----
-
-### 상세 페이지 패턴
-
-**대표**: `admin_user_detail.json`
-
-**구성**:
-```text
-extends: _admin_base
-data_sources: [상세 API (route.id 기반)]
-slots.content:
- └── PageHeader
- └── Card (기본 정보 섹션)
- └── Card (활동 내역 섹션)
- └── Card (권한 정보 섹션)
-```
-
-**data_sources**:
-```json
-{
- "id": "user",
- "endpoint": "/api/admin/users/{{route.id}}",
- "method": "GET",
- "auto_fetch": true
-}
-```
-
-**핸들러 패턴**:
-- `apiCall` — 상태 변경 (활성화/비활성화, 역할 변경)
-- `navigate` — 목록으로 이동, 수정 페이지 이동
-
----
-
-### 폼 페이지 패턴
-
-**대표**: `admin_user_form.json`, `admin_role_form.json`
-
-**구성**:
-```text
-extends: _admin_base
-data_sources: [상세 API (수정 시), 참조 데이터 (Select 옵션)]
-slots.content:
- └── PageHeader
- └── Form
- ├── FormField + Input (텍스트)
- ├── FormField + Select (선택)
- ├── FormField + Toggle (토글)
- └── Button (저장/취소)
-```
-
-**핸들러 패턴**:
-- `apiCall` — 생성 (POST) / 수정 (PUT)
-- `navigate` — 성공 후 목록/상세로 이동
-
-**주의사항**:
-```text
-✅ Form 내 Button에 type="button" 명시 (submit 방지)
-✅ 수정 폼은 route.id 존재 여부로 생성/수정 구분
-✅ FormField에 error prop으로 서버 검증 에러 표시
-```
-
----
-
-### 설정 페이지 패턴
-
-**대표**: `admin_settings.json`
-
-**구성**:
-```text
-extends: _admin_base
-data_sources: [설정 API]
-slots.content:
- └── PageHeader
- └── TabNavigation (tabs)
- └── Div (탭별 partial 조건부 렌더링)
- ├── if: activeTab === 'general' → partial: _tab_general.json
- ├── if: activeTab === 'mail' → partial: _tab_mail.json
- ├── if: activeTab === 'security' → partial: _tab_security.json
- └── ...
-```
-
-**Partial 구조**: `partials/admin_settings/_tab_*.json` (9개 탭)
-
-**핸들러 패턴**:
-- `setState` — 탭 전환
-- `apiCall` — 설정 저장
-- `openModal` — 확인 다이얼로그
-
----
-
-### 확장 관리 패턴
-
-**대표**: `admin_module_list.json`, `admin_plugin_list.json`, `admin_template_list.json`
-
-**구성**:
-```text
-extends: _admin_base
-data_sources: [확장 목록 API]
-slots.content:
- └── PageHeader (새로고침, 수동 설치 버튼)
- └── DataGrid/CardGrid (확장 목록)
- ├── StatusBadge (상태)
- ├── ActionMenu (설치/활성화/비활성화/삭제/업데이트)
- └── ExtensionBadge (모듈 식별)
-modals:
- ├── _modal_detail.json (상세 정보)
- ├── _modal_install.json (설치 확인)
- ├── _modal_uninstall.json (삭제 확인)
- ├── _modal_update.json (업데이트)
- └── _modal_force_activate.json 등
-```
-
-**핸들러 패턴**:
-- `apiCall` — 설치, 활성화, 비활성화, 삭제, 업데이트
-- `openModal` — 확인 다이얼로그
-- `setState` — 선택된 확장 정보 저장
-
-**특수사항**:
-- 템플릿 관리는 Admin/User 탭 분리 (`_tab_admin.json`, `_tab_user.json`)
-- 레이아웃 편집기 (`admin_template_layout_edit.json`)는 CodeEditor + 실시간 미리보기
-
----
-
-### 에러 페이지 패턴
-
-**대표**: `errors/404.json`
-
-**구성**:
-```text
-(extends 없음 — 독립 레이아웃)
-components:
- └── Div (전체 화면 중앙 정렬)
- ├── Icon (에러 아이콘)
- ├── H1 (에러 코드)
- ├── P (에러 메시지)
- └── Button (홈으로 이동)
-```
-
-**핸들러 패턴**:
-- `navigate` — 대시보드/홈으로 이동
-
----
-
-## 베이스 레이아웃 구조
-
-### _admin_base.json
-
-모든 관리자 페이지의 공통 구조를 정의합니다.
-
-```text
-_admin_base.json
-├── init_actions: [initTheme, initMenuFromUrl]
-├── data_sources: [admin_menu, notifications]
-├── components:
-│ ├── AdminSidebar (menu: admin_menu.data)
-│ ├── AdminHeader (user, notifications)
-│ ├── Toast
-│ ├── PageTransitionIndicator
-│ └── Div (content area)
-│ └── slot: "content" (← 하위 레이아웃이 채움)
-└── AdminFooter
-```
-
-**슬롯**:
-- `content` — 각 페이지의 메인 콘텐츠가 삽입되는 위치
-
----
-
-## 관련 문서
-
-- [sirsoft-admin_basic 컴포넌트](./components.md)
-- [sirsoft-admin_basic 핸들러](./handlers.md)
-- [레이아웃 JSON 스키마](../../layout-json.md)
-- [레이아웃 상속](../../layout-json-inheritance.md)
-- [sirsoft-basic 레이아웃](../sirsoft-basic/layouts.md)
diff --git a/docs/frontend/templates/sirsoft-basic/components.md b/docs/frontend/templates/sirsoft-basic/components.md
index 1556fcf4..ab099b4f 100644
--- a/docs/frontend/templates/sirsoft-basic/components.md
+++ b/docs/frontend/templates/sirsoft-basic/components.md
@@ -242,6 +242,6 @@ AdminSidebar, AdminHeader, AdminFooter, PageHeader, DataGrid, CodeEditor, Dynami
- [sirsoft-basic 핸들러](./handlers.md)
- [sirsoft-basic 레이아웃](./layouts.md)
-- [sirsoft-admin_basic 컴포넌트](../sirsoft-admin_basic/components.md)
+- [sirsoft-admin_basic 컴포넌트](../../../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
- [컴포넌트 개발 규칙](../../components.md)
- [컴포넌트 Props 레퍼런스](../../component-props.md)
diff --git a/docs/frontend/templates/sirsoft-basic/handlers.md b/docs/frontend/templates/sirsoft-basic/handlers.md
index 78269e8d..30380f17 100644
--- a/docs/frontend/templates/sirsoft-basic/handlers.md
+++ b/docs/frontend/templates/sirsoft-basic/handlers.md
@@ -428,4 +428,4 @@ setLocale은 엔진 레벨(ActionDispatcher) 빌트인 — 별도 등록 불필
- [액션 핸들러 개요](../../actions-handlers.md)
- [sirsoft-basic 컴포넌트](./components.md)
- [sirsoft-basic 레이아웃](./layouts.md)
-- [sirsoft-admin_basic 핸들러](../sirsoft-admin_basic/handlers.md)
+- [sirsoft-admin_basic 핸들러](../../../../templates/_bundled/sirsoft-admin_basic/docs/handlers.md)
diff --git a/docs/frontend/templates/sirsoft-basic/layouts.md b/docs/frontend/templates/sirsoft-basic/layouts.md
index d9d1ec57..4ae239d8 100644
--- a/docs/frontend/templates/sirsoft-basic/layouts.md
+++ b/docs/frontend/templates/sirsoft-basic/layouts.md
@@ -642,6 +642,6 @@ _user_base.json
- [sirsoft-basic 컴포넌트](./components.md)
- [sirsoft-basic 핸들러](./handlers.md)
-- [sirsoft-admin_basic 레이아웃](../sirsoft-admin_basic/layouts.md)
+- [sirsoft-admin_basic 레이아웃](../../../../templates/_bundled/sirsoft-admin_basic/docs/layouts.md)
- [레이아웃 JSON 스키마](../../layout-json.md)
- [레이아웃 상속](../../layout-json-inheritance.md)
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md
index 580bd675..68e73ecb 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/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.2] - 2026-08-31
+
+### Added
+
+- 동의 이력의 출처 "会員退会"(회원탈퇴) 일본어 라벨 추가
+
## [1.0.1] - 2026-08-10
### Added
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php
index 1f01be16..ab8d69cf 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php
@@ -203,6 +203,7 @@ return [
'register' => '会員登録',
'mypage' => 'マイページ',
'mypage_renew_all' => 'マイページ一括再同意',
+ 'withdraw' => '会員退会',
],
'col' => [
'created_at' => '時点',
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json
index 917a2e3f..ee0b4268 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json
@@ -238,7 +238,8 @@
"preference_center": "環境設定",
"mypage": "マイページ",
"register": "会員登録",
- "mypage_renew_all": "マイページ一括再同意"
+ "mypage_renew_all": "マイページ一括再同意",
+ "withdraw": "会員退会"
},
"col": {
"created_at": "時点",
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json
index 799fe5b4..393526fc 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 plugin (sirsoft-gdpr) Japanese language pack (bundled)",
"ja": "G7 プラグイン (sirsoft-gdpr) 日本語 言語パック(バンドル)"
},
- "version": "1.0.1",
+ "version": "1.0.2",
"license": "MIT",
"scope": "plugin",
"target_identifier": "sirsoft-gdpr",
diff --git a/modules/_bundled/sirsoft-board/AGENTS.md b/modules/_bundled/sirsoft-board/AGENTS.md
new file mode 100644
index 00000000..d8989f2a
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/AGENTS.md
@@ -0,0 +1,191 @@
+# 게시판 — 에이전트 가이드
+
+> 이 문서는 이 모듈을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
+
+## TL;DR (5초 요약)
+
+```text
+1. 유형: 모듈 (sirsoft-board) — 게시판·게시글·댓글·신고·게시판별 알림설정 도메인. 관리자 CRUD + 공개 API 만 소유하고, 방문자 화면(목록/상세/글쓰기)은 템플릿이 그린다
+2. 확장 방식: 발행 훅 90개(전량 action/filter) — 새 콘텐츠 타입 연동은 `EcommerceInquiryHookListener` 식 필터 훅 위임 패턴을 참고
+3. 건드리면 안 되는 것: 비밀글 게이팅(`SecretContentGate`)을 우회하는 신규 조회 경로, `chunk()`(OFFSET) 로 삭제·갱신 순회, count 컬럼(posts_count 등) 직접 갱신
+4. 작업 위치: `modules/_bundled/sirsoft-board` — 활성 디렉토리 직접 수정 금지
+5. 반영: `php artisan module:update sirsoft-board --force`
+```
+
+## 1. 이 확장은 무엇인가
+
+
+게시판·게시글·댓글·신고·게시판별 알림설정을 소유하는 콘텐츠 도메인 모듈입니다. 운영자가
+`/admin/boards`에서 게시판을 자유롭게 생성(게시판 유형 `basic`/`gallery`/`card` 선택, 비밀글·
+답변형·트리거 기반 관리자 알림 등 게시판별 세부 설정)하면, 그 게시판마다 동적 권한
+(`sirsoft-board.{slug}.*`)·역할(`{slug}.manager`/`{slug}.step`)·메뉴가 자동 생성됩니다 —
+게시판 하나하나가 사실상 독립된 작은 확장처럼 자기 권한 체계를 갖습니다.
+
+**소유 범위는 관리자 CRUD + 공개 API 까지입니다.** 방문자가 실제로 보는 목록/상세/글쓰기
+화면은 이 모듈이 그리지 않습니다 — `resources/layouts/` 46개가 전부 `admin` 그룹인 것이
+그 증거입니다. 공개 조회·작성은 `routes/api.php` 의 `boards.*` 라우트(비회원도 접근하는
+`optional.sanctum`)로만 나가고, 그 API 를 소비해 실제 화면을 그리는 것은 템플릿(`sirsoft-basic`)
+쪽 책임입니다. 그래서 이 모듈에 의존하는 확장은 지금 템플릿 하나뿐입니다 — 새 방문자 화면이
+필요하면 이 모듈이 아니라 그 화면을 쓰는 템플릿/모듈 쪽에 레이아웃을 추가합니다.
+
+**설계 원칙**: 게시판은 이커머스 문의·후기처럼 "게시판을 흉내 낸" 다른 도메인의 콘텐츠 저장소로도
+쓰입니다. 그래서 이 모듈을 다른 도메인이 재사용하는 지점은 코드 결합이 아니라 **필터 훅
+위임**(`EcommerceInquiryHookListener`)입니다 — 이커머스 모듈이 자기 문의 게시판을 만들 때
+`sirsoft-board` 를 직접 `use` 하지 않고, board 쪽 흐름 중간에서 자기 로직으로 갈아끼웁니다.
+
+**의도적으로 하지 않는 것**: 게시판 콘텐츠에 대한 실시간 브로드캐스트(WebSocket)는 제공하지
+않습니다 — 알림은 전부 코어 `GenericNotification`(mail/database) 경유이며, 새로고침 없이
+갱신되는 목록 같은 기능은 이 모듈의 범위 밖입니다. 또한 비밀글 판정은 이 모듈이 API 계층에서
+전량 서버측으로 강제합니다(`SecretContentGate`, KVE-2026-1914) — 클라이언트가 비밀글 여부를
+판단해 화면만 가리는 방식은 쓰지 않습니다.
+
+
+## 2. 디렉토리 지도
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
+| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
+| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
+| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
+| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
+| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
+| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
+| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
+| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
+| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
+| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
+| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
+| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
+| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
+| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
+| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update sirsoft-board --force` (빌드 불필요) |
+| `resources/routes/` | 라우트 → 레이아웃 매핑 (분할) | `php artisan module:update sirsoft-board --force` |
+| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan module:build` → `php artisan module:update sirsoft-board --force` |
+| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan module:update sirsoft-board --force` |
+| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan module:update sirsoft-board --force` |
+| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+
+
+## 3. 핵심 흐름
+
+
+**게시판 생성**: `Admin\BoardController` → `StoreBoardRequest`(다국어 이름·slug 유일성·게시판
+유형 검증) → `BoardService::createBoard()` → `BoardRepository` 로 `boards` 행 생성 후, 같은
+트랜잭션 안에서 `BoardPermissionService`(동적 권한 3계층 생성) → 역할(manager/step) 생성 →
+관리자 메뉴 등록까지 연쇄 실행됩니다. 이 연쇄 때문에 `getDynamicPermissionIdentifiers()` /
+`getDynamicRoleIdentifiers()` / `getDynamicMenuSlugs()` 가 `boards` 테이블을 다시 조회해
+전수를 재구성합니다 — 게시판 삭제·정리(clean-up) 시 "stale 판정"의 기준이 이 세 메서드입니다.
+
+**게시글 작성 → 비밀글 게이팅**: `User\PostController` → `StorePostRequest`
+(`sirsoft-board.post.store_validation_rules` 필터로 게시판별 커스텀 규칙 추가) →
+`PostService::createPost()` (`before_create`→`filter_create_data`→`after_create` 훅 순서,
+`sirsoft-board.post.user_create` IDV 정책 게이트 통과 후) → `PostRepository`. 조회 시에는
+`PostResource` 가 `SecretContentGate` 로 비밀글 여부·열람 권한을 판정해 `content`/`title`/
+`reply`/`attachments` 를 마스킹합니다 — 이 판정은 댓글 목록·첨부 다운로드에도 **개별
+재적용**됩니다(부모 글에서 한 번 판정하고 끝나지 않음, KVE-2026-1914).
+
+**신고 접수 → 처리**: `User\ReportController` → `StoreReportRequest` → `ReportService::create()`
+(`before_create`→`filter_create_data`→`after_create`) → 신고 접수 관리자 알림 발송. 관리자가
+`Admin\ReportController` 에서 처리(블라인드/삭제/복원)하면 `ReportService` 가 대상 게시글/댓글
+서비스(`PostService`/`CommentService`)를 호출해 실제 콘텐츠 상태를 바꾸고, 그 결과가
+`report_action`/`post_action` 알림으로 원 작성자에게 통지됩니다. 신고 삭제·일괄 처리는
+관리자 민감 작업 IDV 정책(`report.delete`/`report.bulk_action`)이 걸려 있습니다.
+
+
+## 4. 확장점
+
+
+| 확장점 | 수 | 상세 |
+|---|---|---|
+| 발행 훅 | 90개 | [발행 훅](docs/extension-points.md#발행-훅) |
+| 구독 훅 | 77개 | [구독 훅](docs/extension-points.md#구독-훅) |
+| 훅 리스너 | 16개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
+| 레이아웃 확장 | 5개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
+| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) |
+| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
+| 스케줄 | 2개 | [스케줄](docs/extension-points.md#스케줄) |
+| 알림 정의 | 7개 | [알림 정의](docs/extension-points.md#알림-정의) |
+
+
+
+발행 훅 90개는 전부 `action`/`filter` 이며 코어 브로드캐스트 채널은 쓰지 않습니다(구독 훅
+목록의 이커머스 8개가 예로 보여주듯, **콘텐츠를 다른 도메인이 재사용하는 자리는 훅이지 상속이
+아닙니다**). 새 게시판 세부 검증 규칙을 추가하려면 `board.store_validation_rules`/
+`update_validation_rules` filter 를, 게시판 생성 후 부가 리소스를 함께 만들려면
+`board.after_create` action 을 잡습니다. 게시글/댓글의 `before_*`→`filter_*_data`→`after_*`
+3단 패턴은 전 도메인(board/comment/report/attachment)에 동일하게 반복되므로, 하나를 배우면
+나머지에 그대로 적용됩니다. 훅 리스너 16개 중 `EcommerceInquiryHookListener` 는 이 모듈의
+CRUD 흐름 자체를 **자기 도메인으로 대체**하는 가장 무거운 형태의 확장 사례입니다 — 새로운
+"게시판을 흉내 낸 도메인"을 만들 때 참고할 선례입니다.
+
+
+## 5. 수정 시 동반 의무
+
+- [ ] `_bundled` 에서만 수정하고 `php artisan module:update sirsoft-board --force` 로 반영
+- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
+- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝
+- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
+- [ ] API 표면 변경 시 `php artisan api:docgen --scope=module:sirsoft-board` 재실행 + `docs/api/**` 갱신
+- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
+- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
+- [ ] 비밀글이 관여하는 새 조회 경로(댓글·첨부·검색 결과 등)를 추가할 때 `SecretContentGate` 재적용 (KVE-2026-1914 — 부모에서 한 번 판정하고 끝나지 않는다)
+- [ ] `boards`/`board_posts`/`board_comments` 의 count 컬럼(`posts_count`/`comments_count`/`replies_count`/`attachments_count`)은 훅 리스너(`*CountSyncListener`)가 갱신 — Service 에서 직접 증감 금지
+- [ ] 게시판 삭제 시 `getDynamicPermissionIdentifiers()`/`getDynamicRoleIdentifiers()`/`getDynamicMenuSlugs()` 가 최신 상태를 반영하도록 동일 트랜잭션에서 정리 (module.php `uninstall()` 의 `chunkById` 패턴 참고 — OFFSET 순회로 삭제 금지)
+
+## 6. 금지 패턴
+
+
+| 금지 | 올바른 사용 | 이유 |
+|---|---|---|
+| 비밀글 상세만 `SecretContentGate` 로 막고 댓글 목록·첨부 다운로드는 그대로 노출 | 댓글 목록은 부모 글 비밀 여부 확인 후 빈 배열, 첨부 서빙은 403 — 두 경로 모두 게이트 재적용 | 상세 API 하나만 막으면 같은 정보가 형제 엔드포인트로 새어나간다 (KVE-2026-1914) |
+| 게시글/댓글 삭제·복원 시 `posts_count`/`comments_count` 를 Service 에서 `increment()`/`decrement()` | 훅(`after_create`/`after_delete`/`after_restore`) 리스너의 count 동기화에 맡긴다 | 직접 증감은 훅 기반 동기화와 이중 집계되어 카운트가 어긋난다 |
+| 게시판별 동적 권한/역할을 board 삭제와 별도 시점에 정리 | `BoardService` 삭제 흐름 안에서 즉시 정리(또는 stale cleanup 이 `getDynamicPermissionIdentifiers()` 로 정확히 판정하게 유지) | 정리가 늦으면 존재하지 않는 게시판의 권한이 역할에 남아 관리 화면에 유령 항목이 뜬다 |
+| 새 콘텐츠 타입을 board 코드에 `if ($type === 'inquiry')` 로 직접 분기 | `EcommerceInquiryHookListener` 처럼 필터 훅으로 CRUD 를 위임 | board 코드가 알지 못하는 도메인이 늘어날수록 분기가 무한 증식한다 |
+
+
+## 7. 테스트 실행
+
+
+| 종류 | 개수 | 위치 |
+|---|---|---|
+| PHPUnit | 155개 | `modules/_bundled/sirsoft-board/tests` |
+| Vitest | 33개 | `vitest.config.ts` |
+| Playwright | 26개 | `tests/Playwright` |
+| 시나리오 매니페스트 | 34개 | `tests/scenarios` |
+
+기저 TestCase: `tests/ModuleTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
+
+```bash
+# PHPUnit (변경 범위만) (Bash)
+php vendor/bin/phpunit modules/_bundled/sirsoft-board/tests --filter='<대상클래스>'
+
+# Vitest (확장 디렉토리에서) (PowerShell)
+cd modules/_bundled/sirsoft-board && powershell -Command "npm run test:run -- <대상>"
+
+# Playwright E2E (Bash)
+npx playwright test modules/_bundled/sirsoft-board/tests/Playwright/specs/<대상>.spec.ts
+
+```
+
+무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
+
+
+## 8. 문서 목차
+
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
+| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
+| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
+| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
diff --git a/modules/_bundled/sirsoft-board/README.md b/modules/_bundled/sirsoft-board/README.md
new file mode 100644
index 00000000..51628325
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/README.md
@@ -0,0 +1,171 @@
+# 게시판
+
+**G7 모듈 · sirsoft-board**
+게시판 관리를 위한 모듈
+
+
+
+
+
+
+
+
+
+
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+게시판·게시글·댓글·신고를 관리하는 콘텐츠 모듈입니다. 운영자가 관리자 화면에서 자유형(가로형/
+갤러리형/카드형) 게시판을 원하는 개수만큼 만들고, 게시판마다 비밀글·답변형·본인인증·자동 알림
+같은 세부 정책을 독립적으로 설정할 수 있습니다.
+
+이 모듈은 관리자 화면과 공개 API 만 제공합니다. 방문자가 보는 목록·상세·글쓰기 화면은
+템플릿(`sirsoft-basic`)이 이 모듈의 API 를 호출해 그립니다 — 운영자 입장에서는 "게시판 콘텐츠는
+여기서 관리하고, 화면 디자인은 템플릿이 담당한다"로 이해하면 됩니다.
+
+
+## 주요 기능
+
+
+| 영역 | 설명 |
+|---|---|
+| 게시판 관리 | 게시판 생성/수정/삭제, 게시판 유형(기본/갤러리/카드) 선택, 게시판별 세부 설정 일괄 적용 |
+| 게시글·댓글 | 작성/수정/삭제/블라인드/복원, 답변형 게시판(원글-답변 트리), 대댓글, 비밀글 |
+| 신고 처리 | 사용자 신고 접수 → 관리자 검토 → 블라인드/삭제/복원 처리, 처리 결과 알림 |
+| 첨부파일 | 업로드/다운로드/순서 변경, 게시판별 허용 확장자·용량 제한 |
+| 대시보드 | 게시판별 게시글·댓글·신고 현황과 추세, 미처리 신고 요약 |
+| 알림 | 새 댓글/대댓글/답변글/신고 접수/처리 결과를 메일·앱 내 알림으로 발송, 회원별 수신 여부 설정 |
+| 본인인증 연동 | 게시글/댓글 삭제, 신고 작성, 첫 글 작성 등 민감 작업에 코어 IDV 정책 적용(기본은 비활성) |
+
+
+## 동작 방식
+
+
+```mermaid
+flowchart LR
+ V[방문자] -->|공개 API 호출| T[템플릿 화면]
+ T -->|GET boards/posts| API[게시판 공개 API]
+ API --> SVC[PostService/CommentService]
+ SVC --> DB[(board_posts 등)]
+
+ A[운영자] -->|관리자 화면| ADM[게시판 관리 UI]
+ ADM -->|CRUD·블라인드·신고 처리| SVC
+ SVC -->|훅 발행| N[알림/집계/검색색인]
+```
+
+방문자는 템플릿이 그린 화면에서 공개 API 만 호출하고, 운영자는 이 모듈이 직접 제공하는
+관리자 화면을 씁니다. 두 경로 모두 결국 같은 Service 계층을 거치므로 비밀글 게이팅·카운트
+동기화·훅 발행은 어느 쪽에서 들어오든 동일하게 적용됩니다.
+
+
+## 요구 사항
+
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=7.0.10` |
+| PHP | `^8.2` |
+
+
+## 설치
+
+
+```bash
+# 번들 설치 (코어에 동봉된 소스에서 설치)
+php artisan module:install sirsoft-board
+
+# 활성화
+php artisan module:activate sirsoft-board
+
+# 업데이트 (번들 소스 기준 강제 반영)
+php artisan module:update sirsoft-board --force
+```
+
+저장소: https://github.com/gnuboard/g7-module-sirsoft-board
+
+
+## 관리자 설정
+
+
+_별도의 관리자 설정 항목이 없습니다._
+
+
+
+위 표가 비어 있는 이유는 이 모듈에 전역 환경설정(`getSettingsSchema()`) 이 없기 때문입니다 —
+설정은 전역이 아니라 **게시판 하나하나**에 딸려 있습니다(`/admin/boards/{slug}/settings`).
+게시판을 만들 때 기본/갤러리/카드 유형을 고르면 그 유형의 기본값이 채워지고, 이후 기본
+정보·목록 표시·게시글 정책·댓글 정책·첨부 정책·본인인증·알림·SEO 탭에서 게시판별로 따로
+조정합니다. 여러 게시판에 같은 값을 한 번에 반영하려면 게시판 목록 화면의 "설정 일괄 적용"을
+씁니다(`settings.before_bulk_apply`/`after_bulk_apply` 훅으로 계측 가능).
+
+
+## 사용 방법
+
+
+**게시판 신설**: `/admin/boards` → "게시판 추가" → 이름·slug·유형 지정 → 저장. 저장 즉시
+관리자 메뉴·동적 권한(`sirsoft-board.{slug}.*`)·역할(`{slug}.manager`)이 자동 생성되므로,
+바로 이어서 "권한" 탭에서 그 게시판을 담당할 운영자에게 `{slug}.manager` 역할을 부여합니다.
+
+**신고 처리**: 방문자가 게시글/댓글을 신고하면 `/admin/boards/reports` 에 접수되고 담당자에게
+메일이 갑니다. 신고 상세에서 신고 사유·신고 이력을 확인한 뒤 블라인드/삭제/복원 중 하나로
+처리하면, 그 결과가 원 작성자에게 자동으로 통지됩니다 — 별도로 작성자에게 안내 메일을 보낼
+필요가 없습니다.
+
+**여러 게시판 설정 일괄 변경**: 예를 들어 전체 게시판의 첨부 용량 상한을 한 번에 올리고 싶으면,
+게시판 목록에서 대상 게시판을 체크한 뒤 "설정 일괄 적용" 모달에서 첨부 탭 값만 바꿔 적용합니다.
+다른 탭 값은 그대로 유지되고, 체크한 게시판에만 반영됩니다.
+
+
+## 다른 확장과의 연동
+
+
+**이 확장이 의존하는 확장**
+
+없음 — 코어만으로 동작합니다.
+
+**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
+
+| 확장 | 유형 | 요구 버전 |
+|---|---|---|
+| `sirsoft-basic` | 템플릿 | `>=1.0.0` |
+
+
+## 문서
+
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
+| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
+| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
+| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
+
+## 트러블슈팅
+
+
+| 증상 | 원인 | 조치 |
+|---|---|---|
+| 게시판 삭제 후에도 관리자 화면에 그 게시판 권한/역할이 남아 있음 | 정리 배치가 아직 실행되지 않았거나 삭제 트랜잭션이 중간에 실패 | `getDynamicPermissionIdentifiers()`/`getDynamicRoleIdentifiers()` 는 현재 `boards` 테이블 기준으로 계산되므로, 확장 정리 커맨드를 다시 실행하면 stale 항목이 잡힙니다 |
+| 검색어에 `+`, `-`, `"` 를 넣으면 결과가 0건으로 나옴 | 코어 검색 정제기가 FULLTEXT 연산자를 제거한 뒤 검색 — 연산자만 입력하면 빈 결과가 정상 동작 | 오류가 아닙니다. 실제 키워드를 함께 입력하면 정상 매칭됩니다 |
+| 비밀글의 댓글 개수가 0으로 보이는데 실제로는 댓글이 있음 | 열람 권한이 없는 요청에는 댓글 목록이 빈 배열(200)로 마스킹됨(KVE-2026-1914) | 정상 동작입니다. 작성자 본인 또는 `posts.read-secret`/관리 권한으로 조회하면 보입니다 |
+| 게시판 설정 일괄 적용 후 일부 게시판만 반영됨 | 대상 게시판 중 일부가 적용 도중 실패(예: 유효성 위반) | `settings.after_bulk_apply_aborted` 훅 시점의 로그로 실패한 게시판을 특정한 뒤 개별 재적용 |
+
+
+## 변경 이력
+
+[CHANGELOG.md](CHANGELOG.md)
+
+## 라이선스
+
+MIT
diff --git a/modules/_bundled/sirsoft-board/docs/README.md b/modules/_bundled/sirsoft-board/docs/README.md
new file mode 100644
index 00000000..f09ee1fe
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/docs/README.md
@@ -0,0 +1,22 @@
+# 게시판 개발자 문서
+
+> modules/_bundled/sirsoft-board · 모듈
+
+
+**훅 수**: 90 · **구독 훅 수**: 77 · **라우트 수**: 80 · **모델 수**: 9 · **테이블 수**: 10 · **마이그레이션 수**: 30 · **레이아웃 수**: 46 · **핸들러 수**: 0
+
+
+## 문서 목차
+
+
+| 문서 | 내용 |
+|---|---|
+| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
+| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
+| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
+| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
+| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
+| [api/](api/README.md) | API 레퍼런스 |
+| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
+| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
+
diff --git a/modules/_bundled/sirsoft-board/docs/architecture.md b/modules/_bundled/sirsoft-board/docs/architecture.md
new file mode 100644
index 00000000..659266c2
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/docs/architecture.md
@@ -0,0 +1,75 @@
+# 게시판 — 아키텍처
+
+> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 설계 의도
+
+
+게시판마다 완결된 권한·역할 체계를 갖게 하면서도, 새 게시판을 만드는 데 코드 변경이 필요 없게
+하는 것이 이 모듈의 핵심 설계 목표입니다. `boards` 테이블 한 행이 하나의 확장처럼 동작하도록,
+권한·역할·메뉴는 모두 **런타임에 게시판 데이터로부터 파생**됩니다(`getDynamicPermissionIdentifiers()`
+등 3개 메서드가 코드가 아니라 DB 를 읽어 계산). 그 대가로 이 세 메서드는 게시판이 하나
+추가/삭제될 때마다 정확해야 하고, 어긋나면 stale 권한이 남거나 존재하는 게시판의 권한이
+정리 대상으로 오판됩니다.
+
+또한 "관리자 백엔드 모듈 + 방문자 화면은 템플릿" 분리를 의도적으로 유지합니다. 방문자 화면을
+이 모듈 안에 두면 템플릿마다 디자인이 다른 게시판 UI 를 이 모듈이 전부 알아야 하는데,
+API 로만 노출하면 템플릿 쪽에서 자유롭게 화면을 구성할 수 있습니다. 이 경계 때문에
+"레이아웃 확장"·"레이아웃"에는 오직 관리자 화면만 나타나며, 그것이 정상입니다.
+
+
+## 계층 지도
+
+
+```
+Http/Controllers (Admin/ 관리자, User/ 공개 API)
+ │
+ ▼
+FormRequest (검증 + *_validation_rules 필터 훅으로 게시판별 규칙 확장 지점)
+ │
+ ▼
+Services (BoardService/PostService/CommentService/ReportService/AttachmentService 등)
+ │ before_* → filter_*_data → 실행 → after_* (전 도메인 공통 3단 훅 패턴)
+ ▼
+Repositories (RepositoryInterface 경유, 정렬 화이트리스트·컬럼 프루닝)
+ │
+ ▼
+Models (SoftDeletes 적용 — Post/Comment/Attachment/Report)
+```
+
+Listeners(`src/Listeners/`)는 이 흐름과 별도 레인입니다 — Service 가 발행한 훅을 받아 카운트
+동기화(`*CountSyncListener`)·활동 로그·SEO 캐시·검색 색인·알림 데이터 추출을 수행하며, Service
+자신은 이 부가효과를 알지 못합니다. 이 분리 덕분에 새 부가효과(예: 신규 카운트 컬럼 동기화)는
+Service 를 건드리지 않고 리스너 추가만으로 끝납니다.
+
+
+## 디렉토리
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
+| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
+| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
+| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
+| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
+| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
+| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
+| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
+| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
+| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
+| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
+| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
+| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
+| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
+| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
+| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update sirsoft-board --force` (빌드 불필요) |
+| `resources/routes/` | 라우트 → 레이아웃 매핑 (분할) | `php artisan module:update sirsoft-board --force` |
+| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan module:build` → `php artisan module:update sirsoft-board --force` |
+| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan module:update sirsoft-board --force` |
+| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan module:update sirsoft-board --force` |
+| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+
diff --git a/modules/_bundled/sirsoft-board/docs/data-model.md b/modules/_bundled/sirsoft-board/docs/data-model.md
new file mode 100644
index 00000000..e052712e
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/docs/data-model.md
@@ -0,0 +1,161 @@
+# 게시판 — 데이터 모델
+
+> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 모델
+
+
+| 모델 | 테이블 | fillable | 관계 | 특성 |
+|---|---|---|---|---|
+| `Attachment` | `board_attachments` | 15 | board→Board, post→Post, creator→User | SoftDeletes |
+| `Board` | `boards` | 37 | creator→User, updater→User, posts→Post, comments→Comment, attachments→Attachment, reports→Report | - |
+| `BoardStat` | `board_stats` | 3 | - | - |
+| `BoardType` | `board_types` | 3 | - | HasUserOverrides |
+| `Comment` | `board_comments` | 14 | board→Board, post→Post, user→User, parent→self, replies→self | SoftDeletes |
+| `Post` | `board_posts` | 21 | board→Board, user→User, parent→self, replies→self, comments→Comment, attachments→Attachment, 외 1개 | SoftDeletes, 검색 색인 |
+| `Report` | `boards_reports` | 11 | board→Board, author→User, logs→ReportLog, processor→User | SoftDeletes |
+| `ReportLog` | `boards_report_logs` | 6 | report→Report, reporter→User | - |
+| `UserNotificationSetting` | `board_user_notification_settings` | 5 | user→User | - |
+
+
+
+`Post`·`Comment` 모두 `parent`/`replies` 자기참조 관계를 갖습니다 — `Post` 의 자기참조는
+"답변형 게시판"(원글에 대한 관리자 답변)을, `Comment` 의 자기참조는 대댓글을 표현합니다. 둘은
+서로 다른 기능이라 관계 이름은 같아도 코드에서 섞어 쓰지 않습니다. `Board` 는 `HasUserOverrides`
+를 쓰지 **않습니다** — 게시판 자체는 운영자가 직접 소유·수정하는 리소스라 "모듈 재설치 시
+운영자 수정 보존"이 필요 없는 반면, `BoardType`(게시판 유형 프리셋)은 모듈이 시딩한 기본값을
+운영자가 손댈 수 있어야 하므로 그 트레이트를 씁니다.
+
+
+## 소유 테이블
+
+
+| 테이블 | 모델 |
+|---|---|
+| `board_attachments` | `Attachment` |
+| `board_comments` | `Comment` |
+| `board_mail_templates` | - |
+| `board_posts` | `Post` |
+| `board_stats` | `BoardStat` |
+| `board_types` | `BoardType` |
+| `board_user_notification_settings` | `UserNotificationSetting` |
+| `boards` | `Board` |
+| `boards_report_logs` | `ReportLog` |
+| `boards_reports` | `Report` |
+
+
+
+`board_mail_templates` 는 모델이 없는 채로 남아 있습니다 — 아래 마이그레이션 표의
+`drop_board_mail_templates_table`(2026-04-13)이 보여주듯, 메일 템플릿을 자체 테이블로
+관리하던 초기 설계를 코어 `GenericNotification` 알림 정의(§알림 정의)로 이관하며 테이블만
+드롭하고 이름은 이력상 남아 있는 상태입니다. 신규 코드에서 이 이름을 참조하지 않습니다.
+테이블 접두어가 `board_*`와 `boards_*` 두 가지로 섞여 있는 것은 설계 의도가 아니라 이력입니다
+— 새 테이블을 추가할 때는 `board_*`(단수)를 따릅니다.
+
+
+## 마이그레이션
+
+
+마이그레이션 30개.
+
+| 파일 | 생성 테이블 | 변경 테이블 | down() |
+|---|---|---|---|
+| `2026_04_01_000001_create_board_types_table.php` | `board_types` | - | ✅ |
+| `2026_04_01_000002_create_boards_table.php` | `boards` | `boards` | ✅ |
+| `2026_04_01_000003_create_board_user_notification_settings_table.php` | `board_user_notification_settings` | `board_user_notification_settings` | ✅ |
+| `2026_04_01_000004_create_board_posts_table.php` | `board_posts` | - | ✅ |
+| `2026_04_01_000005_create_board_comments_table.php` | `board_comments` | - | ✅ |
+| `2026_04_01_000006_create_board_attachments_table.php` | `board_attachments` | - | ✅ |
+| `2026_04_01_000007_create_boards_reports_table.php` | `boards_reports` | `boards_reports` | ✅ |
+| `2026_04_01_000008_create_boards_report_logs_table.php` | `boards_report_logs` | `boards_report_logs` | ✅ |
+| `2026_04_01_000009_create_board_mail_templates_table.php` | `board_mail_templates` | - | ✅ |
+| `2026_04_01_000010_add_fulltext_indexes_to_boards_table.php` | - | `boards` | ✅ |
+| `2026_04_01_000011_add_fulltext_indexes_to_boards_report_logs_table.php` | - | `boards_report_logs` | ✅ |
+| `2026_04_01_000012_add_indexes_to_board_posts_table.php` | - | `board_posts` | ✅ |
+| `2026_04_13_000001_drop_board_mail_templates_table.php` | `board_mail_templates` | - | ✅ |
+| `2026_04_13_000002_add_user_overrides_to_board_types_table.php` | - | `board_types` | ✅ |
+| `2026_04_14_000001_drop_channel_columns_from_boards_table.php` | - | `boards` | ✅ |
+| `2026_04_17_000001_remove_partitions_from_board_tables.php` | - | - | ✅ |
+| `2026_04_17_000002_add_count_columns_to_board_tables.php` | - | `board_posts`, `board_comments` | ✅ |
+| `2026_04_17_000003_add_posts_count_and_comments_count_to_boards_table.php` | - | `boards` | ✅ |
+| `2026_04_17_000004_update_indexes_in_board_tables.php` | - | `board_posts`, `board_comments`, `board_attachments` | ✅ |
+| `2026_05_29_000001_create_board_stats_table.php` | `board_stats` | - | ✅ |
+| `2026_06_08_000001_add_recent_across_boards_index_to_board_posts.php` | - | `board_posts` | ✅ |
+| `2026_06_26_000001_modify_trigger_type_in_board_comments_table.php` | - | - | ✅ |
+| `2026_06_26_000002_add_trigger_type_to_board_attachments_table.php` | - | `board_attachments` | ✅ |
+| `2026_08_01_000001_add_tiebreak_to_board_posts_list_index.php` | - | - | ✅ |
+| `2026_08_01_000002_add_list_sort_index_to_boards_reports_table.php` | - | - | ✅ |
+| `2026_08_06_000001_update_max_reply_depth_comment_in_boards_table.php` | - | - | ✅ |
+| `2026_08_06_000002_add_view_count_sort_index_to_board_posts_table.php` | - | - | ✅ |
+| `2026_08_17_000001_add_reply_delete_policy_to_boards_table.php` | - | `boards` | ✅ |
+| `2026_08_17_000002_modify_trigger_type_in_board_posts_table.php` | - | - | ✅ |
+| `2026_08_22_000001_add_content_thumbnail_url_to_board_posts_table.php` | - | `board_posts` | ✅ |
+
+
+
+목록 성능 마이그레이션이 지속적으로 추가되는 것(인덱스 4건 + 정렬 타이브레이크 2건)은 우연이
+아닙니다 — 게시글 목록은 공지 제외·답변 제외 필터가 항상 걸린 채로 `created_at`/`view_count`
+2가지 정렬을 지원해야 하므로, 새 정렬 옵션을 추가할 때마다 그 정렬에 맞는 복합 인덱스를 함께
+마이그레이션합니다(`getBenchmarkProfiles()` 의 `board_posts_by_view_count` 프로파일이 바로 이
+목적으로 존재합니다). `remove_partitions_from_board_tables`(2026-04-17)는 `board_posts`/`board_comments`/
+`board_attachments` 3개 테이블에 적용했던 파티셔닝을 되돌린 이력입니다 — 그 마이그레이션의
+`down()` 은 "파티션 복원은 데이터 재배치가 필요해 자동 롤백 불가"라고 명시하므로, 파티셔닝을
+다시 도입할 때는 이 파일을 그대로 재실행하는 방식이 아니라 새 마이그레이션으로 설계해야 합니다.
+
+
+## Enum
+
+
+| Enum | backing | case 수 | case |
+|---|---|---|---|
+| `BoardOrderBy` | `string` | 4 | `created_at`, `view_count`, `title`, `author` |
+| `OrderDirection` | `string` | 2 | `ASC`, `DESC` |
+| `PostStatus` | `string` | 3 | `published`, `blinded`, `deleted` |
+| `ReplyDeletePolicy` | `string` | 2 | `block`, `cascade` |
+| `ReportReasonType` | `string` | 9 | `abuse`, `hate_speech`, `spam`, `copyright`, `privacy`, `misinformation`, `sexual`, `violence`, `외 1개` |
+| `ReportStatus` | `string` | 5 | `pending`, `review`, `rejected`, `suspended`, `deleted` |
+| `ReportType` | `string` | 2 | `post`, `comment` |
+| `SecretMode` | `string` | 3 | `disabled`, `enabled`, `always` |
+| `TriggerType` | `string` | 6 | `report`, `admin`, `system`, `auto_hide`, `user`, `cascade` |
+
+
+
+`TriggerType`(6 case: report/admin/system/auto_hide/user/cascade)은 "이 콘텐츠가 왜 지금
+상태가 됐는가"를 기록하는 감사(audit) 축입니다 — 예를 들어 게시글 블라인드가 `report`(신고
+처리 결과)인지 `admin`(관리자 직접 조치)인지에 따라 `post_action`/`report_action` 두 알림이
+갈라집니다(§확장점 "알림 정의" 참고). `cascade` 는 부모(게시글)가 지워질 때 자식(댓글)이
+함께 지워진 경우이며, `ReplyDeletePolicy`(`block`/`cascade`)가 게시판별로 부모 삭제 시
+자식을 막을지 함께 지울지를 결정합니다 — 이 정책과 `TriggerType::Cascade` 는 같은 흐름의
+서로 다른 절반(정책 설정 vs 결과 기록)입니다.
+
+
+## Repository
+
+
+| 클래스 | 종류 | 설명 |
+|---|---|---|
+| `AttachmentRepository` | 구현 | 게시판 첨부파일 Repository 구현체 |
+| `AttachmentRepositoryInterface` | 인터페이스 | 게시판 첨부파일 Repository 인터페이스 |
+| `BoardRepository` | 구현 | 게시판 Repository |
+| `BoardRepositoryInterface` | 인터페이스 | 게시판 Repository 인터페이스 |
+| `BoardStatRepository` | 구현 | 게시판 일별 집계 Repository |
+| `BoardStatRepositoryInterface` | 인터페이스 | 게시판 일별 집계 Repository 인터페이스 |
+| `BoardTypeRepository` | 구현 | - |
+| `BoardTypeRepositoryInterface` | 인터페이스 | - |
+| `CommentRepository` | 구현 | 댓글 Repository |
+| `CommentRepositoryInterface` | 인터페이스 | 댓글 Repository 인터페이스 |
+| `PostRepository` | 구현 | 게시글 Repository |
+| `PostRepositoryInterface` | 인터페이스 | 게시글 Repository 인터페이스 |
+| `ReportRepository` | 구현 | 신고 Repository |
+| `ReportRepositoryInterface` | 인터페이스 | 신고 Repository 인터페이스 |
+| `UserNotificationSettingRepository` | 구현 | 사용자 알림 설정 Repository |
+| `UserNotificationSettingRepositoryInterface` | 인터페이스 | 사용자 알림 설정 Repository 인터페이스 |
+
+
+
+`BoardStatRepository`(일별 집계)는 별도 Repository 로 분리돼 있습니다 — `sirsoft-board:aggregate-stats`
+스케줄이 매시간 `board_stats` 를 갱신하는데, 이 집계 쿼리를 `PostRepository`/`CommentRepository`
+에 섞으면 대시보드 조회 경로와 실시간 CRUD 경로가 같은 클래스 안에서 뒤엉킵니다. 새 Repository
+를 추가할 때는 반드시 인터페이스를 함께 만들고 `CoreServiceProvider`(또는 이 모듈의
+서비스 프로바이더)에서 바인딩합니다 — Service 가 구체 클래스를 직접 타입힌트하면 안 됩니다.
+
diff --git a/modules/_bundled/sirsoft-board/docs/extension-points.md b/modules/_bundled/sirsoft-board/docs/extension-points.md
new file mode 100644
index 00000000..336b8e62
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/docs/extension-points.md
@@ -0,0 +1,323 @@
+# 게시판 — 확장점
+
+> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 발행 훅
+
+
+발행 훅 90종 / 호출 지점 91곳. 이 중 90종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다.
+
+| 훅 이름 | 유형 | 설명 | 발행 위치 |
+|---|---|---|---|
+| `core.module_settings.after_save` | action | — | `src/Http/Controllers/Admin/BoardSettingsController.php:123` |
+| `sirsoft-board.attachment.after_delete` | action | — | `src/Services/AttachmentService.php:463` |
+| `sirsoft-board.attachment.after_download` | action | — | `src/Services/AttachmentService.php:776` |
+| `sirsoft-board.attachment.after_link` | action | — | `src/Services/AttachmentService.php:217` 외 1곳 |
+| `sirsoft-board.attachment.after_reorder` | action | — | `src/Services/AttachmentService.php:631` |
+| `sirsoft-board.attachment.after_upload` | action | — | `src/Services/AttachmentService.php:192` |
+| `sirsoft-board.attachment.before_delete` | action | — | `src/Services/AttachmentService.php:442` |
+| `sirsoft-board.attachment.before_reorder` | action | — | `src/Services/AttachmentService.php:626` |
+| `sirsoft-board.attachment.before_upload` | action | — | `src/Services/AttachmentService.php:117` |
+| `sirsoft-board.attachment.filter_upload_file` | filter | — | `src/Services/AttachmentService.php:120` |
+| `sirsoft-board.attachment.reorder_validation_rules` | filter | — | `src/Http/Requests/ReorderAttachmentsRequest.php:35` |
+| `sirsoft-board.attachment.upload_validation_rules` | filter | — | `src/Http/Requests/UploadAttachmentRequest.php:77` |
+| `sirsoft-board.board.after_add_to_menu` | action | — | `src/Services/BoardService.php:1059` |
+| `sirsoft-board.board.after_create` | action | — | `src/Services/BoardService.php:457` |
+| `sirsoft-board.board.after_delete` | action | — | `src/Services/BoardService.php:623` |
+| `sirsoft-board.board.after_remove_from_menu` | action | — | `src/Services/BoardService.php:1104` |
+| `sirsoft-board.board.after_update` | action | — | `src/Services/BoardService.php:543` |
+| `sirsoft-board.board.before_add_to_menu` | action | — | `src/Services/BoardService.php:1023` |
+| `sirsoft-board.board.before_copy` | action | — | `src/Services/BoardService.php:646` |
+| `sirsoft-board.board.before_create` | action | — | `src/Services/BoardService.php:382` |
+| `sirsoft-board.board.before_delete` | action | — | `src/Services/BoardService.php:574` |
+| `sirsoft-board.board.before_remove_from_menu` | action | — | `src/Services/BoardService.php:1090` |
+| `sirsoft-board.board.before_update` | action | — | `src/Services/BoardService.php:495` |
+| `sirsoft-board.board.filter_copy_data` | filter | — | `src/Services/BoardService.php:703` |
+| `sirsoft-board.board.filter_create_data` | filter | — | `src/Services/BoardService.php:385` |
+| `sirsoft-board.board.filter_menu_data` | filter | — | `src/Services/BoardService.php:1053` |
+| `sirsoft-board.board.filter_update_data` | filter | — | `src/Services/BoardService.php:500` |
+| `sirsoft-board.board.posts.before_force_delete` | action | — | `src/Services/BoardService.php:593` |
+| `sirsoft-board.board.store_validation_rules` | filter | — | `src/Http/Requests/StoreBoardRequest.php:225` |
+| `sirsoft-board.board.update_validation_rules` | filter | — | `src/Http/Requests/UpdateBoardRequest.php:228` |
+| `sirsoft-board.board_type.after_create` | action | — | `src/Services/BoardTypeService.php:42` |
+| `sirsoft-board.board_type.after_delete` | action | — | `src/Services/BoardTypeService.php:107` |
+| `sirsoft-board.board_type.after_update` | action | — | `src/Services/BoardTypeService.php:70` |
+| `sirsoft-board.board_type.before_create` | action | — | `src/Services/BoardTypeService.php:36` |
+| `sirsoft-board.board_type.before_delete` | action | — | `src/Services/BoardTypeService.php:103` |
+| `sirsoft-board.board_type.before_update` | action | — | `src/Services/BoardTypeService.php:62` |
+| `sirsoft-board.board_type.filter_create_data` | filter | — | `src/Services/BoardTypeService.php:38` |
+| `sirsoft-board.board_type.filter_update_data` | filter | — | `src/Services/BoardTypeService.php:66` |
+| `sirsoft-board.comment.after_blind` | action | — | `src/Services/CommentService.php:480` |
+| `sirsoft-board.comment.after_create` | action | — | `src/Services/CommentService.php:375` |
+| `sirsoft-board.comment.after_delete` | action | — | `src/Services/CommentService.php:441` |
+| `sirsoft-board.comment.after_restore` | action | — | `src/Services/CommentService.php:516` |
+| `sirsoft-board.comment.after_update` | action | — | `src/Services/CommentService.php:410` |
+| `sirsoft-board.comment.before_blind` | action | — | `src/Services/CommentService.php:471` |
+| `sirsoft-board.comment.before_create` | action | — | `src/Services/CommentService.php:341` |
+| `sirsoft-board.comment.before_delete` | action | — | `src/Services/CommentService.php:431` |
+| `sirsoft-board.comment.before_restore` | action | — | `src/Services/CommentService.php:507` |
+| `sirsoft-board.comment.before_update` | action | — | `src/Services/CommentService.php:399` |
+| `sirsoft-board.comment.filter_create_data` | filter | — | `src/Services/CommentService.php:344` |
+| `sirsoft-board.comment.filter_update_data` | filter | — | `src/Services/CommentService.php:404` |
+| `sirsoft-board.comment.store_validation_rules` | filter | — | `src/Http/Requests/StoreCommentRequest.php:91` |
+| `sirsoft-board.comment.update_validation_rules` | filter | — | `src/Http/Requests/UpdateCommentRequest.php:67` |
+| `sirsoft-board.permissions.after_create` | action | — | `src/Services/BoardService.php:431` |
+| `sirsoft-board.permissions.after_delete` | action | — | `src/Services/BoardService.php:600` |
+| `sirsoft-board.permissions.after_update` | action | — | `src/Services/BoardService.php:523` |
+| `sirsoft-board.post.after_blind` | action | — | `src/Services/PostService.php:500` |
+| `sirsoft-board.post.after_create` | action | — | `src/Services/PostService.php:275` |
+| `sirsoft-board.post.after_delete` | action | — | `src/Services/PostService.php:459` |
+| `sirsoft-board.post.after_restore` | action | — | `src/Services/PostService.php:564` |
+| `sirsoft-board.post.after_update` | action | — | `src/Services/PostService.php:351` |
+| `sirsoft-board.post.before_blind` | action | — | `src/Services/PostService.php:491` |
+| `sirsoft-board.post.before_create` | action | — | `src/Services/PostService.php:250` |
+| `sirsoft-board.post.before_delete` | action | — | `src/Services/PostService.php:426` |
+| `sirsoft-board.post.before_restore` | action | — | `src/Services/PostService.php:533` |
+| `sirsoft-board.post.before_update` | action | — | `src/Services/PostService.php:324` |
+| `sirsoft-board.post.filter_content_thumbnail` | filter | — | `src/Models/Post.php:138` |
+| `sirsoft-board.post.filter_create_data` | filter | — | `src/Services/PostService.php:253` |
+| `sirsoft-board.post.filter_update_data` | filter | — | `src/Services/PostService.php:329` |
+| `sirsoft-board.post.store_validation_rules` | filter | — | `src/Http/Requests/StorePostRequest.php:120` |
+| `sirsoft-board.post.update_validation_rules` | filter | — | `src/Http/Requests/UpdatePostRequest.php:77` |
+| `sirsoft-board.report.after_blind_content` | action | — | `src/Services/ReportService.php:712` |
+| `sirsoft-board.report.after_bulk_update_status` | action | — | `src/Services/ReportService.php:492` |
+| `sirsoft-board.report.after_create` | action | — | `src/Services/ReportService.php:292` |
+| `sirsoft-board.report.after_delete` | action | — | `src/Services/ReportService.php:559` |
+| `sirsoft-board.report.after_delete_content` | action | — | `src/Services/ReportService.php:880` |
+| `sirsoft-board.report.after_restore_content` | action | — | `src/Services/ReportService.php:678` |
+| `sirsoft-board.report.after_update_status` | action | — | `src/Services/ReportService.php:369` |
+| `sirsoft-board.report.before_bulk_update_status` | action | — | `src/Services/ReportService.php:385` |
+| `sirsoft-board.report.before_create` | action | — | `src/Services/ReportService.php:203` |
+| `sirsoft-board.report.before_delete` | action | — | `src/Services/ReportService.php:554` |
+| `sirsoft-board.report.before_update_status` | action | — | `src/Services/ReportService.php:324` |
+| `sirsoft-board.report.filter_create_data` | filter | — | `src/Services/ReportService.php:227` |
+| `sirsoft-board.roles.after_create` | action | — | `src/Services/BoardService.php:411` |
+| `sirsoft-board.roles.after_delete` | action | — | `src/Services/BoardService.php:604` |
+| `sirsoft-board.search.post.index_should_update` | filter | — | `src/Models/Post.php:280` |
+| `sirsoft-board.settings.after_bulk_apply` | action | — | `src/Services/BoardService.php:1368` |
+| `sirsoft-board.settings.after_bulk_apply_aborted` | action | — | `src/Services/BoardService.php:1359` |
+| `sirsoft-board.settings.before_bulk_apply` | action | — | `src/Services/BoardService.php:1263` |
+| `sirsoft-board.user_post.store_validation_rules` | filter | — | `src/Http/Requests/User/StorePostRequest.php:162` |
+| `sirsoft-board.user_post.update_validation_rules` | filter | — | `src/Http/Requests/User/UpdatePostRequest.php:112` |
+
+
+
+`{도메인}.{동사}` 이름 규칙 안에서 4개 도메인(board/post/comment/report)이 전부 같은 3단
+패턴(`before_*` → `filter_*_data` → `after_*`)을 반복합니다. 새 검증 규칙을 게시판별로 다르게
+걸고 싶으면 `*.store_validation_rules`/`update_validation_rules` filter 를, 저장 직전 데이터를
+가공하고 싶으면 `filter_*_data` 를, 저장 완료 후 부가 작업(알림·외부 연동)을 붙이고 싶으면
+`after_*` action 을 잡습니다 — `before_*` 에서 예외를 던지면 저장 자체를 막을 수 있습니다.
+
+`sirsoft-board.post.filter_content_thumbnail`/`sirsoft-board.search.post.index_should_update`
+두 필터는 Model(`Post.php`) 안에서 직접 발행되는 예외적인 자리입니다 — Service 를 거치지 않는
+지연 평가(썸네일 추출, 검색 색인 갱신 여부 판단)라 Model 이 직접 훅을 겁니다.
+
+
+## 구독 훅
+
+
+| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
+|---|---|---|---|---|
+| `core.activity_log.filter_description_params` | filter | `ActivityLogDescriptionResolver` | `resolveDescriptionParams` | 10 |
+| `core.module_settings.after_save` | action (미선언) | `SeoBoardSettingsCacheListener` | `onModuleSettingsSave` | 20 |
+| `core.notification.filter_default_definitions` | filter | `BoardNotificationDataListener` | `contributeDefaultDefinitions` | 20 |
+| `core.search.build_response` | filter | `SearchPostsListener` | `buildPostsResponse` | 10 |
+| `core.search.index_validation_rules` | filter | `SearchPostsListener` | `addValidationRules` | 10 |
+| `core.search.results` | filter | `SearchPostsListener` | `searchPosts` | 10 |
+| `core.user.after_create` | action (미선언) | `UserNotificationSettingsListener` | `afterCreate` | 10 |
+| `core.user.create_validation_rules` | filter | `UserNotificationSettingsListener` | `addValidationRules` | 10 |
+| `core.user.filter_create_data` | filter | `UserNotificationSettingsListener` | `filterCreateData` | 10 |
+| `core.user.filter_resource_data` | filter | `UserNotificationSettingsListener` | `filterResourceData` | 10 |
+| `core.user.filter_update_data` | filter | `UserNotificationSettingsListener` | `filterUpdateData` | 10 |
+| `core.user.update_profile_validation_rules` | filter | `UserNotificationSettingsListener` | `addValidationRules` | 10 |
+| `core.user.update_validation_rules` | filter | `UserNotificationSettingsListener` | `addValidationRules` | 10 |
+| `sirsoft-board.attachment.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleAttachmentAfterDelete` | 20 |
+| `sirsoft-board.attachment.after_delete` | action (미선언) | `PostAttachmentCountSyncListener` | `syncAttachmentsCount` | 10 |
+| `sirsoft-board.attachment.after_download` | action (미선언) | `BoardActivityLogListener` | `handleAttachmentAfterDownload` | 20 |
+| `sirsoft-board.attachment.after_link` | action (미선언) | `PostAttachmentCountSyncListener` | `syncAttachmentsCount` | 10 |
+| `sirsoft-board.attachment.after_upload` | action (미선언) | `BoardActivityLogListener` | `handleAttachmentAfterUpload` | 20 |
+| `sirsoft-board.attachment.after_upload` | action (미선언) | `PostAttachmentCountSyncListener` | `syncAttachmentsCount` | 10 |
+| `sirsoft-board.board.after_add_to_menu` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterAddToMenu` | 20 |
+| `sirsoft-board.board.after_create` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterCreate` | 20 |
+| `sirsoft-board.board.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterDelete` | 20 |
+| `sirsoft-board.board.after_remove_from_menu` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterRemoveFromMenu` | 20 |
+| `sirsoft-board.board.after_update` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterUpdate` | 20 |
+| `sirsoft-board.board.after_update` | action (미선언) | `SeoBoardCacheListener` | `onBoardUpdate` | 20 |
+| `sirsoft-board.board_type.after_create` | action (미선언) | `BoardActivityLogListener` | `handleBoardTypeAfterCreate` | 20 |
+| `sirsoft-board.board_type.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleBoardTypeAfterDelete` | 20 |
+| `sirsoft-board.board_type.after_update` | action (미선언) | `BoardActivityLogListener` | `handleBoardTypeAfterUpdate` | 20 |
+| `sirsoft-board.comment.after_blind` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterBlind` | 20 |
+| `sirsoft-board.comment.after_create` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterCreate` | 20 |
+| `sirsoft-board.comment.after_create` | action (미선언) | `BoardCommentsCountSyncListener` | `syncCommentsCount` | 10 |
+| `sirsoft-board.comment.after_create` | action (미선언) | `CommentReplySyncListener` | `syncRepliesCount` | 10 |
+| `sirsoft-board.comment.after_create` | action (미선언) | `PostCountSyncListener` | `syncCommentsCount` | 10 |
+| `sirsoft-board.comment.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterDelete` | 20 |
+| `sirsoft-board.comment.after_delete` | action (미선언) | `BoardCommentsCountSyncListener` | `syncCommentsCount` | 10 |
+| `sirsoft-board.comment.after_delete` | action (미선언) | `CommentReplySyncListener` | `syncRepliesCount` | 10 |
+| `sirsoft-board.comment.after_delete` | action (미선언) | `PostCountSyncListener` | `syncCommentsCount` | 10 |
+| `sirsoft-board.comment.after_restore` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterRestore` | 20 |
+| `sirsoft-board.comment.after_restore` | action (미선언) | `BoardCommentsCountSyncListener` | `syncCommentsCount` | 10 |
+| `sirsoft-board.comment.after_restore` | action (미선언) | `CommentReplySyncListener` | `syncRepliesCount` | 10 |
+| `sirsoft-board.comment.after_restore` | action (미선언) | `PostCountSyncListener` | `syncCommentsCount` | 10 |
+| `sirsoft-board.comment.after_update` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterUpdate` | 20 |
+| `sirsoft-board.notification.channels` | filter | `BoardNotificationChannelListener` | `filterChannels` | 10 |
+| `sirsoft-board.notification.extract_data` | filter | `BoardNotificationDataListener` | `extractData` | 20 |
+| `sirsoft-board.post.after_blind` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterBlind` | 20 |
+| `sirsoft-board.post.after_create` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterCreate` | 20 |
+| `sirsoft-board.post.after_create` | action (미선언) | `BoardPostsCountSyncListener` | `syncPostsCount` | 10 |
+| `sirsoft-board.post.after_create` | action (미선언) | `PostReplySyncListener` | `syncRepliesCount` | 10 |
+| `sirsoft-board.post.after_create` | action (미선언) | `SeoBoardCacheListener` | `onPostCreate` | 20 |
+| `sirsoft-board.post.after_delete` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterDelete` | 20 |
+| `sirsoft-board.post.after_delete` | action (미선언) | `BoardPostsCountSyncListener` | `syncPostsCount` | 10 |
+| `sirsoft-board.post.after_delete` | action (미선언) | `PostReplySyncListener` | `syncRepliesCount` | 10 |
+| `sirsoft-board.post.after_delete` | action (미선언) | `SeoBoardCacheListener` | `onPostDelete` | 20 |
+| `sirsoft-board.post.after_restore` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterRestore` | 20 |
+| `sirsoft-board.post.after_restore` | action (미선언) | `BoardPostsCountSyncListener` | `syncPostsCount` | 10 |
+| `sirsoft-board.post.after_restore` | action (미선언) | `PostReplySyncListener` | `syncRepliesCount` | 10 |
+| `sirsoft-board.post.after_update` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterUpdate` | 20 |
+| `sirsoft-board.post.after_update` | action (미선언) | `SeoBoardCacheListener` | `onPostUpdate` | 20 |
+| `sirsoft-board.report.after_blind_content` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterBlindContent` | 20 |
+| `sirsoft-board.report.after_bulk_update_status` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterBulkUpdateStatus` | 20 |
+| `sirsoft-board.report.after_create` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterCreate` | 20 |
+| `sirsoft-board.report.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterDelete` | 20 |
+| `sirsoft-board.report.after_delete_content` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterDeleteContent` | 20 |
+| `sirsoft-board.report.after_restore_content` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterRestoreContent` | 20 |
+| `sirsoft-board.report.after_update_status` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterUpdateStatus` | 20 |
+| `sirsoft-board.settings.after_bulk_apply` | action (미선언) | `BoardActivityLogListener` | `handleSettingsAfterBulkApply` | 20 |
+| `sirsoft-board.settings.after_bulk_apply` | action (미선언) | `SeoBoardSettingsCacheListener` | `onBulkApply` | 20 |
+| `sirsoft-board.settings.after_bulk_apply_aborted` | action (미선언) | `BoardActivityLogListener` | `handleSettingsAfterBulkApplyAborted` | 20 |
+| `sirsoft-ckeditor5.image.filter_reference_sources` | filter | `Ckeditor5ReferenceSourcesListener` | `addBoardSources` | 10 |
+| `sirsoft-ecommerce.inquiry.count_replies` | filter | `EcommerceInquiryHookListener` | `countReplies` | 10 |
+| `sirsoft-ecommerce.inquiry.create` | filter | `EcommerceInquiryHookListener` | `createAndReturn` | 10 |
+| `sirsoft-ecommerce.inquiry.delete` | filter | `EcommerceInquiryHookListener` | `deletePost` | 10 |
+| `sirsoft-ecommerce.inquiry.delete_reply` | filter | `EcommerceInquiryHookListener` | `deleteReplyPost` | 10 |
+| `sirsoft-ecommerce.inquiry.get_by_ids` | filter | `EcommerceInquiryHookListener` | `getByIds` | 10 |
+| `sirsoft-ecommerce.inquiry.get_settings` | filter | `EcommerceInquiryHookListener` | `getBoardSettings` | 10 |
+| `sirsoft-ecommerce.inquiry.update` | filter | `EcommerceInquiryHookListener` | `updatePost` | 10 |
+| `sirsoft-ecommerce.inquiry.update_reply` | filter | `EcommerceInquiryHookListener` | `updateReplyPost` | 10 |
+
+
+
+`core.user.*` 4종을 구독하는 이유는 회원가입/수정 화면에 "댓글 알림 수신 여부" 필드를 끼워
+넣기 위해서입니다 — 이 필드는 `UserNotificationSetting` 모델(board 소유)에 저장되지만, 입력
+자체는 코어 회원 폼에서 받습니다. `sirsoft-ckeditor5.image.filter_reference_sources` 구독은
+board 글 본문(HTML 에디터)에 삽입된 이미지가 삭제 시 함께 정리되도록 참조 소스 목록에 게시글을
+등록하는 자리입니다. `sirsoft-ecommerce.inquiry.*` 8개는 이커머스 "상품 문의"가 board 의
+Post/Comment CRUD 를 그대로 재사용하되 저장 로직만 이커머스가 대신 처리하는 위임 지점입니다.
+
+
+## 훅 리스너
+
+
+| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
+|---|---|---|---|---|
+| `ActivityLogDescriptionResolver` | 1개 | 명시 등록 | ✅ | `src/Listeners/ActivityLogDescriptionResolver.php` |
+| `BoardActivityLogListener` | 30개 | 명시 등록 | ✅ | `src/Listeners/BoardActivityLogListener.php` |
+| `BoardCommentsCountSyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/BoardCommentsCountSyncListener.php` |
+| `BoardNotificationChannelListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/BoardNotificationChannelListener.php` |
+| `BoardNotificationDataListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/BoardNotificationDataListener.php` |
+| `BoardPostsCountSyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/BoardPostsCountSyncListener.php` |
+| `Ckeditor5ReferenceSourcesListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/Ckeditor5ReferenceSourcesListener.php` |
+| `CommentReplySyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/CommentReplySyncListener.php` |
+| `EcommerceInquiryHookListener` | 8개 | 명시 등록 | ✅ | `src/Listeners/EcommerceInquiryHookListener.php` |
+| `PostAttachmentCountSyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/PostAttachmentCountSyncListener.php` |
+| `PostCountSyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/PostCountSyncListener.php` |
+| `PostReplySyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/PostReplySyncListener.php` |
+| `SearchPostsListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/SearchPostsListener.php` |
+| `SeoBoardCacheListener` | 4개 | 명시 등록 | ✅ | `src/Listeners/SeoBoardCacheListener.php` |
+| `SeoBoardSettingsCacheListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/SeoBoardSettingsCacheListener.php` |
+| `UserNotificationSettingsListener` | 7개 | 명시 등록 | ✅ | `src/Listeners/UserNotificationSettingsListener.php` |
+
+
+
+`BoardActivityLogListener` 하나가 30개 훅을 구독하는 것이 의도된 형태입니다 — 활동 로그는
+"무엇이 언제 왜 바뀌었는가"를 도메인 전체에서 일관된 형식으로 남겨야 하므로, 도메인별로
+리스너를 쪼개면 로그 스키마가 갈라질 위험이 커집니다. 반대로 카운트 동기화(`*CountSyncListener`)
+는 목적이 하나씩이라 도메인별로 쪼개져 있습니다 — 첨부 개수와 댓글 개수는 서로 독립적으로
+실패해도 되므로, 한쪽이 예외를 던져도 다른 쪽 동기화는 영향받지 않습니다.
+
+
+## 레이아웃 확장
+
+
+| 대상 | 설명 |
+|---|---|
+| `resources/extensions/admin-ecommerce-inquiry-settings.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/admin_dashboard_community.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/admin_dashboard_quick_menu.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/user-notification-detail.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/user-notification-settings.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+
+
+
+5개 조각 중 `admin-ecommerce-inquiry-settings.json`·`admin_dashboard_community.json`·
+`admin_dashboard_quick_menu.json` 은 board 자신의 화면이 아니라 **다른 확장(이커머스 문의
+설정 화면, 관리자 대시보드)에** 게시판 관련 UI 를 끼워 넣는 조각입니다. 이 모듈이 다른 확장의
+레이아웃을 코드로 알지 못한 채(레이아웃 확장 시스템을 통해서만) UI 를 주입한다는 뜻입니다.
+나머지 2개(`user-notification-*`)는 코어 회원 알림 설정 화면에 board 알림 수신 옵션을
+끼워 넣는 자리입니다.
+
+
+## 미들웨어
+
+
+_등록하는 미들웨어가 없습니다._
+
+
+
+공개 API 라우트(`optional.sanctum`)와 관리자 API 라우트(코어 `auth`+권한 미들웨어)는 전부
+코어가 이미 등록한 미들웨어로 충분합니다. board 만의 요청 전처리(예: 게시판별 rate limit)가
+필요해지면 이 자리에 선언형으로 추가하되, 대상(targets)을 명시해 자기 라우트에만 부착합니다.
+
+
+## 브로드캐스트 채널
+
+
+_등록하는 브로드캐스트 채널이 없습니다._
+
+
+
+실시간 갱신(새 댓글이 열려 있는 화면에 즉시 반영되는 등)은 이 모듈의 범위 밖입니다(§1 참고).
+필요해지면 `sirsoft-board.{slug}.*` 채널을 신설하되, 게시판별로 채널을 분리해야 방문자가
+관심 없는 다른 게시판의 이벤트까지 구독하지 않습니다.
+
+
+## 스케줄
+
+
+| 스케줄 | 주기 | 설명 |
+|---|---|---|
+| `sirsoft-board:aggregate-stats` | `-` | 대시보드 게시물 현황 집계 |
+| `sirsoft-board:prune-attachments --scheduled` | `-` | 방치된 임시 첨부 정리 + 보존기간 경과 삭제 첨부 영구 정리 |
+
+
+
+`prune-attachments` 는 두 가지 서로 다른 작업을 한 스케줄에 묶습니다 — "방치된 임시 첨부
+정리"(업로드했지만 게시글 저장까지 이어지지 않은 파일)는 사용자 파일을 지우지 않으므로 항상
+실행되고, "보존기간 경과 삭제 첨부 영구 정리"(이미 삭제 처리된 첨부의 실제 파일 파기)는
+`attachment_settings.purge_enabled` 로 게이트됩니다 — module.php 의 `enabled_config: null` 은
+스케줄 자체는 끌 수 없다는 뜻이고, 실제 파기 여부만 설정으로 조정됩니다.
+
+
+## 알림 정의
+
+
+| 알림 키 | 채널 |
+|---|---|
+| `new_comment` | `mail`, `database` |
+| `reply_comment` | `mail`, `database` |
+| `post_reply` | `mail`, `database` |
+| `post_action` | `mail`, `database` |
+| `new_post_admin` | `mail`, `database` |
+| `report_received_admin` | `mail`, `database` |
+| `report_action` | `mail`, `database` |
+
+
+
+7종 중 `new_comment`/`reply_comment`/`post_reply` 는 회원이 끌 수 있습니다
+(`UserNotificationSetting`, `core.user.*` 훅으로 회원 폼에 노출) — 반면 관리자 대상 알림
+(`new_post_admin`/`report_received_admin`)과 신고 처리 결과 알림(`post_action`/`report_action`)
+은 끌 수 없습니다. `post_action`과 `report_action`이 정확히 같은 훅 6개를 구독하는 것은
+중복이 아니라 **관점의 차이**입니다 — 관리자가 직접 블라인드했는지, 신고 처리 결과로
+블라인드됐는지에 따라 원 작성자에게 보이는 문구(원인 설명)가 갈라져야 하기 때문입니다.
+
diff --git a/modules/_bundled/sirsoft-board/docs/frontend.md b/modules/_bundled/sirsoft-board/docs/frontend.md
new file mode 100644
index 00000000..6efd4025
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/docs/frontend.md
@@ -0,0 +1,121 @@
+# 게시판 — 프론트엔드
+
+> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 레이아웃
+
+
+레이아웃 46개 (루트: `resources/layouts`).
+
+| 그룹 | 개수 |
+|---|---|
+| `admin` | 46개 |
+
+| 레이아웃 | 그룹 | 종류 | extends |
+|---|---|---|---|
+| `admin_board_form` | `admin` | 화면 | `_admin_base` |
+| `admin_board_index` | `admin` | 화면 | `_admin_base` |
+| `admin_board_post_detail` | `admin` | 화면 | `_admin_base` |
+| `admin_board_post_form` | `admin` | 화면 | `_admin_base` |
+| `admin_board_posts_index` | `admin` | 화면 | `_admin_base` |
+| `admin_board_reports_detail` | `admin` | 화면 | `_admin_base` |
+| `admin_board_reports_index` | `admin` | 화면 | `_admin_base` |
+| `admin_board_settings` | `admin` | 화면 | `_admin_base` |
+| `_board_type_manage_modal` | `admin` | partial | - |
+| `_tab_basic` | `admin` | partial | - |
+| `_tab_list` | `admin` | partial | - |
+| `_tab_notification` | `admin` | partial | - |
+| `_tab_permissions` | `admin` | partial | - |
+| `_tab_post` | `admin` | partial | - |
+| `_comment` | `admin` | partial | - |
+| `_comments` | `admin` | partial | - |
+| `_post_card_content` | `admin` | partial | - |
+| `_reply_card_content` | `admin` | partial | - |
+| `_attachments` | `admin` | partial | - |
+| `_form_fields` | `admin` | partial | - |
+| `_parent_post` | `admin` | partial | - |
+| `_alert_status` | `admin` | partial | - |
+| `_card_history` | `admin` | partial | - |
+| `_card_report_info` | `admin` | partial | - |
+| `_bulk_apply_modal` | `admin` | partial | - |
+| `_modal_identity_policy_delete` | `admin` | partial | - |
+| `_modal_identity_policy_form` | `admin` | partial | - |
+| `_modal_mail_template_edit` | `admin` | partial | - |
+| `_modal_notification_definition_reset` | `admin` | partial | - |
+| `_modal_notification_template_edit` | `admin` | partial | - |
+| `_modal_notification_template_preview` | `admin` | partial | - |
+| `_tab_board_settings_attachment` | `admin` | partial | - |
+| `_tab_board_settings_basic` | `admin` | partial | - |
+| `_tab_board_settings_bulk_apply` | `admin` | partial | - |
+| `_tab_board_settings_comment` | `admin` | partial | - |
+| `_tab_board_settings_list` | `admin` | partial | - |
+| `_tab_board_settings_notification` | `admin` | partial | - |
+| `_tab_board_settings_permissions` | `admin` | partial | - |
+| `_tab_board_settings_post` | `admin` | partial | - |
+| `_tab_board_settings_reply` | `admin` | partial | - |
+| `_tab_general` | `admin` | partial | - |
+| `_tab_identity_policies` | `admin` | partial | - |
+| `_tab_notification_definitions` | `admin` | partial | - |
+| `_tab_report_policy` | `admin` | partial | - |
+| `_tab_seo` | `admin` | partial | - |
+| `_tab_spam_security` | `admin` | partial | - |
+
+
+
+46개 레이아웃이 **전부** `admin` 그룹입니다 — 이것은 이 모듈이 방문자 화면을 그리지 않는다는
+증거입니다(§1, §architecture.md 참고). 새로 방문자용 게시판 화면(예: 다른 스타일의 목록)이
+필요하면 이 디렉토리가 아니라 그 화면을 쓸 템플릿의 `layouts/` 에 추가합니다. `_tab_*` partial
+이 24개로 가장 많은 것은 이 모듈에 탭 구조를 쓰는 화면이 최소 세 곳이기 때문입니다 —
+게시판 생성/수정 폼(`_tab_basic`/`list`/`post`/`permissions`/`notification`), 게시판별
+개별 설정 화면(`_tab_board_settings_*` 9개 — attachment/basic/bulk_apply/comment/list/
+notification/permissions/post/reply), 모듈 전역 환경설정 화면(`_tab_general`/
+`identity_policies`/`notification_definitions`/`report_policy`/`seo`/`spam_security`).
+세 화면의 탭 이름이 겹치더라도(`_tab_basic`, `_tab_board_settings_basic` 등) 서로 다른
+partial 파일이므로 한쪽만 고치면 다른 화면은 그대로입니다 — 같은 항목을 여러 화면에
+반영해야 한다면 파일을 전부 찾아 고쳐야 합니다.
+
+
+## 액션 핸들러
+
+
+_등록하는 액션 핸들러가 없습니다._
+
+
+
+이 모듈의 관리자 레이아웃은 코어 빌트인 핸들러(`apiCall`/`navigate`/`setState` 등)만으로
+전부 구성됩니다 — 게시판 CRUD·신고 처리·설정 저장은 결국 REST 호출 + 표준 폼 상태 관리라
+전용 핸들러를 등록할 필요가 없었습니다. 새 관리자 화면을 추가할 때도 먼저 빌트인 핸들러
+조합으로 가능한지 확인하고, 그래도 부족할 때만(예: 파일 업로드 진행률 같은 복잡한 클라이언트
+상태) `resources/js/` 에 전용 핸들러를 신설합니다.
+
+
+## 전역 진입점
+
+
+_프론트 엔트리포인트가 없습니다._
+
+
+
+액션 핸들러가 없는 것과 같은 이유로 `window.__[Name]` 재등록 진입점도 없습니다 — 로케일
+전환 후 재등록해야 할 자체 핸들러가 이 모듈에는 없기 때문입니다. 프론트 전용 코드
+(`resources/js/`)를 신설하면 그 순간부터 이 자리에 진입점을 만들어야 합니다(§CLAUDE.md
+"확장 미들웨어는..." 항목 인근의 재등록 진입점 규정 참고) — 없으면 로케일 전환 후 그 확장의
+액션이 전부 무반응이 됩니다.
+
+
+## 에셋
+
+
+| 경로 | 구분 |
+|---|---|
+| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
+
+로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
+
+
+
+`editor-spec.json` 이 이 모듈의 유일한 프론트 자산인 것도 위와 같은 이유입니다 — 레이아웃
+편집기가 게시판 관리 화면의 컴포넌트를 인식하려면 이 선언이 필요하지만, 실행 시점에 로드할
+JS/CSS 번들은 없습니다. `priority: 100` 은 다른 확장의 에셋 우선순위와 충돌하지 않는 기본값이며,
+`dependencies: []` 는 이 확장의 에디터 스펙이 다른 확장의 스펙 로드를 전제하지 않는다는 뜻입니다.
+
diff --git a/modules/_bundled/sirsoft-board/docs/settings.md b/modules/_bundled/sirsoft-board/docs/settings.md
new file mode 100644
index 00000000..da7c4889
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/docs/settings.md
@@ -0,0 +1,98 @@
+# 게시판 — 설정·권한·라우트
+
+> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 설정 스키마
+
+
+_`getSettingsSchema()` 선언이 없습니다._
+
+기본값 파일: `config/settings/defaults.json`
+
+
+
+`getSettingsSchema()` 가 없는 것은 누락이 아니라 설계입니다 — 이 모듈에는 "전역 설정"이라
+부를 만한 것이 없습니다. 운영자가 조정하는 값은 전부 **게시판 하나**에 속한 설정
+(`BoardSettingsService`, `/admin/boards/{slug}/settings`)이라 코어의 전역 설정 스키마
+메커니즘과 맞지 않습니다. `config/board.php` 는 운영자가 바꾸는 자리가 아니라 개발자가
+정의하는 상수(첨부 저장 디스크, 게시판별 동적 권한 정의 템플릿)이며, 이 값은 `.env` 로만
+바꿉니다.
+
+
+## 권한
+
+
+| 카테고리 | 이름 | 액션 | 라우트 키 |
+|---|---|---|---|
+| `boards` | 게시판 관리 | `read`, `create`, `update`, `delete` | `board` |
+| `settings` | 환경설정 | `read`, `update` | - |
+| `identity.policies` | 게시판 본인인증 정책 | `read`, `update` | - |
+| `dashboard` | 게시판 대시보드 | `view` | - |
+| `reports` | 게시판 신고 관리 | `view`, `manage` | `report` |
+
+
+
+위 표는 **모듈 레벨** 권한(게시판 관리 자체를 다루는 관리자 권한)만 보여줍니다. 게시판 하나를
+만들면 그 게시판 전용 권한이 `config/board.php` 의 `board_permission_definitions` 템플릿을
+기반으로 추가 생성됩니다(admin.posts.read/write, posts.read-secret 등 — 게시판마다 독립적인
+권한 묶음). 그 동적 권한은 이 표에 나타나지 않으며 `getDynamicPermissionIdentifiers()` 로만
+전수를 확인할 수 있습니다. `boards`/`reports` 카테고리에 `resource_route_key`/`owner_key` 가
+붙어 있는 것은 소유자 기반 스코프 판정(자기 글만 관리 가능한 `manager` 이하 역할 등)이 걸려
+있다는 뜻입니다.
+
+
+## 메뉴
+
+
+| 구분 | slug | 이름 | URL | 하위 |
+|---|---|---|---|---|
+| 관리자 | `sirsoft-board` | 게시판 관리 | - | 3개 |
+
+
+
+정적 관리자 메뉴는 "게시판 관리" 3개 하위 메뉴(환경설정/목록/신고현황)뿐입니다. 게시판을
+만들 때마다 생기는 `board-{slug}` 메뉴는 동적 메뉴라 이 표에 없으며
+`getDynamicMenuSlugs()` 로 전수를 확인합니다. 방문자용 메뉴(사이트 상단 게시판 링크 등)는
+이 모듈이 등록하지 않습니다 — 템플릿이 공개 API(`boards.board-menu`)를 호출해 직접 구성합니다.
+
+
+## 라우트
+
+
+| 종류 | 파일 | URL prefix |
+|---|---|---|
+| `api` | `src/routes/api.php` | `/api/modules/sirsoft-board/...` |
+
+확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
+
+
+
+같은 `src/routes/api.php` 파일 안에 관리자 전용 그룹(`/admin/board/{slug}/...`, 권한 미들웨어)과
+공개 그룹(`/boards/...`, `optional.sanctum`)이 함께 있습니다 — 파일을 분리하지 않은 것은
+board 의 라우트가 20개 안팎으로 한 파일에서 관리 가능한 규모이기 때문입니다. 새 공개
+엔드포인트를 추가할 때는 반드시 `optional.sanctum`(비회원도 접근 가능, 회원이면 컨텍스트
+주입)을 쓰고 `auth:sanctum` 을 쓰지 않습니다 — 게시판 열람은 비회원에게도 열려 있어야 합니다.
+
+
+## 의존 관계
+
+
+**이 확장이 의존하는 확장**
+
+없음 — 코어만으로 동작합니다.
+
+**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
+
+| 확장 | 유형 | 요구 버전 |
+|---|---|---|
+| `sirsoft-basic` | 템플릿 | `>=1.0.0` |
+
+
+
+이 모듈이 의존하는 확장이 "없음"인 것은 board 가 코어 훅·API 만으로 완결되도록 설계됐다는
+뜻입니다. 반대로 이 모듈에 의존하는 쪽은 하나(템플릿)뿐이지만, 그보다 결합이 느슨한
+**필터 훅 위임** 소비자(이커머스 문의)는 `dependencies` 로 선언되지 않습니다 — 이커머스는
+board 를 자기 도메인으로 대체할 뿐 board API 계약에 실제로 묶여 있지 않기 때문입니다.
+이 모듈의 공개 표면(라우트·API 응답 구조)을 바꿀 때는 `sirsoft-basic` 의 최소 버전 상향을
+검토해야 합니다(§CLAUDE.md "확장 → 확장 동기화").
+
diff --git a/plugins/_bundled/sirsoft-gdpr/AGENTS.md b/plugins/_bundled/sirsoft-gdpr/AGENTS.md
new file mode 100644
index 00000000..84ba34ca
--- /dev/null
+++ b/plugins/_bundled/sirsoft-gdpr/AGENTS.md
@@ -0,0 +1,184 @@
+# GDPR (일반 데이터 보호 규정) — 에이전트 가이드
+
+> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
+
+## TL;DR (5초 요약)
+
+```text
+1. 유형: 플러그인 (sirsoft-gdpr) — 쿠키 동의 배너·동의 이력·GDPR/개인정보보호법 대응을 소유
+2. 확장 방식: `sirsoft-gdpr.consent.granted`/`revoked` 훅 구독, `data-gdpr-category` HTML 속성으로 자체 호스팅 자원 등록
+3. 건드리면 안 되는 것: `CookieConsentMiddleware`(functional 미동의 시 Set-Cookie 게이팅)의 strictly-necessary allowlist, 동의 이력(immutable append-only) 직접 수정
+4. 작업 위치: `plugins/_bundled/sirsoft-gdpr` — 활성 디렉토리 직접 수정 금지
+5. 반영: `php artisan plugin:update sirsoft-gdpr --force`
+```
+
+## 1. 이 확장은 무엇인가
+
+
+GDPR(EU) 및 한국 개인정보보호법이 요구하는 "동의 전 처리 금지" 원칙을 서버·클라이언트 양쪽에서
+강제하는 플러그인입니다. 두 계층이 서로 다른 것을 막습니다 — 서버 계층(`CookieConsentMiddleware`)
+은 백엔드가 심으려는 Set-Cookie 헤더를, 클라이언트 계층(자동 차단 스크립트)은 외부 추적
+스크립트·iframe·1st-party 저장소(localStorage/sessionStorage) 접근을 각각 동의 전까지 막습니다.
+"동의했다"는 사실 자체도 상태(`gdpr_user_consents`, mutable)와 이력(`gdpr_user_consent_histories`,
+immutable append-only)으로 이중 기록합니다 — 지금 상태 조회와 "언제 무엇에 동의했었는가" 입증
+(Art.7(1))은 서로 다른 질문이라 하나로 합칠 수 없습니다.
+
+**설계 원칙**: 정책 버전 발행은 수동입니다 — 정책 본문이 바뀌었다고 자동으로 전 회원 재동의를
+트리거하지 않습니다. 운영자가 "이 변경이 재동의가 필요한 변경인가"를 판단해 명시적으로 발행
+버튼을 눌러야 합니다(README "사용 방법" 표 참고). 자동화하면 사소한 오탈자 수정에도 전 회원이
+재동의 화면을 보게 되어 UX 를 해칩니다.
+
+**의도적으로 하지 않는 것**: 게스트 → 회원 동의 자동 승계(§README 소개 참고), 그리고 운영자가
+등록한 "허용" functional 쿠키 화이트리스트도 두지 않습니다 — functional 미동의 시 strictly
+necessary 4종(`XSRF-TOKEN`/세션/`laravel_maintenance`/`gdpr_session`)을 제외한 **모든** 쿠키를
+차단합니다. EDPB Guidelines 2/2023 §16 원칙이 "비필수는 동의 전 전면 차단"이지 "등록된 것만
+차단"이 아니기 때문입니다.
+
+
+## 2. 디렉토리 지도
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
+| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
+| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
+| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
+| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
+| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
+| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
+| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
+| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
+| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
+| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
+| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
+| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
+| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-gdpr --force` (빌드 불필요) |
+| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan plugin:update sirsoft-gdpr --force` |
+| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-gdpr --force` |
+| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-gdpr --force` |
+| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-gdpr --force` |
+| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
+| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-gdpr --force` |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
+
+
+## 3. 핵심 흐름
+
+
+**동의 부여**: `Public\GdprCookieConsentController` → `GdprConsentService::grantConsent()` —
+회원이면 `user_id`, 게스트면 서명된 `gdpr_session` 쿠키로 식별한 뒤 `GdprUserConsent`(현재
+상태, upsert)와 `GdprUserConsentHistory`(이력, insert-only) 를 같은 트랜잭션에서 함께 기록하고
+`sirsoft-gdpr.consent.granted` 훅을 발행합니다. 이 흐름은 배너·마이페이지·회원가입·전체
+재동의 4개 진입점이 전부 공유합니다 — 진입점마다 다른 저장 로직을 만들지 않습니다.
+
+**요청마다 반복되는 게이팅**: `CookieConsentMiddleware`(web/api 그룹에 prepend)가 응답 직전
+`GdprConsentService::getCurrentCookieConsents()` 로 현재 functional 동의 여부를 조회하고,
+미동의면 strictly-necessary 4종을 제외한 모든 Set-Cookie 헤더를 응답에서 제거합니다. 이
+서버측 게이팅과 별개로, 클라이언트에서는 `data-gdpr-category` 속성이 붙은 스크립트/iframe 과
+분석/마케팅 카테고리 도메인 매칭 리소스가 동의 전까지 로드되지 않습니다.
+
+**회원탈퇴와 완전삭제는 다른 훅, 다른 처리**입니다 — `GdprUserWithdrawListener`
+(`core.user.after_withdraw`)는 코어가 user 행 자체를 보존하는 "탈퇴"에 반응해 활성 동의를
+전부 철회 처리(UPDATE + `source=withdraw` revoked 이력 INSERT)할 뿐 신원 정보는 그대로
+남깁니다 — 탈퇴는 "의사 표시 종료"이지 신원 삭제가 아니기 때문입니다. 반대로
+`GdprUserDeleteListener`(`core.user.before_delete`, 완전 삭제/hard delete)는 이력 행의
+`user_id`/IP/User-Agent 만 NULL 로 **익명화**하고 행 자체는 남깁니다(Art.17 삭제권과 Art.7(1)
+입증 의무 양립). 두 훅을 헷갈리면 탈퇴 시점에 신원이 조기 삭제되거나, 완전삭제 시점에 입증
+자료 행까지 통째로 사라집니다.
+
+
+## 4. 확장점
+
+
+| 확장점 | 수 | 상세 |
+|---|---|---|
+| 발행 훅 | 2개 | [발행 훅](docs/extension-points.md#발행-훅) |
+| 구독 훅 | 4개 | [구독 훅](docs/extension-points.md#구독-훅) |
+| 훅 리스너 | 4개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
+| 레이아웃 확장 | 2개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
+| 미들웨어 | 1개 | [미들웨어](docs/extension-points.md#미들웨어) |
+| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
+| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
+| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
+
+
+
+다른 확장이 이 플러그인 없이는 몰랐을 사실(방문자가 방금 분석 카테고리에 동의/철회했다)을
+알아야 할 때 `sirsoft-gdpr.consent.granted`/`revoked` 훅을 잡습니다 — 예: 분석 SDK 초기화를
+"페이지 로드 시 무조건"이 아니라 "동의 부여 시에만" 하고 싶은 확장. 반대로 자체 호스팅
+추적 자원을 이 플러그인의 자동 차단·복원 대상에 포함시키고 싶다면 훅이 아니라 HTML 속성
+(`data-gdpr-category="analytics"`)을 붙이는 쪽이 맞습니다 — 이 플러그인이 그 속성을 스캔해
+차단/복원을 대신 수행하므로 소비 측 코드가 필요 없습니다.
+
+
+## 5. 수정 시 동반 의무
+
+- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-gdpr --force` 로 반영
+- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
+- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝
+- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
+- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-gdpr` 재실행 + `docs/api/**` 갱신
+- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
+- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
+- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
+- [ ] `gdpr_user_consent_histories` 는 append-only — UPDATE/DELETE 로 기존 행을 고치지 않는다 (완전삭제 시 익명화 UPDATE 예외는 `GdprUserDeleteListener` 단일 지점에서만 수행)
+- [ ] 새 자동 차단 카테고리(기능/분석/마케팅 외)를 추가하면 배너 UI·`blocked_domains` 스키마·차단 스크립트 3곳 동기화
+- [ ] `CookieConsentMiddleware` 의 strictly-necessary allowlist(4종)를 확장할 때는 ePrivacy Art.5(3) 면제 항목인지 먼저 검토 — 임의로 늘리면 동의 전 차단 원칙이 무력화된다
+
+## 6. 금지 패턴
+
+
+| 금지 | 올바른 사용 | 이유 |
+|---|---|---|
+| `gdpr_user_consent_histories` 행을 UPDATE/DELETE 로 직접 정정 | 정정이 필요하면 새 이력 행을 INSERT | 이력은 시점별 스냅샷이 생명 — 과거 행을 고치면 Art.7(1) 입증 자료로서 효력을 잃는다 |
+| 회원탈퇴(`after_withdraw`)에서 신원 정보(user_id 등)를 제거 | 활성 동의만 철회 처리, 신원은 완전삭제(`before_delete`) 시점에만 익명화 | 두 이벤트를 섞으면 탈퇴 회원의 재가입·이력 조회가 깨진다 |
+| 운영자가 등록하지 않은 functional 쿠키를 화이트리스트에 추가 | strictly necessary 4종 고정 목록만 예외 | GDPR 은 "동의 전 전면 차단"이 원칙이지 "등록된 것만 차단"이 아니다 |
+| 정책 버전 발행을 코드/배치로 자동화 | 운영자가 매번 명시적으로 "+ 새 버전 발행" 클릭 | 자동화하면 사소한 문구 수정에도 전 회원이 재동의 화면을 보게 된다 |
+
+
+## 7. 테스트 실행
+
+
+| 종류 | 개수 | 위치 |
+|---|---|---|
+| PHPUnit | 24개 | `plugins/_bundled/sirsoft-gdpr/tests` |
+| Vitest | 12개 | `vitest.config.ts` |
+| Playwright | 3개 | `tests/Playwright` |
+| 시나리오 매니페스트 | 5개 | `tests/scenarios` |
+
+기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
+
+```bash
+# PHPUnit (변경 범위만) (Bash)
+php vendor/bin/phpunit plugins/_bundled/sirsoft-gdpr/tests --filter='<대상클래스>'
+
+# Vitest (확장 디렉토리에서) (PowerShell)
+cd plugins/_bundled/sirsoft-gdpr && powershell -Command "npm run test:run -- <대상>"
+
+# Playwright E2E (Bash)
+npx playwright test plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/<대상>.spec.ts
+
+```
+
+무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
+
+
+## 8. 문서 목차
+
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
+| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
+| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
+| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md
index 255bdaa4..3751f453 100644
--- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-gdpr/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.4] - 2026-08-31
+
+### Fixed
+
+- 회원탈퇴로 자동 철회된 동의 이력이 관리자 「GDPR 동의 이력」 화면의 출처 필터로 걸러지지 않던 문제를 수정했습니다.
+
## [1.0.3] - 2026-08-19
### Fixed
diff --git a/plugins/_bundled/sirsoft-gdpr/README.md b/plugins/_bundled/sirsoft-gdpr/README.md
index 729a99b7..bf22c348 100644
--- a/plugins/_bundled/sirsoft-gdpr/README.md
+++ b/plugins/_bundled/sirsoft-gdpr/README.md
@@ -1,151 +1,209 @@
-# GDPR Plugin for G7
+# GDPR
-GDPR(유럽 일반 데이터 보호 규정) 및 한국 개인정보보호법 대응 핵심 기능을 제공하는 G7 플러그인입니다. 쿠키 동의 배너·동의 전 자동 차단·동의 이력 영구 저장·마이페이지 동의 철회를 한 패키지로 제공합니다.
+**G7 플러그인 · sirsoft-gdpr**
+GDPR·개인정보보호법 대응 쿠키 동의 배너와 동의 이력 관리를 제공하는 플러그인
-## 핵심 기능
+
+
+
+
+
+
+
+
-| 기능 | 설명 |
-|------|------|
-| 쿠키 동의 배너 | 필수/기능/분석/마케팅 4분류 (ICO·CNIL 권장 표준), 다크 모드, 4 위치(하단 바·좌하단·우하단·중앙 모달) |
-| 동의 전 자동 차단 | 외부 추적 스크립트·iframe + 기능 카테고리 1st-party 저장소(localStorage·sessionStorage·1st-party 쿠키) 게이팅 |
-| 동의 이력 저장 | 정책 버전·출처·카테고리 스냅샷을 함께 immutable 보존 (GDPR Art.7(1) 입증 자료) |
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+GDPR(유럽 일반 데이터 보호 규정) 및 한국 개인정보보호법 대응 핵심 기능을 제공하는 플러그인입니다.
+쿠키 동의 배너·동의 전 자동 차단·동의 이력 영구 보존·마이페이지 동의 철회를 한 패키지로
+제공합니다.
+
+이 플러그인이 의도적으로 하지 않는 것 하나는 **게스트 → 회원 동의 자동 승계**입니다. GDPR
+Art.6/ePrivacy Art.5(3) 관점에서 게스트(디바이스 단위)와 회원(주체 단위)은 별도 동의 모델이며,
+회원가입 폼 동의로 Art.7(1) 입증 책임이 별도로 충족됩니다. 게스트 시절 동의 이력은 세션 기준
+으로 보존될 뿐 회원 계정과 자동으로 이어붙지 않습니다.
+
+
+## 주요 기능
+
+
+| 영역 | 설명 |
+|---|---|
+| 쿠키 동의 배너 | 필수/기능/분석/마케팅 4분류(ICO·CNIL 권장 표준), 다크 모드, 4가지 위치(하단 바·좌하단·우하단·중앙 모달) |
+| 동의 전 자동 차단 | 외부 추적 스크립트·iframe + 기능 카테고리 1st-party 저장소(localStorage·sessionStorage·쿠키) 게이팅 |
+| 동의 이력 저장 | 정책 버전·출처·카테고리 스냅샷을 함께 immutable 보존(GDPR Art.7(1) 입증 자료) |
| 마이페이지 동의 관리 | 회원이 자신의 동의 현황을 조회·개별 철회·재동의·전체 일괄 재동의 |
-| 관리자 동의 이력 조회 | 회원/게스트 동의 변경 이력 (이메일·세션 검색, 카테고리·출처 다중 필터, 카테고리 스냅샷 표) |
-| 정책 버전 수동 발행 | 정책 본문 변경 시 「+ 새 버전 발행」 클릭으로 모든 회원 재동의 트리거 |
+| 관리자 동의 이력 조회 | 회원/게스트 동의 변경 이력(이메일·세션 검색, 카테고리·출처 다중 필터, 카테고리 스냅샷 표) |
+| 정책 버전 발행 | 정책 본문 변경 시 수동 발행으로 모든 회원에게 재동의를 트리거 |
+
+
+## 동작 방식
+
+
+```mermaid
+flowchart LR
+ V[방문자] -->|첫 방문| Banner[쿠키 배너 노출]
+ Banner -->|동의 전| Block[외부 스크립트·iframe·1st-party 저장소 자동 차단]
+ Banner -->|동의| Grant[GdprUserConsent 저장 + 훅 발행]
+ Grant --> Restore[차단 해제·스크립트 로드]
+ Grant --> History[(동의 이력 append-only 보존)]
+
+ Admin[운영자] -->|정책 본문 변경| Publish[새 정책 버전 발행]
+ Publish -->|다음 방문| Renew[모든 회원 재동의 안내]
+```
+
+동의는 부여든 철회든 상태 저장(`gdpr_user_consents`, mutable)과 이력 기록
+(`gdpr_user_consent_histories`, immutable append-only)이 항상 함께 일어납니다 — "지금 동의
+상태가 무엇인가"와 "언제 무엇에 동의했었는가"를 구분해서 답할 수 있어야 하기 때문입니다.
+
+
+## 요구 사항
+
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=7.0.6` |
+| PHP | `^8.2` |
+
## 설치
+
```bash
+# 번들 설치 (코어에 동봉된 소스에서 설치)
php artisan plugin:install sirsoft-gdpr
+
+# 활성화
php artisan plugin:activate sirsoft-gdpr
+
+# 업데이트 (번들 소스 기준 강제 반영)
+php artisan plugin:update sirsoft-gdpr --force
```
-설치 직후 쿠키 배너는 **비활성** 상태로 제공됩니다. 운영자가 운영 주체명·데이터 저장 위치·정책 페이지 슬러그를 입력한 뒤 「쿠키 배너 노출」 토글을 켜야 사이트에 노출됩니다.
+저장소: https://github.com/gnuboard/g7-plugin-sirsoft-gdpr
+
-## 설정
+설치 직후 쿠키 배너는 **비활성** 상태입니다. 운영자가 운영 주체명·데이터 저장 위치·정책 페이지
+슬러그를 입력한 뒤 "쿠키 배너 노출" 토글을 켜야 사이트에 노출됩니다.
-`관리자 → 플러그인 → GDPR 설정` 에서 구성합니다.
+## 관리자 설정
-### 운영 정보
+
+| 키 | 의미 | 기본값 |
+|---|---|---|
+| `privacy_policy_slug` | 개인정보처리방침 페이지 슬러그 | `privacy` |
+| `legal_entity_name` | 운영 주체명 | - |
+| `data_storage_location` | 데이터 저장 위치 | - |
+| `banner_enabled` | 쿠키 배너 노출 | `true` |
+| `banner_position` | 배너 위치 | `bottom_bar` |
+| `blocked_domains` | 추적 도메인 차단 목록 | `{"functional":["*.crisp.chat","client.crisp.chat","*.intercom.io","widget.intercom.io","*.tawk.to","embed.tawk.to","cdn.weglot.com","*.weglot.com","*.usercentrics.eu"],"analytics":["google-analytics.com","*.google-analytics.com","googletagmanager.com","*.googletagmanager.com","ssl.google-analytics.com","*.hotjar.com","static.hotjar.com","*.mixpanel.com","cdn.mxpnl.com","*.amplitude.com","cdn.amplitude.com","*.segment.io","*.segment.com","wcs.naver.net","wcs.naver.com","*.beusable.net"],"marketing":["facebook.net","connect.facebook.net","facebook.com","*.facebook.com","doubleclick.net","*.doubleclick.net","googleadservices.com","googlesyndication.com","ads.google.com","*.criteo.com","static.criteo.net","*.adnxs.com","*.taboola.com","cdn.taboola.com","*.outbrain.com","*.kakao.com","analytics.ad.daum.net","platform.twitter.com","*.twitter.com","platform.linkedin.com","*.linkedin.com"]}` |
+| `cookie_categories` | 쿠키 카테고리 정의 | `[]` |
-| 항목 | 설명 |
-|------|------|
-| 운영 주체명 (`legal_entity_name`) | 쿠키 배너 푸터·마이페이지 동의 카드에 노출되는 사이트 운영 주체 (예: "(주)홍길동컴퍼니") |
-| 데이터 저장 위치 (`data_storage_location`) | 사용자에게 안내할 데이터 저장 국가 (예: "대한민국", "미국 (AWS)"). IP 주소·CIDR·클라우드 리전 코드(예: `ap-northeast-2`)는 보안상 자동 거부됩니다 |
-| 개인정보처리방침 슬러그 (`privacy_policy_slug`) | sirsoft-page 플러그인에 등록된 처리방침 페이지의 슬러그. 비어있으면 쿠키 배너의 정책 링크가 자동 숨겨집니다 |
+개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
+
-### 쿠키 배너
+
+`쿠키 배너 노출` 은 마스터 토글입니다 — ON 하면 배너와 자동 차단이 **함께** 켜집니다. GDPR
+Art.6 "동의 전 처리 금지"를 강제하는 메커니즘인 자동 차단만 단독으로 끌 수 없도록 의도적으로
+하나의 토글에 묶었습니다. 반대로 마이페이지 동의 관리 카드는 이 토글과 무관하게, 동의/철회
+이력이 있는 회원에게는 항상 노출됩니다(Art.7(3) 철회 대칭성 보장 — 동의를 배너로 받았다면
+철회도 언제든 마이페이지에서 가능해야 합니다).
-| 항목 | 설명 |
-|------|------|
-| 쿠키 배너 노출 (`banner_enabled`) | 마스터 토글 — ON 시 배너 + 자동 차단이 함께 활성화됩니다. GDPR Art.6 "동의 전 처리 금지" 의 강제 메커니즘인 자동 차단을 단독 OFF 할 수 없도록 단일 토글로 통합되어 있습니다. 마이페이지 동의 관리 카드는 이 토글과 무관하게 동의/철회 이력이 있는 회원에게 항상 노출됩니다 (Art.7(3) 철회 대칭성 보장) |
-| 배너 위치 (`banner_position`) | 하단 바 / 좌하단 팝업 / 우하단 팝업 / 중앙 모달 |
+데이터 저장 위치(`data_storage_location`)에는 IP 주소·CIDR·클라우드 리전 코드(예:
+`ap-northeast-2`)를 입력할 수 없습니다 — 보안상 자동 거부됩니다. "대한민국", "미국 (AWS)"처럼
+사용자에게 안내할 국가/지역명만 입력합니다.
-### 자동 차단 정책
+자동 차단 대상 도메인은 카테고리(기능/분석/마케팅)별 카탈로그로 관리하며, 기본 카탈로그(예:
+Google Analytics, Facebook Pixel, Kakao Pixel 등)가 시드되어 있고 운영자가 추가·삭제할 수
+있습니다. 도메인 형식은 `example.com` 또는 와일드카드 `*.example.com` 만 지원하며, `localhost`
+같은 단일 라벨과 한글 도메인(xn-- 변환)은 지원하지 않습니다.
+
-쿠키 배너가 ON 일 때 다음 카테고리의 외부 도메인 리소스가 동의 전까지 자동 차단됩니다. 카탈로그 기본값이 시드되어 있으며, 운영자가 카테고리별로 추가·삭제할 수 있습니다.
+## 사용 방법
-| 카테고리 | 기본 카탈로그 예시 |
-|---------|------------------|
-| 기능 (functional) | Crisp, Intercom, Tawk.to, Weglot 등 |
-| 분석 (analytics) | Google Analytics, Hotjar, Mixpanel, 네이버 프리미엄 로그분석 등 |
-| 마케팅 (marketing) | Facebook Pixel, Google Ads, Kakao Pixel, YouTube embed 등 |
-
-도메인 형식: `example.com` 또는 와일드카드 `*.example.com`. `localhost` 같은 단일 라벨, 한글 도메인(xn-- 변환)은 미지원.
-
-## 자체 호스팅 추적 자원 분류
-
-자체 도메인에서 호스팅되는 추적 스크립트·iframe·임베드는 도메인 매칭 대상이 아니므로 HTML 속성으로 분류합니다.
+
+**자체 호스팅 추적 자원 등록**: 자체 도메인에서 서빙하는 추적 스크립트·iframe·임베드는 도메인
+매칭 대상이 아니므로 HTML 속성으로 분류합니다.
```html
```
-동의 전까지 자동 차단되며, 동의 후 자동 복원됩니다. 동의 철회 시 다시 차단됩니다.
+동의 전까지 자동 차단되고, 동의 후 자동 복원되며, 동의 철회 시 다시 차단됩니다.
-## 정책 버전 발행
-
-`관리자 → 플러그인 → GDPR 설정` 의 「정책 버전」 카드에서 「+ 새 버전 발행」 을 클릭합니다. 발행 즉시 모든 회원이 다음 방문 시 재동의 화면(amber 안내 박스 + 「최신 정책으로 갱신」 버튼)을 보게 됩니다.
+**정책 버전 발행**: `관리자 → 플러그인 → GDPR 설정` 의 "정책 버전" 카드에서 "+ 새 버전 발행"을
+누르면 모든 회원이 다음 방문 시 재동의 화면을 보게 됩니다. 아래 기준으로 발행 여부를 판단합니다.
| 발행이 필요한 변경 | 발행이 필요 없는 변경 |
-|------------------|---------------------|
-| 정책 본문 (개인정보처리방침 페이지) 변경 | 차단 도메인 추가/삭제 |
+|---|---|
+| 정책 본문(개인정보처리방침 페이지) 변경 | 차단 도메인 추가/삭제 |
| 카테고리 의미 변경 | UI 라벨/설명 정정 |
| 위탁자·데이터 보관 정보 변경 | 운영 주체명·저장 위치 정정 |
-발행 시 변경 사유 메모를 함께 저장합니다 (GDPR Art.30 처리 기록 의무).
+발행 시 변경 사유 메모를 함께 저장합니다(GDPR Art.30 처리 기록 의무).
-## 동의 이력 조회
+**동의 이력 조회**: `관리자 → GDPR 동의 이력` 메뉴에서 이메일 부분 일치·세션 ID 로 검색하고,
+카테고리·출처(banner/mypage/register/withdraw 등)·동의 액션(granted/revoked)으로 필터링합니다.
+각 행을 펼치면 동의 시점의 전체 카테고리 의사 스냅샷을 확인할 수 있습니다(Art.7(1) 입증 자료).
+
-`관리자 → GDPR 동의 이력` 메뉴에서 회원/게스트의 모든 동의 변경 이력을 조회할 수 있습니다.
+## 다른 확장과의 연동
-- **검색**: 이메일 부분 일치, 세션 ID
-- **필터**: 카테고리(필수/기능/분석/마케팅), 출처(banner/mypage/register/withdraw 등), 동의 액션(granted/revoked)
-- **카테고리 스냅샷**: 각 행 펼침에서 동의 시점의 전체 카테고리 의사 표를 immutable 보존 (GDPR Art.7(1) 입증 자료)
+
+**이 확장이 의존하는 확장**
-## 가용 훅 (Hook)
+없음 — 코어만으로 동작합니다.
-다른 확장에서 본 플러그인의 동의 이벤트를 구독할 수 있습니다.
+**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
-### 액션 훅
+없음.
+
-| 훅 이름 | 시점 | 인수 |
-|---------|------|------|
-| `sirsoft-gdpr.consent.granted` | 동의 부여 시 | `GdprUserConsent $consent, string $source` |
-| `sirsoft-gdpr.consent.revoked` | 동의 철회 시 | `GdprUserConsent $consent, string $source` |
+
+`sirsoft-page` 는 소프트 의존(런타임 체크)입니다 — 미설치 시 쿠키 배너의 "자세히" 정책 링크만
+자동으로 숨겨지고 나머지 기능은 정상 동작합니다. `sirsoft-basic` 템플릿은 배너가 주입되는
+지점(`_user_base.json` 의 공용 확장 지점)을 제공해야 하므로, 다른 사용자 템플릿을 쓰려면
+그 템플릿에도 같은 주입 지점이 있어야 배너가 정상 노출됩니다.
+
-`$source` 값: `banner` / `mypage` / `mypage_renew_all` / `register` / `withdraw`.
+## 문서
-### 훅 등록 예시
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
+| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
+| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
+| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
-```php
-use App\Extension\HookManager;
+## 트러블슈팅
-HookManager::addAction(
- 'sirsoft-gdpr.consent.granted',
- function ($consent, string $source) {
- // 예: 분석 동의 부여 시 외부 분석 도구에 사용자 식별 전송
- if ($consent->consent_key === 'cookie_analytics' && $consent->is_consented) {
- AnalyticsService::identify($consent->user_id);
- }
- },
- priority: 10
-);
-```
+
+| 증상 | 원인 | 조치 |
+|---|---|---|
+| 쿠키 배너 설정을 저장했는데도 배너가 안 뜸 | "쿠키 배너 노출" 마스터 토글이 꺼져 있음 | 관리자 설정에서 토글을 켠다 — 개별 항목 저장만으로는 배너가 켜지지 않는다 |
+| 정책 페이지 링크가 배너에 안 보임 | `privacy_policy_slug` 미설정 또는 `sirsoft-page` 미설치 | 슬러그를 입력하거나, 링크 없이 운영할지 결정한다(자동 숨김은 정상 동작) |
+| 배너에서 동의했는데 마이페이지에 이력이 안 보임 | 게스트 상태에서 동의 후 회원가입한 경우 — 게스트→회원 자동 승계를 제공하지 않음 | 의도된 동작이다(§소개 참고). 회원가입 시 별도 동의를 다시 받는다 |
+| 자체 호스팅 스크립트가 동의 전에도 로드됨 | `data-gdpr-category` 속성 누락 | 스크립트/iframe 태그에 카테고리 속성을 추가한다 |
+
-## 데이터베이스
+## 변경 이력
-| 테이블 | 용도 | 보존 정책 |
-|--------|------|----------|
-| `gdpr_user_consents` | 회원 현재 동의 상태 (mutable) | 사용자 삭제 시 명시 삭제 |
-| `gdpr_user_consent_histories` | 동의 변경 이력 (immutable append-only) | 사용자 삭제 시 `user_id`/IP/UA 만 NULL 익명화하여 행 보존 (GDPR Art.17 + Art.7(1) 양립) |
-| `gdpr_policy_versions` | 정책 버전 발행 이력 (불변) | 영구 보존 (Art.30 처리 기록) |
-
-플러그인 제거 시 위 3 테이블이 자동 DROP 됩니다.
-
-## 게스트 → 회원 동의 승계
-
-본 플러그인은 게스트 → 회원 동의 자동 승계를 제공하지 않습니다. GDPR Art.6/ePrivacy Art.5(3) 관점에서 게스트(디바이스 단위)와 회원(주체 단위)은 별도 동의 모델이며, 글로벌 CMP 대부분도 자동 승계를 기본으로 제공하지 않습니다. 회원가입 폼 동의로 Art.7(1) 입증 책임이 충족되며, 게스트 시절 동의 이력은 세션 기준으로 보존됩니다.
-
-## 의존성
-
-| 대상 | 의존 수준 | 미설치 시 동작 |
-|------|----------|---------------|
-| `sirsoft-page` 모듈 | 소프트 (런타임 체크) | 배너 "자세히" 링크만 자동 숨김. 나머지 정상 |
-| `sirsoft-basic` 템플릿 | 주입 지점 의존 | 다른 사용자 템플릿 사용 시 해당 템플릿에도 `_user_base.json` 의 공용 확장 지점이 있어야 정상 동작 |
-
-## 테스트 실행
-
-```bash
-# 백엔드
-php vendor/bin/phpunit plugins/_bundled/sirsoft-gdpr/tests
-
-# 프론트엔드
-cd plugins/_bundled/sirsoft-gdpr
-npm run test:run
-```
+[CHANGELOG.md](CHANGELOG.md)
## 라이선스
-MIT
\ No newline at end of file
+MIT
diff --git a/plugins/_bundled/sirsoft-gdpr/composer.json b/plugins/_bundled/sirsoft-gdpr/composer.json
index d8fa5fea..fd0b4152 100644
--- a/plugins/_bundled/sirsoft-gdpr/composer.json
+++ b/plugins/_bundled/sirsoft-gdpr/composer.json
@@ -2,7 +2,7 @@
"name": "plugins/sirsoft-gdpr",
"description": "GDPR (General Data Protection Regulation) plugin for G7 platform by sirsoft",
"type": "library",
- "version": "1.0.3",
+ "version": "1.0.4",
"license": "MIT",
"authors": [
{
diff --git a/plugins/_bundled/sirsoft-gdpr/docs/README.md b/plugins/_bundled/sirsoft-gdpr/docs/README.md
new file mode 100644
index 00000000..b2f5a3a6
--- /dev/null
+++ b/plugins/_bundled/sirsoft-gdpr/docs/README.md
@@ -0,0 +1,22 @@
+# GDPR (일반 데이터 보호 규정) 개발자 문서
+
+> plugins/_bundled/sirsoft-gdpr · 플러그인
+
+
+**훅 수**: 2 · **구독 훅 수**: 4 · **라우트 수**: 15 · **모델 수**: 3 · **테이블 수**: 3 · **마이그레이션 수**: 4 · **레이아웃 수**: 4 · **핸들러 수**: 1
+
+
+## 문서 목차
+
+
+| 문서 | 내용 |
+|---|---|
+| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
+| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
+| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
+| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
+| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
+| [api/](api/README.md) | API 레퍼런스 |
+| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
+| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
+
diff --git a/plugins/_bundled/sirsoft-gdpr/docs/architecture.md b/plugins/_bundled/sirsoft-gdpr/docs/architecture.md
new file mode 100644
index 00000000..d3891fe0
--- /dev/null
+++ b/plugins/_bundled/sirsoft-gdpr/docs/architecture.md
@@ -0,0 +1,78 @@
+# GDPR (일반 데이터 보호 규정) — 아키텍처
+
+> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 설계 의도
+
+
+"동의 전 처리 금지"를 소스 하나가 아니라 **서버(쿠키)·클라이언트(스크립트/저장소) 두 표면
+모두**에서 강제하는 것이 이 플러그인의 핵심 설계입니다. 서버 표면만 막으면 클라이언트에서
+직접 실행되는 추적 스크립트를 막지 못하고, 클라이언트 표면만 막으면 백엔드가 심는 분석용
+쿠키를 막지 못합니다. 두 표면은 서로 다른 코드 경로(미들웨어 vs 프론트 차단 로직)이지만
+같은 동의 판정 소스(`GdprConsentService`)를 공유해야 판정이 갈리지 않습니다.
+
+동의 "상태"와 "이력"을 분리 보존하는 것도 설계 결정입니다 — 상태(mutable)는 지금 게이팅
+판정에 쓰이고, 이력(immutable append-only)은 감사 대응(Art.7(1))에 쓰입니다. 회원탈퇴·완전삭제
+두 이벤트에서 서로 다른 처리(철회 vs 익명화)를 하는 것도 이 분리 때문에 가능합니다 — 하나의
+테이블이었다면 "지금 상태를 지울까 이력을 지울까"를 매번 다시 판단해야 했을 것입니다.
+
+
+## 계층 지도
+
+
+```
+Http/Controllers (Admin/ 관리자 설정·동의이력·정책버전, Public/ 배너 API, User/ 마이페이지)
+ │
+ ▼
+Services (GdprConsentService/GdprSettingsService/GdprPolicyVersionService/
+ GdprConsentLogService/CookieCategoryService)
+ │
+ ├──▶ Models (GdprUserConsent 상태 · GdprUserConsentHistory 이력 · GdprPolicyVersion)
+ │
+ └──▶ 훅 발행 (consent.granted/revoked) ──▶ 다른 확장 리스너
+
+CookieConsentMiddleware (web/api 그룹 prepend)
+ │
+ └──▶ GdprConsentService::getCurrentCookieConsents() 조회 후 응답 Set-Cookie 게이팅
+
+core.user.after_withdraw / before_delete 훅
+ │
+ └──▶ GdprUserWithdrawListener(철회 처리) / GdprUserDeleteListener(이력 익명화)
+```
+
+미들웨어는 위 Service 계층과 별도 레인에서 **매 요청마다** 동작하고, 회원탈퇴/삭제 리스너는
+코어 회원 도메인 이벤트에 반응하는 또 다른 별도 레인입니다. 세 레인 모두 같은
+`GdprConsentService`/모델을 공유하므로 그 계층 하나만 잘 유지하면 나머지는 일관됩니다.
+
+
+## 디렉토리
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
+| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
+| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
+| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
+| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
+| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
+| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
+| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
+| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
+| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
+| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
+| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
+| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
+| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-gdpr --force` (빌드 불필요) |
+| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan plugin:update sirsoft-gdpr --force` |
+| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-gdpr --force` |
+| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-gdpr --force` |
+| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-gdpr --force` |
+| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
+| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-gdpr --force` |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
+
diff --git a/plugins/_bundled/sirsoft-gdpr/docs/data-model.md b/plugins/_bundled/sirsoft-gdpr/docs/data-model.md
new file mode 100644
index 00000000..123b1cb3
--- /dev/null
+++ b/plugins/_bundled/sirsoft-gdpr/docs/data-model.md
@@ -0,0 +1,107 @@
+# GDPR (일반 데이터 보호 규정) — 데이터 모델
+
+> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 모델
+
+
+| 모델 | 테이블 | fillable | 관계 | 특성 |
+|---|---|---|---|---|
+| `GdprPolicyVersion` | `gdpr_policy_versions` | 5 | createdBy→User | - |
+| `GdprUserConsent` | `gdpr_user_consents` | 11 | user→User | - |
+| `GdprUserConsentHistory` | `gdpr_user_consent_histories` | 9 | user→User | - |
+
+
+
+`GdprUserConsent`(mutable, "지금 동의 상태")와 `GdprUserConsentHistory`(immutable append-only,
+"동의 변경 이력")를 별도 모델·테이블로 분리한 것이 이 도메인의 핵심 결정입니다. 게이팅
+판정(`CookieConsentMiddleware`)은 상태만 읽고, 감사 대응(Art.7(1))은 이력만 봅니다. 하나로
+합쳤다면 상태를 UPDATE 할 때마다 과거 값을 별도 보존하는 로직을 매번 다시 구현해야 했을
+것입니다. `GdprPolicyVersion` 은 정책 발행 이력이며 이 역시 immutable — 발행된 버전은 그
+시점의 정책 내용을 그대로 유지해야 "그 버전에 동의했다"는 이력이 의미를 가집니다.
+
+
+## 소유 테이블
+
+
+| 테이블 | 모델 |
+|---|---|
+| `gdpr_policy_versions` | `GdprPolicyVersion` |
+| `gdpr_user_consent_histories` | `GdprUserConsentHistory` |
+| `gdpr_user_consents` | `GdprUserConsent` |
+
+
+
+3개 테이블이 전부입니다 — 쿠키 카테고리 정의(`blocked_domains`/`cookie_categories`)는 별도
+테이블이 아니라 `getSettingsSchema()` 의 JSON 설정 값으로 저장됩니다(§settings.md). 별도
+테이블로 만들지 않은 이유는 그 값이 "운영자가 조정하는 설정"이지 "사용자별로 쌓이는 데이터"가
+아니기 때문입니다 — 전자는 설정 스키마, 후자만 전용 테이블을 둡니다.
+
+
+## 마이그레이션
+
+
+마이그레이션 4개.
+
+| 파일 | 생성 테이블 | 변경 테이블 | down() |
+|---|---|---|---|
+| `2026_04_27_000001_create_gdpr_user_consents_table.php` | `gdpr_user_consents` | `gdpr_user_consents` | ✅ |
+| `2026_04_27_000002_create_gdpr_user_consent_histories_table.php` | `gdpr_user_consent_histories` | `gdpr_user_consent_histories` | ✅ |
+| `2026_05_12_000003_create_gdpr_policy_versions_table.php` | `gdpr_policy_versions` | `gdpr_policy_versions` | ✅ |
+| `2026_07_14_000001_add_rejection_to_gdpr_user_consents.php` | - | `gdpr_user_consents` | ✅ |
+
+
+
+`add_rejection_to_gdpr_user_consents`(2026-07-14)는 "동의 안 함"을 명시적으로 기록하기 위한
+추가입니다 — 그전에는 동의 행이 없으면 "아직 응답 안 함"과 "거부함"을 구분할 수 없었습니다.
+`ConsentAction::Rejected` 케이스와 짝을 이루는 마이그레이션입니다.
+
+
+## Enum
+
+
+| Enum | backing | case 수 | case |
+|---|---|---|---|
+| `ConsentAction` | `string` | 3 | `granted`, `revoked`, `rejected` |
+| `ConsentSource` | `string` | 6 | `banner`, `preference_center`, `register`, `mypage`, `mypage_renew_all`, `withdraw` |
+| `CookieCategory` | `string` | 4 | `cookie_necessary`, `cookie_functional`, `cookie_analytics`, `cookie_marketing` |
+| `GdprPolicyChangeType` | `string` | 3 | `material`, `non_material`, `initial` |
+
+
+
+`ConsentSource` 는 자기 docblock에 "어휘를 이 enum 밖(서비스/리스너 리터럴)에 흩어 두면 화면
+필터가 실제 기록 어휘의 부분집합이 되어 일부 행이 어떤 필터로도 도달하지 못한다"고 명시합니다
+(#492 과거 결함). `withdraw`(회원탈퇴 시 일괄 철회, `GdprConsentService::revokeAllOnWithdraw()`
+/ `GdprUserConsentRepository::revokeAllForUser()`)가 정확히 이 결함군으로 한 번 더 발생했던
+case입니다 — 두 지점 모두 enum이 아닌 `'withdraw'` 리터럴을 직접 기록해, 그렇게 기록된 행이
+관리자 동의 이력 화면의 어떤 출처 필터로도 걸러지지 않고 라벨도 원시 문자열로 노출됐습니다.
+`ConsentSourceVocabularyParityTest`(기록 경로가 enum 을 참조하는지 검사)가 이미 있었는데도
+놓친 이유는 두 가지입니다 — 검사 대상 파일 목록에 `GdprUserConsentRepository.php` 가
+빠져 있었고, 정규식이 `'source' =>`/`'last_source' =>` 형태만 잡아 `updateConsent(...,
+'withdraw')` 같은 **위치 인자** 형태는 못 봤습니다. `Withdraw` case 추가 + 두 지점을
+`ConsentSource::Withdraw->value` 참조로 교체 + 테스트의 스캔 대상 파일 목록에 Repository
+추가로 정정했습니다. 새 기록 지점을 추가할 때 위치 인자로 리터럴을 넘기면 이 가드가 여전히
+못 볼 수 있다는 점을 유의하세요 — 가능하면 `'source' =>`/`'last_source' =>` 형태(배열 키)를
+쓰거나 이 테스트의 정규식을 함께 넓힙니다.
+
+
+## Repository
+
+
+| 클래스 | 종류 | 설명 |
+|---|---|---|
+| `GdprPolicyVersionRepository` | 구현 | GDPR 정책 버전 Repository 구현체 (immutable append-only) |
+| `GdprPolicyVersionRepositoryInterface` | 인터페이스 | GDPR 정책 버전 Repository 인터페이스 (immutable append-only) |
+| `GdprUserConsentHistoryRepository` | 구현 | GDPR 동의 변경 이력 Repository 구현체 (immutable append-only) |
+| `GdprUserConsentHistoryRepositoryInterface` | 인터페이스 | GDPR 동의 변경 이력 Repository 인터페이스 (immutable append-only) |
+| `GdprUserConsentRepository` | 구현 | GDPR 사용자 현재 동의 상태 Repository 구현체 |
+| `GdprUserConsentRepositoryInterface` | 인터페이스 | GDPR 사용자 현재 동의 상태 Repository 인터페이스 |
+
+
+
+`GdprPolicyVersionRepository`·`GdprUserConsentHistoryRepository` 설명에 "immutable
+append-only"가 반복 명시된 것은 우연이 아닙니다 — 이 두 Repository 에는 `update()`/`delete()`
+류 메서드를 추가하지 않습니다(§AGENTS.md 금지 패턴). 상태를 고치는 메서드가 필요하다면 그것은
+`GdprUserConsentRepository`(mutable) 의 몫이며, 두 종류를 같은 Repository 에 섞으면 "이
+메서드가 이력을 고치는지 상태를 고치는지"를 매 호출부에서 다시 확인해야 합니다.
+
diff --git a/plugins/_bundled/sirsoft-gdpr/docs/extension-points.md b/plugins/_bundled/sirsoft-gdpr/docs/extension-points.md
new file mode 100644
index 00000000..8baf1e1b
--- /dev/null
+++ b/plugins/_bundled/sirsoft-gdpr/docs/extension-points.md
@@ -0,0 +1,128 @@
+# GDPR (일반 데이터 보호 규정) — 확장점
+
+> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 발행 훅
+
+
+발행 훅 2종 / 호출 지점 12곳. 훅 이름이 상수·변수로 조립된 호출이 12곳 있어 호출 위치가 표에 다 실리지 않을 수 있습니다.
+
+| 훅 이름 | 유형 | 설명 | 발행 위치 |
+|---|---|---|---|
+| `sirsoft-gdpr.consent.granted` | action | 동의 부여 시 발화 | 선언 (호출 위치 미확인) |
+| `sirsoft-gdpr.consent.revoked` | action | 동의 철회 시 발화 | 선언 (호출 위치 미확인) |
+
+
+
+두 훅 모두 `GdprUserConsent $consent, string $source` 를 인자로 넘깁니다. `$source` 값은
+`banner`/`mypage`/`mypage_renew_all`/`register`/`withdraw` 중 하나이며, 어떤 화면에서 동의가
+바뀌었는지 구분해야 하는 리스너(예: 배너 동의만 특정 방식으로 처리하고 싶은 경우)는 이 값으로
+분기합니다. `revoked` 는 명시적 철회(마이페이지)뿐 아니라 회원탈퇴로 인한 일괄 철회
+(`source=withdraw`)에서도 발화됩니다 — "동의 취소" 이벤트를 하나로 통일해 구독자가 두 경로를
+따로 처리하지 않아도 되게 합니다.
+
+
+## 구독 훅
+
+
+| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
+|---|---|---|---|---|
+| `core.auth.logout` | action (미선언) | `GdprAuthLogoutListener` | `forgetGdprCookies` | 10 |
+| `core.auth.record_consents` | action (미선언) | `GdprAuthConsentListener` | `recordRegisterConsents` | 10 |
+| `core.user.after_withdraw` | action (미선언) | `GdprUserWithdrawListener` | `handleWithdraw` | 10 |
+| `core.user.before_delete` | action (미선언) | `GdprUserDeleteListener` | `cascadePluginData` | 10 |
+
+
+
+`core.auth.record_consents` 는 회원가입 폼에서 받은 동의 값을 코어가 이 플러그인에 **위임**하는
+자리입니다 — 회원가입 컨트롤러는 동의 저장 로직을 몰라도 되고, 이 플러그인이 폼 데이터에서
+동의 관련 키만 추출해 회원가입과 같은 트랜잭션에서 기록합니다. 나머지 3개
+(`logout`/`after_withdraw`/`before_delete`)는 전부 회원 생명주기 이벤트에 반응하는 정리 로직이며,
+§핵심 흐름(AGENTS.md)에서 다룬 대로 탈퇴와 완전삭제는 반드시 구분해서 처리합니다.
+
+
+## 훅 리스너
+
+
+| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
+|---|---|---|---|---|
+| `GdprAuthConsentListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/GdprAuthConsentListener.php` |
+| `GdprAuthLogoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/GdprAuthLogoutListener.php` |
+| `GdprUserDeleteListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/GdprUserDeleteListener.php` |
+| `GdprUserWithdrawListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/GdprUserWithdrawListener.php` |
+
+
+
+4개 리스너가 전부 훅 1개씩만 구독하는 것은 각자 트리거가 회원 생명주기의 서로 다른 순간
+(로그인 로그아웃/동의 기록/탈퇴/완전삭제)이라 합쳐도 이득이 없기 때문입니다.
+`GdprAuthLogoutListener` 는 로그아웃 시 `gdpr_session` 게스트 쿠키를 폐기합니다 — 로그인
+후에는 신원이 회원으로 바뀌므로, 로그아웃 시 남아 있는 게스트 세션 쿠키가 다음 방문자와
+뒤섞이지 않도록 정리하는 것입니다.
+
+
+## 레이아웃 확장
+
+
+| 대상 | 설명 |
+|---|---|
+| `resources/extensions/cookie_banner.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/mypage_privacy_tab.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+
+
+
+`cookie_banner.json` 은 사이트 전역에 배너를 띄우는 조각(코어/템플릿 공용 확장 지점에 주입)이고,
+`mypage_privacy_tab.json` 은 마이페이지에 "개인정보/동의 관리" 탭을 추가하는 조각입니다. 둘 다
+`sirsoft-basic` 템플릿의 확장 지점에 의존하므로, 다른 사용자 템플릿을 쓰려면 그 템플릿에도
+같은 지점이 있어야 두 UI 가 정상 노출됩니다(README "다른 확장과의 연동" 참고).
+
+
+## 미들웨어
+
+
+| 미들웨어 | 부착 대상(targets) | 우선순위 |
+|---|---|---|
+| `CookieConsentMiddleware` | `everything` | - |
+
+
+
+대상이 `everything`(모든 요청)인 이유는 functional 미동의 상태에서 어느 응답이 쿠키를
+심으려 하는지 이 플러그인이 미리 알 수 없기 때문입니다 — 특정 라우트만 골라 부착하면 그
+목록에서 빠진 응답의 쿠키는 게이팅되지 않습니다. 등록은 `GdprServiceProvider::boot()` 에서
+Laravel 커널의 `prependMiddlewareToGroup('web'|'api')` 로 이뤄지며, 코어의 미들웨어
+self-gate 규정(대상 명시)에 대한 근거가 바로 이 전면 적용 필요성입니다.
+
+
+## 브로드캐스트 채널
+
+
+_등록하는 브로드캐스트 채널이 없습니다._
+
+
+
+동의 상태 변경은 실시간 브로드캐스트 대상이 아닙니다 — 같은 방문자가 여러 탭을 열어둔 상태를
+동기화해야 할 만큼 시급한 이벤트가 아니고, 다음 페이지 요청 시 미들웨어가 최신 상태를 다시
+평가하므로 자연스럽게 수렴합니다.
+
+
+## 스케줄
+
+
+_등록하는 스케줄이 없습니다._
+
+
+
+동의 이력은 영구 보존이 원칙(Art.30)이라 배치로 정리할 대상이 없습니다. 게스트 세션 데이터의
+만료·정리는 코어 세션 메커니즘에 위임하며, 이 플러그인이 별도 정리 스케줄을 두지 않습니다.
+
+
+## 알림 정의
+
+
+_등록하는 알림 정의가 없습니다._
+
+
+
+동의 부여/철회는 방문자 본인의 조작 결과이므로 본인에게 알림을 보낼 이유가 없고, 정책 버전
+발행처럼 운영자가 이미 인지하고 수행한 조작도 마찬가지입니다. 관리자에게 알려야 할 이벤트가
+생기면(예: 대량 철회 급증 같은 이상 신호) 그때 코어 알림 정의를 신설합니다.
+
diff --git a/plugins/_bundled/sirsoft-gdpr/docs/frontend.md b/plugins/_bundled/sirsoft-gdpr/docs/frontend.md
new file mode 100644
index 00000000..1f81e491
--- /dev/null
+++ b/plugins/_bundled/sirsoft-gdpr/docs/frontend.md
@@ -0,0 +1,84 @@
+# GDPR (일반 데이터 보호 규정) — 프론트엔드
+
+> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 레이아웃
+
+
+레이아웃 4개 (루트: `resources/layouts`).
+
+| 그룹 | 개수 |
+|---|---|
+| `admin` | 4개 |
+
+| 레이아웃 | 그룹 | 종류 | extends |
+|---|---|---|---|
+| `gdpr_consent_log` | `admin` | 화면 | `_admin_base` |
+| `_policy_version_snapshot_modal` | `admin` | partial | - |
+| `_policy_version_publish_modal` | `admin` | partial | - |
+| `plugin_settings` | `admin` | 화면 | `_admin_base` |
+
+
+
+관리자 화면 4개뿐이고 **쿠키 배너·마이페이지 동의 탭은 이 표에 없습니다** — 그 둘은
+"레이아웃"이 아니라 "레이아웃 확장 조각"(`resources/extensions/cookie_banner.json`,
+`mypage_privacy_tab.json`, §extension-points.md)으로 다른 확장/템플릿 레이아웃에 주입되는
+형태라 별도 수집 축에 잡힙니다. 방문자가 실제로 보는 UI를 찾으려면 이 표가 아니라
+확장점 문서를 봐야 합니다.
+
+
+## 액션 핸들러
+
+
+핸들러 1개 (정의: `resources/js/index.ts`).
+
+| 핸들러 | 레이아웃에서 부르는 이름 |
+|---|---|
+| `syncConsent` | `sirsoft-gdpr.syncConsent` |
+
+
+
+`syncConsent` 하나뿐인 이유는 배너·마이페이지 UI 가 사실상 "동의 상태를 서버에 반영하고
+화면을 갱신한다"는 단일 동작만 필요로 하기 때문입니다. 자동 차단/복원 로직(외부 스크립트·
+iframe·1st-party 저장소 게이팅)은 이 액션 핸들러가 아니라 `dist/js/plugin.iife.js` 가 페이지
+로드 시 스스로 수행합니다 — 사용자 조작에 반응하는 것과 페이지 로드마다 항상 실행되는 것을
+액션 핸들러/전역 스크립트로 구분한 것입니다.
+
+
+## 전역 진입점
+
+
+| 항목 | 값 |
+|---|---|
+| 엔트리 파일 | `resources/js/index.ts` |
+| 전역 객체 | `window.__SirsoftGdpr` |
+| 재등록 진입점 | `initPlugin()` |
+
+로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.
+
+
+
+`initPlugin()` 이 "핸들러 재등록만" 하도록 좁혀 둔 것은 코어 규정(§CLAUDE.md "확장 미들웨어는
+...")을 그대로 따른 결과입니다 — 자동 차단 스크립트의 부팅(도메인 카탈로그 로드, DOM 스캔
+시작)을 여기 넣으면 로케일 전환마다 그 부팅이 중복 실행됩니다. 자동 차단 부팅은 `blocker.ts`/
+`preblocker.ts` 가 페이지 최초 로드 시 1회만 수행합니다.
+
+
+## 에셋
+
+
+| 경로 | 구분 |
+|---|---|
+| `dist/css/plugin.css` | 빌드 산출물 (커밋 대상) |
+| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) |
+| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
+
+로딩 설정: `{"strategy":"global","priority":0,"dependencies":[]}`
+
+
+
+`priority: 0`(다른 확장보다 먼저 로드)인 이유는 자동 차단이 **다른 확장의 추적 스크립트가
+실행되기 전에** 걸려 있어야 하기 때문입니다 — 이 플러그인이 늦게 로드되면 동의 없이 이미
+로드된 스크립트를 사후에 막을 방법이 없습니다. `strategy: "global"` 도 같은 이유로, 특정
+페이지에서만 지연 로드하면 그 페이지에서는 동의 전 차단이 통째로 빠집니다.
+
diff --git a/plugins/_bundled/sirsoft-gdpr/docs/settings.md b/plugins/_bundled/sirsoft-gdpr/docs/settings.md
new file mode 100644
index 00000000..19e19b86
--- /dev/null
+++ b/plugins/_bundled/sirsoft-gdpr/docs/settings.md
@@ -0,0 +1,96 @@
+# GDPR (일반 데이터 보호 규정) — 설정·권한·라우트
+
+> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 설정 스키마
+
+
+| 키 | 타입 | 기본값 | 설명 |
+|---|---|---|---|
+| `privacy_policy_slug` | `string` | `privacy` | 개인정보처리방침 페이지 슬러그 |
+| `legal_entity_name` | `string` | - | 운영 주체명 |
+| `data_storage_location` | `string` | - | 데이터 저장 위치 |
+| `banner_enabled` | `boolean` | `true` | 쿠키 배너 노출 |
+| `banner_position` | `string` | `bottom_bar` | 배너 위치 |
+| `blocked_domains` | `json` | `{"functional":["*.crisp.chat","client.crisp.chat","*.intercom.io","widget.intercom.io","*.tawk.to","embed.tawk.to","cdn.weglot.com","*.weglot.com","*.usercentrics.eu"],"analytics":["google-analytics.com","*.google-analytics.com","googletagmanager.com","*.googletagmanager.com","ssl.google-analytics.com","*.hotjar.com","static.hotjar.com","*.mixpanel.com","cdn.mxpnl.com","*.amplitude.com","cdn.amplitude.com","*.segment.io","*.segment.com","wcs.naver.net","wcs.naver.com","*.beusable.net"],"marketing":["facebook.net","connect.facebook.net","facebook.com","*.facebook.com","doubleclick.net","*.doubleclick.net","googleadservices.com","googlesyndication.com","ads.google.com","*.criteo.com","static.criteo.net","*.adnxs.com","*.taboola.com","cdn.taboola.com","*.outbrain.com","*.kakao.com","analytics.ad.daum.net","platform.twitter.com","*.twitter.com","platform.linkedin.com","*.linkedin.com"]}` | 추적 도메인 차단 목록 |
+| `cookie_categories` | `json` | `[]` | 쿠키 카테고리 정의 |
+
+기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json`
+
+
+
+`blocked_domains` 가 카테고리별(functional/analytics/marketing) 배열을 담은 단일 JSON 컬럼인
+것은, 카테고리 추가·삭제가 스키마 변경이 아니라 값 변경으로 끝나게 하기 위해서입니다 — 새
+카테고리를 추가할 때 마이그레이션이 필요하지 않습니다(다만 배너 UI 의 카테고리 목록은 별도
+동기화가 필요합니다, §AGENTS.md 수정 시 동반 의무). `cookie_categories` 가 기본값 `[]` 로
+비어 있는 것은 4대 표준 카테고리(필수/기능/분석/마케팅)가 이미 코드/Enum(`CookieCategory`)에
+고정돼 있어, 이 설정은 그 표준을 벗어나는 **추가** 카테고리를 위한 자리이기 때문입니다.
+
+
+## 권한
+
+
+| 카테고리 | 이름 | 액션 | 라우트 키 |
+|---|---|---|---|
+| `privacy` | 개인정보 보호 | `view`, `update` | - |
+
+
+
+권한이 `view`/`update` 하나씩만 있고 관리자 동의 이력·정책 버전 발행이 별도 권한으로 세분화
+되지 않은 것은, 이 플러그인의 관리자 기능 전체가 "개인정보 보호 담당자"라는 하나의 역할
+단위로 다뤄지기 때문입니다. 조회와 변경(설정 수정·정책 발행)을 분리해 둔 것은 감사 목적으로
+"누가 정책을 발행했는지"와 "누가 그냥 보기만 했는지"를 구분할 필요가 있어서입니다.
+
+
+## 메뉴
+
+
+| 구분 | slug | 이름 | URL | 하위 |
+|---|---|---|---|---|
+| 관리자 | `sirsoft-gdpr-consent-log` | GDPR 동의 이력 | `/admin/plugins/sirsoft-gdpr/consent-log` | - |
+
+
+
+"GDPR 설정"(정책 버전 발행 포함) 화면은 별도 메뉴가 아니라 플러그인 공통 설정 화면
+(`관리자 → 플러그인 → GDPR 설정`)에 얹혀 있고, "동의 이력" 조회만 독립 메뉴입니다 — 설정은
+가끔 바꾸는 화면이라 플러그인 목록에서 진입해도 충분하지만, 동의 이력은 자주 확인하는
+운영 화면이라 별도 메뉴로 빠르게 도달할 수 있어야 하기 때문입니다.
+
+
+## 라우트
+
+
+| 종류 | 파일 | URL prefix |
+|---|---|---|
+| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-gdpr/...` |
+
+확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
+
+
+
+`Public\GdprCookieConsentController`/`GdprSettingsController` 는 인증 없이 호출됩니다 —
+게스트 방문자도 배너를 봐야 하고 동의를 기록해야 하므로, 공개 라우트로 두되 게스트 식별은
+`gdpr_session` 서명 쿠키로 처리합니다. 반대로 관리자·마이페이지 라우트는 코어 인증/권한
+미들웨어가 걸립니다 — 이 셋을 하나의 미들웨어 그룹으로 묶지 않고 컨트롤러 계층(Public/User/Admin)
+으로 분리한 것이 인증 요구사항의 차이를 드러냅니다.
+
+
+## 의존 관계
+
+
+**이 확장이 의존하는 확장**
+
+없음 — 코어만으로 동작합니다.
+
+**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
+
+없음.
+
+
+
+formal `dependencies` 선언이 둘 다 "없음"인데도 README 는 `sirsoft-page`(소프트, 런타임 체크)와
+`sirsoft-basic`(레이아웃 확장 주입 지점)을 명시합니다 — 둘 다 manifest 의존성 제약으로
+선언할 만큼 강한 결합이 아니기 때문입니다. `sirsoft-page` 미설치는 기능 저하(링크 숨김)로
+그치고, `sirsoft-basic` 미사용은 다른 템플릿이 같은 주입 지점을 제공하면 해소됩니다 — 둘 다
+"없으면 설치가 막히는" 수준의 의존이 아니라 manifest 의존성 목록에 넣지 않습니다.
+
diff --git a/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php b/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php
index 4a3f4b48..3d48465d 100644
--- a/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php
+++ b/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php
@@ -201,6 +201,7 @@ return [
'register' => 'Sign-up',
'mypage' => 'MyPage',
'mypage_renew_all' => 'MyPage bulk re-consent',
+ 'withdraw' => 'Account withdrawal',
],
'col' => [
'created_at' => 'Time',
diff --git a/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php b/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php
index 993e3aa6..4784e3f1 100644
--- a/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php
+++ b/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php
@@ -201,6 +201,7 @@ return [
'register' => '회원가입',
'mypage' => '마이페이지',
'mypage_renew_all' => '마이페이지 일괄 재동의',
+ 'withdraw' => '회원탈퇴',
],
'col' => [
'created_at' => '시점',
diff --git a/plugins/_bundled/sirsoft-gdpr/package-lock.json b/plugins/_bundled/sirsoft-gdpr/package-lock.json
new file mode 100644
index 00000000..086be7a9
--- /dev/null
+++ b/plugins/_bundled/sirsoft-gdpr/package-lock.json
@@ -0,0 +1,2689 @@
+{
+ "name": "@plugins/sirsoft-gdpr",
+ "version": "1.0.4",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@plugins/sirsoft-gdpr",
+ "version": "1.0.4",
+ "devDependencies": {
+ "@testing-library/jest-dom": "^6.5.0",
+ "@testing-library/react": "^16.0.0",
+ "@types/node": "^22.0.0",
+ "@vitest/ui": "^2.1.0",
+ "jsdom": "^25.0.0",
+ "typescript": "^5.6.0",
+ "vite": "^5.4.0",
+ "vitest": "^2.1.0"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
+ "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz",
+ "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz",
+ "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz",
+ "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz",
+ "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz",
+ "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz",
+ "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz",
+ "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz",
+ "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz",
+ "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz",
+ "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz",
+ "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz",
+ "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz",
+ "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz",
+ "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz",
+ "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz",
+ "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz",
+ "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz",
+ "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz",
+ "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz",
+ "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz",
+ "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz",
+ "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz",
+ "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz",
+ "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.3",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
+ "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "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/@types/node": {
+ "version": "22.20.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
+ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "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/ui": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-2.1.9.tgz",
+ "integrity": "sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "2.1.9",
+ "fflate": "^0.8.2",
+ "flatted": "^3.3.1",
+ "pathe": "^1.1.2",
+ "sirv": "^3.0.0",
+ "tinyglobby": "^0.2.10",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "vitest": "2.1.9"
+ }
+ },
+ "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/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "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/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "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/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cssstyle/node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "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-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "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/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "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/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/flatted": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/form-data": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.4",
+ "mime-types": "^2.1.35"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "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/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/jsdom": {
+ "version": "25.0.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz",
+ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.1.0",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.4.3",
+ "form-data": "^4.0.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.5",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.12",
+ "parse5": "^7.1.2",
+ "rrweb-cssom": "^0.7.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.0.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^2.11.2"
+ },
+ "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": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "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/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "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.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "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/nwsapi": {
+ "version": "2.2.27",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz",
+ "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "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/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "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.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "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/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
+ "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==",
+ "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": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.1",
+ "@rollup/rollup-android-arm64": "4.63.1",
+ "@rollup/rollup-darwin-arm64": "4.63.1",
+ "@rollup/rollup-darwin-x64": "4.63.1",
+ "@rollup/rollup-freebsd-arm64": "4.63.1",
+ "@rollup/rollup-freebsd-x64": "4.63.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.1",
+ "@rollup/rollup-linux-arm64-musl": "4.63.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.1",
+ "@rollup/rollup-linux-loong64-musl": "4.63.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.1",
+ "@rollup/rollup-linux-x64-gnu": "4.63.1",
+ "@rollup/rollup-linux-x64-musl": "4.63.1",
+ "@rollup/rollup-openbsd-x64": "4.63.1",
+ "@rollup/rollup-openharmony-arm64": "4.63.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.1",
+ "@rollup/rollup-win32-x64-gnu": "4.63.1",
+ "@rollup/rollup-win32-x64-msvc": "4.63.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rrweb-cssom": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
+ "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "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/sirv": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "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": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
+ "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-gdpr/package.json b/plugins/_bundled/sirsoft-gdpr/package.json
index bf745274..94582e63 100644
--- a/plugins/_bundled/sirsoft-gdpr/package.json
+++ b/plugins/_bundled/sirsoft-gdpr/package.json
@@ -1,6 +1,6 @@
{
"name": "@plugins/sirsoft-gdpr",
- "version": "1.0.3",
+ "version": "1.0.4",
"private": true,
"type": "module",
"scripts": {
diff --git a/plugins/_bundled/sirsoft-gdpr/plugin.json b/plugins/_bundled/sirsoft-gdpr/plugin.json
index 5398adca..c6e0f1f0 100644
--- a/plugins/_bundled/sirsoft-gdpr/plugin.json
+++ b/plugins/_bundled/sirsoft-gdpr/plugin.json
@@ -5,7 +5,7 @@
"ko": "GDPR (일반 데이터 보호 규정)",
"en": "GDPR (General Data Protection Regulation)"
},
- "version": "1.0.3",
+ "version": "1.0.4",
"description": {
"ko": "쿠키 동의 배너, 자동 차단, 동의 이력 저장, 마이페이지 동의 철회를 제공하는 GDPR 대응 플러그인입니다.",
"en": "GDPR-ready plugin: cookie consent banner, auto-blocking, consent history, and mypage consent withdrawal."
diff --git a/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json b/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json
index 441bf3ad..5515f6f8 100644
--- a/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json
+++ b/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json
@@ -238,7 +238,8 @@
"preference_center": "Preferences",
"mypage": "MyPage",
"register": "Sign-up",
- "mypage_renew_all": "MyPage bulk re-consent"
+ "mypage_renew_all": "MyPage bulk re-consent",
+ "withdraw": "Account withdrawal"
},
"col": {
"created_at": "Time",
diff --git a/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json b/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json
index 8ab8e172..51172713 100644
--- a/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json
+++ b/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json
@@ -238,7 +238,8 @@
"preference_center": "환경설정",
"mypage": "마이페이지",
"register": "회원가입",
- "mypage_renew_all": "마이페이지 일괄 재동의"
+ "mypage_renew_all": "마이페이지 일괄 재동의",
+ "withdraw": "회원탈퇴"
},
"col": {
"created_at": "시점",
diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json
index 9bebf3a7..5c40109b 100644
--- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json
+++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json
@@ -1119,6 +1119,52 @@
"text": "$t:sirsoft-gdpr.admin.consent_log.source.mypage_renew_all"
}
]
+ },
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "inline-clickable"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "type": "checkbox",
+ "className": "checkbox",
+ "checked": "{{(_local.filter.sources || []).includes('withdraw')}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "local",
+ "filter.sources": "{{$event.target.checked ? [...(_local.filter.sources || []).filter(s => s !== 'withdraw'), 'withdraw'] : (_local.filter.sources || []).filter(s => s !== 'withdraw')}}"
+ }
+ },
+ {
+ "actionRef": "searchConsentLogs"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-label"
+ },
+ "text": "$t:sirsoft-gdpr.admin.consent_log.source.withdraw"
+ }
+ ]
}
]
}
diff --git a/plugins/_bundled/sirsoft-gdpr/src/Enums/ConsentSource.php b/plugins/_bundled/sirsoft-gdpr/src/Enums/ConsentSource.php
index b7ae22a1..1ddd135e 100644
--- a/plugins/_bundled/sirsoft-gdpr/src/Enums/ConsentSource.php
+++ b/plugins/_bundled/sirsoft-gdpr/src/Enums/ConsentSource.php
@@ -17,6 +17,7 @@ namespace Plugins\Sirsoft\Gdpr\Enums;
* - register: 회원가입 시 동의 (GdprAuthConsentListener)
* - mypage: 마이페이지 동의 관리에서 변경
* - mypage_renew_all: 정책 개정 후 마이페이지에서 일괄 재동의 (GdprConsentService::renewAll)
+ * - withdraw: 회원탈퇴 시 활성 동의 일괄 철회 (GdprConsentService::revokeAllOnWithdraw)
*/
enum ConsentSource: string
{
@@ -25,6 +26,7 @@ enum ConsentSource: string
case Register = 'register';
case Mypage = 'mypage';
case MypageRenewAll = 'mypage_renew_all';
+ case Withdraw = 'withdraw';
/**
* 사용자 친화 라벨을 반환합니다.
diff --git a/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentRepository.php b/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentRepository.php
index 1af4bb23..33fe1814 100644
--- a/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentRepository.php
+++ b/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentRepository.php
@@ -3,6 +3,7 @@
namespace Plugins\Sirsoft\Gdpr\Repositories;
use Illuminate\Support\Collection;
+use Plugins\Sirsoft\Gdpr\Enums\ConsentSource;
use Plugins\Sirsoft\Gdpr\Models\GdprUserConsent;
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentRepositoryInterface;
@@ -14,8 +15,8 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface
/**
* 사용자 ID와 동의 키로 동의 상태를 조회합니다.
*
- * @param int $userId 사용자 ID
- * @param string $consentKey 동의 항목 키
+ * @param int $userId 사용자 ID
+ * @param string $consentKey 동의 항목 키
* @return GdprUserConsent|null
*/
public function findByUserAndKey(int $userId, string $consentKey): ?GdprUserConsent
@@ -28,7 +29,7 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface
/**
* 사용자 ID로 모든 동의 상태를 조회합니다.
*
- * @param int $userId 사용자 ID
+ * @param int $userId 사용자 ID
* @return Collection
*/
public function getAllByUserId(int $userId): Collection
@@ -39,7 +40,7 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface
/**
* 사용자 ID로 활성 동의(is_consented=true)만 조회합니다.
*
- * @param int $userId 사용자 ID
+ * @param int $userId 사용자 ID
* @return Collection
*/
public function getActiveByUserId(int $userId): Collection
@@ -52,14 +53,14 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface
/**
* 가상의 비활성 동의 상태 모델을 합성합니다 (DB 미저장).
*
- * @param int $userId 사용자 ID
- * @param string $consentKey 동의 항목 키 (cookie_ 접두사 포함)
- * @param string|null $consentCategory 카테고리
+ * @param int $userId 사용자 ID
+ * @param string $consentKey 동의 항목 키 (cookie_ 접두사 포함)
+ * @param string|null $consentCategory 카테고리
* @return GdprUserConsent 합성된 비활성 모델 (DB 미저장)
*/
public function buildVirtualStatus(int $userId, string $consentKey, ?string $consentCategory = null): GdprUserConsent
{
- $row = new GdprUserConsent();
+ $row = new GdprUserConsent;
$row->user_id = $userId;
$row->consent_key = $consentKey;
$row->consent_category = $consentCategory;
@@ -79,9 +80,9 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface
/**
* 동의 상태 레코드를 생성하거나 업데이트합니다.
*
- * @param int $userId 사용자 ID
- * @param string $consentKey 동의 항목 키
- * @param array $data 업데이트 데이터
+ * @param int $userId 사용자 ID
+ * @param string $consentKey 동의 항목 키
+ * @param array $data 업데이트 데이터
* @return GdprUserConsent
*/
public function upsert(int $userId, string $consentKey, array $data): GdprUserConsent
@@ -99,7 +100,7 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface
/**
* 사용자의 모든 활성 동의를 일괄 철회 처리합니다 (탈퇴 시 사용).
*
- * @param int $userId 사용자 ID
+ * @param int $userId 사용자 ID
* @return int 영향받은 행 수
*/
public function revokeAllForUser(int $userId): int
@@ -109,14 +110,14 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface
->update([
'is_consented' => false,
'revoked_at' => now(),
- 'last_source' => 'withdraw',
+ 'last_source' => ConsentSource::Withdraw->value,
]);
}
/**
* 특정 동의 키에 동의한 사용자 수를 반환합니다.
*
- * @param string $consentKey 동의 항목 키
+ * @param string $consentKey 동의 항목 키
* @return int
*/
public function countConsentedByKey(string $consentKey): int
@@ -129,7 +130,7 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface
/**
* 사용자 ID로 모든 동의 상태 레코드를 삭제합니다.
*
- * @param int $userId 사용자 ID
+ * @param int $userId 사용자 ID
* @return void
*/
public function deleteByUserId(int $userId): void
diff --git a/plugins/_bundled/sirsoft-gdpr/src/Services/GdprConsentService.php b/plugins/_bundled/sirsoft-gdpr/src/Services/GdprConsentService.php
index fbb9a7e4..1f5fb781 100644
--- a/plugins/_bundled/sirsoft-gdpr/src/Services/GdprConsentService.php
+++ b/plugins/_bundled/sirsoft-gdpr/src/Services/GdprConsentService.php
@@ -73,7 +73,7 @@ class GdprConsentService
* @param string|null $sessionId 게스트 세션 ID (회원이면 NULL)
* @param string $consentKey 동의 항목 키
* @param bool $value 동의 여부
- * @param string $source 변경 경로 (허용 어휘는 ConsentSource enum — banner/preference_center/register/mypage/mypage_renew_all)
+ * @param string $source 변경 경로 (허용 어휘는 {@see ConsentSource} 가 SSoT)
* @param array|null $categories 카테고리 스냅샷 (배너 일괄 변경 시)
* @param bool $isRejection 명시적 거부 신호 (이슈 #430). 선택형 미동의 항목을 is_rejected=true 로 저장.
* @return void
@@ -302,7 +302,7 @@ class GdprConsentService
// 멱등하게 작성해야 한다.
DB::transaction(function () use ($userId, $activeConsents) {
foreach ($activeConsents as $consent) {
- $this->updateConsent($userId, null, $consent->consent_key, false, 'withdraw');
+ $this->updateConsent($userId, null, $consent->consent_key, false, ConsentSource::Withdraw->value);
}
});
}
diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/consent-log-source-filter.spec.ts b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/consent-log-source-filter.spec.ts
new file mode 100644
index 00000000..8fd04ad0
--- /dev/null
+++ b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/consent-log-source-filter.spec.ts
@@ -0,0 +1,55 @@
+/**
+ * E2E: 관리자 「GDPR 동의 이력」 화면 — 출처 필터 「회원탈퇴」 체크박스 (#601 문서화 세션 중 발견)
+ *
+ * @scenario admin_gdpr_consent_log_source_filter_withdraw
+ * @effects withdraw_checkbox_visible, withdraw_filter_updates_query_and_refetches
+ *
+ * 배경: `ConsentSource` enum 에 `withdraw`(회원탈퇴 시 일괄 철회) case 가 없어, 그 출처로
+ * 기록된 동의 이력 행이 출처 필터 어디로도 걸러지지 않던 결함을 발견해 enum·기록 지점(Service·
+ * Repository)·라벨(ko/en/ja)·이 필터 체크박스를 함께 추가했다. PHPUnit 쪽은
+ * `ConsentSourceVocabularyParityTest` 가 JSON 안의 `includes('withdraw')`/라벨 바인딩
+ * 존재를 정적으로 검증하지만, 그 체크박스가 실제 브라우저에서 보이고 클릭 시 목록이 다시
+ * 조회되는지는 별도로 확인해야 한다.
+ *
+ * 검증:
+ * 1. 동의 이력 화면에 "회원탈퇴" 출처 필터 체크박스가 보인다
+ * 2. 체크하면 URL 쿼리에 `sources` 값으로 `withdraw` 가 반영되고 목록이 재조회된다
+ * 3. 다시 해제하면 `withdraw` 가 쿼리에서 빠진다
+ */
+import { test, expect, authenticatePage } from '../../fixtures/gdpr-auth';
+
+const WITHDRAW_FILTER_LABEL = '회원탈퇴';
+
+async function gotoConsentLog(page: import('@playwright/test').Page): Promise {
+ await page.goto('/admin/plugins/sirsoft-gdpr/consent-log');
+ await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
+ await expect(page.locator('#gdpr_consent_log_datagrid__body')).toBeAttached({ timeout: 20_000 });
+}
+
+// @scenario source_filter=withdraw
+// @effects withdraw_checkbox_visible
+test('#601 - 동의 이력 출처 필터에 "회원탈퇴" 체크박스가 보인다', async ({ page, privacyManageToken }) => {
+ await authenticatePage(page, privacyManageToken);
+ await gotoConsentLog(page);
+
+ const checkbox = page.getByLabel(WITHDRAW_FILTER_LABEL);
+ await expect(checkbox).toBeAttached({ timeout: 10_000 });
+ await expect(checkbox).not.toBeChecked();
+});
+
+// @scenario source_filter=withdraw, toggle=on_then_off
+// @effects withdraw_filter_updates_query_and_refetches
+test('#601 - "회원탈퇴" 체크 시 URL 쿼리에 반영되고, 해제하면 빠진다', async ({ page, privacyManageToken }) => {
+ await authenticatePage(page, privacyManageToken);
+ await gotoConsentLog(page);
+
+ const checkbox = page.getByLabel(WITHDRAW_FILTER_LABEL);
+ await checkbox.check();
+
+ // searchConsentLogs 가 navigate(mergeQuery) 로 sources 배열을 쿼리에 싣는다.
+ await expect(page).toHaveURL(/withdraw/, { timeout: 10_000 });
+ await expect(checkbox).toBeChecked();
+
+ await checkbox.uncheck();
+ await expect(page).not.toHaveURL(/withdraw/, { timeout: 10_000 });
+});
diff --git a/plugins/_bundled/sirsoft-gdpr/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-gdpr/tests/PluginTestCase.php
index 9bb299c6..f272f29c 100644
--- a/plugins/_bundled/sirsoft-gdpr/tests/PluginTestCase.php
+++ b/plugins/_bundled/sirsoft-gdpr/tests/PluginTestCase.php
@@ -3,10 +3,12 @@
namespace Plugins\Sirsoft\Gdpr\Tests;
use App\Enums\PermissionType;
+use App\Extension\HookManager;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Route;
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprPolicyVersionRepositoryInterface;
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentHistoryRepositoryInterface;
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentRepositoryInterface;
@@ -48,6 +50,39 @@ abstract class PluginTestCase extends TestCase
$this->app->bind(GdprUserConsentRepositoryInterface::class, GdprUserConsentRepository::class);
$this->app->bind(GdprUserConsentHistoryRepositoryInterface::class, GdprUserConsentHistoryRepository::class);
$this->app->bind(GdprPolicyVersionRepositoryInterface::class, GdprPolicyVersionRepository::class);
+
+ $this->registerPluginApiRoutes();
+ }
+
+ /**
+ * 플러그인 API 라우트를 테스트 앱에 등록합니다.
+ *
+ * `PluginRouteServiceProvider` 는 `plugins` 테이블의 활성 행을 대조해서만 라우트를
+ * 등록합니다(#603). 테스트는 `RefreshDatabase` 로 매번 빈 테이블에서 부팅하므로 그
+ * 게이트가 항상 닫히고, 이 플러그인의 모든 엔드포인트가 404 가 됩니다 — 실패는
+ * 권한·검증이 아니라 "주소 없음" 으로 나타나 원인이 드러나지 않습니다.
+ *
+ * 활성 행을 심는 것으로는 낫지 않습니다. 라우트 등록은 `parent::setUp()` 의 앱 부팅
+ * 시점에 끝나고 DB 초기화는 그 뒤에 오기 때문입니다. 다른 번들 확장의 테스트 베이스도
+ * 같은 이유로 라우트 파일을 직접 그룹에 물립니다.
+ *
+ * prefix·name·middleware 는 프로바이더와 동일하게 맞춥니다 — 어긋나면 테스트가
+ * 통과해도 운영 주소와 다른 곳을 밟게 됩니다.
+ *
+ * @return void
+ */
+ protected function registerPluginApiRoutes(): void
+ {
+ $apiRoutesFile = dirname(__DIR__).'/src/routes/api.php';
+
+ if (! file_exists($apiRoutesFile)) {
+ return;
+ }
+
+ Route::prefix('api/plugins/sirsoft-gdpr')
+ ->name('api.plugins.sirsoft-gdpr.')
+ ->middleware('api')
+ ->group($apiRoutesFile);
}
/**
@@ -69,10 +104,10 @@ abstract class PluginTestCase extends TestCase
*/
private function snapshotHookManager(): void
{
- $ref = new \ReflectionClass(\App\Extension\HookManager::class);
+ $ref = new \ReflectionClass(HookManager::class);
$this->hookSnapshot = [
- 'hooks' => $ref->getProperty('hooks')->getValue(),
- 'filters' => $ref->getProperty('filters')->getValue(),
+ 'hooks' => $ref->getProperty('hooks')->getValue(),
+ 'filters' => $ref->getProperty('filters')->getValue(),
'dispatching' => $ref->getProperty('dispatching')->getValue(),
];
}
@@ -88,7 +123,7 @@ abstract class PluginTestCase extends TestCase
return;
}
- $ref = new \ReflectionClass(\App\Extension\HookManager::class);
+ $ref = new \ReflectionClass(HookManager::class);
$ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']);
$ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']);
$ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']);
@@ -165,17 +200,17 @@ abstract class PluginTestCase extends TestCase
{
$paths = ['database/migrations'];
foreach (glob(base_path('modules/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
- $paths[] = str_replace(base_path() . DIRECTORY_SEPARATOR, '', $p);
+ $paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
foreach (glob(base_path('plugins/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
- $paths[] = str_replace(base_path() . DIRECTORY_SEPARATOR, '', $p);
+ $paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
return [
'--drop-views' => $this->shouldDropViews(),
'--drop-types' => $this->shouldDropTypes(),
- '--seed' => false,
- '--path' => $paths,
+ '--seed' => false,
+ '--path' => $paths,
];
}
}
diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Unit/ConsentSourceVocabularyParityTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Unit/ConsentSourceVocabularyParityTest.php
index 6c3ab263..007110b0 100644
--- a/plugins/_bundled/sirsoft-gdpr/tests/Unit/ConsentSourceVocabularyParityTest.php
+++ b/plugins/_bundled/sirsoft-gdpr/tests/Unit/ConsentSourceVocabularyParityTest.php
@@ -17,6 +17,7 @@ use Plugins\Sirsoft\Gdpr\Enums\ConsentSource;
*
* DB·라우트에 의존하지 않는 정적 검사이므로 `Tests\TestCase` 가 아니라 순수 TestCase 를 상속합니다.
*/
+// audit:allow test-extension-base-class reason: 파일 내용·enum 값만 비교하는 순수 정적 검사 — DB/라우트/오토로드 부팅이 필요 없어 PluginTestCase 상속 시 불필요한 부팅 비용만 늘어난다
class ConsentSourceVocabularyParityTest extends TestCase
{
/** 플러그인 루트 경로 */
@@ -36,9 +37,14 @@ class ConsentSourceVocabularyParityTest extends TestCase
$declared = ConsentSource::allValues();
// 실제 기록 지점 — source:/'source' =>/'last_source' => 인자로 넘어가는 리터럴
+ //
+ // Repository 도 포함한다 — GdprUserConsentRepository::revokeAllForUser() 가
+ // Service 를 거치지 않고 직접 'last_source' => 리터럴을 UPDATE 쿼리에 싣는다.
+ // 이 파일이 빠져 있으면 그 리터럴은 어떤 축으로도 검사되지 않는다.
$sources = [
'src/Listeners/GdprAuthConsentListener.php',
'src/Services/GdprConsentService.php',
+ 'src/Repositories/GdprUserConsentRepository.php',
'src/Http/Controllers/User/GdprConsentController.php',
];
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/AGENTS.md b/plugins/_bundled/sirsoft-pay_kginicis/AGENTS.md
new file mode 100644
index 00000000..994314bd
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_kginicis/AGENTS.md
@@ -0,0 +1,173 @@
+# KG 이니시스 — 에이전트 가이드
+
+> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
+
+## TL;DR (5초 요약)
+
+```text
+1. 유형: 플러그인 (sirsoft-pay_kginicis) — KG 이니시스 PG 연동(PC/모바일/가상계좌/에스크로/일본 CBT). 소유 테이블 없음 — 상태는 sirsoft-ecommerce 소유
+2. 확장 방식: `RegisterPgProviderListener`/`RegisterCashReceiptProviderListener` 로 이커머스 레지스트리에 등록 — 이커머스 코드는 이 플러그인을 모른다
+3. 건드리면 안 되는 것: `authUrl`/`P_REQ_URL`/`netCancelUrl` 화이트리스트 검증 생략, 콜백 재처리 방지 로직 우회, IP 화이트리스트 미들웨어(`InicisNotifyIpWhitelist`) 미부착
+4. 작업 위치: `plugins/_bundled/sirsoft-pay_kginicis` — 활성 디렉토리 직접 수정 금지
+5. 반영: `php artisan plugin:update sirsoft-pay_kginicis --force`
+```
+
+## 1. 이 확장은 무엇인가
+
+
+KG 이니시스 PG(결제 게이트웨이)를 `sirsoft-ecommerce`에 연결하는 어댑터입니다. 결제수단마다
+프로토콜이 다릅니다 — PC 는 브라우저 결제창 + 서버 승인 API, 모바일은 폼 POST 이동 + 별도
+승인 API, 일본 CBT 는 완전히 다른 인증/승인 체계(JPPG)를 씁니다. 이 플러그인의 역할은 그
+세 가지 서로 다른 프로토콜을 전부 흡수해 이커머스 쪽에는 "결제 성공/실패/취소"라는 하나의
+결과만 넘기는 것입니다.
+
+**설계 원칙**: 이 플러그인은 상태를 소유하지 않습니다(§data-model.md — 모델·테이블 0개).
+주문·결제 상태는 전부 `sirsoft-ecommerce`의 테이블에 있고, 이 플러그인은 PG API 와 그 상태를
+동기화하는 역할만 합니다. 등록도 코드 결합이 아니라 훅 기반입니다
+(`sirsoft-ecommerce.payment.registered_pg_providers` 필터) — 이커머스 모듈은 이 플러그인의
+존재를 컴파일 타임에 몰라도 됩니다.
+
+**의도적으로 하지 않는 것**: 결제 실패 시 자동으로 다른 PG 로 재시도하지 않습니다 — PG 마다
+가맹점 계약·결제수단이 다르므로 자동 전환은 이중 결제·과금 위험을 만듭니다. 또한 일본 결제
+설정이 불완전할 때 한국 표준결제로 조용히 대체하지 않고 결제 자체를 중단합니다 — 설정 실수를
+"어쨌든 결제는 된다"로 감추면 잘못된 통화·수수료로 승인될 수 있습니다.
+
+
+## 2. 디렉토리 지도
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
+| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
+| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
+| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
+| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
+| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
+| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
+| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
+| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
+| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_kginicis --force` (빌드 불필요) |
+| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_kginicis --force` |
+| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_kginicis --force` |
+| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_kginicis --force` |
+| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
+| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_kginicis --force` |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
+
+
+## 3. 핵심 흐름
+
+
+**PC 결제 승인**: `PaymentCallbackController`(KG 이니시스가 POST 하는 authToken/authUrl 수신)
+→ `authUrl` 화이트리스트 검증 → `sirsoft-pay_kginicis.payment.before_authorize` 훅 →
+`KgInicisApiService` 가 승인 API 호출 → `sirsoft-pay_kginicis.payment.after_authorize` 훅 →
+이커머스 주문 결제 완료 처리. 승인 후 로컬 처리 실패 시 `netCancelUrl` 로 망취소를 시도합니다
+— 이 지점이 실패하면 "PG 는 승인, 우리는 실패"인 가장 위험한 상태이므로 반드시 오류 로그를
+남깁니다.
+
+**결제 취소(환불)**: 관리자가 주문 취소(`cancel_pg=true`) → 코어가
+`sirsoft-ecommerce.payment.refund` 필터 발화 → 이 플러그인의 `PaymentRefundListener`
+(우선순위 10)가 먼저 KG 이니시스 취소 API 호출 → `CancelActivityLogListener`(우선순위 20)가
+그 결과(PG 응답 시각·취소 TID)를 활동 로그에 별도 기록. 우선순위 순서가 중요합니다 — 취소가
+실제로 성공한 뒤에야 로그를 남겨야 "로그는 있는데 실제 취소는 실패"가 생기지 않습니다.
+
+**일본 CBT 승인**: `/payment/cbt/hash-data` 로 해시 생성(타임스탬프 신선도 검증) →
+CBT 인증 URL 로 폼 POST → KG 이니시스가 `sid` 를 콜백으로 전달 → `cbtapprove` API 호출 →
+카드/PayPay 는 즉시 완료, 편의점은 입금대기로 저장 후 별도 NOTI 수신 시 완료. 로컬 후속
+처리가 실패하면 CBT 전용 취소 API 로 자동 취소를 시도하고, 그마저 실패하면 수동 취소가
+필요하다는 오류 로그를 남깁니다.
+
+
+## 4. 확장점
+
+
+| 확장점 | 수 | 상세 |
+|---|---|---|
+| 발행 훅 | 6개 | [발행 훅](docs/extension-points.md#발행-훅) |
+| 구독 훅 | 14개 | [구독 훅](docs/extension-points.md#구독-훅) |
+| 훅 리스너 | 11개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
+| 레이아웃 확장 | 4개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
+| 미들웨어 | 1개 | [미들웨어](docs/extension-points.md#미들웨어) |
+| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
+| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
+| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
+
+
+
+`before_authorize`/`before_cancel`/`before_cbt_refund` 는 PG 호출 **전** 개입 지점입니다 —
+예를 들어 고액 결제에 본인인증을 추가로 요구하고 싶은 확장이 `before_cancel` 을 잡아 조건
+미충족 시 예외를 던지면 KG 이니시스 API 호출 자체가 일어나지 않습니다(`before_cancel` 의
+용도로 이미 "본인인증 등 확장 지점"이라 발행 위치에 명시돼 있습니다). `after_*` 훅은 PG 응답을
+받은 뒤 부가효과(추가 로그, 알림 등)를 붙이는 자리입니다. 구독 훅 14개 중 다수가
+`core.layout_extension.after_apply` 인 이유는 관리자 주문 목록/상세 화면에 "테스트 모드
+배지"·"거래 조회 UI"를 레이아웃 확장으로 주입하기 때문입니다(§레이아웃 확장).
+
+
+## 5. 수정 시 동반 의무
+
+- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-pay_kginicis --force` 로 반영
+- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
+- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
+- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-pay_kginicis` 재실행 + `docs/api/**` 갱신
+- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
+- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
+- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
+- [ ] 승인/취소 흐름을 고칠 때 `before_*`/`after_*` 훅 순서와 우선순위(`PaymentRefundListener` < `CancelActivityLogListener`)를 유지 — 로그가 실제 처리보다 먼저 실행되면 안 된다
+- [ ] IP 화이트리스트(`InicisNotifyIpWhitelist`) 대상 라우트를 추가/변경하면 미들웨어 부착 대상(targets)도 함께 갱신
+- [ ] 새 결제수단·통화를 추가하면 그 결제수단의 콜백 URL을 관리자 설정 안내(README "콜백/통보 URL 등록")에도 반영
+
+## 6. 금지 패턴
+
+
+| 금지 | 올바른 사용 | 이유 |
+|---|---|---|
+| PG 콜백의 `authUrl`/`P_REQ_URL`/`netCancelUrl`을 화이트리스트 없이 그대로 호출 | KG 이니시스 허용 URL 목록과 대조 후에만 호출 | 콜백 파라미터를 신뢰하면 공격자가 임의 URL로 서버발 요청을 유도할 수 있다(SSRF) |
+| 동일 거래번호 콜백을 매번 재처리 | 콜백 재처리 방지 검사를 거친 뒤 처리 | 재처리를 막지 않으면 같은 결제가 중복 완료 처리되거나 중복 환불될 수 있다 |
+| 결제창 서명/모바일 해시/CBT 해시 요청에 타임스탬프 검증 생략 | 타임스탬프 신선도 검증 유지 | 오래된 서명 재사용(replay)으로 위조 결제 요청이 통과할 수 있다 |
+| 일본 결제 설정 미완료 시 한국 표준결제로 조용히 대체 | 설정 미완료면 결제 자체를 중단 | 통화·수수료·정산 구조가 다른 결제가 잘못된 흐름으로 승인될 수 있다 |
+| 라이브 키(사인키·INIAPI 키/IV·해시키)를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 결제창 서명을 위조할 수 있다 |
+
+
+## 7. 테스트 실행
+
+
+| 종류 | 개수 | 위치 |
+|---|---|---|
+| PHPUnit | 35개 | `plugins/_bundled/sirsoft-pay_kginicis/tests` |
+| Vitest | 12개 | `vitest.config.ts` |
+| Playwright | 0개 | — |
+| 시나리오 매니페스트 | 2개 | `tests/scenarios` |
+
+기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
+
+```bash
+# PHPUnit (변경 범위만) (Bash)
+php vendor/bin/phpunit plugins/_bundled/sirsoft-pay_kginicis/tests --filter='<대상클래스>'
+
+# Vitest (확장 디렉토리에서) (PowerShell)
+cd plugins/_bundled/sirsoft-pay_kginicis && powershell -Command "npm run test:run -- <대상>"
+
+```
+
+무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
+
+
+## 8. 문서 목차
+
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
+| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
+| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
+| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/README.md b/plugins/_bundled/sirsoft-pay_kginicis/README.md
index efe9a60c..83d283d1 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/README.md
+++ b/plugins/_bundled/sirsoft-pay_kginicis/README.md
@@ -1,267 +1,274 @@
-# KG Inicis Plugin for G7
+# KG 이니시스
-KG 이니시스 표준결제를 G7 `sirsoft-ecommerce` 모듈에 연결하는 결제 플러그인입니다.
+**G7 플러그인 · sirsoft-pay_kginicis**
+KG 이니시스 표준결제를 sirsoft-ecommerce 에 연결하는 결제 플러그인
-PC 결제는 KG 이니시스 `INIStdPay.js` 표준결제창을 사용하고, 모바일 결제는 KG 이니시스 모바일 표준결제창으로 이동한 뒤 서버 승인 API로 최종 승인합니다. 일본 엔(JPY) 결제는 KG 이니시스 CBT(JPPG) 흐름을 사용합니다.
+
+
+
+
+
+
+
+
+
+
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+KG 이니시스 표준결제를 G7 `sirsoft-ecommerce` 모듈에 연결하는 결제 플러그인입니다. PC 결제는
+`INIStdPay.js` 표준결제창을, 모바일 결제는 모바일 표준결제창으로 이동한 뒤 서버 승인 API로
+최종 승인하는 흐름을 씁니다. 일본 엔(JPY) 결제는 별도의 KG 이니시스 CBT(JPPG) 흐름을 씁니다.
+
+이 플러그인은 결제 자체의 상태(주문·결제 성공/실패/취소)를 소유하지 않습니다 — 그 상태는
+`sirsoft-ecommerce`의 주문·결제 테이블에 있고, 이 플러그인은 "그 상태를 KG 이니시스 API 와
+어떻게 주고받는가"만 책임집니다. 그래서 이 플러그인은 소유 테이블/모델이 하나도 없습니다
+(§data-model.md).
+
## 주요 기능
-- 신용카드, 계좌이체, 가상계좌, 휴대폰결제 지원
-- PC 표준결제창 연동
-- 모바일 표준결제창 연동 및 `P_CHKFAKE` 위변조 방지 해시 생성
-- 삼성페이, L.pay, 카카오페이 간편결제 버튼 주입
-- 가상계좌 발급, PC/모바일 입금통보 처리
-- 에스크로 결제, 배송 등록, 구매결정, 구매거절확인 연동
-- 결제 취소 및 부분취소 연동
-- PG 측 결제 취소 확인 시점의 활동 로그 별도 기록 (PG 응답 시각·취소 TID 사후 추적)
-- 주문 완료/마이페이지 영수증 버튼 주입
-- 관리자 주문 상세의 거래 조회, 에스크로 처리 UI 확장
-- 현금영수증 발급/취소 (이커머스 모듈의 공용 현금영수증 프로바이더로 등록 — PG 결제사와 독립 선택 가능)
-- 일본 결제 CBT(JPPG) 인증/승인, 테스트 상품 생성, 연결 진단
-- 승인 URL 화이트리스트, 콜백 재처리 방지, 타임스탬프 신선도 검증
+
+| 영역 | 설명 |
+|---|---|
+| 결제수단 | 신용카드, 계좌이체, 가상계좌, 휴대폰결제 |
+| 간편결제 | 삼성페이, L.pay, 카카오페이 버튼 주입 (다른 PG가 기본이어도 노출 가능) |
+| 가상계좌 | 발급 + PC/모바일 입금통보 처리 |
+| 에스크로 | 결제, 배송 등록, 구매결정, 구매거절확인 연동 |
+| 결제 취소 | 전액/부분취소, PG 취소 확인 시점 별도 활동 로그(PG 응답 시각·취소 TID) |
+| 영수증 | 주문 완료/마이페이지 영수증 버튼, 현금영수증 발급/취소(이커머스 공용 프로바이더) |
+| 관리자 확장 | 주문 상세 거래 조회, 에스크로 처리 UI |
+| 일본 결제(CBT) | 인증/승인, 테스트 상품 생성, 연결 진단 |
+| 보안 | 승인 URL 화이트리스트, 콜백 재처리 방지, 타임스탬프 신선도 검증 |
+
+
+## 동작 방식
+
+
+```mermaid
+flowchart LR
+ A[체크아웃 주문 생성] -->|PC| B["/payment/signature 호출 → INIStdPay.js 결제창"]
+ A -->|모바일| C["/payment/mobile/signature → 모바일 표준결제창"]
+ A -->|JPY| D["/payment/cbt/hash-data → CBT 인증 URL"]
+ B --> E["/payment/callback (authToken·authUrl)"]
+ C --> F["/payment/mobile/callback (P_TID·P_REQ_URL)"]
+ D --> G["/payment/cbt/callback (sid)"]
+ E --> H[서버가 authUrl 화이트리스트 검증 후 승인 API 호출]
+ F --> H
+ G --> I[cbtapprove API 호출]
+ H --> J[주문 결제 완료 처리]
+ I --> J
+ J --> K[성공 URL 리다이렉트]
+```
+
+승인 후 로컬 처리가 실패하면 PC/모바일은 각각 netCancel/취소 API로, CBT는 CBT 전용 취소
+API로 자동 취소를 시도합니다(자동 취소까지 실패하면 수동 취소가 필요하다는 오류 로그를
+남깁니다) — "PG 는 승인됐는데 우리 시스템은 실패"라는 상태가 남지 않도록 하기 위함입니다.
+
+가상계좌는 결제창에서 발급되면 주문이 입금대기 상태로 유지되다가, KG 이니시스가 입금통보
+URL로 결과를 POST 하면 거래번호 재처리·금액 검증 후 결제 완료 처리됩니다. PC/모바일 입금통보는
+URL이 다르지만 같은 IP 화이트리스트 미들웨어를 거칩니다.
+
+일본 CBT 는 카드/PayPay는 즉시 승인 후 완료 처리되고, 편의점(CVS) 결제는 입금대기로 저장된 뒤
+`/payment/cbt/cvs-notify` 수신 시 완료 처리됩니다. JPY 주문은 일본 결제 설정이 완료된 경우에만
+CBT 결제창으로 진입하며, 설정이 부족하면 한국 표준결제로 대체하지 않고 결제 자체를 중단합니다.
+
## 요구 사항
-| 항목 | 내용 |
-|------|------|
-| G7 | `>= 7.0.0-beta.2` |
-| 의존 모듈 | `sirsoft-ecommerce >= 1.0.0-beta.4` |
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=7.0.10` |
| PHP | `^8.2` |
+| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
+
+
+
+| 항목 | 필요한 것 |
+|---|---|
| 운영 환경 | HTTPS 도메인, 올바른 `APP_URL`, KG 이니시스 가맹점 계약 정보 |
| PC 결제 | MID, signKey |
| 모바일 결제 | MID, 모바일 hash key |
| 취소/거래조회/현금영수증 | INIAPI key, INIAPI IV |
| 일본 CBT | 별도 일본 결제 MID, CBT hash key |
-서버에서 KG 이니시스 결제/INIAPI/CBT 호스트로 HTTPS outbound 요청이 가능해야 합니다. CBT 테스트 환경 `devcbt.inicis.com`은 KG 이니시스 측에 서버 egress IP 등록이 필요할 수 있습니다.
+서버에서 KG 이니시스 결제/INIAPI/CBT 호스트로 HTTPS outbound 요청이 가능해야 합니다. CBT
+테스트 환경 `devcbt.inicis.com`은 KG 이니시스 측에 서버 egress IP 등록이 필요할 수 있습니다.
+
## 설치
-플러그인을 G7 프로젝트의 플러그인 디렉토리에 배치합니다.
-
-```text
-plugins/sirsoft-pay_kginicis
-```
-
-프론트엔드 에셋을 수정한 경우 플러그인 디렉토리에서 빌드합니다.
-
+
```bash
-npm install
-npm run build
+# 번들 설치 (코어에 동봉된 소스에서 설치)
+php artisan plugin:install sirsoft-pay_kginicis
+
+# 활성화
+php artisan plugin:activate sirsoft-pay_kginicis
+
+# 업데이트 (번들 소스 기준 강제 반영)
+php artisan plugin:update sirsoft-pay_kginicis --force
```
-그다음 G7 관리자에서 플러그인을 활성화하고, 이커머스 결제 설정에서 PG 제공자를 `KG 이니시스`로 선택합니다.
+저장소: https://github.com/gnuboard/g7-plugin-sirsoft-pay_kginicis
+
+
+설치·활성화 후 이커머스 결제 설정에서 PG 제공자를 "KG 이니시스"로 선택해야 실제로 결제
+흐름에 연결됩니다 — 활성화만으로는 체크아웃 화면에 나타나지 않습니다.
## 관리자 설정
-관리자 플러그인 설정 화면에서 KG 이니시스 계약 정보를 입력합니다.
+
+| 키 | 의미 | 기본값 |
+|---|---|---|
+| `is_test_mode` | 테스트 모드 | `true` |
+| `test_mid` | 테스트 가맹점 ID (MID) | `INIpayTest` |
+| `test_sign_key` | 테스트 사인키 | `SU5JTElURV9UUklQTEVERVNfS0VZU1RS` |
+| `test_iniapi_key` | 테스트 INIAPI 키 | `ItEQKi3rY7uvDS8l` |
+| `test_iniapi_iv` | 테스트 INIAPI IV | `HYb3yQ4f65QL89==` |
+| `live_mid` | 라이브 가맹점 ID (MID) | - |
+| `live_sign_key` | 라이브 사인키 | - |
+| `live_iniapi_key` | 라이브 INIAPI 키 | - |
+| `live_iniapi_iv` | 라이브 INIAPI IV | - |
+| `test_mobile_hash_key` | 테스트 모바일 해시키 | `3CB8183A4BE283555ACC8363C0360223` |
+| `live_mobile_hash_key` | 라이브 모바일 해시키 | - |
+| `use_escrow` | 에스크로 결제 활성화 | `false` |
+| `japan_enabled` | 일본 결제 활성화 | `false` |
+| `japan_restrict_jpy_payment_methods` | JPY 주문 결제수단 제한 | `false` |
+| `test_japan_sign_key` | 테스트 일본 CBT 해시키 | `5AL5Djb1Ipualn0F` |
+| `live_japan_mid` | 라이브 일본 MID | - |
+| `live_japan_sign_key` | 라이브 일본 CBT 해시키 | - |
+| `japan_merchant_name` | 일본 결제 가맹점명 | `サンプルストア` |
+| `japan_merchant_name_kana` | 일본 결제 가맹점명 Kana | `サンプルストア` |
+| `japan_merchant_name_alphabet` | 일본 결제 가맹점명 영문 | `Sample Store` |
+| `japan_merchant_name_short` | 일본 결제 가맹점 약칭 | `サンプル` |
+| `japan_contact_name` | 일본 결제 문의처명 | `サポート窓口` |
+| `japan_contact_email` | 일본 결제 문의 이메일 | `support@example.com` |
+| `japan_contact_phone` | 일본 결제 문의 전화번호 | `0120-123-456` |
+| `japan_contact_opening_hours` | 일본 결제 문의 영업시간 | `10:00-18:00` |
+| `redirect_success_url` | 결제 성공 리다이렉트 URL | `{shopBase}/orders/{orderId}/complete` |
+| `redirect_fail_url` | 결제 실패 리다이렉트 URL | `{shopBase}/checkout` |
+| `easy_pay_allow_with_other_pg` | 타 PG와 사용가능함 | `false` |
+| `easy_pay_samsung_pay` | KG이니시스 삼성페이 사용 | `false` |
+| `easy_pay_naverpay` | KG이니시스 네이버페이 사용 | `false` |
+| `easy_pay_show_brand_button` | 간편결제 브랜드 버튼 표시 | `false` |
+| `easy_pay_lpay` | KG이니시스 L.pay 사용 | `false` |
+| `easy_pay_kakaopay` | KG이니시스 카카오페이 사용 | `false` |
+| `use_credit_point` | 신용카드 포인트 사용 | `false` |
-| 설정 | 설명 |
-|------|------|
-| 테스트 모드 | 활성화 시 KG 이니시스 테스트 환경을 사용합니다. 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 거래는 매일 23:00~23:50 사이 자동 취소될 수 있습니다. |
-| 테스트 MID | 기본값은 `INIpayTest`입니다. 에스크로 테스트 사용 시 내부적으로 `iniescrow0`을 사용합니다. |
-| 테스트 사인키 | PC 결제창 서명 생성에 사용합니다. |
-| 테스트 INIAPI 키/IV | 취소, 거래조회, 현금영수증, 에스크로 API 인증에 사용합니다. |
-| 테스트 모바일 해시키 | 모바일 `P_CHKFAKE` 생성에 사용합니다. |
-| 라이브 MID | 운영 MID입니다. `SIR` prefix 없이 입력해도 플러그인이 자동 보정합니다. |
-| 라이브 사인키 | 운영 결제창 서명 생성에 사용합니다. 외부에 노출하지 마세요. |
-| 라이브 INIAPI 키/IV | 운영 취소, 거래조회, 현금영수증, 에스크로 API 인증에 사용합니다. |
-| 라이브 모바일 해시키 | 운영 모바일 `P_CHKFAKE` 생성에 사용합니다. |
-| 에스크로 결제 활성화 | PC는 `acceptmethod`에 `useescrow`, 모바일은 `P_RESERVED`에 `useescrow=Y`를 추가합니다. |
-| 일본 결제 활성화 | JPY 주문에서 KG 이니시스 CBT(JPPG) 결제 흐름을 사용합니다. |
-| 테스트 일본 CBT 해시키 | CBT 테스트 해시 생성에 사용합니다. 테스트 MID는 `CBTTEST001` 고정값을 사용합니다. |
-| 라이브 일본 MID/해시키 | 운영 CBT 결제에 사용합니다. |
-| JPPG 결제창 표시 정보 | 일본 결제창 `extraData`에 포함되는 가맹점명, 가나명, 영문명, 문의처 정보를 설정합니다. 운영 전 실제 계약 정보로 교체하세요. |
-| 결제 성공 URL | 기본값은 `/shop/orders/{orderId}/complete`입니다. |
-| 결제 실패 URL | 기본값은 `/shop/checkout`입니다. |
-| 간편결제 | KG 이니시스 계약이 완료된 간편결제만 활성화하세요. |
-| 타 PG와 사용가능함 | 다른 PG가 기본값이어도 KG 이니시스 간편결제 버튼을 체크아웃 화면에 표시합니다. |
-| 신용카드 포인트 사용 | PC 카드 결제 `acceptmethod`에 신용카드 포인트 사용 옵션을 추가합니다. |
+개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
+
-테스트 모드 주문은 실제 배송하지 마세요. 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 거래는 매일 23:00~23:50 사이 자동 취소될 수 있습니다.
+
+테스트 모드에서는 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 거래는 매일
+23:00~23:50 사이 자동 취소될 수 있습니다 — **테스트 모드 주문을 실제로 배송하지 마세요.**
+운영 키(라이브 사인키·INIAPI 키/IV·모바일/CBT 해시키)는 외부에 노출하지 말고, 배포 전
+테스트 모드가 의도한 값인지 반드시 확인하세요.
-운영 키와 해시키는 외부에 노출하지 말고, 배포 전 테스트 모드가 의도한 값인지 확인하세요.
+라이브 MID는 `SIR` 접두사 없이 입력해도 플러그인이 자동 보정합니다. 에스크로는 PC의
+`acceptmethod`에 `useescrow`를, 모바일은 `P_RESERVED`에 `useescrow=Y`를 추가하는 방식으로
+켜집니다. 일본 결제를 운영 모드로 켜려면 라이브 일본 MID/CBT 해시키와 실제 JPPG 가맹점
+표시 정보가 필요합니다 — 기본 샘플값이 남아 있으면 설정 저장 단계에서 차단됩니다.
-## 콜백 및 통보 URL
-
-KG 이니시스 가맹점 관리자에 아래 URL을 등록합니다. 도메인은 실제 운영 도메인으로 바꿔 입력하세요.
+**콜백/통보 URL 등록** — KG 이니시스 가맹점 관리자에 아래 URL을 실제 운영 도메인으로 등록합니다.
| 용도 | URL |
-|------|-----|
-| PC 결제 결과 Return URL | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/callback` |
-| PC 가상계좌 입금통보 URL | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/vbank-notify` |
-| 모바일 결제 결과 `P_NEXT_URL` | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/mobile/callback` |
-| 모바일 가상계좌 입금통보 URL | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/mobile/vbank-notify` |
-| CBT 콜백 URL | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/cbt/callback` |
-| CBT 편의점 입금 NOTI URL | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify` |
-| 에스크로 구매결정 화면 | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/escrow-confirm/{orderNumber}` |
+|---|---|
+| PC 결제 결과 Return URL | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/callback` |
+| PC 가상계좌 입금통보 URL | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/vbank-notify` |
+| 모바일 결제 결과 `P_NEXT_URL` | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/mobile/callback` |
+| 모바일 가상계좌 입금통보 URL | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/mobile/vbank-notify` |
+| CBT 콜백 URL | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/cbt/callback` |
+| CBT 편의점 입금 NOTI URL | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify` |
+| 에스크로 구매결정 화면 | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/escrow-confirm/{orderNumber}` |
-PC 결제 결과 Return URL과 모바일 `P_NEXT_URL`은 사용자 브라우저를 통해 호출됩니다. 가상계좌 입금통보 URL은 KG 이니시스 서버가 직접 호출하므로 운영 환경에서 IP 화이트리스트가 적용됩니다.
+가상계좌 입금통보 URL은 KG 이니시스 서버가 직접 호출하므로 운영 환경에서 IP 화이트리스트가
+적용됩니다(`203.238.37.15`, `39.115.212.9`, `118.129.210.25`, `183.109.71.153` — 운영 전
+KG 이니시스 최신 연동 가이드로 다시 확인하세요). `local`/`testing` 환경에서는 개발·테스트를
+위해 이 제한을 우회합니다.
+
-KG 이니시스 PC 에스크로 매뉴얼 기준 별도 webhook 통보 채널은 사용하지 않습니다. 에스크로 배송등록, 구매결정, 구매거절확인은 플러그인이 제공하는 화면과 API를 통해 처리합니다.
+## 사용 방법
-## IP 화이트리스트
+
+**결제 취소/부분취소**: 관리자가 주문 취소를 요청(`cancel_pg=true`)하면 코어가
+`sirsoft-ecommerce.payment.refund` 필터 훅을 발화하고, 이 플러그인의 `PaymentRefundListener`
+가 KG 이니시스 취소/부분취소 API를 호출합니다(전액취소는 `cancelPrice=null` +
+`totalAmount=null`, 부분취소는 취소 금액 + 원래 결제금액). 배송비가 포함된 주문은 전체취소 시
+배송비도 함께 환불 레코드에 반영되고, 쿠폰이 적용된 주문은 실결제금액(쿠폰 차감 후)이 PG
+취소 금액으로 전달됩니다. 부분취소로 쿠폰 최소 주문금액 조건을 더 이상 충족하지 못하면
+코어가 취소 자체를 거부(422)해 PG 호출이 아예 발생하지 않습니다. KG 이니시스 API 호출이
+실패하면 주문 상태 변경이 롤백됩니다.
-운영 환경에서는 아래 IP에서 들어온 KG 이니시스 가상계좌 입금통보만 허용합니다. `local`, `testing` 환경에서는 개발과 테스트를 위해 제한을 우회합니다.
+**에스크로 처리**: 에스크로 결제 완료 후 관리자 주문 상세에서 배송 등록을 호출할 수 있고,
+사용자는 에스크로 구매결정 화면에서 구매확인을 진행합니다. 구매거절이 발생한 주문은 관리자
+주문 상세에서 구매거절확인을 호출할 수 있습니다.
-| IP |
-|----|
-| `203.238.37.15` |
-| `39.115.212.9` |
-| `118.129.210.25` |
-| `183.109.71.153` |
+**CBT 연결 진단**: 일본 결제 테스트가 실패하면 관리자 CBT 연결 진단(§API)에서 서버 egress
+IP와 `devcbt.inicis.com` 443 연결 상태를 먼저 확인합니다.
-운영 전 KG 이니시스 가맹점 관리자와 최신 연동 가이드의 통보 서버 IP를 다시 확인하세요.
+전체 API 목록(사용자/관리자)은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은
+[docs/extension-points.md](docs/extension-points.md) 를 참고하세요.
+
-## 결제 흐름
+## 다른 확장과의 연동
-### PC 결제
+
+**이 확장이 의존하는 확장**
-```text
-체크아웃 주문 생성
-→ 프론트엔드 핸들러가 /payment/signature 호출
-→ INIStdPay.js 결제창 실행
-→ KG 이니시스가 /payment/callback 으로 authToken, authUrl POST
-→ 서버가 authUrl 화이트리스트 검증 후 승인 API 호출
-→ 주문 결제 완료 처리
-→ 성공 URL로 리다이렉트
-```
+| 확장 | 유형 | 버전 제약 | 번들 |
+|---|---|---|---|
+| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ |
-승인 후 주문 처리에 실패하면 KG 이니시스 netCancel URL로 망취소를 시도합니다.
+**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
-### 모바일 결제
+없음.
+
-```text
-체크아웃 주문 생성
-→ /payment/mobile/signature 호출
-→ 모바일 표준결제창으로 form POST
-→ KG 이니시스가 /payment/mobile/callback 으로 P_TID, P_REQ_URL 전달
-→ 서버가 P_REQ_URL 화이트리스트 검증 후 모바일 승인 API 호출
-→ 주문 결제 완료 처리
-→ 성공 URL로 리다이렉트
-```
+
+`RegisterPgProviderListener`가 이 플러그인을 이커머스의 PG 제공자 레지스트리에, `RegisterCashReceiptProviderListener`
+가 현금영수증 프로바이더 레지스트리에 각각 등록합니다 — PG 결제사 선택과 현금영수증 발급사
+선택은 서로 독립적이라, 다른 PG를 쓰면서도 KG 이니시스로 현금영수증만 발급하는 조합이
+가능합니다.
+
-모바일 승인 후 주문 처리에 실패하면 취소 API로 자동 취소를 시도합니다.
+## 문서
-### 가상계좌
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
+| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
+| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
+| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
-```text
-결제창에서 가상계좌 발급
-→ 주문 결제 정보에 은행, 계좌번호, 예금주, 만료일 저장
-→ 주문은 입금대기 상태 유지
-→ KG 이니시스가 입금통보 URL로 입금 결과 POST
-→ 거래번호 재처리와 금액 검증 후 주문 결제 완료 처리
-```
+## 트러블슈팅
-PC와 모바일 입금통보는 서로 다른 URL을 사용하지만, 모두 같은 IP 화이트리스트 미들웨어를 통과해야 합니다.
+
+| 증상 | 원인 | 조치 |
+|---|---|---|
+| 가상계좌 입금통보가 반영되지 않음 | 운영 환경 IP 화이트리스트에 KG 이니시스 통보 서버 IP가 없음 | 최신 연동 가이드의 통보 서버 IP로 화이트리스트를 갱신 |
+| 결제 승인 후 주문이 실패 상태로 남음 | 로컬 후속 처리 실패 후 자동 취소(망취소/취소 API)까지 실패 | 오류 로그의 안내대로 수동 취소 진행 — PG 승인은 이미 됐을 수 있음 |
+| 일본 결제창이 안 열리고 결제가 중단됨 | 일본 결제 설정(라이브 MID/해시키/가맹점 정보) 미완료 | 설정을 완료하거나, 완료 전까지는 JPY 주문을 받지 않음 — 한국 표준결제로 자동 대체되지 않음 |
+| CBT 테스트가 계속 실패함 | 서버 egress IP 미등록 또는 방화벽으로 443 포트 차단 | 관리자 CBT 연결 진단 실행 후 KG 이니시스에 서버 IP 등록 요청 |
+| 결제 성공했는데 간편결제 버튼 클릭 시 오류 | KG 이니시스 계약이 없는 결제수단/간편결제를 활성화 | 계약이 완료된 결제수단만 관리자 설정에서 활성화 |
+
-### 일본 CBT
+## 변경 이력
-```text
-JPY 주문 생성
-→ /payment/cbt/hash-data 호출
-→ CBT 인증 URL로 form POST
-→ KG 이니시스가 /payment/cbt/callback 으로 sid 전달
-→ 서버가 cbtapprove API 호출
-→ 카드/PayPay는 주문 결제 완료 처리
-→ 편의점(CVS)은 입금대기 저장 후 /payment/cbt/cvs-notify 입금 NOTI 수신 시 결제 완료 처리
-```
-
-CBT 승인 이후 로컬 후속 처리에 실패하면 CBT 전용 취소 API로 자동 취소를 시도합니다. 자동 취소까지 실패한 경우에는 운영자 수동 취소가 필요하다는 오류 로그를 남깁니다.
-
-일본 엔(JPY) 주문은 일본 결제 설정이 완료된 경우에만 CBT 결제창으로 진입합니다. 설정이 부족하면 한국 표준결제 흐름으로 대체하지 않고 결제를 중단합니다.
-
-현재 CBT 결제창은 선택한 결제수단에 맞춰 지불수단을 제한합니다. 신용카드는 `CARD`만, PayPay는 `PAYpay`만, 일본 편의점결제는 `CVS`만 열립니다.
-
-운영 모드에서 일본 결제를 활성화하려면 라이브 일본 MID/CBT 해시키와 실제 JPPG 가맹점 표시 정보가 필요합니다. 기본 샘플값이 남아 있으면 설정 저장 단계에서 차단됩니다.
-
-### 에스크로
-
-에스크로를 활성화하면 결제 요청에 에스크로 옵션을 전달합니다. 에스크로 결제 완료 후 관리자 주문 상세에서 배송 등록을 호출할 수 있고, 사용자는 에스크로 구매결정 화면에서 구매확인을 진행할 수 있습니다.
-
-구매거절이 발생한 주문은 관리자 주문 상세에서 구매거절확인을 호출할 수 있습니다.
-
-### 결제 취소 / 부분취소
-
-```text
-관리자 주문 취소 요청 (cancel_pg=true)
-→ 코어가 sirsoft-ecommerce.payment.refund 필터 훅 발화
-→ PaymentRefundListener 가 KG 이니시스 취소/부분취소 API 호출
- · 전액취소: cancelPrice=null, totalAmount=null
- · 부분취소: cancelPrice=취소 금액, totalAmount=원래 결제금액
-→ 코어가 환불 레코드 생성 + 쿠폰 / 마일리지 / 재고 복원
-→ CancelActivityLogListener 가 PG 응답 시각·취소 TID를 활동 로그에 기록
-```
-
-배송비가 포함된 주문은 전체취소 시 배송비도 함께 환불 레코드에 반영되고, 쿠폰이 적용된 주문은 실결제금액(쿠폰 차감 후) 이 PG 취소 금액으로 전달됩니다. 부분취소 시 쿠폰 최소 주문금액 조건을 더 이상 충족하지 못하면 코어가 취소 자체를 거부 (422) 하여 PG 호출이 발생하지 않습니다. KG 이니시스 API 호출이 실패하면 주문 상태 변경이 롤백됩니다.
-
-## API
-
-### 사용자 API
-
-| Method | Path | 설명 |
-|--------|------|------|
-| `POST` | `/api/plugins/sirsoft-pay_kginicis/payment/signature` | PC 결제창 서명 생성 |
-| `POST` | `/api/plugins/sirsoft-pay_kginicis/payment/mobile/signature` | 모바일 `P_CHKFAKE` 생성 |
-| `POST` | `/api/plugins/sirsoft-pay_kginicis/payment/cbt/hash-data` | CBT hashData 생성 |
-| `GET` | `/api/plugins/sirsoft-pay_kginicis/user/orders/{orderNumber}/receipt` | KG 이니시스 영수증 URL 조회 |
-
-### 관리자 API
-
-| Method | Path | 설명 |
-|--------|------|------|
-| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/vbank-notify-url` | PC/모바일 가상계좌 입금통보 URL 조회 |
-| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/test-mode-map` | 주문목록 테스트 모드 배지용 맵 조회 |
-| `POST` | `/api/plugins/sirsoft-pay_kginicis/admin/transaction/query` | TID로 KG 이니시스 거래 조회 |
-| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/transaction-status` | 주문번호로 거래 상태 조회 |
-| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-delivery` | 에스크로 배송 등록 폼 데이터 조회 |
-| `POST` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-delivery` | KG 이니시스 에스크로 배송 등록 |
-| `POST` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-deny-confirm` | 에스크로 구매거절확인 |
-| `POST` | `/api/plugins/sirsoft-pay_kginicis/admin/cbt-test-product` | CBT 테스트용 JPY 상품 생성 |
-| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/cbt-connectivity-check` | CBT 테스트 호스트 연결 진단 |
-
-## 훅
-
-다른 모듈이나 플러그인에서 아래 훅에 연결해 결제 흐름을 확장할 수 있습니다.
-
-| 훅 | 타입 | 시점 |
-|----|------|------|
-| `sirsoft-pay_kginicis.payment.before_authorize` | action | KG 이니시스 서버 승인 API 호출 전 |
-| `sirsoft-pay_kginicis.payment.after_authorize` | action | KG 이니시스 서버 승인 완료 후 |
-| `sirsoft-pay_kginicis.payment.before_cancel` | action | KG 이니시스 결제 취소 API 호출 전 |
-| `sirsoft-pay_kginicis.payment.after_cancel` | action | KG 이니시스 결제 취소 완료 후 |
-
-`sirsoft-ecommerce.payment.refund` 필터를 통해 이커머스 환불 요청을 KG 이니시스 취소/부분취소 API로 연결합니다.
-
-## 보안 및 운영 참고
-
-- 운영 도메인의 `APP_URL`을 HTTPS 절대 URL로 정확히 설정하세요.
-- 운영 signKey, INIAPI key, INIAPI IV, 모바일 hash key, CBT hash key는 외부에 노출하지 마세요.
-- 결제창 서명, 모바일 해시, CBT 해시 생성 요청은 타임스탬프 신선도를 검증합니다.
-- CBT 해시 생성 요청은 주문자 이메일/연락처와 서버 주문 정보를 대조하고, IP/주문번호 단위 요청 횟수를 제한합니다.
-- CBT 환불은 결제 당시 저장된 테스트/운영 모드와 일본 MID 기준으로 처리합니다.
-- PC `authUrl`, 모바일 `P_REQ_URL`, PC `netCancelUrl`은 KG 이니시스 허용 URL만 사용합니다.
-- 동일 거래번호 콜백은 중복 처리하지 않도록 방어합니다.
-- 운영 환경에서는 가상계좌 입금통보 IP 화이트리스트가 적용됩니다.
-- 가상계좌 입금통보 URL은 KG 이니시스 가맹점 관리자에 반드시 등록해야 합니다.
-- KG 이니시스 계약이 없는 결제수단이나 간편결제를 활성화하면 결제창 오류가 발생할 수 있습니다.
-- CBT 테스트가 실패하면 관리자 CBT 연결 진단에서 서버 egress IP와 `devcbt.inicis.com` 443 연결 상태를 먼저 확인하세요.
-
-## 테스트
-
-플러그인을 G7 프로젝트에 배치한 뒤 G7 루트에서 PHP 테스트를 실행합니다.
-
-```bash
-php artisan test plugins/sirsoft-pay_kginicis/tests
-```
-
-프론트엔드 테스트와 빌드는 플러그인 디렉토리에서 실행합니다.
-
-```bash
-npm install
-npm run test:run
-npm run build
-```
+[CHANGELOG.md](CHANGELOG.md)
## 라이선스
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/README.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/README.md
new file mode 100644
index 00000000..6f32a869
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/README.md
@@ -0,0 +1,22 @@
+# KG 이니시스 개발자 문서
+
+> plugins/_bundled/sirsoft-pay_kginicis · 플러그인
+
+
+**훅 수**: 6 · **구독 훅 수**: 14 · **라우트 수**: 35 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 1
+
+
+## 문서 목차
+
+
+| 문서 | 내용 |
+|---|---|
+| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
+| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
+| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
+| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
+| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
+| [api/](api/README.md) | API 레퍼런스 |
+| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
+| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
+
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/architecture.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/architecture.md
new file mode 100644
index 00000000..51add76a
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/architecture.md
@@ -0,0 +1,65 @@
+# KG 이니시스 — 아키텍처
+
+> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 설계 의도
+
+
+결제 프로토콜(PC/모바일/CBT)마다 별도 컨트롤러·서비스 경로를 두면서도, 이커머스 쪽에는
+`sirsoft-ecommerce.payment.registered_pg_providers`/`registered_cash_receipt_providers`
+필터로 등록하는 하나의 진입점만 노출합니다. 이 경계 덕분에 KG 이니시스가 프로토콜을 바꾸거나
+새 결제수단을 추가해도 이커머스 모듈 코드는 건드리지 않습니다 — 변경은 이 플러그인 안에서만
+일어납니다. 반대로 이 플러그인이 소유 테이블을 두지 않는 것도 같은 경계 원칙입니다: 결제
+"사실"(성공/실패/금액/취소)은 이커머스가 소유하고, 이 플러그인은 그 사실을 만드는 절차만
+소유합니다.
+
+
+## 계층 지도
+
+
+```
+Controllers (PaymentCallbackController 등 — PG 콜백 수신, 화이트리스트·재처리 방지 검증)
+ │
+ ▼
+Services (KgInicisApiService — 승인/취소/CBT API 호출, 서명·해시 생성)
+ │
+ ├──▶ Repositories (CbtCvsOperationsRepository/CbtReconciliationRepository
+ │ — sirsoft-ecommerce 의 Order 모델을 조회, 자체 테이블 없음)
+ │
+ └──▶ 훅 발행 (before/after_authorize·cancel·cbt_refund) ──▶ 다른 확장 리스너
+
+Listeners (RegisterPgProviderListener 등 — 이커머스 레지스트리 등록,
+ 레이아웃 확장 주입, 설정 검증)
+```
+
+미들웨어(`InicisNotifyIpWhitelist`)는 이 흐름과 별도 레인에서 가상계좌/CBT 편의점 입금통보
+라우트 앞단을 지킵니다 — Service 계층이 아니라 라우팅 계층에서 걸러야 신뢰할 수 없는 발신자의
+요청이 애초에 비즈니스 로직에 닿지 않습니다.
+
+
+## 디렉토리
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
+| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
+| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
+| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
+| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
+| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
+| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
+| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
+| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
+| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_kginicis --force` (빌드 불필요) |
+| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_kginicis --force` |
+| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_kginicis --force` |
+| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_kginicis --force` |
+| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
+| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_kginicis --force` |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
+
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/data-model.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/data-model.md
new file mode 100644
index 00000000..f10ff9cd
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/data-model.md
@@ -0,0 +1,75 @@
+# KG 이니시스 — 데이터 모델
+
+> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 모델
+
+
+_소유 모델이 없습니다._
+
+
+
+결제 상태(주문·결제 성공/실패/취소/금액)는 전부 `sirsoft-ecommerce`의 `Order`/결제 모델에
+있습니다. 이 플러그인이 자체 모델을 두지 않는 것은 실수나 미완성이 아니라 설계입니다(§AGENTS.md
+"이 확장은 무엇인가") — PG 마다 결제 기록 테이블을 따로 두면 "이 주문이 지금 실제로 어떤
+상태인가"를 물을 때 여러 테이블을 조인해야 하고, PG 를 교체하면 과거 주문의 결제 이력을
+조회할 방법이 갈라집니다.
+
+
+## 소유 테이블
+
+
+_소유 테이블이 없습니다._
+
+
+
+KG 이니시스 고유 정보(MID·서명키 등)는 코어 `PluginSettingsService`(설정 스키마)에, 가상계좌
+계좌정보·CBT 승인 정보 같은 거래별 데이터는 이커머스 주문/결제 레코드의 JSON 컬럼 또는
+연관 필드에 함께 저장됩니다 — 이 플러그인이 그 값을 "소유"하지 않고 이커머스 테이블에
+"기록"만 남기는 형태입니다.
+
+
+## 마이그레이션
+
+
+_마이그레이션이 없습니다._
+
+
+
+소유 테이블이 없으므로 마이그레이션도 없습니다. 이 플러그인이 설정 스키마를 바꿀 때는
+마이그레이션이 아니라 `config/settings/defaults.json`(§settings.md)과 필요 시 업그레이드
+스텝(과거 설정값 정정용)을 씁니다.
+
+
+## Enum
+
+
+_Enum 이 없습니다._
+
+
+
+결제수단·상태 분류(카드/계좌이체/가상계좌/휴대폰 등)는 이 플러그인이 아니라 이커머스가 소유한
+결제수단 Enum 을 그대로 따릅니다 — PG 마다 결제수단 이름을 다시 정의하면 이커머스가 PG 를
+교체 가능한 형태로 다룰 수 없습니다. KG 이니시스 API 고유의 코드값(예: `acceptmethod` 문자열
+조합)은 Enum 이 아니라 KG 이니시스 API 스펙에 맞춘 문자열 상수로만 존재합니다.
+
+
+## Repository
+
+
+| 클래스 | 종류 | 설명 |
+|---|---|---|
+| `CbtCvsOperationsRepository` | 구현 | - |
+| `CbtCvsOperationsRepositoryInterface` | 인터페이스 | - |
+| `CbtReconciliationRepository` | 구현 | - |
+| `CbtReconciliationRepositoryInterface` | 인터페이스 | - |
+
+
+
+두 Repository 모두 자체 테이블이 아니라 `sirsoft-ecommerce`의 `Order` 모델을 조회합니다
+(`CbtCvsOperationsRepository::findOrderWithPayment()`가 대표적 예). 소유 데이터가 없는데도
+Repository 인터페이스를 쓰는 이유는 "이 플러그인이 이커머스 데이터에 접근하는 지점"을
+Service 안에 흩어진 쿼리가 아니라 한 곳으로 모아, 나중에 이커머스의 주문 조회 방식이 바뀌어도
+이 두 클래스만 고치면 되게 하기 위해서입니다 — 일반적인 "내 테이블 CRUD" Repository 와는
+쓰임이 다릅니다.
+
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/extension-points.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/extension-points.md
new file mode 100644
index 00000000..fc79ae42
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/extension-points.md
@@ -0,0 +1,155 @@
+# KG 이니시스 — 확장점
+
+> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 발행 훅
+
+
+발행 훅 6종 / 호출 지점 6곳.
+
+| 훅 이름 | 유형 | 설명 | 발행 위치 |
+|---|---|---|---|
+| `sirsoft-pay_kginicis.payment.after_authorize` | action | KG 이니시스 서버 승인 완료 후 | `src/Controllers/PaymentCallbackController.php:247` |
+| `sirsoft-pay_kginicis.payment.after_cancel` | action | KG 이니시스 결제 취소 완료 후 | `src/Services/KgInicisApiService.php:780` |
+| `sirsoft-pay_kginicis.payment.after_cbt_refund` | action | KG 이니시스 일본 CBT 결제 취소 완료 후 | `src/Services/KgInicisApiService.php:592` |
+| `sirsoft-pay_kginicis.payment.before_authorize` | action | KG 이니시스 서버 승인 API 호출 전 | `src/Controllers/PaymentCallbackController.php:243` |
+| `sirsoft-pay_kginicis.payment.before_cancel` | action | KG 이니시스 결제 취소 API 호출 전 (본인인증 등 확장 지점) | `src/Services/KgInicisApiService.php:758` |
+| `sirsoft-pay_kginicis.payment.before_cbt_refund` | action | KG 이니시스 일본 CBT 결제 취소 API 호출 전 | `src/Services/KgInicisApiService.php:564` |
+
+
+
+일반 결제(승인/취소)와 CBT(일본)가 각각 별도 `before/after_cbt_refund` 훅 쌍을 갖는 이유는
+두 흐름이 서로 다른 API·통화·해시 체계를 쓰기 때문입니다 — 하나로 합치면 구독자가 매번
+"이게 CBT 인지 일반인지"를 페이로드로 분기해야 합니다. `before_cancel`은 발행 위치 설명에
+"본인인증 등 확장 지점"이라고 명시돼 있습니다 — 고액 취소에 관리자 재인증을 강제하고 싶은
+확장은 이 훅에서 예외를 던져 PG 호출 자체를 막을 수 있습니다.
+
+
+## 구독 훅
+
+
+| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
+|---|---|---|---|---|
+| `core.layout_extension.after_apply` | filter | `AdjustEcommercePaymentMethodsLayoutListener` | `adjustPaymentMethodsLayout` | 20 |
+| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderDetailPaymentQueryLayoutListener` | `ensurePaymentQueryLayout` | 66 |
+| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderDetailTestModeLayoutListener` | `ensureTestModeLayout` | 65 |
+| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderListTestBadgeLayoutListener` | `ensureTestBadgeLayout` | 60 |
+| `core.plugin_settings.before_save` | action (미선언) | `ValidateCbtSettingsListener` | `validateBeforeSave` | 10 |
+| `core.plugins.updated` | action | `RestoreLayoutExtensionsAfterUpdateListener` | `restoreCurrentExtensionsAfterUpdate` | 20 |
+| `sirsoft-ecommerce.cash_receipt.cancel` | filter | `RegisterCashReceiptProviderListener` | `cancel` | 10 |
+| `sirsoft-ecommerce.cash_receipt.issue` | filter | `RegisterCashReceiptProviderListener` | `issue` | 10 |
+| `sirsoft-ecommerce.cash_receipt.registered_providers` | filter | `RegisterCashReceiptProviderListener` | `registerProvider` | 10 |
+| `sirsoft-ecommerce.payment.get_client_config` | filter | `RegisterPgProviderListener` | `getClientConfig` | 10 |
+| `sirsoft-ecommerce.payment.refund` | filter | `CancelActivityLogListener` | `logCancelConfirmed` | 20 |
+| `sirsoft-ecommerce.payment.refund` | filter | `PaymentRefundListener` | `processRefund` | 10 |
+| `sirsoft-ecommerce.payment.registered_pg_providers` | filter | `RegisterPgProviderListener` | `registerProvider` | 10 |
+| `sirsoft-ecommerce.settings.filter_available_payment_methods` | filter | `RegisterEasyPayMethodsListener` | `injectEasyPayMethods` | 20 |
+
+
+
+`core.layout_extension.after_apply`를 구독하는 3개 리스너(`Ensure*LayoutListener`)가 서로
+다른 우선순위(60/65/66)를 갖는 것은 우연이 아닙니다 — 관리자 주문 목록/상세 레이아웃에 여러
+확장이 조각을 주입할 수 있어, 이 플러그인의 조각들이 서로 겹치지 않는 순서로 배치되도록
+번호를 나눠 씁니다. `sirsoft-ecommerce.payment.refund`를 구독하는 두 리스너의 우선순위
+(`PaymentRefundListener`=10, `CancelActivityLogListener`=20)는 §AGENTS.md 핵심 흐름에서
+설명한 대로 "취소 성공 후에만 로그 기록"을 강제하기 위한 순서입니다 — 뒤바뀌면 실패한 취소도
+로그에 성공처럼 남을 수 있습니다.
+
+
+## 훅 리스너
+
+
+| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
+|---|---|---|---|---|
+| `AdjustEcommercePaymentMethodsLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php` |
+| `CancelActivityLogListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/CancelActivityLogListener.php` |
+| `EnsureAdminOrderDetailPaymentQueryLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderDetailPaymentQueryLayoutListener.php` |
+| `EnsureAdminOrderDetailTestModeLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderDetailTestModeLayoutListener.php` |
+| `EnsureAdminOrderListTestBadgeLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderListTestBadgeLayoutListener.php` |
+| `PaymentRefundListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/PaymentRefundListener.php` |
+| `RegisterCashReceiptProviderListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/RegisterCashReceiptProviderListener.php` |
+| `RegisterEasyPayMethodsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterEasyPayMethodsListener.php` |
+| `RegisterPgProviderListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/RegisterPgProviderListener.php` |
+| `RestoreLayoutExtensionsAfterUpdateListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RestoreLayoutExtensionsAfterUpdateListener.php` |
+| `ValidateCbtSettingsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/ValidateCbtSettingsListener.php` |
+
+
+
+`RegisterPgProviderListener`·`RegisterCashReceiptProviderListener`·`RegisterEasyPayMethodsListener`
+가 이 플러그인의 "등록" 축입니다 — 이 셋이 없으면 플러그인을 활성화해도 이커머스 화면에서
+KG 이니시스가 보이지 않습니다. 나머지는 전부 부가 UI(`Ensure*LayoutListener`)나 정합성
+검증(`ValidateCbtSettingsListener`)입니다.
+
+
+## 레이아웃 확장
+
+
+| 대상 | 설명 |
+|---|---|
+| `resources/extensions/admin_order_list_test_badge.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/admin_order_payment_query.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/checkout_payment_error.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/user_order_show.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+
+
+
+`checkout_payment_error.json`은 체크아웃 화면에 KG 이니시스 특유의 오류 메시지(예: 계약되지
+않은 결제수단 선택 시 안내)를 끼워 넣고, `user_order_show.json`은 주문 상세에 영수증 버튼을
+추가합니다. 관리자 쪽 두 조각(`admin_order_list_test_badge`/`admin_order_payment_query`)은
+"이 주문이 테스트 모드로 결제됐는가"와 "PG 거래 상태를 다시 조회"를 관리자 화면에서 바로
+확인하게 하는 운영 편의 기능입니다 — 실제 결제 로직과는 분리돼 있어 이 조각만 비활성화해도
+결제 자체는 영향받지 않습니다.
+
+
+## 미들웨어
+
+
+| 미들웨어 | 부착 대상(targets) | 우선순위 |
+|---|---|---|
+| `InicisNotifyIpWhitelist` | `web.plugins.sirsoft-pay_kginicis.payment.cbt.cvs-notify`, `web.plugins.sirsoft-pay_kginicis.payment.vbank-notify`, `web.plugins.sirsoft-pay_kginicis.payment.mobile.vbank-notify` | - |
+
+
+
+가상계좌/CBT 편의점 입금통보 3개 라우트에만 부착되고 결제 승인/취소 콜백 라우트에는
+부착되지 않습니다 — 입금통보는 KG 이니시스 서버가 발신자 인증 수단 없이 단순 POST 로
+호출하므로 IP 로 걸러야 하지만, 승인/취소 콜백은 `authUrl`/`authToken` 자체가 위조 방지
+수단(§AGENTS.md 금지 패턴)이라 별도 IP 제한이 없어도 안전합니다. `local`/`testing` 환경에서는
+이 제한이 우회됩니다 — 개발 중에는 실제 KG 이니시스 IP 대역에서 요청이 오지 않기 때문입니다.
+
+
+## 브로드캐스트 채널
+
+
+_등록하는 브로드캐스트 채널이 없습니다._
+
+
+
+결제 진행 상황(승인 대기 등)을 실시간으로 밀어줄 필요가 없습니다 — PC/모바일 결제는 결제창이
+닫히고 콜백이 오는 시점에 화면이 이미 그 페이지에 있고, 가상계좌 입금통보는 방문자가 화면을
+보고 있지 않은 시점에 도착하므로 알림(§알림 정의)이나 다음 방문 시 조회가 더 적절합니다.
+
+
+## 스케줄
+
+
+_등록하는 스케줄이 없습니다._
+
+
+
+가상계좌 만료 처리나 CBT 정산 대사(reconciliation) 같은 주기적 점검이 있을 법하지만
+(`CbtReconciliationRepository` 참고), 현재는 관리자가 필요할 때 수동으로 조회·확인하는
+구조입니다 — 자동 스케줄로 상태를 바꾸면 결제 상태 변경 시점을 운영자가 놓칠 수 있습니다.
+
+
+## 알림 정의
+
+
+_등록하는 알림 정의가 없습니다._
+
+
+
+결제 완료/실패 알림은 이 플러그인이 아니라 `sirsoft-ecommerce`의 주문 상태 알림 정의가
+담당합니다 — PG 가 여러 개일 수 있는데 PG 마다 "결제 완료 알림"을 각자 만들면 같은 이벤트에
+대해 서로 다른 알림 정의가 난립합니다. 이 플러그인은 "그 결제가 KG 이니시스를 통했다"는
+사실만 이커머스에 전달하고, 알림 발송은 이커머스가 단일하게 책임집니다.
+
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/frontend.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/frontend.md
new file mode 100644
index 00000000..9e81ca7f
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/frontend.md
@@ -0,0 +1,81 @@
+# KG 이니시스 — 프론트엔드
+
+> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 레이아웃
+
+
+레이아웃 1개 (루트: `resources/layouts`).
+
+| 그룹 | 개수 |
+|---|---|
+| `admin` | 1개 |
+
+| 레이아웃 | 그룹 | 종류 | extends |
+|---|---|---|---|
+| `plugin_settings` | `admin` | 화면 | `_admin_base` |
+
+
+
+이 플러그인이 소유한 화면 레이아웃은 관리자 설정 화면 하나뿐입니다 — 체크아웃·주문상세의
+결제 UI는 이 플러그인 소유가 아니라 §레이아웃 확장(다른 확장/템플릿 레이아웃에 주입되는
+조각)으로 존재합니다. "화면"과 "레이아웃 확장 조각"을 헷갈리면 체크아웃 결제 버튼을 찾으러
+`resources/layouts/`를 뒤지게 되는데, 실제로는 `resources/extensions/`에 있습니다.
+
+
+## 액션 핸들러
+
+
+핸들러 1개 (정의: `resources/js/handlers/index.ts`).
+
+| 핸들러 | 레이아웃에서 부르는 이름 |
+|---|---|
+| `requestPayment` | `sirsoft-pay_kginicis.requestPayment` |
+
+
+
+`requestPayment` 하나로 PC/모바일/CBT 3가지 프로토콜을 전부 처리합니다 — 체크아웃 버튼은
+결제수단·통화가 무엇이든 이 핸들러 하나만 호출하고, PC 결제창을 열지 모바일 폼을 제출할지
+CBT 인증 URL로 이동할지는 핸들러 내부에서 서버 응답(§API `/payment/*/signature`,
+`/payment/cbt/hash-data`)에 따라 분기합니다. 레이아웃 JSON 작성자가 결제수단별로 다른
+핸들러를 호출할 필요가 없다는 뜻입니다.
+
+
+## 전역 진입점
+
+
+| 항목 | 값 |
+|---|---|
+| 엔트리 파일 | `resources/js/index.ts` |
+| 전역 객체 | `window.__SirsoftKginicis` |
+| 재등록 진입점 | `initPlugin()` |
+
+로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.
+
+
+
+`window.__SirsoftKginicis` 로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로 재등록
+진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). `initPlugin()`이 KG 이니시스 결제창
+스크립트(`INIStdPay.js`) 자체를 미리 로드하지 않는 것도 의도입니다 — 그 스크립트는
+`requestPayment` 핸들러가 실제 결제 시도 시점에만 동적으로 로드합니다(모든 방문자가 결제
+페이지에 오는 것은 아니므로 전역 부팅에서 미리 불러올 필요가 없습니다).
+
+
+## 에셋
+
+
+| 경로 | 구분 |
+|---|---|
+| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) |
+| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
+
+로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
+
+
+
+KG 이니시스가 제공하는 `INIStdPay.js`/모바일 결제창 스크립트는 이 목록에 없습니다 — 그
+스크립트들은 KG 이니시스 CDN 에서 결제 시도 시점에 동적으로 로드되는 제3자 자산이라, 이
+플러그인이 빌드 시 번들링하는 `dist/` 산출물과는 다른 층입니다. CSS 산출물이 없는 것은
+결제창 자체는 KG 이니시스가 그리고, 이 플러그인은 결제 버튼 같은 최소한의 UI만 코어
+컴포넌트로 구성하기 때문입니다.
+
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/settings.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/settings.md
new file mode 100644
index 00000000..ccd4f50c
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/settings.md
@@ -0,0 +1,119 @@
+# KG 이니시스 — 설정·권한·라우트
+
+> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 설정 스키마
+
+
+| 키 | 타입 | 기본값 | 설명 |
+|---|---|---|---|
+| `is_test_mode` | `boolean` | `true` | 테스트 모드 |
+| `test_mid` | `string` | `INIpayTest` | 테스트 가맹점 ID (MID) |
+| `test_sign_key` | `string` | `SU5JTElURV9UUklQTEVERVNfS0VZU1RS` | 테스트 사인키 |
+| `test_iniapi_key` | `string` | `ItEQKi3rY7uvDS8l` | 테스트 INIAPI 키 |
+| `test_iniapi_iv` | `string` | `HYb3yQ4f65QL89==` | 테스트 INIAPI IV |
+| `live_mid` | `string` | - | 라이브 가맹점 ID (MID) |
+| `live_sign_key` | `string` | - | 라이브 사인키 |
+| `live_iniapi_key` | `string` | - | 라이브 INIAPI 키 |
+| `live_iniapi_iv` | `string` | - | 라이브 INIAPI IV |
+| `test_mobile_hash_key` | `string` | `3CB8183A4BE283555ACC8363C0360223` | 테스트 모바일 해시키 |
+| `live_mobile_hash_key` | `string` | - | 라이브 모바일 해시키 |
+| `use_escrow` | `boolean` | `false` | 에스크로 결제 활성화 |
+| `japan_enabled` | `boolean` | `false` | 일본 결제 활성화 |
+| `japan_restrict_jpy_payment_methods` | `boolean` | `false` | JPY 주문 결제수단 제한 |
+| `test_japan_sign_key` | `string` | `5AL5Djb1Ipualn0F` | 테스트 일본 CBT 해시키 |
+| `live_japan_mid` | `string` | - | 라이브 일본 MID |
+| `live_japan_sign_key` | `string` | - | 라이브 일본 CBT 해시키 |
+| `japan_merchant_name` | `string` | `サンプルストア` | 일본 결제 가맹점명 |
+| `japan_merchant_name_kana` | `string` | `サンプルストア` | 일본 결제 가맹점명 Kana |
+| `japan_merchant_name_alphabet` | `string` | `Sample Store` | 일본 결제 가맹점명 영문 |
+| `japan_merchant_name_short` | `string` | `サンプル` | 일본 결제 가맹점 약칭 |
+| `japan_contact_name` | `string` | `サポート窓口` | 일본 결제 문의처명 |
+| `japan_contact_email` | `string` | `support@example.com` | 일본 결제 문의 이메일 |
+| `japan_contact_phone` | `string` | `0120-123-456` | 일본 결제 문의 전화번호 |
+| `japan_contact_opening_hours` | `string` | `10:00-18:00` | 일본 결제 문의 영업시간 |
+| `redirect_success_url` | `string` | `{shopBase}/orders/{orderId}/complete` | 결제 성공 리다이렉트 URL |
+| `redirect_fail_url` | `string` | `{shopBase}/checkout` | 결제 실패 리다이렉트 URL |
+| `easy_pay_allow_with_other_pg` | `boolean` | `false` | 타 PG와 사용가능함 |
+| `easy_pay_samsung_pay` | `boolean` | `false` | KG이니시스 삼성페이 사용 |
+| `easy_pay_naverpay` | `boolean` | `false` | KG이니시스 네이버페이 사용 |
+| `easy_pay_show_brand_button` | `boolean` | `false` | 간편결제 브랜드 버튼 표시 |
+| `easy_pay_lpay` | `boolean` | `false` | KG이니시스 L.pay 사용 |
+| `easy_pay_kakaopay` | `boolean` | `false` | KG이니시스 카카오페이 사용 |
+| `use_credit_point` | `boolean` | `false` | 신용카드 포인트 사용 |
+
+기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json`
+
+
+
+`test_*`/`live_*` 접두어 쌍이 반복되는 것이 이 스키마의 핵심 구조입니다 — 테스트 모드와
+운영 모드가 완전히 다른 자격증명 집합을 쓰기 때문에, 하나의 키를 두고 모드에 따라 값을
+바꾸는 대신 애초에 별도 키로 분리했습니다. 이 덕분에 `is_test_mode` 를 껐다 켰다 해도 각
+모드의 자격증명은 서로 덮어쓰이지 않습니다. 일본(`japan_*`) 설정군이 특히 많은 이유는
+KG 이니시스 CBT 결제창의 `extraData`(가맹점 표시 정보)가 한국 표준결제와 별개의 계약·심사
+단위이기 때문입니다.
+
+
+## 권한
+
+
+_선언된 권한이 없습니다._
+
+
+
+결제 설정은 이커머스의 관리자 권한 체계 안에서 다뤄집니다 — "결제 설정을 볼 수 있는 사람"은
+PG 마다 다시 정의할 이유가 없는 하나의 개념이라, 이 플러그인이 별도 권한을 선언하지 않고
+이커머스의 결제/설정 권한에 얹혀 갑니다.
+
+
+## 메뉴
+
+
+_등록하는 메뉴가 없습니다._
+
+
+
+설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해
+접근합니다 — PG 플러그인마다 전용 사이드바 메뉴를 만들면 PG 를 여러 개 설치했을 때 메뉴가
+난립합니다.
+
+
+## 라우트
+
+
+| 종류 | 파일 | URL prefix |
+|---|---|---|
+| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-pay_kginicis/...` |
+| `web` | `src/routes/web.php` | `/plugins/sirsoft-pay_kginicis/...` |
+
+확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
+
+
+
+`api`(Bearer 토큰 인증, 결제창 서명·해시 생성처럼 로그인 사용자가 브라우저에서 직접 호출하는
+엔드포인트)와 `web`(콜백·입금통보처럼 KG 이니시스 서버나 리다이렉트로 도달하는 엔드포인트)이
+분리된 이유는 인증 방식이 다르기 때문입니다 — KG 이니시스는 우리 서비스의 Bearer 토큰을 모르므로
+콜백 라우트에 `api` 인증 미들웨어를 걸 수 없습니다. 새 KG 이니시스 콜백을 추가할 때는 `web`
+쪽에, 프론트엔드가 로그인 상태로 직접 호출하는 기능은 `api` 쪽에 둡니다.
+
+
+## 의존 관계
+
+
+**이 확장이 의존하는 확장**
+
+| 확장 | 유형 | 버전 제약 | 번들 |
+|---|---|---|---|
+| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ |
+
+**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
+
+없음.
+
+
+
+`sirsoft-ecommerce >=1.1.0` 하드 의존은 §data-model.md 에서 설명한 구조(결제 상태는 이커머스가
+소유, 이 플러그인은 절차만 소유)의 직접적 결과입니다 — 이커머스 없이는 이 플러그인이 다룰
+주문 자체가 존재하지 않습니다. 이커머스의 PG 등록 훅(`registered_pg_providers` 등)이나
+`Order` 모델 구조가 바뀌면 이 최소 버전을 올려야 합니다(§CLAUDE.md "확장 → 확장 동기화").
+
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/AGENTS.md b/plugins/_bundled/sirsoft-pay_nhnkcp/AGENTS.md
new file mode 100644
index 00000000..4d88e782
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/AGENTS.md
@@ -0,0 +1,177 @@
+# NHN KCP — 에이전트 가이드
+
+> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
+
+## TL;DR (5초 요약)
+
+```text
+1. 유형: 플러그인 (sirsoft-pay_nhnkcp) — NHN KCP PG 연동(PC CLI 승인/모바일 SOAP/가상계좌/에스크로). 소유 테이블 없음 — 상태는 sirsoft-ecommerce 소유
+2. 확장 방식: `RegisterPgProviderListener`/`RegisterEasyPayMethodsListener` 로 이커머스 레지스트리에 등록 — 이커머스 코드는 이 플러그인을 모른다
+3. 건드리면 안 되는 것: KCP CLI(`bin/pp_cli*`) 호출 인자 사전검증(`assertSafeCliValue`) 생략, 동일 거래번호 콜백 재처리 방지 우회, IP 화이트리스트 미들웨어(`RestrictKcpIp`) 미부착
+4. 작업 위치: `plugins/_bundled/sirsoft-pay_nhnkcp` — 활성 디렉토리 직접 수정 금지
+5. 반영: `php artisan plugin:update sirsoft-pay_nhnkcp --force`
+```
+
+## 1. 이 확장은 무엇인가
+
+
+NHN KCP PG(결제 게이트웨이)를 `sirsoft-ecommerce`에 연결하는 어댑터입니다. PC 결제는 KCP CLI
+바이너리(`bin/pp_cli*`)를 서버에서 직접 실행해 승인 응답을 받고, 모바일 결제는 SOAP
+승인키 발급 후 모바일 결제창으로 폼 이동합니다. 두 프로토콜 모두 `sirsoft-pay_kginicis`(HTTP
+API 호출)와 달리 **로컬 프로세스 실행**을 최종 승인 수단으로 쓴다는 점이 이 플러그인 고유의
+설계 축입니다.
+
+**설계 원칙**: 이 플러그인도 `sirsoft-pay_kginicis`와 마찬가지로 상태를 소유하지 않습니다
+(§data-model.md — 모델·테이블·Repository 0개, kginicis 의 CBT 정산 Repository 2개조차 없음:
+이 플러그인은 일본/CBT 결제를 아예 구현하지 않기 때문입니다). 등록은 훅 기반입니다
+(`sirsoft-ecommerce.payment.registered_pg_providers` 필터) — 이커머스 모듈은 이 플러그인의
+존재를 컴파일 타임에 몰라도 됩니다.
+
+**의도적으로 하지 않는 것**: CLI 실행 권한이 사라진 경우(예: `plugin:update` 가 `_bundled` 의
+0664 권한을 활성 디렉토리로 그대로 복사) 조용히 실패하지 않고 결제 hot path 에서
+`ensureCliExecutable()` 로 0755 자가 복구를 시도합니다 — "결제 버튼을 눌렀는데 원인 불명으로
+9502 오류"라는 상태를 막기 위함입니다. 또한 CLI 인자에 위험 문자·제어문자가 섞이면 그 값을
+정제해서 통과시키지 않고 `NhnKcpApiException` 으로 즉시 거부합니다 — 부분 정제는 안전하다는
+착각을 주면서 실제로는 우회 경로를 남길 수 있습니다.
+
+
+## 2. 디렉토리 지도
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
+| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
+| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
+| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
+| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
+| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
+| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
+| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
+| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_nhnkcp --force` (빌드 불필요) |
+| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_nhnkcp --force` |
+| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_nhnkcp --force` |
+| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_nhnkcp --force` |
+| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
+| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_nhnkcp --force` |
+| `bin/` | 확장이 실행하는 외부 바이너리·인증서 | 교체 시 OS별 파일과 권한을 함께 확인 (비면 해당 기능 정지) |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
+
+
+## 3. 핵심 흐름
+
+
+**PC 결제 승인**: `PaymentCallbackController`(KCP 결제창이 POST 하는 `enc_data`/`enc_info`
+수신) → `sirsoft-pay_nhnkcp.payment.before_confirm` 훅 → `NhnKcpApiService::executeCli()` 가
+OS 판별 후 `executeCliWindows()`/`executeCliLinux()` 로 분기 → CLI 인자 전량
+`assertSafeCliValue()` 사전검증 → `escapeshellarg()` 이중 quoting 후 `exec()` 실행 →
+`sirsoft-pay_nhnkcp.payment.after_confirm` 훅 → 이커머스 주문 결제 완료 처리.
+`PreventsReplayCallback` 트레이트가 콜백 진입 시점에 동일 `transaction_id` 가 이미 `paid`
+상태인지 먼저 확인해 재처리를 조기 차단합니다.
+
+**모바일 결제 승인**: `/mobile/approval-key` API 호출 → KCP SOAP `approve` 로 승인키·`pay_url`
+획득 → 브라우저가 `pay_url` 로 폼 POST → KCP가 `/payment/callback` 으로 결과 POST(PC와 동일
+콜백 엔드포인트 공유) → 이후는 PC 흐름과 합류.
+
+**가상계좌 입금통보 / 에스크로 공통통보**: KCP 서버가 `/payment/vbank-notify` 또는
+`/payment/escrow-common-notify` 를 직접 호출 → `RestrictKcpIp` 미들웨어가 운영 모드에서
+발신 IP 를 화이트리스트와 대조 → `EscrowCommonNotifyController` 가 `tx_cd`/`cl_status` 조합으로
+4가지 훅(`escrow.purchase_confirmed`/`purchase_cancelled`/`denial_confirmed`/
+`delivery_started`) 중 하나를 분기 발화. 결제 취소는 `PaymentRefundListener`(우선순위 10)가
+먼저 KCP 취소 API 를 호출한 뒤 `CancelActivityLogListener`(우선순위 20)가 그 결과를 활동
+로그에 별도 기록합니다 — `sirsoft-pay_kginicis` 와 동일한 순서 원칙입니다.
+
+
+## 4. 확장점
+
+
+| 확장점 | 수 | 상세 |
+|---|---|---|
+| 발행 훅 | 8개 | [발행 훅](docs/extension-points.md#발행-훅) |
+| 구독 훅 | 9개 | [구독 훅](docs/extension-points.md#구독-훅) |
+| 훅 리스너 | 8개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
+| 레이아웃 확장 | 5개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
+| 미들웨어 | 1개 | [미들웨어](docs/extension-points.md#미들웨어) |
+| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
+| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
+| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
+
+
+
+`before_confirm`/`before_cancel` 은 PG(CLI/SOAP) 호출 **전** 개입 지점입니다 — 예를 들어
+`before_cancel` 을 잡아 조건 미충족 시 예외를 던지면 KCP 취소 API 자체가 호출되지 않습니다.
+`after_*` 훅은 응답을 받은 뒤 부가효과를 붙이는 자리입니다. 에스크로 훅 4종은 KCP 공통통보의
+`tx_cd`/`cl_status` 조합을 이미 해석해 발화하므로, 구독하는 확장은 원시 통보 파라미터를
+다시 파싱할 필요가 없습니다. 구독 훅의 `core.layout_extension.after_apply` 3건은 관리자
+주문 목록/상세에 "테스트 모드 배지"·"거래 조회 UI"를 레이아웃 확장으로 주입하기 때문입니다.
+
+
+## 5. 수정 시 동반 의무
+
+- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-pay_nhnkcp --force` 로 반영
+- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
+- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
+- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-pay_nhnkcp` 재실행 + `docs/api/**` 갱신
+- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
+- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
+- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
+- [ ] CLI 인자 조립부를 고칠 때 `assertSafeCliValue()` 검증을 모든 인자에 유지 — 인자 하나만 빠져도 그 필드가 injection 통로가 된다
+- [ ] `bin/` 바이너리(OS별 CLI·`pub.key`·WSDL) 교체 시 실행 권한(0755)과 파일 존재를 관리자 설정 화면의 시스템 점검(§API)으로 확인
+- [ ] 승인/취소 흐름을 고칠 때 `before_*`/`after_*` 훅 순서와 우선순위(`PaymentRefundListener` < `CancelActivityLogListener`)를 유지 — 로그가 실제 처리보다 먼저 실행되면 안 된다
+- [ ] IP 화이트리스트(`RestrictKcpIp`) 대상 라우트를 추가/변경하면 미들웨어 부착 대상(targets)도 함께 갱신
+- [ ] 새 결제수단·통화를 추가하면 그 결제수단의 콜백 URL을 관리자 설정 안내(README "콜백 및 통보 URL")에도 반영
+
+## 6. 금지 패턴
+
+
+| 금지 | 올바른 사용 | 이유 |
+|---|---|---|
+| KCP CLI 인자(`site_cd`/`tx_cd`/`enc_data` 등)를 검증 없이 문자열 결합해 `exec()` 에 전달 | `assertSafeCliValue()` 로 위험 문자·제어문자 사전 거부 후 `escapeshellarg()` 로 quoting | 검증을 생략하면 서버가 받은 KCP 응답 값이 그대로 셸 명령 인자가 되어 명령 삽입(command injection)으로 이어질 수 있다 |
+| CLI 실행 권한 오류(9502)를 그대로 사용자에게 노출하고 자가 복구를 생략 | `ensureCliExecutable()` 로 결제 hot path 진입 시 0755 자가 복구 시도 | `plugin:update` 가 파일 권한을 0664 로 되돌리는 것은 배포 절차의 부작용이지 운영자 실수가 아니다 — 매 결제 실패로 드러나게 두면 안 된다 |
+| 동일 `transaction_id` 콜백을 매번 재처리 | `PreventsReplayCallback::wasAlreadyPaid()` 로 이미 `paid` 상태면 멱등 응답 | KCP 서버의 재전송·사용자의 새로고침으로 같은 콜백이 두 번 오면 결제완료 알림·마일리지가 중복 적립될 수 있다 |
+| 에스크로 공통통보의 `tx_cd`/`cl_status` 매핑을 컨트롤러 밖(리스너 등)에서 다시 판정 | `EscrowCommonNotifyController` 의 매핑표(§핵심 흐름)를 SSoT 로 유지 | 판정 로직이 두 곳에 있으면 KCP 가 새 `cl_status` 값을 보낼 때 한쪽만 갱신되어 조용히 어긋난다 |
+| 라이브 사이트 키(`live_site_key`)를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 결제창 요청을 위조할 수 있다 |
+
+
+## 7. 테스트 실행
+
+
+| 종류 | 개수 | 위치 |
+|---|---|---|
+| PHPUnit | 28개 | `plugins/_bundled/sirsoft-pay_nhnkcp/tests` |
+| Vitest | 8개 | `vitest.config.ts` |
+| Playwright | 0개 | — |
+| 시나리오 매니페스트 | 1개 | `tests/scenarios` |
+
+기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
+
+```bash
+# PHPUnit (변경 범위만) (Bash)
+php vendor/bin/phpunit plugins/_bundled/sirsoft-pay_nhnkcp/tests --filter='<대상클래스>'
+
+# Vitest (확장 디렉토리에서) (PowerShell)
+cd plugins/_bundled/sirsoft-pay_nhnkcp && powershell -Command "npm run test:run -- <대상>"
+
+```
+
+무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
+
+
+## 8. 문서 목차
+
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
+| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
+| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
+| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/README.md b/plugins/_bundled/sirsoft-pay_nhnkcp/README.md
index e71edf19..6696b025 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/README.md
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/README.md
@@ -1,30 +1,96 @@
-# NHN KCP Plugin for G7
+# NHN KCP
-NHN KCP Standard Pay 결제를 G7 `sirsoft-ecommerce` 모듈에 연결하는 결제 플러그인입니다.
+**G7 플러그인 · sirsoft-pay_nhnkcp**
+NHN KCP Standard Pay 결제를 sirsoft-ecommerce 에 연결하는 결제 플러그인
-PC 결제는 KCP `payplus_web.jsp` 결제창과 KCP CLI 승인 모듈을 사용하고, 모바일 결제는 SmartPhone Pay SOAP 승인키를 받은 뒤 KCP 모바일 결제창으로 이동합니다.
+
+
+
+
+
+
+
+
+
+
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+NHN KCP Standard Pay 결제를 G7 `sirsoft-ecommerce` 모듈에 연결하는 결제 플러그인입니다. PC
+결제는 `payplus_web.jsp` 결제창 + 서버의 KCP CLI 승인 모듈을, 모바일 결제는 SmartPhone Pay
+SOAP 승인키 발급 + 모바일 결제창을 씁니다.
+
+`sirsoft-pay_kginicis`와 마찬가지로 이 플러그인은 결제 자체의 상태(주문·결제 성공/실패/취소)를
+소유하지 않습니다 — 그 상태는 `sirsoft-ecommerce`의 주문·결제 테이블에 있고, 이 플러그인은
+"그 상태를 KCP CLI/SOAP API 와 어떻게 주고받는가"만 책임집니다(§data-model.md). 다른 PG
+플러그인과 구별되는 이 플러그인만의 특징은 PC 결제 최종 승인이 HTTP API 호출이 아니라
+**서버에서 실행하는 CLI 바이너리**라는 점입니다 — KCP 가 표준결제 승인 로직을 컴파일된
+실행파일로만 배포하기 때문입니다.
+
## 주요 기능
-- 신용카드, 계좌이체, 가상계좌, 휴대폰결제 지원
-- PC Standard Pay 결제창 연동
-- 모바일 SmartPhone Pay 승인키 발급 및 모바일 결제창 연동
-- PAYCO, 네이버페이, 네이버페이 포인트, 카카오페이, Apple Pay 간편결제 버튼 주입
-- 가상계좌 발급, 입금통보, 테스트 모드 모의입금 처리
-- 에스크로 결제, 에스크로 배송 등록, 공통통보 처리
-- 결제 취소 및 부분취소 연동
-- PG 측 결제 취소 확인 시점의 활동 로그 별도 기록 (PG 응답 시각·취소 거래번호 사후 추적)
-- 주문 완료/마이페이지 영수증, 현금영수증 조회 버튼 주입
-- 관리자 주문 상세의 KCP 거래 정보 표시
-- 관리자 설정 화면의 KCP 실행 환경 점검
+
+| 영역 | 설명 |
+|---|---|
+| 결제수단 | 신용카드, 계좌이체, 가상계좌, 휴대폰결제 |
+| 간편결제 | PAYCO, 네이버페이, 네이버페이 포인트, 카카오페이, Apple Pay 버튼 주입 |
+| PC 결제 | `payplus_web.jsp` 표준결제창 + 서버 KCP CLI 승인 |
+| 모바일 결제 | SmartPhone Pay SOAP 승인키 발급 + 모바일 결제창 |
+| 가상계좌 | 발급, 입금통보, 테스트 모드 모의입금 |
+| 에스크로 | 결제, 배송 등록, 공통통보(구매확인/구매취소/구매취소확인/배송시작) |
+| 결제 취소 | 전액/부분취소, PG 취소 확인 시점 별도 활동 로그(PG 응답 시각·취소 거래번호) |
+| 영수증 | 주문 완료/마이페이지 영수증, 현금영수증 조회 버튼 |
+| 관리자 확장 | 주문 상세 KCP 거래 정보 표시, KCP 실행 환경(CLI/SOAP) 점검 |
+
+
+## 동작 방식
+
+
+```mermaid
+flowchart LR
+ A[체크아웃 주문 생성] -->|PC| B["payplus_web.jsp 결제창 iframe 로드"]
+ A -->|모바일| C["/mobile/approval-key 호출 → SOAP 승인키 발급"]
+ B --> D["/payment/callback (enc_data·enc_info)"]
+ C --> E["모바일 결제창 → /payment/callback"]
+ D --> F[서버가 KCP CLI 실행해 승인 확인]
+ E --> F
+ F --> G[주문 결제 완료 처리]
+ G --> H[성공 URL 리다이렉트]
+```
+
+PC 결제 승인은 `NhnKcpApiService`가 OS 를 판별해 `pp_cli`/`pp_cli_x64`/`pp_cli_exe.exe` 중
+하나를 `exec()`로 실행하는 방식입니다. 모든 CLI 인자는 `assertSafeCliValue()`로 위험
+문자·제어문자를 사전 거부한 뒤 `escapeshellarg()`로 quoting 합니다 — KCP 응답값을 검증 없이
+셸 명령에 넣으면 명령 삽입(command injection) 통로가 됩니다. `PreventsReplayCallback`
+트레이트가 콜백 진입 시점에 동일 거래번호가 이미 결제완료 상태인지 확인해 중복 처리를
+막습니다.
+
+가상계좌는 결제창에서 발급되면 주문이 입금대기 상태로 유지되다가, KCP 가 입금통보 URL로
+결과를 POST 하면 금액 검증 후 결제 완료 처리됩니다. 에스크로는 KCP 공통통보의 `tx_cd`/
+`cl_status` 조합을 해석해 구매확인/구매취소/구매취소확인/배송시작 4가지 훅으로 분기
+발화합니다.
+
## 요구 사항
-| 항목 | 내용 |
-|------|------|
-| G7 | `>= 7.0.0-beta.2` |
-| 의존 모듈 | `sirsoft-ecommerce >= 1.0.0-beta.5` |
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=7.0.10` |
| PHP | `^8.2` |
+| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
+
+
+
+| 항목 | 필요한 것 |
+|---|---|
| PC 결제 | PHP `exec()` 사용 가능, KCP CLI 바이너리, `pub.key` |
| 모바일 결제 | PHP SOAP 확장, KCP WSDL 파일 |
| 운영 환경 | HTTPS 도메인, 올바른 `APP_URL`, KCP 가맹점 계약 정보 |
@@ -47,61 +113,70 @@ chmod 755 plugins/sirsoft-pay_nhnkcp/bin/pp_cli
chmod 755 plugins/sirsoft-pay_nhnkcp/bin/pp_cli_x64
```
-관리자 설정 화면의 시스템 점검 API가 실행 권한을 자동 복구할 수 있지만, 서버 권한 정책에 따라 직접 조치가 필요할 수 있습니다.
+관리자 설정 화면의 시스템 점검 API 와 결제 hot path 의 자가 복구(`ensureCliExecutable()`)가
+실행 권한을 자동 복구할 수 있지만, 서버 권한 정책에 따라 직접 조치가 필요할 수 있습니다.
+
## 설치
-플러그인을 G7 프로젝트의 플러그인 디렉토리에 배치합니다.
-
-```text
-plugins/sirsoft-pay_nhnkcp
-```
-
-프론트엔드 에셋을 수정한 경우 플러그인 디렉토리에서 빌드합니다.
-
+
```bash
-npm install
-npm run build
+# 번들 설치 (코어에 동봉된 소스에서 설치)
+php artisan plugin:install sirsoft-pay_nhnkcp
+
+# 활성화
+php artisan plugin:activate sirsoft-pay_nhnkcp
+
+# 업데이트 (번들 소스 기준 강제 반영)
+php artisan plugin:update sirsoft-pay_nhnkcp --force
```
-그다음 G7 관리자에서 플러그인을 활성화하고, 이커머스 결제 설정에서 PG 제공자를 `NHN KCP`로 선택합니다.
+저장소: https://github.com/gnuboard/g7-plugin-sirsoft-pay_nhnkcp
+
+
+설치·활성화 후 이커머스 결제 설정에서 PG 제공자를 "NHN KCP"로 선택해야 실제로 결제 흐름에
+연결됩니다 — 활성화만으로는 체크아웃 화면에 나타나지 않습니다.
## 관리자 설정
-관리자 플러그인 설정 화면에서 KCP 계약 정보를 입력합니다.
+
+| 키 | 의미 | 기본값 |
+|---|---|---|
+| `is_test_mode` | 테스트 모드 | `true` |
+| `test_site_cd` | 테스트 사이트 코드 (site_cd) | `T0000` |
+| `test_site_key` | 테스트 사이트 키 (site_key) | - |
+| `live_site_cd` | 라이브 사이트 코드 (site_cd) | - |
+| `live_site_key` | 라이브 사이트 키 (site_key) | - |
+| `redirect_success_url` | 결제 성공 리다이렉트 URL | `{shopBase}/orders/{orderId}/complete` |
+| `redirect_fail_url` | 결제 실패 리다이렉트 URL | `{shopBase}/checkout` |
+| `use_escrow` | 에스크로 결제 활성화 | `false` |
+| `escrow_test_site_cd` | 테스트 에스크로 사이트 코드 | - |
+| `vbank_expire_days` | 가상계좌 입금 만료(일) | `3` |
+| `easy_pay_allow_with_other_pg` | - | `false` |
+| `easy_pay_payco` | - | `false` |
+| `easy_pay_naverpay` | - | `false` |
+| `easy_pay_naverpay_point` | - | `false` |
+| `easy_pay_kakaopay` | - | `false` |
+| `easy_pay_applepay` | - | `false` |
-| 설정 | 설명 |
-|------|------|
-| 테스트 모드 | 활성화 시 KCP 테스트 환경을 사용합니다. |
-| 테스트 사이트 코드 | 기본값은 `T0000`입니다. |
-| 테스트 사이트 키 | KCP 테스트 site key입니다. |
-| 라이브 사이트 코드 | 운영 site code입니다. `SR` prefix 없이 입력해도 플러그인이 자동 보정합니다. |
-| 라이브 사이트 키 | 운영 site key입니다. 외부에 노출하지 마세요. |
-| 결제 성공 URL | 기본값은 `/shop/orders/{orderId}/complete`입니다. |
-| 결제 실패 URL | 기본값은 `/shop/checkout`입니다. |
-| 가상계좌 입금 만료일 | 가상계좌 발급 후 입금 가능 기간입니다. |
-| 에스크로 결제 사용 | 활성화 시 KCP 에스크로 결제 파라미터를 함께 전달합니다. |
-| 에스크로 테스트 사이트 코드 | 테스트 에스크로 site code입니다. 기본 fallback은 `T0007`입니다. |
-| 간편결제 | KCP 계약이 완료된 간편결제만 활성화하세요. |
-| 타 PG와 사용가능함 | 다른 PG가 기본값이어도 KCP 간편결제 버튼을 체크아웃 화면에 표시합니다. |
+개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
+
-PAYCO 테스트 결제는 내부 기본값으로 간편결제 테스트 site code `S6729`를 사용합니다.
+
+라이브 사이트 키는 외부에 노출하지 마세요. 배포 전 테스트 모드가 의도한 값인지 반드시
+확인하세요.
-## 콜백 및 통보 URL
-
-KCP 가맹점 관리자에 아래 URL을 등록합니다. 도메인은 실제 운영 도메인으로 바꿔 입력하세요.
+**콜백 및 통보 URL 등록** — KCP 가맹점 관리자에 아래 URL을 실제 운영 도메인으로 등록합니다.
| 용도 | URL |
-|------|-----|
-| 결제 결과 Return URL | `https://your-domain.com/plugins/sirsoft-pay_nhnkcp/payment/callback` |
-| 가상계좌 입금통보 URL | `https://your-domain.com/plugins/sirsoft-pay_nhnkcp/payment/vbank-notify` |
-| 에스크로 공통통보 URL | `https://your-domain.com/plugins/sirsoft-pay_nhnkcp/payment/escrow-common-notify` |
+|---|---|
+| 결제 결과 Return URL | `https://{도메인}/plugins/sirsoft-pay_nhnkcp/payment/callback` |
+| 가상계좌 입금통보 URL | `https://{도메인}/plugins/sirsoft-pay_nhnkcp/payment/vbank-notify` |
+| 에스크로 공통통보 URL | `https://{도메인}/plugins/sirsoft-pay_nhnkcp/payment/escrow-common-notify` |
-결제 결과 Return URL은 브라우저가 POST하는 경로이므로 IP 제한을 적용하지 않습니다. 가상계좌 입금통보와 에스크로 공통통보는 KCP 서버가 직접 호출하므로 운영 모드에서 IP 화이트리스트를 적용합니다.
-
-## IP 화이트리스트
-
-운영 모드에서는 아래 IP에서 들어온 KCP 서버 통보만 허용합니다. 테스트 모드에서는 개발과 KCP testadmin 모의입금을 위해 IP 제한을 우회합니다.
+결제 결과 Return URL은 브라우저가 POST하는 경로이므로 IP 제한을 적용하지 않습니다. 가상계좌
+입금통보와 에스크로 공통통보는 KCP 서버가 직접 호출하므로 운영 모드에서 아래 IP
+화이트리스트를 적용합니다(테스트 모드에서는 개발·KCP testadmin 모의입금을 위해 우회).
| IP |
|----|
@@ -116,49 +191,22 @@ KCP 가맹점 관리자에 아래 URL을 등록합니다. 도메인은 실제
| `210.122.72.173` |
운영 전 KCP 가맹점 관리자와 최신 연동 가이드의 통보 서버 IP를 다시 확인하세요.
+
-## 결제 흐름
+## 사용 방법
-### PC 결제
+
+**결제 취소/부분취소**: 관리자가 주문 취소를 요청(`cancel_pg=true`)하면 코어가
+`sirsoft-ecommerce.payment.refund` 필터 훅을 발화하고, 이 플러그인의 `PaymentRefundListener`
+가 KCP 취소 API를 호출합니다(전액취소는 `isPartial=false`, 부분취소는 `isPartial=true` +
+원래 결제금액). 배송비가 포함된 주문은 전체취소 시 배송비도 함께 환불 레코드에 반영되고,
+쿠폰이 적용된 주문은 실결제금액(쿠폰 차감 후)이 PG `cancelAmt`로 전달됩니다. 부분취소로
+쿠폰 최소 주문금액 조건을 더 이상 충족하지 못하면 코어가 취소 자체를 거부(422)해 PG 호출이
+아예 발생하지 않습니다. KCP API 호출이 실패하면 주문 상태 변경이 롤백됩니다.
-```text
-체크아웃 주문 생성
-→ 프론트엔드 핸들러가 payplus_web.jsp 결제창 실행
-→ KCP가 /payment/callback 으로 enc_data, enc_info POST
-→ 서버가 KCP CLI로 승인 확인
-→ 주문 결제 완료 처리
-→ 성공 URL로 리다이렉트
-```
-
-### 모바일 결제
-
-```text
-체크아웃 주문 생성
-→ /api/plugins/sirsoft-pay_nhnkcp/mobile/approval-key 호출
-→ 서버가 KCP SOAP approve 로 approval_key, pay_url 획득
-→ 브라우저가 pay_url 로 form POST
-→ KCP가 /payment/callback 으로 결과 POST
-→ 주문 결제 완료 처리
-→ 성공 URL로 리다이렉트
-```
-
-### 가상계좌
-
-```text
-결제창에서 가상계좌 발급
-→ 주문 결제 정보에 은행, 계좌번호, 예금주, 만료일 저장
-→ 주문은 입금대기 상태 유지
-→ KCP가 /payment/vbank-notify 로 입금통보 POST
-→ 입금 금액 검증 후 주문 결제 완료 처리
-```
-
-테스트 모드에서는 마이페이지 주문 상세에 KCP testadmin 모의입금 폼이 표시될 수 있습니다.
-
-### 에스크로
-
-에스크로를 활성화하면 결제 요청에 `escw_used=Y`, `pay_mod=O`를 전달합니다. 에스크로 결제 완료 후 관리자 주문 상세에서 운송장번호와 택배사를 입력해 KCP 배송 등록을 호출할 수 있습니다.
-
-KCP 공통통보는 아래 이벤트를 처리합니다.
+**에스크로 처리**: 에스크로를 활성화하면 결제 요청에 `escw_used=Y`, `pay_mod=O`를
+전달합니다. 에스크로 결제 완료 후 관리자 주문 상세에서 운송장번호와 택배사를 입력해 KCP
+배송 등록을 호출할 수 있습니다. KCP 공통통보는 아래 이벤트를 처리합니다.
| tx_cd | 조건 | 처리 |
|-------|------|------|
@@ -167,82 +215,64 @@ KCP 공통통보는 아래 이벤트를 처리합니다.
| `TX02` | `cl_status=3` | 구매취소 확인 훅 실행 |
| `TX03` | - | 배송시작 훅 실행 |
-### 결제 취소 / 부분취소
+**가상계좌 모의입금**: 테스트 모드에서는 마이페이지 주문 상세에 KCP testadmin 모의입금
+폼이 표시될 수 있습니다.
-```text
-관리자 주문 취소 요청 (cancel_pg=true)
-→ 코어가 sirsoft-ecommerce.payment.refund 필터 훅 발화
-→ PaymentRefundListener 가 KCP cancelPayment API 호출
- · 전액취소: isPartial=false
- · 부분취소: isPartial=true, totalAmt=원래 결제금액
-→ 코어가 환불 레코드 생성 + 쿠폰 / 마일리지 / 재고 복원
-→ CancelActivityLogListener 가 PG 응답 시각·취소 거래번호를 활동 로그에 기록
-```
+전체 API 목록(사용자/관리자)은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은
+[docs/extension-points.md](docs/extension-points.md) 를 참고하세요.
+
-배송비가 포함된 주문은 전체취소 시 배송비도 함께 환불 레코드에 반영되고, 쿠폰이 적용된 주문은 실결제금액(쿠폰 차감 후) 이 PG cancelAmt 로 전달됩니다. 부분취소 시 쿠폰 최소 주문금액 조건을 더 이상 충족하지 못하면 코어가 취소 자체를 거부 (422) 하여 PG 호출이 발생하지 않습니다. KCP API 호출이 실패하면 주문 상태 변경이 롤백됩니다.
+## 다른 확장과의 연동
-## API
+
+**이 확장이 의존하는 확장**
-### 사용자 API
+| 확장 | 유형 | 버전 제약 | 번들 |
+|---|---|---|---|
+| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ |
-| Method | Path | 설명 |
-|--------|------|------|
-| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/user/orders/{orderNumber}/receipt` | KCP 영수증, 현금영수증 URL 조회 |
-| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/user/orders/{orderNumber}/vbank-mock-deposit-info` | 테스트 모드 가상계좌 모의입금 정보 조회 |
-| `POST` | `/api/plugins/sirsoft-pay_nhnkcp/mobile/approval-key` | 모바일 결제 승인키 발급 |
+**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
-### 관리자 API
+없음.
+
-| Method | Path | 설명 |
-|--------|------|------|
-| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/vbank-notify-url` | 가상계좌/에스크로 통보 URL 조회 |
-| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/orders/test-mode-map` | 주문목록 테스트 모드 배지용 맵 조회 |
-| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/transaction-status` | 저장된 KCP 거래 정보 조회 |
-| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/escrow-delivery` | 에스크로 배송 등록 폼 데이터 조회 |
-| `POST` | `/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/escrow-delivery` | KCP 에스크로 배송 등록 |
-| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/health` | KCP 실행 환경 점검 |
+
+`RegisterPgProviderListener`가 이 플러그인을 이커머스의 PG 제공자 레지스트리에,
+`RegisterEasyPayMethodsListener`가 간편결제 결제수단 레지스트리에 각각 등록합니다 — PG
+결제사 선택과 간편결제 노출은 서로 독립적이라, 다른 PG가 기본값이어도 KCP 간편결제 버튼을
+체크아웃 화면에 노출하는 조합이 가능합니다(`easy_pay_allow_with_other_pg`).
+
-## 훅
+## 문서
-다른 모듈이나 플러그인에서 아래 훅에 연결해 결제 흐름을 확장할 수 있습니다.
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
+| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
+| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
+| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
-| 훅 | 타입 | 시점 |
-|----|------|------|
-| `sirsoft-pay_nhnkcp.payment.before_confirm` | action | KCP CLI 승인 확인 전 |
-| `sirsoft-pay_nhnkcp.payment.after_confirm` | action | KCP CLI 승인 확인 후 |
-| `sirsoft-pay_nhnkcp.payment.before_cancel` | action | KCP 취소 API 호출 전 |
-| `sirsoft-pay_nhnkcp.payment.after_cancel` | action | KCP 취소 API 호출 후 |
-| `sirsoft-pay_nhnkcp.escrow.purchase_confirmed` | action | 에스크로 구매확인 통보 수신 |
-| `sirsoft-pay_nhnkcp.escrow.purchase_cancelled` | action | 에스크로 구매취소 통보 수신 |
-| `sirsoft-pay_nhnkcp.escrow.denial_confirmed` | action | 에스크로 구매취소 확인 통보 수신 |
-| `sirsoft-pay_nhnkcp.escrow.delivery_started` | action | 에스크로 배송시작 통보 수신 |
+## 트러블슈팅
-## 보안 및 운영 참고
+
+| 증상 | 원인 | 조치 |
+|---|---|---|
+| 결제 승인 시 res_cd=9502 오류 | `plugin:update` 가 CLI 바이너리 실행 권한을 0664 로 되돌림 | 관리자 설정 화면의 시스템 점검을 실행하거나 `chmod 755` 로 직접 복구 |
+| 가상계좌 입금통보가 반영되지 않음 | 운영 환경 IP 화이트리스트에 KCP 통보 서버 IP가 없음 | 최신 연동 가이드의 통보 서버 IP로 화이트리스트를 갱신 |
+| 결제 요청이 CLI 인자 오류로 거부됨 | 주문번호·인코딩 데이터 등에 위험 문자/제어문자 포함 | `assertSafeCliValue()`가 의도적으로 거부한 것 — 원인 값을 정제하지 말고 왜 그런 값이 만들어졌는지 상위 데이터를 확인 |
+| 모바일 결제 승인키 발급 실패 | PHP SOAP 확장 미설치 또는 WSDL 파일 누락 | `php -m`으로 soap 확장 확인, `bin/*.wsdl` 존재 확인 |
+| 결제 성공했는데 간편결제 버튼 클릭 시 오류 | KCP 계약이 없는 결제수단/간편결제를 활성화 | 계약이 완료된 결제수단만 관리자 설정에서 활성화 |
+
-- 운영 도메인의 `APP_URL`을 HTTPS 절대 URL로 정확히 설정하세요.
-- 운영 site key는 외부에 노출하지 마세요.
-- 운영 모드에서는 KCP 서버 통보 IP 화이트리스트가 적용됩니다.
-- 결제 승인 후 서버 후속 처리에 실패하면 PG 잔존 승인을 자동 취소합니다.
-- 동일 거래번호 콜백은 중복 처리하지 않도록 방어합니다.
-- KCP CLI 호출 인자는 위험 문자와 제어문자를 사전에 거부합니다.
-- 가상계좌와 에스크로 통보 URL은 KCP 가맹점 관리자에 반드시 등록해야 합니다.
-- KCP 계약이 없는 결제수단이나 간편결제를 활성화하면 KCP 오류가 발생할 수 있습니다.
+## 변경 이력
-## 테스트
-
-플러그인을 G7 프로젝트에 배치한 뒤 G7 루트에서 PHP 테스트를 실행합니다.
-
-```bash
-php artisan test plugins/sirsoft-pay_nhnkcp/tests
-```
-
-프론트엔드 테스트와 빌드는 플러그인 디렉토리에서 실행합니다.
-
-```bash
-npm install
-npm run test:run
-npm run build
-```
+[CHANGELOG.md](CHANGELOG.md)
## 라이선스
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/README.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/README.md
new file mode 100644
index 00000000..d5b170b7
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/README.md
@@ -0,0 +1,22 @@
+# NHN KCP 개발자 문서
+
+> plugins/_bundled/sirsoft-pay_nhnkcp · 플러그인
+
+
+**훅 수**: 8 · **구독 훅 수**: 9 · **라우트 수**: 16 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 3
+
+
+## 문서 목차
+
+
+| 문서 | 내용 |
+|---|---|
+| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
+| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
+| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
+| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
+| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
+| [api/](api/README.md) | API 레퍼런스 |
+| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
+| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
+
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/architecture.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/architecture.md
new file mode 100644
index 00000000..c11875ba
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/architecture.md
@@ -0,0 +1,63 @@
+# NHN KCP — 아키텍처
+
+> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 설계 의도
+
+
+NHN KCP 표준결제(Standard Pay)를 `sirsoft-ecommerce` 에 연결하는 어댑터입니다. 다른 PG
+플러그인(`sirsoft-pay_kginicis`, `sirsoft-tosspayments` 등)이 전부 HTTP API 로 승인을
+받는 것과 달리, 이 플러그인의 PC 결제 승인은 **서버에서 KCP CLI 바이너리를 실행**하는
+방식입니다 — KCP 가 표준결제 승인 로직을 컴파일된 실행파일로만 배포하기 때문입니다. 이
+차이가 데이터 모델(§data-model.md — Repository 조차 없는 이유), 확장점(CLI 실행 권한
+점검 API), 금지 패턴(CLI 인자 injection 방어) 전체에 스며 있습니다.
+
+이 플러그인도 결제 상태 자체는 소유하지 않습니다 — 주문·결제 테이블은 `sirsoft-ecommerce`
+소유이고, 이 플러그인은 "그 상태를 KCP CLI/SOAP API 와 어떻게 주고받는가"만 책임집니다.
+
+
+## 계층 지도
+
+
+```text
+Controller (PaymentCallbackController / EscrowCommonNotifyController / MobileApprovalController)
+ → NhnKcpApiService (CLI 실행 · SOAP 호출 · 인자 사전검증)
+ → sirsoft-ecommerce 의 Order/OrderPayment 모델 (직접 참조 — 이 플러그인 소유 모델 없음)
+
+Listener (RegisterPgProviderListener 등)
+ → sirsoft-ecommerce 의 필터 훅에 등록 (컴파일 타임 결합 없음)
+```
+
+이 플러그인에는 FormRequest 계층이 얕습니다 — 결제 승인·통보 콜백은 사용자가 채운 폼이
+아니라 KCP 가 보내는 고정 스키마이므로, 검증의 대부분은 `NhnKcpApiService`의
+`assertSafeCliValue()`(CLI 인자 안전성)와 `PreventsReplayCallback`(중복 콜백 방지)이
+담당합니다. 일반 CRUD 플러그인의 "Controller → FormRequest → Service → Repository → Model"
+5단 계층과 다른 이유가 여기 있습니다.
+
+
+## 디렉토리
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
+| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
+| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
+| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
+| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
+| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
+| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
+| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
+| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_nhnkcp --force` (빌드 불필요) |
+| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_nhnkcp --force` |
+| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_nhnkcp --force` |
+| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_nhnkcp --force` |
+| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
+| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_nhnkcp --force` |
+| `bin/` | 확장이 실행하는 외부 바이너리·인증서 | 교체 시 OS별 파일과 권한을 함께 확인 (비면 해당 기능 정지) |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
+
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/data-model.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/data-model.md
new file mode 100644
index 00000000..8af8563c
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/data-model.md
@@ -0,0 +1,69 @@
+# NHN KCP — 데이터 모델
+
+> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 모델
+
+
+_소유 모델이 없습니다._
+
+
+
+결제 상태는 이 플러그인이 아니라 `sirsoft-ecommerce`의 `Order`/`OrderPayment` 모델이
+소유합니다(§AGENTS.md "설계 원칙"). 이 플러그인은 그 모델을 직접 참조해 읽고 쓸 뿐, 자기
+Repository 조차 두지 않았습니다(§Repository) — `sirsoft-pay_kginicis`가 CBT(일본) 정산용
+Repository 2개를 갖는 것과 달리, 이 플러그인은 일본/CBT 결제를 아예 구현하지 않으므로
+그 계층이 존재할 이유가 없습니다.
+
+
+## 소유 테이블
+
+
+_소유 테이블이 없습니다._
+
+
+
+가상계좌 발급 정보(은행·계좌번호·예금주·만료일)와 KCP 거래 정보(`tno` 등)는 이커머스
+`OrderPayment` 테이블의 기존 컬럼/메타에 저장됩니다 — PG 마다 별도 결제상세 테이블을 두면
+관리자 주문 상세가 PG 종류에 따라 다른 테이블을 조인해야 해 화면 로직이 PG 개수만큼
+분기합니다.
+
+
+## 마이그레이션
+
+
+_마이그레이션이 없습니다._
+
+
+
+소유 테이블이 없으므로(§소유 테이블) 스키마 변경 자체가 발생하지 않습니다. 이 플러그인의
+설정 스키마 변경(§settings.md)은 `config/settings/defaults.json` 갱신만으로 끝나며 DB
+마이그레이션 대상이 아닙니다.
+
+
+## Enum
+
+
+_Enum 이 없습니다._
+
+
+
+KCP 공통통보의 `tx_cd`/`cl_status` 값은 Enum 대신 `EscrowCommonNotifyController`(§extension-points.md
+"핵심 흐름"의 매핑표)의 조건 분기로 직접 처리합니다 — 이 값들은 이 플러그인 코드 어디에도
+재사용되지 않는 KCP 고유 프로토콜 상수라, Enum 으로 승격해도 얻는 타입 안전성 대비 간접
+계층만 늘어납니다.
+
+
+## Repository
+
+
+_Repository 가 없습니다._
+
+
+
+이 플러그인이 이커머스 `Order`/`OrderPayment`를 읽고 쓰는 지점(컨트롤러·리스너·`Concerns`
+트레이트)은 모두 이커머스가 이미 노출한 Eloquent 모델을 직접 참조합니다 — 자기 소유
+테이블이 없는 상태에서 남의 모델을 감싸는 Repository 를 새로 만드는 것은 위임만 하는
+빈 계층입니다. `sirsoft-pay_kginicis`의 CBT Repository 2개는 이 플러그인에는 없는
+일본 결제 전용 정산 데이터(자체 소유 테이블)를 다루기 위한 것이라 대칭이 아닙니다.
+
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/extension-points.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/extension-points.md
new file mode 100644
index 00000000..eac1100f
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/extension-points.md
@@ -0,0 +1,148 @@
+# NHN KCP — 확장점
+
+> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 발행 훅
+
+
+발행 훅 8종 / 호출 지점 8곳. 이 중 4종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다.
+
+| 훅 이름 | 유형 | 설명 | 발행 위치 |
+|---|---|---|---|
+| `sirsoft-pay_nhnkcp.escrow.delivery_started` | action | — | `src/Controllers/EscrowCommonNotifyController.php:101` |
+| `sirsoft-pay_nhnkcp.escrow.denial_confirmed` | action | — | `src/Controllers/EscrowCommonNotifyController.php:98` |
+| `sirsoft-pay_nhnkcp.escrow.purchase_cancelled` | action | — | `src/Controllers/EscrowCommonNotifyController.php:97` |
+| `sirsoft-pay_nhnkcp.escrow.purchase_confirmed` | action | — | `src/Controllers/EscrowCommonNotifyController.php:96` |
+| `sirsoft-pay_nhnkcp.payment.after_cancel` | action | KCP 결제 취소 완료 후 | `src/Services/NhnKcpApiService.php:250` |
+| `sirsoft-pay_nhnkcp.payment.after_confirm` | action | KCP 결제 승인 확인 완료 후 | `src/Controllers/PaymentCallbackController.php:279` |
+| `sirsoft-pay_nhnkcp.payment.before_cancel` | action | KCP 결제 취소 API 호출 전 (본인인증 등 확장 지점) | `src/Services/NhnKcpApiService.php:229` |
+| `sirsoft-pay_nhnkcp.payment.before_confirm` | action | KCP 결제 승인 확인 전 | `src/Controllers/PaymentCallbackController.php:274` |
+
+
+
+`escrow.*` 4종에 `유형`/`설명`이 비어 있는 것은 실수가 아니라 선언 누락입니다 — 소스에서
+자동 감지된 훅이라 `getHooks()`에 등록하면 이름 그대로도 의미가 분명해 설명을 생략했습니다.
+`before_confirm`/`before_cancel`은 KCP API 호출 **전** 개입 지점이라 여기서 예외를 던지면
+실제 KCP 호출 자체가 일어나지 않습니다(예: 고액 결제에 추가 인증을 요구하고 싶은 확장이
+`before_cancel`에서 조건 미충족 시 예외). `after_*`는 응답을 받은 뒤 부가효과(로그, 알림)를
+붙이는 자리입니다.
+
+
+## 구독 훅
+
+
+| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
+|---|---|---|---|---|
+| `core.layout_extension.after_apply` | filter | `AdjustEcommercePaymentMethodsLayoutListener` | `adjustPaymentMethodsLayout` | 30 |
+| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderDetailPaymentQueryLayoutListener` | `ensurePaymentQueryLayout` | 66 |
+| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderListTestBadgeLayoutListener` | `ensureTestBadgeLayout` | 60 |
+| `core.plugins.updated` | action | `RestoreLayoutExtensionsAfterUpdateListener` | `restoreCurrentExtensionsAfterUpdate` | 20 |
+| `sirsoft-ecommerce.payment.get_client_config` | filter | `RegisterPgProviderListener` | `getClientConfig` | 10 |
+| `sirsoft-ecommerce.payment.refund` | filter | `CancelActivityLogListener` | `logCancelConfirmed` | 20 |
+| `sirsoft-ecommerce.payment.refund` | filter | `PaymentRefundListener` | `processRefund` | 10 |
+| `sirsoft-ecommerce.payment.registered_pg_providers` | filter | `RegisterPgProviderListener` | `registerProvider` | 10 |
+| `sirsoft-ecommerce.settings.filter_available_payment_methods` | filter | `RegisterEasyPayMethodsListener` | `injectEasyPayMethods` | 30 |
+
+
+
+`RegisterPgProviderListener` 가 우선순위 10 으로 두 훅(`get_client_config`/
+`registered_pg_providers`)을 모두 구독하는 이유는 "이 PG 가 존재한다는 사실"과 "체크아웃
+화면이 필요로 하는 클라이언트 설정값"이 같은 리스너의 책임이기 때문입니다 — 등록과 설정
+노출이 다른 리스너로 갈라지면 한쪽만 갱신되는 사각이 생깁니다. `PaymentRefundListener`(10)
+가 `CancelActivityLogListener`(20)보다 먼저 실행되도록 우선순위를 명시한 것은 실제 취소가
+성공한 뒤에야 활동 로그를 남기기 위함입니다 — 순서가 뒤바뀌면 "로그는 있는데 취소는 실패"가
+생깁니다.
+
+
+## 훅 리스너
+
+
+| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
+|---|---|---|---|---|
+| `AdjustEcommercePaymentMethodsLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php` |
+| `CancelActivityLogListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/CancelActivityLogListener.php` |
+| `EnsureAdminOrderDetailPaymentQueryLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderDetailPaymentQueryLayoutListener.php` |
+| `EnsureAdminOrderListTestBadgeLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderListTestBadgeLayoutListener.php` |
+| `PaymentRefundListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/PaymentRefundListener.php` |
+| `RegisterEasyPayMethodsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterEasyPayMethodsListener.php` |
+| `RegisterPgProviderListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/RegisterPgProviderListener.php` |
+| `RestoreLayoutExtensionsAfterUpdateListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RestoreLayoutExtensionsAfterUpdateListener.php` |
+
+
+
+`RestoreLayoutExtensionsAfterUpdateListener`가 존재하는 이유는 `plugin:update`가 레이아웃
+확장 조각(§레이아웃 확장)의 활성/비활성 상태를 초기화할 수 있어서입니다 — 운영자가 특정
+화면(예: 테스트배지)을 꺼둔 상태로 플러그인을 업데이트해도 그 선택이 사라지지 않도록
+업데이트 직후 복원합니다. 8개 리스너 전부가 `HookListenerInterface`를 구현하는 것은
+auto-discovery 대상이라는 뜻이 아니라 이 저장소의 전 리스너 공통 계약입니다.
+
+
+## 레이아웃 확장
+
+
+| 대상 | 설명 |
+|---|---|
+| `resources/extensions/admin_order_list_test_badge.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/admin_order_payment_query.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/checkout_easy_pay.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/user_order_complete_receipt.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/user_order_show.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+
+
+
+5개 조각은 각각 독립적인 화면 관심사입니다 — 관리자 주문 목록의 테스트배지, 관리자 주문
+상세의 거래조회 UI, 체크아웃의 간편결제 버튼, 주문완료/마이페이지의 영수증 버튼이 서로
+다른 화면·다른 컴포넌트 트리에 주입되므로 하나의 조각으로 합치지 않았습니다. 새 KCP 기능이
+필요로 하는 화면이 이 5개 중 하나에 해당하면 새 조각을 만들지 말고 기존 조각을 확장합니다.
+
+
+## 미들웨어
+
+
+| 미들웨어 | 부착 대상(targets) | 우선순위 |
+|---|---|---|
+| `RestrictKcpIp` | `web.plugins.sirsoft-pay_nhnkcp.payment.vbank-notify`, `web.plugins.sirsoft-pay_nhnkcp.payment.escrow-common-notify` | - |
+
+
+
+결제 결과 Return URL(`/payment/callback`)에는 이 미들웨어가 붙지 않습니다 — 그 경로는
+브라우저가 POST 하는 경로라 발신 IP 가 사용자마다 다르기 때문입니다. IP 화이트리스트가
+의미 있는 것은 KCP 서버가 직접 호출하는 두 통보 경로(가상계좌 입금통보·에스크로 공통통보)
+뿐입니다. 테스트 모드에서는 개발 편의와 KCP testadmin 모의입금을 위해 이 제한을 우회합니다
+— 운영 모드로 전환할 때 이 우회가 함께 꺼지는지 확인해야 합니다.
+
+
+## 브로드캐스트 채널
+
+
+_등록하는 브로드캐스트 채널이 없습니다._
+
+
+
+결제 승인·통보는 전부 동기 HTTP 요청/응답 안에서 끝나는 흐름이라 실시간 브로드캐스트가
+필요한 지점이 없습니다 — 가상계좌 입금통보조차 KCP 서버의 POST 요청 하나로 완결됩니다.
+
+
+## 스케줄
+
+
+_등록하는 스케줄이 없습니다._
+
+
+
+가상계좌 만료 처리(§settings.md `vbank_expire_days`)는 이 플러그인이 크론으로 직접 만료
+스캔을 하지 않고, 만료 이후 도착하는 KCP 입금통보를 거부하는 방식으로 처리됩니다 — 별도
+스케줄 작업이 필요 없습니다.
+
+
+## 알림 정의
+
+
+_등록하는 알림 정의가 없습니다._
+
+
+
+결제 완료/실패 알림은 이커머스 모듈이 주문 상태 변화를 기준으로 발송하는 공용 알림에 이미
+포함됩니다 — PG 마다 별도 알림 정의를 만들면 같은 이벤트(결제완료)에 대해 PG 수만큼 중복
+알림 정의가 생깁니다.
+
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/frontend.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/frontend.md
new file mode 100644
index 00000000..c7072db4
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/frontend.md
@@ -0,0 +1,85 @@
+# NHN KCP — 프론트엔드
+
+> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 레이아웃
+
+
+레이아웃 1개 (루트: `resources/layouts`).
+
+| 그룹 | 개수 |
+|---|---|
+| `admin` | 1개 |
+
+| 레이아웃 | 그룹 | 종류 | extends |
+|---|---|---|---|
+| `plugin_settings` | `admin` | 화면 | `_admin_base` |
+
+
+
+`sirsoft-pay_kginicis`와 마찬가지로 이 플러그인이 소유한 화면 레이아웃은 관리자 설정 화면
+하나뿐입니다 — 체크아웃·주문상세·마이페이지의 결제 UI는 이 플러그인 소유가 아니라
+§레이아웃 확장(다른 확장/템플릿 레이아웃에 주입되는 조각)으로 존재합니다.
+
+
+## 액션 핸들러
+
+
+핸들러 3개 (정의: `resources/js/handlers/index.ts`).
+
+| 핸들러 | 레이아웃에서 부르는 이름 |
+|---|---|
+| `requestPayment` | `sirsoft-pay_nhnkcp.requestPayment` |
+| `setPaymentMethod` | `sirsoft-pay_nhnkcp.setPaymentMethod` |
+| `copyToClipboard` | `sirsoft-pay_nhnkcp.copyToClipboard` |
+
+
+
+`sirsoft-pay_kginicis`가 핸들러 1개(`requestPayment`)로 끝나는 것과 달리 이 플러그인은
+3개입니다. `setPaymentMethod`가 별도로 필요한 이유는 KCP 간편결제 버튼(PAYCO/네이버페이/
+카카오페이/Apple Pay)이 레이아웃 컴포넌트가 아니라 KCP 가 제공하는 DOM 을 그대로 쓰기
+때문입니다 — React 상태로 선택 하이라이트를 그리는 대신 DOM 을 직접 조작해 선택된 버튼에
+테두리를 입힙니다(`updateEasyPayButtonStyles`). Apple Pay 는 iOS 모바일이 아니면 여기서
+바로 오류 모달을 띄우고 요청 자체를 막습니다 — KCP 서버까지 보냈다가 거부당하면 사용자가
+결제 실패 이유를 알 수 없기 때문입니다. `copyToClipboard`는 가상계좌 계좌번호 복사
+버튼처럼 결제와 무관한 범용 유틸리티라 KCP 고유 로직이 없습니다.
+
+
+## 전역 진입점
+
+
+| 항목 | 값 |
+|---|---|
+| 엔트리 파일 | `resources/js/index.ts` |
+| 전역 객체 | `window.__SirsoftNhnkcp` |
+| 재등록 진입점 | `initPlugin()` |
+
+로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.
+
+
+
+`window.__SirsoftNhnkcp`로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로 재등록
+진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). KCP 결제창 스크립트 자체는 이
+진입점이 미리 로드하지 않습니다 — 모든 방문자가 결제 페이지에 오는 것은 아니므로 전역
+부팅에서 미리 불러올 필요가 없습니다(`requestPayment` 핸들러가 실제 결제 시도 시점에만
+동적으로 로드).
+
+
+## 에셋
+
+
+| 경로 | 구분 |
+|---|---|
+| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) |
+| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
+
+로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
+
+
+
+KCP 가 제공하는 `payplus_web.jsp` SDK 는 이 목록에 없습니다 — `requestPayment` 핸들러가
+결제 시도 시점에 iframe 안으로 동기 로드하는 제3자 자산이라, 이 플러그인이 빌드 시
+번들링하는 `dist/` 산출물과는 다른 층입니다. CSS 산출물이 없는 것은 결제창 자체는 KCP 가
+그리고, 이 플러그인은 간편결제 버튼·복사 버튼 같은 최소한의 UI만 코어 컴포넌트로
+구성하기 때문입니다.
+
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/settings.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/settings.md
new file mode 100644
index 00000000..db69f342
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/settings.md
@@ -0,0 +1,101 @@
+# NHN KCP — 설정·권한·라우트
+
+> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 설정 스키마
+
+
+| 키 | 타입 | 기본값 | 설명 |
+|---|---|---|---|
+| `is_test_mode` | `boolean` | `true` | 테스트 모드 |
+| `test_site_cd` | `string` | `T0000` | 테스트 사이트 코드 (site_cd) |
+| `test_site_key` | `string` | - | 테스트 사이트 키 (site_key) |
+| `live_site_cd` | `string` | - | 라이브 사이트 코드 (site_cd) |
+| `live_site_key` | `string` | - | 라이브 사이트 키 (site_key) |
+| `redirect_success_url` | `string` | `{shopBase}/orders/{orderId}/complete` | 결제 성공 리다이렉트 URL |
+| `redirect_fail_url` | `string` | `{shopBase}/checkout` | 결제 실패 리다이렉트 URL |
+| `use_escrow` | `boolean` | `false` | 에스크로 결제 활성화 |
+| `escrow_test_site_cd` | `string` | - | 테스트 에스크로 사이트 코드 |
+| `vbank_expire_days` | `integer` | `3` | 가상계좌 입금 만료(일) |
+| `easy_pay_allow_with_other_pg` | `boolean` | `false` | - |
+| `easy_pay_payco` | `boolean` | `false` | - |
+| `easy_pay_naverpay` | `boolean` | `false` | - |
+| `easy_pay_naverpay_point` | `boolean` | `false` | - |
+| `easy_pay_kakaopay` | `boolean` | `false` | - |
+| `easy_pay_applepay` | `boolean` | `false` | - |
+
+기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json`
+
+
+
+`test_*`/`live_*` 쌍 구조는 `sirsoft-pay_kginicis`와 동일한 이유입니다 — 테스트 모드와 운영
+모드가 완전히 다른 자격증명 집합을 쓰므로 `is_test_mode`를 켜고 꺼도 서로의 값을 덮어쓰지
+않습니다. kginicis 와 달리 `japan_*` 설정군이 전혀 없는 것은 이 플러그인이 KCP 의 일본/CBT
+결제 상품을 구현하지 않기 때문입니다(§data-model.md, §architecture.md) — 이 플러그인에
+일본 결제를 요구하는 요청이 오면 새 설정 키를 추가하는 대신 별도 플러그인 여부를 먼저
+검토해야 합니다.
+
+
+## 권한
+
+
+_선언된 권한이 없습니다._
+
+
+
+결제 설정 접근 권한은 이커머스의 관리자 권한 체계 안에서 다뤄집니다 — PG 마다 별도 권한을
+선언하면 PG 를 여러 개 설치했을 때 "결제 설정을 볼 수 있는 사람"이라는 하나의 개념이
+플러그인 수만큼 중복 정의됩니다.
+
+
+## 메뉴
+
+
+_등록하는 메뉴가 없습니다._
+
+
+
+설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해
+접근합니다 — PG 플러그인마다 전용 사이드바 메뉴를 만들면 PG 를 여러 개 설치했을 때 메뉴가
+난립합니다.
+
+
+## 라우트
+
+
+| 종류 | 파일 | URL prefix |
+|---|---|---|
+| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-pay_nhnkcp/...` |
+| `web` | `src/routes/web.php` | `/plugins/sirsoft-pay_nhnkcp/...` |
+
+확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
+
+
+
+`api`(Bearer 토큰 인증, 모바일 승인키 발급처럼 로그인 사용자가 브라우저에서 직접 호출하는
+엔드포인트)와 `web`(콜백·입금통보·공통통보처럼 KCP 서버나 리다이렉트로 도달하는
+엔드포인트)이 분리된 이유는 인증 방식이 다르기 때문입니다 — KCP 는 우리 서비스의 Bearer
+토큰을 모르므로 콜백 라우트에 `api` 인증 미들웨어를 걸 수 없습니다. 새 KCP 콜백을 추가할
+때는 `web` 쪽에 둡니다.
+
+
+## 의존 관계
+
+
+**이 확장이 의존하는 확장**
+
+| 확장 | 유형 | 버전 제약 | 번들 |
+|---|---|---|---|
+| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ |
+
+**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
+
+없음.
+
+
+
+`sirsoft-ecommerce >=1.1.0` 하드 의존은 §data-model.md 에서 설명한 구조(결제 상태는
+이커머스가 소유, 이 플러그인은 절차만 소유)의 직접적 결과입니다 — 이커머스 없이는 이
+플러그인이 다룰 주문 자체가 존재하지 않습니다. 이커머스의 PG 등록 훅이나 `Order` 모델
+구조가 바뀌면 이 최소 버전을 올려야 합니다(§CLAUDE.md "확장 → 확장 동기화").
+
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/AGENTS.md b/plugins/_bundled/sirsoft-pay_nicepayments/AGENTS.md
new file mode 100644
index 00000000..db6c03fd
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/AGENTS.md
@@ -0,0 +1,169 @@
+# 나이스페이먼츠 — 에이전트 가이드
+
+> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
+
+## TL;DR (5초 요약)
+
+```text
+1. 유형: 플러그인 (sirsoft-pay_nicepayments) — 나이스페이먼츠 PG 연동(인증+승인 2단계/가상계좌/에스크로/간편결제 8종). 소유 테이블 없음 — 상태는 sirsoft-ecommerce 소유
+2. 확장 방식: `RegisterPgProviderListener`/`RegisterEasyPayMethodsListener` 로 이커머스 레지스트리에 등록 — 이커머스 코드는 이 플러그인을 모른다
+3. 건드리면 안 되는 것: 결제창 인증 실패(`AuthResultCode != '0000'`)를 승인 API 호출 전인데도 hard failure 로 취급, 가상계좌 입금통보 IP 화이트리스트(`VbankNotifyIpWhitelist`) 미부착
+4. 작업 위치: `plugins/_bundled/sirsoft-pay_nicepayments` — 활성 디렉토리 직접 수정 금지
+5. 반영: `php artisan plugin:update sirsoft-pay_nicepayments --force`
+```
+
+## 1. 이 확장은 무엇인가
+
+
+나이스페이먼츠 PG를 `sirsoft-ecommerce`에 연결하는 어댑터입니다. 결제 승인은 **인증→승인
+2단계**입니다 — 결제창(`goPay` iframe 팝업/모바일 폼)이 먼저 인증 결과(`AuthResultCode`)를
+`/payment/callback`으로 POST 하고, 서버가 그 결과를 받아 `NextAppURL`로 다시 승인 API를
+호출해야 최종 완료됩니다. `sirsoft-pay_kginicis`(결제창 후 단일 승인 API)와 달리 인증
+단계에서 실패하는 것과 승인 단계에서 실패하는 것을 서로 다르게 취급해야 합니다(§금지 패턴).
+
+**설계 원칙**: 이 플러그인도 상태를 소유하지 않습니다(§data-model.md — 모델·테이블·Repository
+0개). 주문·결제 상태는 `sirsoft-ecommerce`에 있고, 이 플러그인은 그 상태를 나이스페이먼츠
+API 와 동기화하는 역할만 합니다. 등록은 훅 기반입니다
+(`sirsoft-ecommerce.payment.registered_pg_providers` 필터).
+
+**의도적으로 하지 않는 것**: 사용자가 결제창에서 취소하거나 PG 가 인증을 거부한 경우
+(`AuthResultCode != '0000'`)는 아직 승인 API 호출 전이므로 일반 오류 메시지를 띄우지 않고
+체크아웃으로 조용히 리다이렉트합니다 — 이 시점은 "결제 시도 자체를 안 한 것"과 사실상
+같아서, 사용자에게 오류로 보이면 혼란만 커집니다. 운영 가시성은 로그(`auth_result_code`/
+`auth_result_msg`)로만 보존합니다. 반면 2단계(승인) 이후의 실패(서명·MID·금액 불일치)는
+"돈이 오갔을 수 있는" 실패라 `?error=` 쿼리로 명시적으로 안내합니다.
+
+
+## 2. 디렉토리 지도
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
+| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
+| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
+| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
+| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
+| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
+| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
+| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
+| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_nicepayments --force` (빌드 불필요) |
+| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_nicepayments --force` |
+| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_nicepayments --force` |
+| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_nicepayments --force` |
+| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
+| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_nicepayments --force` |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
+
+
+## 3. 핵심 흐름
+
+
+**PC/모바일 결제 승인**: 결제창(iframe 팝업)이 `/payment/callback`으로 인증 결과 POST →
+`AuthResultCode == '0000'` 확인(아니면 §1 "의도적으로 하지 않는 것"의 silent redirect) →
+`sirsoft-pay_nicepayments.payment.before_authorize` 훅 → 서버가 `NextAppURL`로 승인 API
+호출 → `sirsoft-pay_nicepayments.payment.after_authorize` 훅 → 이커머스 주문 결제 완료
+처리. 결제 요청 시점에 주문의 `total_tax_amount`/`total_vat_amount`/`total_tax_free_amount`
+가 모두 0이 아니면 과세 필드를 폼에 포함합니다(§4 "과세 처리").
+
+**결제 취소(환불)**: 관리자가 주문 취소(`cancel_pg=true`) → 코어가
+`sirsoft-ecommerce.payment.refund` 필터 발화 → `PaymentRefundListener`(우선순위 10)가 먼저
+나이스페이먼츠 취소 API 호출(전액취소 `isPartial=0`/부분취소 `isPartial=1`) →
+`CancelActivityLogListener`(우선순위 20)가 결과를 활동 로그에 별도 기록. 가상계좌 입금
+완료 건은 환불 계좌 정보가 필요해 일반 취소 API가 아니라 별도 어드민 환불 계좌 API 경로로
+처리됩니다. 취소 API 호출이 실패하면 `refund_failed` 훅이 발화합니다.
+
+**에스크로 배송 등록**: 관리자 주문 상세에서 운송장번호·택배사를 입력 →
+`AdminEscrowController::registerDelivery()` → 나이스페이먼츠 배송 등록 API 호출.
+`EscrowDeliveryRegisterRequest`가 입력을 검증합니다.
+
+
+## 4. 확장점
+
+
+| 확장점 | 수 | 상세 |
+|---|---|---|
+| 발행 훅 | 5개 | [발행 훅](docs/extension-points.md#발행-훅) |
+| 구독 훅 | 9개 | [구독 훅](docs/extension-points.md#구독-훅) |
+| 훅 리스너 | 8개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
+| 레이아웃 확장 | 5개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
+| 미들웨어 | 1개 | [미들웨어](docs/extension-points.md#미들웨어) |
+| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
+| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
+| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
+
+
+
+`before_authorize`/`before_cancel`은 API 호출 **전** 개입 지점이라 여기서 예외를 던지면
+실제 나이스페이먼츠 호출이 일어나지 않습니다. `refund_failed`는 취소 API 호출이 실패했을
+때만 발화하는 별도 훅입니다 — `after_cancel`(성공 응답 후)과 구분해서 구독해야 합니다.
+운영자 알림(예: Slack) 을 붙이고 싶은 확장은 `after_*`가 아니라 `refund_failed`를 잡습니다.
+
+
+## 5. 수정 시 동반 의무
+
+- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-pay_nicepayments --force` 로 반영
+- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
+- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
+- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-pay_nicepayments` 재실행 + `docs/api/**` 갱신
+- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
+- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
+- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
+- [ ] 승인/취소 흐름을 고칠 때 `before_*`/`after_*` 훅 순서와 우선순위(`PaymentRefundListener` < `CancelActivityLogListener`)를 유지 — 로그가 실제 처리보다 먼저 실행되면 안 된다
+- [ ] 인증 실패(1단계)와 승인 실패(2단계)의 사용자 안내 방식(silent redirect vs `?error=`)을 구분 유지 — §1 "의도적으로 하지 않는 것" 참고
+- [ ] IP 화이트리스트(`VbankNotifyIpWhitelist`) 대상 라우트를 추가/변경하면 미들웨어 부착 대상(targets)도 함께 갱신
+- [ ] 새 간편결제 수단을 추가하면 그 결제수단의 계약 상태를 관리자 안내에도 반영
+
+## 6. 금지 패턴
+
+
+| 금지 | 올바른 사용 | 이유 |
+|---|---|---|
+| 결제창 인증 실패(`AuthResultCode != '0000'`)를 승인 실패와 동일하게 `?error=` 로 안내 | 승인 API 호출 전 실패는 체크아웃으로 silent redirect, 로그로만 기록 | 아직 결제 시도 자체가 성립하지 않은 단계인데 오류 메시지를 띄우면 사용자가 "돈이 빠져나갔나" 불필요하게 불안해한다 |
+| 가상계좌 입금통보(`vbank-notify`)에 IP 화이트리스트 미부착 | `VbankNotifyIpWhitelist` 미들웨어 유지 | 통보 엔드포인트는 나이스페이먼츠 서버만 호출해야 하며, 화이트리스트가 없으면 제3자가 위조 입금통보를 보내 결제 상태를 조작할 수 있다 |
+| 부분취소인데 가상계좌 입금 완료 건을 일반 취소 API로 처리 | 환불 계좌 정보가 필요한 가상계좌 건은 별도 어드민 환불 계좌 API 경로로 처리 | 가상계좌는 카드와 달리 PG가 자동으로 환불할 계좌를 모르므로 일반 취소 API를 호출하면 실패하거나 환불이 누락된다 |
+| 라이브 가맹점 키를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 결제 요청을 위조할 수 있다 |
+
+
+## 7. 테스트 실행
+
+
+| 종류 | 개수 | 위치 |
+|---|---|---|
+| PHPUnit | 23개 | `plugins/_bundled/sirsoft-pay_nicepayments/tests` |
+| Vitest | 7개 | `vitest.config.ts` |
+| Playwright | 0개 | — |
+| 시나리오 매니페스트 | 1개 | `tests/scenarios` |
+
+기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
+
+```bash
+# PHPUnit (변경 범위만) (Bash)
+php vendor/bin/phpunit plugins/_bundled/sirsoft-pay_nicepayments/tests --filter='<대상클래스>'
+
+# Vitest (확장 디렉토리에서) (PowerShell)
+cd plugins/_bundled/sirsoft-pay_nicepayments && powershell -Command "npm run test:run -- <대상>"
+
+```
+
+무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
+
+
+## 8. 문서 목차
+
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
+| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
+| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
+| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/README.md b/plugins/_bundled/sirsoft-pay_nicepayments/README.md
index 74e4d83e..250d0d33 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/README.md
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/README.md
@@ -1,146 +1,236 @@
-# NicePayments Plugin for G7
+# NicePayments
-나이스페이먼츠(NicePayments) PG 연동 플러그인입니다. G7 플랫폼의 sirsoft-ecommerce 모듈과 함께 동작합니다.
+**G7 플러그인 · sirsoft-pay_nicepayments**
+나이스페이먼츠 결제를 sirsoft-ecommerce 에 연결하는 결제 플러그인
-## 지원 결제 수단
+
+
+
+
+
+
+
+
+
-| 결제 수단 | PayMethod |
-|-----------|-----------|
-| 신용카드 | CARD |
-| 가상계좌 | VBANK |
-| 계좌이체 | BANK |
-| 휴대폰결제 | CELLPHONE |
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+나이스페이먼츠(NicePayments) 표준결제를 G7 `sirsoft-ecommerce` 모듈에 연결하는 결제
+플러그인입니다. 결제 승인은 **인증→승인 2단계**로 나뉩니다 — 결제창(`goPay` iframe
+팝업/모바일 폼)이 먼저 인증 결과를 서버로 보내고, 서버가 그 결과를 받아 별도 승인 API를
+호출해야 최종 완료됩니다.
+
+이 플러그인은 결제 자체의 상태(주문·결제 성공/실패/취소)를 소유하지 않습니다 — 그 상태는
+`sirsoft-ecommerce`의 주문·결제 테이블에 있고, 이 플러그인은 "그 상태를 나이스페이먼츠
+API 와 어떻게 주고받는가"만 책임집니다. 그래서 이 플러그인은 소유 테이블/모델이 하나도
+없습니다(§data-model.md).
+
+
+## 주요 기능
+
+
+| 영역 | 설명 |
+|---|---|
+| 결제수단 | 신용카드, 계좌이체, 가상계좌, 휴대폰결제 |
+| 간편결제 | 네이버페이, 카카오페이, 삼성페이, 애플페이, PAYCO, 11pay, SSG페이, L.pay 버튼 주입 |
+| 승인 방식 | 결제창 인증 + 서버 승인 API 2단계 |
+| 가상계좌 | 발급 + 입금통보 처리 |
+| 에스크로 | 배송 등록, 거래 조회 |
+| 결제 취소 | 전액/부분취소, PG 취소 확인 시점 별도 활동 로그(PG 응답 시각·취소 TID), 실패 시 `refund_failed` 훅 |
+| 영수증 | 주문 완료/마이페이지 영수증 버튼 |
+| 관리자 확장 | 주문 상세 거래 조회, 에스크로 배송 등록 UI |
+| 과세 처리 | 주문의 세금/부가세/면세 금액 자동 반영 |
+
+
+## 동작 방식
+
+
+```mermaid
+flowchart LR
+ A[체크아웃 주문 생성] --> B["결제창(goPay iframe) 로드"]
+ B --> C["/payment/callback (AuthResultCode)"]
+ C -->|실패| D[체크아웃으로 silent redirect]
+ C -->|성공| E["NextAppURL 로 승인 API 호출"]
+ E --> F[주문 결제 완료 처리]
+ F --> G[성공 URL 리다이렉트]
+```
+
+결제창에서 사용자가 취소하거나 PG 가 인증을 거부하면(`AuthResultCode != '0000'`) 아직
+승인 API 호출 전이므로 일반 오류 메시지를 띄우지 않고 체크아웃으로 조용히 리다이렉트합니다
+— 운영 가시성은 로그(`auth_result_code`/`auth_result_msg`)로 보존합니다. 2단계(승인) 이후의
+실패(서명/MID/금액 불일치)는 `?error=` 쿼리로 명시적으로 안내합니다.
+
+가상계좌는 결제창에서 발급되면 주문이 입금대기 상태로 유지되다가, 나이스페이먼츠가 입금통보
+URL로 결과를 POST 하면 결제 완료 처리됩니다.
+
+
+## 요구 사항
+
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=7.0.10` |
+| PHP | `^8.2` |
+| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
+
+
+
+| 항목 | 필요한 것 |
+|---|---|
+| 운영 환경 | HTTPS 도메인, 올바른 `APP_URL`, 나이스페이먼츠 가맹점 계약 정보 |
+| PC/모바일 결제 | MID, 가맹점 키 |
+
+서버에서 나이스페이먼츠 API 호스트로 HTTPS outbound 요청이 가능해야 합니다.
+
## 설치
+
```bash
-# 플러그인 디렉토리에 배치 후
-composer install
-npm install && npm run build
+# 번들 설치 (코어에 동봉된 소스에서 설치)
+php artisan plugin:install sirsoft-pay_nicepayments
+
+# 활성화
+php artisan plugin:activate sirsoft-pay_nicepayments
+
+# 업데이트 (번들 소스 기준 강제 반영)
+php artisan plugin:update sirsoft-pay_nicepayments --force
```
-## 설정
+저장소: https://github.com/gnuboard/g7-plugin-sirsoft-pay_nicepayments
+
-관리자 → 플러그인 → NicePayments 설정에서 구성합니다.
+설치·활성화 후 이커머스 결제 설정에서 PG 제공자를 "나이스페이먼츠"로 선택해야 실제로 결제
+흐름에 연결됩니다 — 활성화만으로는 체크아웃 화면에 나타나지 않습니다.
-| 항목 | 설명 |
-|------|------|
-| 테스트 모드 | 활성화 시 나이스페이먼츠 공용 테스트 MID를 사용합니다. 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 계정 결제는 당일 23:30경 일괄 자동 취소됩니다. |
-| 테스트 MID | 테스트 가맹점 ID (`nicepay00m` 기본값) |
-| 테스트 가맹점 키 | 나이스페이 공용 테스트 키 |
-| 라이브 MID | 실서비스 가맹점 ID |
-| 라이브 가맹점 키 | 실서비스 가맹점 키 (외부 노출 금지) |
-| 결제 성공 URL | 결제 완료 후 리다이렉트 경로 (`{orderId}` 치환 지원) |
-| 결제 실패 URL | 결제 실패 후 리다이렉트 경로 |
+## 관리자 설정
-테스트 모드 주문은 실제 배송하지 마세요. 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 계정 결제는 당일 23:30경 일괄 자동 취소됩니다.
+
+| 키 | 의미 | 기본값 |
+|---|---|---|
+| `is_test_mode` | 테스트 모드 | `true` |
+| `test_mid` | 테스트 가맹점 ID (MID) | `nicepay00m` |
+| `test_merchant_key` | 테스트 가맹점 키 | `EYzu8jGGMfqaDEp76gSckuvnaHHu+bC4opsSN6lHv3b2lurNYkVXrZ7Z1AoqQnXI3eLuaUFyoRNC6FkrzVjceg==` |
+| `live_mid` | 라이브 가맹점 ID (MID) | - |
+| `live_merchant_key` | 라이브 가맹점 키 | - |
+| `redirect_success_url` | 결제 성공 리다이렉트 URL | `{shopBase}/orders/{orderId}/complete` |
+| `redirect_fail_url` | 결제 실패 리다이렉트 URL | `{shopBase}/checkout` |
+| `use_escrow` | 에스크로 결제 사용 | `false` |
+| `easy_pay_allow_with_other_pg` | 타 PG와 사용가능함 | `false` |
+| `easy_pay_naverpay` | 네이버페이 간편결제 | `false` |
+| `easy_pay_kakaopay` | 카카오페이 간편결제 | `false` |
+| `easy_pay_samsungpay` | 삼성페이 간편결제 | `false` |
+| `easy_pay_applepay` | 애플페이 간편결제 | `false` |
+| `easy_pay_payco` | PAYCO 간편결제 | `false` |
+| `easy_pay_skpay` | 11pay (SK페이) 간편결제 | `false` |
+| `easy_pay_ssgpay` | SSG페이 간편결제 | `false` |
+| `easy_pay_lpay` | L.pay 간편결제 | `false` |
-## 웹훅 (가상계좌 입금 통보)
+개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
+
-나이스페이먼츠 관리자에서 가상계좌 입금 통보 URL을 아래로 설정하세요:
+
+테스트 모드에서는 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 계정 결제는 당일
+23:30경 일괄 자동 취소될 수 있습니다 — **테스트 모드 주문을 실제로 배송하지 마세요.** 라이브
+가맹점 키는 외부에 노출하지 마세요.
-```
+**웹훅(가상계좌 입금 통보)** — 나이스페이먼츠 관리자에 아래 URL을 실제 운영 도메인으로
+등록합니다.
+
+```text
https://your-domain.com/plugins/sirsoft-pay_nicepayments/payment/vbank-notify
```
-### IP 화이트리스트
-
-나이스페이먼츠 서버 IP만 허용됩니다. 로컬/테스트 환경에서는 자동으로 우회됩니다.
+가상계좌 입금 통보는 나이스페이먼츠 서버가 직접 호출하므로 아래 IP 화이트리스트가
+적용됩니다(로컬/테스트 환경에서는 자동 우회).
| IP |
|----|
-| 121.133.126.10 |
-| 121.133.126.11 |
-| 211.33.136.39 |
+| `121.133.126.10` |
+| `121.133.126.11` |
+| `211.33.136.39` |
+
-## 결제 흐름
+## 사용 방법
-### PC / 모바일 결제 (인증 + 승인 2단계)
+
+**결제 취소/부분취소**: 관리자가 주문 취소를 요청(`cancel_pg=true`)하면 코어가
+`sirsoft-ecommerce.payment.refund` 필터 훅을 발화하고, `PaymentRefundListener`가
+나이스페이먼츠 취소 API를 호출합니다(전액취소 `isPartial=0`/부분취소 `isPartial=1`).
+배송비가 포함된 주문은 전체취소 시 배송비도 함께 환불 레코드에 반영되고, 쿠폰이 적용된
+주문은 실결제금액(쿠폰 차감 후)이 PG `cancelAmt`로 전달됩니다. 부분취소로 쿠폰 최소
+주문금액 조건을 더 이상 충족하지 못하면 코어가 취소 자체를 거부(422)해 PG 호출이 아예
+발생하지 않습니다. 가상계좌 입금 완료 건은 환불 계좌 정보가 필요해 일반 취소 API가 아닌
+별도 어드민 환불 계좌 API 경로로 처리됩니다.
-```
-브라우저 → goPay(form) / 모바일 결제창 form POST
-결제창 → POST /payment/callback → authCallback() (1단계 인증)
-서버 → POST NextAppURL → 승인 API 호출 (2단계)
-승인 완료 → completePayment() → 성공 페이지 리다이렉트
-```
+**에스크로 배송 등록**: 관리자 주문 상세에서 운송장번호·택배사를 입력해 나이스페이먼츠
+배송 등록을 호출할 수 있습니다.
-### 결제창 취소 / 인증 실패
+**거래 단건 조회**: `NicePaymentsApiService::queryTransaction(string $tid): array`로 거래
+상태를 조회할 수 있습니다.
-모바일 결제창에서 사용자가 취소버튼을 누르거나 PG 가 인증을 거부하면 (AuthResultCode != '0000') 결제 승인 (NextAppURL 호출) 이전이므로 사용자에게 generic 오류 메시지를 띄우지 않고 체크아웃으로 silent redirect 합니다. 운영 가시성은 로그(`auth_result_code` / `auth_result_msg`) 로 보존됩니다. 2단계 이후 hard failure (signature / mid / amount / authorize) 는 종전대로 `?error=` 쿼리 부착하여 안내합니다.
+전체 API 목록(사용자/관리자)은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은
+[docs/extension-points.md](docs/extension-points.md) 를 참고하세요.
+
-### 결제 취소 / 부분취소
+## 다른 확장과의 연동
-```text
-관리자 주문 취소 요청 (cancel_pg=true)
-→ 코어가 sirsoft-ecommerce.payment.refund 필터 훅 발화
-→ PaymentRefundListener 가 NicePayments cancelPayment API 호출
- · 전액취소: isPartial=0
- · 부분취소: isPartial=1
-→ 코어가 환불 레코드 생성 + 쿠폰 / 마일리지 / 재고 복원
-→ CancelActivityLogListener 가 PG 응답 시각·취소 TID를 활동 로그에 기록
-```
+
+**이 확장이 의존하는 확장**
-배송비가 포함된 주문은 전체취소 시 배송비도 함께 환불 레코드에 반영되고, 쿠폰이 적용된 주문은 실결제금액(쿠폰 차감 후) 이 PG cancelAmt 로 전달됩니다. 부분취소 시 쿠폰 최소 주문금액 조건을 더 이상 충족하지 못하면 코어가 취소 자체를 거부 (422) 하여 PG 호출이 발생하지 않습니다. 가상계좌 입금 완료 건은 환불 계좌 정보가 필요해 일반 취소 API 가 아닌 별도 어드민 환불 계좌 API 경로로 처리됩니다.
+| 확장 | 유형 | 버전 제약 | 번들 |
+|---|---|---|---|
+| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ |
-## 가용 훅 (Hook)
+**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
-다른 플러그인이나 리스너에서 아래 훅에 연결할 수 있습니다.
+없음.
+
-### 액션 훅
+
+`RegisterPgProviderListener`가 이 플러그인을 이커머스의 PG 제공자 레지스트리에,
+`RegisterEasyPayMethodsListener`가 간편결제 결제수단 레지스트리에 각각 등록합니다 — PG
+결제사 선택과 간편결제 노출은 서로 독립적이라, 다른 PG가 기본값이어도 나이스페이먼츠
+간편결제 버튼을 체크아웃 화면에 노출하는 조합이 가능합니다(`easy_pay_allow_with_other_pg`).
+
-| 훅 이름 | 시점 | 인수 |
-|---------|------|------|
-| `sirsoft-pay_nicepayments.payment.before_authorize` | 서버 승인 API 호출 직전 | `Order $order, array $pgParams` |
-| `sirsoft-pay_nicepayments.payment.after_authorize` | 서버 승인 API 응답 직후 | `Order $order, array $pgResponse` |
-| `sirsoft-pay_nicepayments.payment.before_cancel` | NicePayments 취소 API 호출 직전 | `Order $order, OrderPayment $payment, float $refundAmount` |
-| `sirsoft-pay_nicepayments.payment.after_cancel` | NicePayments 취소 API 호출 직후 | `Order $order, OrderPayment $payment, array $pgResponse` |
-| `sirsoft-pay_nicepayments.payment.refund_failed` | 환불 API 호출 실패 시 | `Order $order, OrderPayment $payment, array $context` |
+## 문서
-#### `refund_failed` context 구조
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
+| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
+| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
+| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
-```php
-[
- 'tid' => string, // 나이스페이 거래번호
- 'cancel_amt' => int, // 환불 시도 금액 (원)
- 'error' => string, // 오류 메시지
-]
-```
+## 트러블슈팅
-### 훅 등록 예시
+
+| 증상 | 원인 | 조치 |
+|---|---|---|
+| 결제창에서 취소했는데 오류 화면이 뜸 | `AuthResultCode != '0000'` 분기가 silent redirect 대신 오류를 노출하도록 잘못 수정됨 | §동작 방식의 의도된 동작(조용히 체크아웃 복귀)으로 되돌리고 로그로만 확인 |
+| 가상계좌 입금통보가 반영되지 않음 | 운영 환경 IP 화이트리스트에 나이스페이먼츠 통보 서버 IP가 없음 | 위 IP 목록으로 화이트리스트를 갱신 |
+| 결제 승인 후 주문이 실패 상태로 남음 | 인증은 성공했지만 승인 API 호출 또는 로컬 후속 처리 실패 | 오류 로그 확인 — PG 승인은 이미 됐을 수 있으므로 수동 확인 필요 |
+| 부분취소가 실패하고 422 응답 | 부분취소로 쿠폰 최소 주문금액 조건 미충족 | 코어가 의도적으로 거부한 것 — 쿠폰 조건을 다시 충족하거나 전액취소 |
+| 결제 성공했는데 간편결제 버튼 클릭 시 오류 | 나이스페이먼츠 계약이 없는 결제수단/간편결제를 활성화 | 계약이 완료된 결제수단만 관리자 설정에서 활성화 |
+
-```php
-use App\Extension\HookManager;
+## 변경 이력
-HookManager::addAction(
- 'sirsoft-pay_nicepayments.payment.refund_failed',
- function (Order $order, OrderPayment $payment, array $context) {
- // 예: Slack 알림 발송
- SlackNotifier::send("환불 실패: 주문 #{$order->order_number}, 오류: {$context['error']}");
- },
- priority: 10
-);
-```
-
-## API 단건 조회
-
-`NicePaymentsApiService::queryTransaction(string $tid): array` 메서드로 거래 상태를 조회할 수 있습니다.
-
-```php
-$apiService = app(\Plugins\Sirsoft\PayNicepayments\Services\NicePaymentsApiService::class);
-$result = $apiService->queryTransaction('NICE_TID_12345');
-// $result['ResultCode'], $result['Amt'], ...
-```
-
-## 과세 처리
-
-결제 요청 시 주문의 `total_tax_amount`, `total_vat_amount`, `total_tax_free_amount` 값을 자동으로 나이스페이 폼에 포함합니다. 세 값이 모두 0이면 과세 필드를 생략합니다.
-
-## 테스트 실행
-
-```bash
-cd c:/g7
-php artisan test --filter=Nicepayments
-```
+[CHANGELOG.md](CHANGELOG.md)
## 라이선스
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/README.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/README.md
new file mode 100644
index 00000000..a89e9caf
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/README.md
@@ -0,0 +1,22 @@
+# 나이스페이먼츠 개발자 문서
+
+> plugins/_bundled/sirsoft-pay_nicepayments · 플러그인
+
+
+**훅 수**: 5 · **구독 훅 수**: 9 · **라우트 수**: 15 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 2
+
+
+## 문서 목차
+
+
+| 문서 | 내용 |
+|---|---|
+| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
+| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
+| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
+| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
+| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
+| [api/](api/README.md) | API 레퍼런스 |
+| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
+| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
+
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/architecture.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/architecture.md
new file mode 100644
index 00000000..4b0aa54a
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/architecture.md
@@ -0,0 +1,60 @@
+# 나이스페이먼츠 — 아키텍처
+
+> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 설계 의도
+
+
+나이스페이먼츠 표준결제를 `sirsoft-ecommerce` 에 연결하는 어댑터입니다. 이 플러그인의
+고유한 설계 축은 승인이 **인증→승인 2단계**로 나뉜다는 점입니다 — 결제창이 먼저 인증
+결과만 보내고, 서버가 그 결과를 별도 승인 API 호출로 확정합니다. `sirsoft-pay_kginicis`처럼
+결제창 콜백 하나로 승인까지 끝나는 구조와 달리, 이 플러그인은 "인증은 됐지만 아직 승인
+전"이라는 중간 상태를 다뤄야 합니다(§AGENTS.md "의도적으로 하지 않는 것").
+
+이 플러그인도 결제 상태 자체는 소유하지 않습니다 — 주문·결제 테이블은 `sirsoft-ecommerce`
+소유이고, 이 플러그인은 그 상태를 나이스페이먼츠 API 와 어떻게 주고받는가만 책임집니다.
+
+
+## 계층 지도
+
+
+```text
+Controller (PaymentCallbackController / AdminEscrowController / AdminTransactionController)
+ → NicePaymentsApiService (인증 검증 · 승인/취소/단건조회 API 호출)
+ → sirsoft-ecommerce 의 Order/OrderPayment 모델 (직접 참조 — 이 플러그인 소유 모델 없음)
+
+Listener (RegisterPgProviderListener 등)
+ → sirsoft-ecommerce 의 필터 훅에 등록 (컴파일 타임 결합 없음)
+```
+
+`PaymentCallbackController`가 인증 결과(`AuthResultCode`)를 먼저 판정하고 실패 시 승인
+API 호출 없이 조기 반환하는 것이 이 플러그인 계층 구조의 특징입니다 — 일반적인 "Controller
+→ FormRequest → Service" 흐름과 달리, 이 판정 자체가 Controller 안에 있는 이유는 그
+판정 결과에 따라 아예 다른 응답(silent redirect vs `?error=`)을 골라야 하기 때문입니다.
+
+
+## 디렉토리
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
+| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
+| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
+| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
+| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
+| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
+| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
+| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
+| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_nicepayments --force` (빌드 불필요) |
+| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_nicepayments --force` |
+| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_nicepayments --force` |
+| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_nicepayments --force` |
+| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
+| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_nicepayments --force` |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
+
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/data-model.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/data-model.md
new file mode 100644
index 00000000..62de628d
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/data-model.md
@@ -0,0 +1,65 @@
+# 나이스페이먼츠 — 데이터 모델
+
+> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 모델
+
+
+_소유 모델이 없습니다._
+
+
+
+결제 상태는 이 플러그인이 아니라 `sirsoft-ecommerce`의 `Order`/`OrderPayment` 모델이
+소유합니다(§AGENTS.md "설계 원칙"). 이 플러그인은 그 모델을 직접 참조해 읽고 쓸 뿐, 자기
+Repository 조차 두지 않았습니다(§Repository) — 인증→승인 2단계 흐름의 중간 상태도 별도
+테이블에 저장하지 않고, 인증 결과를 받은 요청 컨텍스트 안에서만 다루다가 승인이 확정되는
+순간 이커머스 테이블에 반영합니다.
+
+
+## 소유 테이블
+
+
+_소유 테이블이 없습니다._
+
+
+
+가상계좌 발급 정보와 나이스페이먼츠 거래번호(TID)는 이커머스 `OrderPayment` 테이블의 기존
+컬럼/메타에 저장됩니다 — PG 마다 별도 결제상세 테이블을 두면 관리자 주문 상세가 PG
+종류에 따라 다른 테이블을 조인해야 합니다.
+
+
+## 마이그레이션
+
+
+_마이그레이션이 없습니다._
+
+
+
+소유 테이블이 없으므로(§소유 테이블) 스키마 변경 자체가 발생하지 않습니다. 설정 스키마
+변경(§settings.md)은 `config/settings/defaults.json` 갱신만으로 끝나며 DB 마이그레이션
+대상이 아닙니다.
+
+
+## Enum
+
+
+_Enum 이 없습니다._
+
+
+
+`AuthResultCode`(`'0000'` = 성공)와 PG 응답 코드는 Enum 대신 컨트롤러의 조건 분기로 직접
+판정합니다 — 나이스페이먼츠 고유 프로토콜 상수라 이 플러그인 코드 어디에도 재사용되지
+않으며, Enum 으로 승격해도 얻는 타입 안전성 대비 간접 계층만 늘어납니다.
+
+
+## Repository
+
+
+_Repository 가 없습니다._
+
+
+
+이 플러그인이 이커머스 `Order`/`OrderPayment`를 읽고 쓰는 지점(컨트롤러·리스너)은 모두
+이커머스가 이미 노출한 Eloquent 모델을 직접 참조합니다 — 자기 소유 테이블이 없는 상태에서
+남의 모델을 감싸는 Repository 를 새로 만드는 것은 위임만 하는 빈 계층입니다.
+
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/extension-points.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/extension-points.md
new file mode 100644
index 00000000..eabb9fb5
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/extension-points.md
@@ -0,0 +1,141 @@
+# 나이스페이먼츠 — 확장점
+
+> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 발행 훅
+
+
+발행 훅 5종 / 호출 지점 5곳. 이 중 1종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다.
+
+| 훅 이름 | 유형 | 설명 | 발행 위치 |
+|---|---|---|---|
+| `sirsoft-pay_nicepayments.payment.after_authorize` | action | 나이스페이먼츠 서버 승인 완료 후 | `src/Controllers/PaymentCallbackController.php:257` |
+| `sirsoft-pay_nicepayments.payment.after_cancel` | action | 나이스페이먼츠 결제 취소 완료 후 | `src/Services/NicePaymentsApiService.php:308` |
+| `sirsoft-pay_nicepayments.payment.before_authorize` | action | 나이스페이먼츠 서버 승인 API 호출 전 | `src/Controllers/PaymentCallbackController.php:252` |
+| `sirsoft-pay_nicepayments.payment.before_cancel` | action | 나이스페이먼츠 결제 취소 API 호출 전 (본인인증 등 확장 지점) | `src/Services/NicePaymentsApiService.php:285` |
+| `sirsoft-pay_nicepayments.payment.refund_failed` | action | — | `src/Listeners/PaymentRefundListener.php:131` |
+
+
+
+`before_authorize`/`before_cancel`은 API 호출 **전** 개입 지점입니다 — 예외를 던지면 실제
+나이스페이먼츠 호출 자체가 일어나지 않습니다. `refund_failed`는 `getHooks()` 선언 없이
+소스에서 자동 감지된 훅으로, `after_cancel`(성공 응답)과 달리 취소 API 호출이 **실패**했을
+때만 발화합니다 — 운영 알림을 붙이려는 확장은 이 훅을 구독해야 합니다.
+
+
+## 구독 훅
+
+
+| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
+|---|---|---|---|---|
+| `core.layout_extension.after_apply` | filter | `AdjustEcommercePaymentMethodsLayoutListener` | `adjustPaymentMethodsLayout` | 40 |
+| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderDetailPaymentQueryLayoutListener` | `ensurePaymentQueryLayout` | 66 |
+| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderListTestBadgeLayoutListener` | `ensureTestBadgeLayout` | 60 |
+| `core.plugins.updated` | action | `RestoreLayoutExtensionsAfterUpdateListener` | `restoreCurrentExtensionsAfterUpdate` | 20 |
+| `sirsoft-ecommerce.payment.get_client_config` | filter | `RegisterPgProviderListener` | `getClientConfig` | 10 |
+| `sirsoft-ecommerce.payment.refund` | filter | `CancelActivityLogListener` | `logCancelConfirmed` | 20 |
+| `sirsoft-ecommerce.payment.refund` | filter | `PaymentRefundListener` | `processRefund` | 10 |
+| `sirsoft-ecommerce.payment.registered_pg_providers` | filter | `RegisterPgProviderListener` | `registerProvider` | 10 |
+| `sirsoft-ecommerce.settings.filter_available_payment_methods` | filter | `RegisterEasyPayMethodsListener` | `injectEasyPayMethods` | 40 |
+
+
+
+`PaymentRefundListener`(10)가 `CancelActivityLogListener`(20)보다 먼저 실행되도록 우선순위를
+명시한 것은 실제 취소가 성공한 뒤에야 활동 로그를 남기기 위함입니다 — 순서가 뒤바뀌면
+"로그는 있는데 취소는 실패"가 생깁니다. `RegisterEasyPayMethodsListener`가 8종의 간편결제
+방식을 하나의 필터 훅에서 한 번에 주입하는 이유는 §settings.md 의 설정 스키마와 대칭을
+맞추기 위함입니다 — 설정 키가 8개인데 훅 등록이 여러 곳으로 흩어지면 한쪽만 갱신되는
+사각이 생깁니다.
+
+
+## 훅 리스너
+
+
+| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
+|---|---|---|---|---|
+| `AdjustEcommercePaymentMethodsLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php` |
+| `CancelActivityLogListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/CancelActivityLogListener.php` |
+| `EnsureAdminOrderDetailPaymentQueryLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderDetailPaymentQueryLayoutListener.php` |
+| `EnsureAdminOrderListTestBadgeLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderListTestBadgeLayoutListener.php` |
+| `PaymentRefundListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/PaymentRefundListener.php` |
+| `RegisterEasyPayMethodsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterEasyPayMethodsListener.php` |
+| `RegisterPgProviderListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/RegisterPgProviderListener.php` |
+| `RestoreLayoutExtensionsAfterUpdateListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RestoreLayoutExtensionsAfterUpdateListener.php` |
+
+
+
+`RestoreLayoutExtensionsAfterUpdateListener`는 `plugin:update`가 레이아웃 확장 조각(§레이아웃
+확장)의 활성/비활성 상태를 초기화할 수 있어서 존재합니다 — 운영자가 특정 화면을 꺼둔 상태로
+업데이트해도 그 선택이 사라지지 않도록 복원합니다. 8개 리스너 전부가
+`HookListenerInterface`를 구현하는 것은 이 저장소의 전 리스너 공통 계약입니다.
+
+
+## 레이아웃 확장
+
+
+| 대상 | 설명 |
+|---|---|
+| `resources/extensions/admin_order_list_test_badge.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/admin_order_payment_query.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/checkout_easy_pay.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/user_mypage_order_receipt.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/user_order_complete_receipt.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+
+
+
+5개 조각은 각각 독립적인 화면 관심사입니다 — 관리자 주문 목록의 테스트배지, 관리자 주문
+상세의 거래조회 UI, 체크아웃의 간편결제 버튼, 마이페이지·주문완료의 영수증 버튼이 서로
+다른 화면에 주입되므로 하나로 합치지 않았습니다. `user_mypage_order_receipt.json`과
+`user_order_complete_receipt.json`이 별도 파일인 것은 두 화면의 컴포넌트 트리와 데이터
+소스가 다르기 때문입니다(§AGENTS.md "설계 원칙" — 상태는 이커머스 소유이므로 화면마다
+필요한 조회 방식이 다를 수 있습니다).
+
+
+## 미들웨어
+
+
+| 미들웨어 | 부착 대상(targets) | 우선순위 |
+|---|---|---|
+| `VbankNotifyIpWhitelist` | `web.plugins.sirsoft-pay_nicepayments.payment.vbank-notify` | - |
+
+
+
+결제 결과 콜백(`/payment/callback`)에는 이 미들웨어가 붙지 않습니다 — 그 경로는 브라우저가
+POST 하는 경로라 발신 IP 가 사용자마다 다르기 때문입니다. IP 화이트리스트가 의미 있는 것은
+나이스페이먼츠 서버가 직접 호출하는 가상계좌 입금통보뿐입니다. 로컬/테스트 환경에서는
+이 제한을 자동 우회합니다.
+
+
+## 브로드캐스트 채널
+
+
+_등록하는 브로드캐스트 채널이 없습니다._
+
+
+
+결제 승인·통보는 전부 동기 HTTP 요청/응답 안에서 끝나는 흐름이라 실시간 브로드캐스트가
+필요한 지점이 없습니다.
+
+
+## 스케줄
+
+
+_등록하는 스케줄이 없습니다._
+
+
+
+가상계좌 만료는 이 플러그인이 크론으로 스캔하지 않고, 만료 이후 도착하는 나이스페이먼츠
+입금통보를 거부하는 방식으로 처리됩니다.
+
+
+## 알림 정의
+
+
+_등록하는 알림 정의가 없습니다._
+
+
+
+결제 완료/실패 알림은 이커머스 모듈이 주문 상태 변화를 기준으로 발송하는 공용 알림에 이미
+포함됩니다 — PG 마다 별도 알림 정의를 만들면 같은 이벤트에 대해 PG 수만큼 중복 정의가
+생깁니다. 운영자에게 실패를 알리고 싶은 확장은 §발행 훅의 `refund_failed` 를 구독합니다.
+
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/frontend.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/frontend.md
new file mode 100644
index 00000000..f97fceb5
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/frontend.md
@@ -0,0 +1,79 @@
+# 나이스페이먼츠 — 프론트엔드
+
+> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 레이아웃
+
+
+레이아웃 1개 (루트: `resources/layouts`).
+
+| 그룹 | 개수 |
+|---|---|
+| `admin` | 1개 |
+
+| 레이아웃 | 그룹 | 종류 | extends |
+|---|---|---|---|
+| `plugin_settings` | `admin` | 화면 | `_admin_base` |
+
+
+
+다른 PG 플러그인들과 마찬가지로 이 플러그인이 소유한 화면 레이아웃은 관리자 설정 화면
+하나뿐입니다 — 체크아웃·주문상세·마이페이지의 결제 UI는 이 플러그인 소유가 아니라
+§레이아웃 확장(다른 확장/템플릿 레이아웃에 주입되는 조각)으로 존재합니다.
+
+
+## 액션 핸들러
+
+
+핸들러 2개 (정의: `resources/js/handlers/index.ts`).
+
+| 핸들러 | 레이아웃에서 부르는 이름 |
+|---|---|
+| `requestPayment` | `sirsoft-pay_nicepayments.requestPayment` |
+| `setPaymentMethod` | `sirsoft-pay_nicepayments.setPaymentMethod` |
+
+
+
+`setPaymentMethod`가 별도로 필요한 이유는 나이스페이먼츠 간편결제 버튼(네이버페이/카카오페이/
+삼성페이/애플페이/PAYCO/11pay/SSG페이/L.pay 8종)이 레이아웃 컴포넌트가 아니라 PG가
+제공하는 DOM을 그대로 쓰기 때문입니다 — React 상태로 선택 하이라이트를 그리는 대신 DOM을
+직접 조작해 선택된 버튼에 테두리를 입힙니다(`updateEasyPayButtonStyles`). `requestPayment`
+하나로 PC/모바일 2가지 프로토콜(§docs/architecture.md "인증→승인 2단계")을 모두 처리합니다.
+
+
+## 전역 진입점
+
+
+| 항목 | 값 |
+|---|---|
+| 엔트리 파일 | `resources/js/index.ts` |
+| 전역 객체 | `window.__SirsoftNicepayments` |
+| 재등록 진입점 | `initPlugin()` |
+
+로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.
+
+
+
+`window.__SirsoftNicepayments`로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로 재등록
+진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). 나이스페이먼츠 결제창 스크립트
+자체는 이 진입점이 미리 로드하지 않습니다 — `requestPayment` 핸들러가 결제 시도 시점에
+동적으로 스크립트를 삽입한 뒤 `goPay()`를 호출합니다.
+
+
+## 에셋
+
+
+| 경로 | 구분 |
+|---|---|
+| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) |
+| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
+
+로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
+
+
+
+나이스페이먼츠가 제공하는 결제창 SDK는 이 목록에 없습니다 — `requestPayment` 핸들러가
+결제 시도 시점에 동적으로 로드하는 제3자 자산이라, 이 플러그인이 빌드 시 번들링하는
+`dist/` 산출물과는 다른 층입니다. CSS 산출물이 없는 것은 결제창 자체는 PG 가 그리고, 이
+플러그인은 간편결제 버튼 같은 최소한의 UI만 코어 컴포넌트로 구성하기 때문입니다.
+
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/settings.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/settings.md
new file mode 100644
index 00000000..d2c590c1
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/settings.md
@@ -0,0 +1,102 @@
+# 나이스페이먼츠 — 설정·권한·라우트
+
+> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 설정 스키마
+
+
+| 키 | 타입 | 기본값 | 설명 |
+|---|---|---|---|
+| `is_test_mode` | `boolean` | `true` | 테스트 모드 |
+| `test_mid` | `string` | `nicepay00m` | 테스트 가맹점 ID (MID) |
+| `test_merchant_key` | `string` | `EYzu8jGGMfqaDEp76gSckuvnaHHu+bC4opsSN6lHv3b2lurNYkVXrZ7Z1AoqQnXI3eLuaUFyoRNC6FkrzVjceg==` | 테스트 가맹점 키 |
+| `live_mid` | `string` | - | 라이브 가맹점 ID (MID) |
+| `live_merchant_key` | `string` | - | 라이브 가맹점 키 |
+| `redirect_success_url` | `string` | `{shopBase}/orders/{orderId}/complete` | 결제 성공 리다이렉트 URL |
+| `redirect_fail_url` | `string` | `{shopBase}/checkout` | 결제 실패 리다이렉트 URL |
+| `use_escrow` | `boolean` | `false` | 에스크로 결제 사용 |
+| `easy_pay_allow_with_other_pg` | `boolean` | `false` | 타 PG와 사용가능함 |
+| `easy_pay_naverpay` | `boolean` | `false` | 네이버페이 간편결제 |
+| `easy_pay_kakaopay` | `boolean` | `false` | 카카오페이 간편결제 |
+| `easy_pay_samsungpay` | `boolean` | `false` | 삼성페이 간편결제 |
+| `easy_pay_applepay` | `boolean` | `false` | 애플페이 간편결제 |
+| `easy_pay_payco` | `boolean` | `false` | PAYCO 간편결제 |
+| `easy_pay_skpay` | `boolean` | `false` | 11pay (SK페이) 간편결제 |
+| `easy_pay_ssgpay` | `boolean` | `false` | SSG페이 간편결제 |
+| `easy_pay_lpay` | `boolean` | `false` | L.pay 간편결제 |
+
+기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json`
+
+
+
+`test_*`/`live_*` 쌍 구조는 다른 PG 플러그인과 동일한 이유입니다 — 테스트 모드와 운영
+모드가 완전히 다른 자격증명을 쓰므로 `is_test_mode`를 켜고 꺼도 서로의 값을 덮어쓰지
+않습니다. 간편결제 플래그가 8개(네이버페이/카카오페이/삼성페이/애플페이/PAYCO/11pay/
+SSG페이/L.pay)로 다른 PG 플러그인보다 많은 것은 나이스페이먼츠가 실제로 이 8종 전부를
+중계하기 때문입니다 — 계약이 없는 수단을 켜면 결제 시도 시점에 PG 오류로 드러납니다
+(§AGENTS.md "금지 패턴"과 유사하게, 계약 여부는 이 플러그인이 검증하지 않고 PG 응답에
+위임합니다).
+
+
+## 권한
+
+
+_선언된 권한이 없습니다._
+
+
+
+결제 설정 접근 권한은 이커머스의 관리자 권한 체계 안에서 다뤄집니다 — PG 마다 별도 권한을
+선언하면 PG 를 여러 개 설치했을 때 "결제 설정을 볼 수 있는 사람"이라는 하나의 개념이
+플러그인 수만큼 중복 정의됩니다.
+
+
+## 메뉴
+
+
+_등록하는 메뉴가 없습니다._
+
+
+
+설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해
+접근합니다 — PG 플러그인마다 전용 사이드바 메뉴를 만들면 PG 를 여러 개 설치했을 때 메뉴가
+난립합니다.
+
+
+## 라우트
+
+
+| 종류 | 파일 | URL prefix |
+|---|---|---|
+| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-pay_nicepayments/...` |
+| `web` | `src/routes/web.php` | `/plugins/sirsoft-pay_nicepayments/...` |
+
+확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
+
+
+
+`api`(Bearer 토큰 인증, 관리자 거래조회·에스크로 배송등록처럼 로그인 사용자가 직접
+호출하는 엔드포인트)와 `web`(결제 콜백·가상계좌 입금통보처럼 결제창이나 PG 서버가
+도달하는 엔드포인트)이 분리된 이유는 인증 방식이 다르기 때문입니다 — 나이스페이먼츠는
+우리 서비스의 Bearer 토큰을 모르므로 콜백 라우트에 `api` 인증 미들웨어를 걸 수 없습니다.
+
+
+## 의존 관계
+
+
+**이 확장이 의존하는 확장**
+
+| 확장 | 유형 | 버전 제약 | 번들 |
+|---|---|---|---|
+| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ |
+
+**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
+
+없음.
+
+
+
+`sirsoft-ecommerce >=1.1.0` 하드 의존은 §data-model.md 에서 설명한 구조(결제 상태는
+이커머스가 소유, 이 플러그인은 절차만 소유)의 직접적 결과입니다 — 이커머스 없이는 이
+플러그인이 다룰 주문 자체가 존재하지 않습니다. 이커머스의 PG 등록 훅이나 `Order` 모델
+구조가 바뀌면 이 최소 버전을 올려야 합니다(§CLAUDE.md "확장 → 확장 동기화").
+
diff --git a/plugins/_bundled/sirsoft-tosspayments/AGENTS.md b/plugins/_bundled/sirsoft-tosspayments/AGENTS.md
new file mode 100644
index 00000000..3ee72510
--- /dev/null
+++ b/plugins/_bundled/sirsoft-tosspayments/AGENTS.md
@@ -0,0 +1,178 @@
+# 토스페이먼츠 — 에이전트 가이드
+
+> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
+
+## TL;DR (5초 요약)
+
+```text
+1. 유형: 플러그인 (sirsoft-tosspayments) — 토스페이먼츠 PG 연동(통합결제창 SDK/가상계좌 웹훅 secret 대조/에스크로 3-상태). 소유 테이블 없음 — 상태는 sirsoft-ecommerce 소유
+2. 확장 방식: `RegisterPgProviderListener`/`RegisterTossPaymentMethodsListener`/`RegisterCashReceiptProviderListener` 로 이커머스 레지스트리에 등록 — 이커머스 코드는 이 플러그인을 모른다
+3. 건드리면 안 되는 것: 결제 승인 확인 시 서버가 재계산한 금액과 PG 콜백 금액 대조(amount mismatch 검사) 생략, 가상계좌 웹훅의 secret 대조(`webhook_secret_verify`) 우회 — 토스는 notify IP 목록·서명을 제공하지 않아 secret 대조가 유일한 위조 방지 수단
+4. 작업 위치: `plugins/_bundled/sirsoft-tosspayments` — 활성 디렉토리 직접 수정 금지
+5. 반영: `php artisan plugin:update sirsoft-tosspayments --force`
+```
+
+## 1. 이 확장은 무엇인가
+
+
+토스페이먼츠 PG(결제 게이트웨이)를 `sirsoft-ecommerce`에 연결하는 어댑터입니다. 승인은
+브라우저 리다이렉트 기반입니다 — 통합결제창 SDK(`js.tosspayments.com/v2/standard`)가
+결제를 처리한 뒤 브라우저를 `?paymentKey&orderId&amount`가 붙은 콜백 URL로 리다이렉트하고,
+서버가 그 파라미터로 승인 확인 API를 호출합니다. 다른 PG 플러그인(iframe 팝업/CLI/SOAP)과
+달리 프론트엔드 계층이 SDK 호출 하나로 끝나고 프로토콜 복잡도가 대부분 서버 쪽(확인·웹훅)에
+있습니다.
+
+이 플러그인은 **결제창형**과 **주문서형**(`order_sheet_mode`) 두 UI 모드를 지원합니다.
+결제창형은 통합결제창이 결제수단을 전부 처리하는 카드 하나로 노출되고, 주문서형은
+`method_*` 설정으로 활성화한 개별 토스 결제수단(카드/가상계좌/계좌이체/휴대폰/토스페이/
+카카오페이/네이버페이/페이코/삼성페이)이 체크아웃 화면에 개별 버튼으로 뜹니다 — 어느
+쪽이든 최종 처리는 토스 결제창 하나로 귀결되므로 각 수단은 `pg_provider` 를 이 플러그인
+자신으로 고정(`pg_locked`)합니다(§AGENTS.md "4. 확장점").
+
+**설계 원칙**: 이 플러그인도 상태를 소유하지 않습니다(§data-model.md — 모델·테이블 0개).
+가상계좌 웹훅 검증은 토스가 notify IP 목록이나 서명을 제공하지 않는다는 제약에서
+비롯됩니다 — 승인 확인 응답에만 실리는 `secret` 값을 `payment_meta`에 저장해 두었다가
+웹훅이 도착하면 대조하는 것이 토스 공식 문서가 제시하는 유일한 위조 방지 수단입니다.
+
+**의도적으로 하지 않는 것**: 승인 확인 시 PG가 돌려준 금액과 서버가 재계산한 주문 금액이
+다르면(`amount_mismatch`) 결제를 완료 처리하지 않고 실패로 되돌립니다 — 콜백 URL의
+`amount` 쿼리 파라미터는 브라우저를 거치므로 신뢰할 수 없는 입력이기 때문입니다.
+
+
+## 2. 디렉토리 지도
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
+| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
+| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
+| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
+| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
+| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
+| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
+| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
+| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-tosspayments --force` (빌드 불필요) |
+| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-tosspayments --force` |
+| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-tosspayments --force` |
+| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
+| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-tosspayments --force` |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
+
+
+## 3. 핵심 흐름
+
+
+**결제 승인**: 통합결제창이 브라우저를 `/payment/callback?paymentKey&orderId&amount`로
+리다이렉트 → `PaymentCallbackController`가 주문 조회 →
+`sirsoft-tosspayments.payment.before_confirm` 훅 → `TossPaymentsApiService::confirmPayment()`
+가 토스 승인 확인 API 호출 → 응답 금액과 서버 재계산 금액 대조(불일치 시 실패 처리) →
+`sirsoft-tosspayments.payment.after_confirm` 훅 → 이커머스 주문 결제 완료 처리. 가상계좌가
+발급된 경우 응답에 실린 `secret`을 `payment_meta.toss_secret`에 저장해 이후 웹훅 대조에
+씁니다.
+
+**가상계좌 입금 웹훅**: 토스가 `/webhook/deposit`으로 POST → 저장된 `toss_secret`과 웹훅
+본문의 `secret`을 대조(`webhook_secret_verify` 설정이 꺼져 있지 않은 한 강제) → 일치하면
+결제 완료 처리, 불일치하면 경고 로그만 남기고 처리하지 않습니다.
+
+**결제 취소(환불)**: 관리자가 주문 취소(`cancel_pg=true`) → 코어가
+`sirsoft-ecommerce.payment.refund` 필터 발화 → `PaymentRefundListener`가 토스 취소 API 호출
+→ `before_cancel`/`after_cancel` 훅 발화.
+
+**설정 저장 검증**: 관리자가 플러그인 설정을 저장 → `core.plugin_settings.before_save`
+(동기 훅, `sync: true`) → `ValidateTossSettingsListener`가 `vbank_valid_hours`(1~2160시간)와
+`use_escrow`(`off`/`on`/`buyer_choice` 3-상태) 범위를 검증 → 위반 시 `ValidationException`으로
+저장 자체를 막습니다.
+
+
+## 4. 확장점
+
+
+| 확장점 | 수 | 상세 |
+|---|---|---|
+| 발행 훅 | 4개 | [발행 훅](docs/extension-points.md#발행-훅) |
+| 구독 훅 | 9개 | [구독 훅](docs/extension-points.md#구독-훅) |
+| 훅 리스너 | 6개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
+| 레이아웃 확장 | 3개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
+| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) |
+| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
+| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
+| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
+
+
+
+`before_confirm`/`before_cancel`은 API 호출 **전** 개입 지점이라 예외를 던지면 실제
+토스 호출이 일어나지 않습니다. `core.plugin_settings.before_save`를 `ValidateTossSettingsListener`
+가 구독하는 것은 다른 PG 플러그인에는 없는 패턴입니다 — 이 훅은 코어 `PluginSettingsService`
+가 발행하며, `sync: true`가 없으면 큐로 비동기 디스패치되어 `ValidationException`이 워커
+안에서 죽고 저장은 그대로 진행됩니다(§CLAUDE.md "Listener 데이터 접근 규정" 의 sync 훅
+규칙과 동일한 이유).
+
+
+## 5. 수정 시 동반 의무
+
+- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-tosspayments --force` 로 반영
+- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
+- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
+- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-tosspayments` 재실행 + `docs/api/**` 갱신
+- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
+- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
+- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
+- [ ] 승인 확인에서 금액 대조(`amount_mismatch`) 로직을 우회하거나 완화하지 않는다
+- [ ] 가상계좌 웹훅의 secret 대조(`webhook_secret_verify`)를 기본값 `true` 이외로 바꾸지 않는다 — 끄면 토스 노티 위조를 막을 수단이 사라진다
+- [ ] `order_sheet_mode` 관련 로직을 고칠 때 `RegisterPgProviderListener`(enabled_methods)와 `RegisterTossPaymentMethodsListener`(builtin 결제수단 주입) 양쪽을 함께 갱신 — 한쪽만 고치면 설정과 노출 목록이 어긋난다
+- [ ] `ValidateTossSettingsListener`에 새 범위 검증을 추가하면 `core.plugin_settings.before_save` 의 `sync: true`를 유지
+
+## 6. 금지 패턴
+
+
+| 금지 | 올바른 사용 | 이유 |
+|---|---|---|
+| 콜백 URL의 `amount` 쿼리 파라미터를 그대로 신뢰해 결제 완료 처리 | 서버가 주문 금액을 재계산해 PG 응답 금액과 대조, 불일치 시 실패 처리 | 콜백은 브라우저를 거치므로 사용자가 쿼리 파라미터를 조작해 실제 결제 금액보다 낮은 금액으로 완료 처리를 유도할 수 있다 |
+| 가상계좌 웹훅의 secret 대조를 생략하거나 항상 통과 | `payment_meta.toss_secret`과 웹훅 본문의 secret을 항상 대조 | 토스는 notify IP 목록·서명을 제공하지 않아 secret 대조가 유일한 위조 방지 수단이다 — 생략하면 제3자가 임의 주문에 대해 위조 입금통보를 보낼 수 있다 |
+| `core.plugin_settings.before_save` 리스너에 `sync: true` 없이 등록 | 저장을 막아야 하는 검증 훅은 반드시 `sync: true` | 기본값(비동기 큐)이면 `ValidationException`이 워커 안에서 죽고 저장이 그대로 진행되어 검증이 무력화된다 |
+| 라이브 시크릿 키를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 서버측 API를 위조 호출할 수 있다 |
+
+
+## 7. 테스트 실행
+
+
+| 종류 | 개수 | 위치 |
+|---|---|---|
+| PHPUnit | 12개 | `plugins/_bundled/sirsoft-tosspayments/tests` |
+| Vitest | 5개 | `vitest.config.ts` |
+| Playwright | 0개 | — |
+| 시나리오 매니페스트 | 3개 | `tests/scenarios` |
+
+기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
+
+```bash
+# PHPUnit (변경 범위만) (Bash)
+php vendor/bin/phpunit plugins/_bundled/sirsoft-tosspayments/tests --filter='<대상클래스>'
+
+# Vitest (확장 디렉토리에서) (PowerShell)
+cd plugins/_bundled/sirsoft-tosspayments && powershell -Command "npm run test:run -- <대상>"
+
+```
+
+무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
+
+
+## 8. 문서 목차
+
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
+| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
+| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
+| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
diff --git a/plugins/_bundled/sirsoft-tosspayments/README.md b/plugins/_bundled/sirsoft-tosspayments/README.md
new file mode 100644
index 00000000..c16051cf
--- /dev/null
+++ b/plugins/_bundled/sirsoft-tosspayments/README.md
@@ -0,0 +1,213 @@
+# 토스페이먼츠
+
+**G7 플러그인 · sirsoft-tosspayments**
+토스페이먼츠 결제 게이트웨이 (통합결제창 연동)
+
+
+
+
+
+
+
+
+
+
+
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+토스페이먼츠 통합결제창 결제를 G7 `sirsoft-ecommerce` 모듈에 연결하는 결제 플러그인입니다.
+승인은 브라우저 리다이렉트 기반입니다 — 결제창(SDK)이 결제를 처리한 뒤 브라우저를 콜백
+URL로 돌려보내고, 서버가 그 파라미터로 승인 확인 API를 호출합니다.
+
+이 플러그인은 결제창형(통합결제창 카드 하나)과 주문서형(개별 토스 결제수단 버튼) 두 UI
+모드를 지원합니다. 결제 자체의 상태(주문·결제 성공/실패/취소)는 소유하지 않습니다 — 그
+상태는 `sirsoft-ecommerce`의 주문·결제 테이블에 있고, 이 플러그인은 "그 상태를
+토스페이먼츠 API 와 어떻게 주고받는가"만 책임집니다. 그래서 이 플러그인은 소유
+테이블/모델이 하나도 없습니다(§data-model.md).
+
+
+## 주요 기능
+
+
+| 영역 | 설명 |
+|---|---|
+| 승인 방식 | 통합결제창 SDK + 브라우저 리다이렉트 콜백 + 서버 승인 확인 |
+| UI 모드 | 결제창형(단일 카드) / 주문서형(개별 결제수단 버튼) 전환 |
+| 결제수단 | 카드, 가상계좌, 계좌이체, 휴대폰, 토스페이, 카카오페이, 네이버페이, 페이코, 삼성페이 |
+| 가상계좌 | 발급 + 웹훅 입금통보(secret 대조로 위조 방지) |
+| 에스크로 | 3-상태(끔/켬/구매자선택) |
+| 현금영수증 | 카드/계좌이체 발급·취소 프로바이더 등록 |
+| 결제 취소 | 전액/부분취소, 실패 시 별도 훅 |
+| 설정 저장 검증 | 가상계좌 유효시간·에스크로 값 서버측 범위 강제 |
+
+
+## 동작 방식
+
+
+```mermaid
+flowchart LR
+ A[체크아웃 주문 생성] --> B["통합결제창 SDK 호출"]
+ B --> C["/payment/callback (paymentKey·orderId·amount)"]
+ C --> D[서버가 금액 재계산 후 대조]
+ D -->|일치| E["승인 확인 API 호출"]
+ D -->|불일치| F[결제 실패 처리]
+ E --> G[주문 결제 완료 처리]
+ G --> H[성공 URL 리다이렉트]
+```
+
+가상계좌가 발급되면 승인 확인 응답에만 실리는 secret 값을 저장해 두었다가, 토스가 입금
+웹훅을 보내면 그 secret 을 대조해 위조를 막습니다 — 토스는 notify IP 목록이나 서명을
+제공하지 않아 이 방식이 공식적으로 제시되는 유일한 검증 수단입니다.
+
+
+## 요구 사항
+
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=7.0.10` |
+| PHP | `^8.2` |
+| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
+
+
+## 설치
+
+
+```bash
+# 번들 설치 (코어에 동봉된 소스에서 설치)
+php artisan plugin:install sirsoft-tosspayments
+
+# 활성화
+php artisan plugin:activate sirsoft-tosspayments
+
+# 업데이트 (번들 소스 기준 강제 반영)
+php artisan plugin:update sirsoft-tosspayments --force
+```
+
+저장소: https://github.com/gnuboard/g7-plugin-sirsoft-tosspayments
+
+
+## 관리자 설정
+
+
+| 키 | 의미 | 기본값 |
+|---|---|---|
+| `is_test_mode` | 테스트 모드 | `true` |
+| `test_client_key` | 테스트 클라이언트 키 | - |
+| `test_secret_key` | 테스트 시크릿 키 | - |
+| `live_client_key` | 라이브 클라이언트 키 | - |
+| `live_secret_key` | 라이브 시크릿 키 | - |
+| `redirect_success_url` | 결제 성공 리다이렉트 URL | `{shopBase}/orders/{orderId}/complete` |
+| `redirect_fail_url` | 결제 실패 리다이렉트 URL | `{shopBase}/checkout` |
+| `order_sheet_mode` | 주문서형 결제 | `false` |
+| `method_card` | 카드 | `true` |
+| `method_virtual_account` | 가상계좌 | `false` |
+| `method_transfer` | 계좌이체 | `false` |
+| `method_mobile_phone` | 휴대폰 | `false` |
+| `method_tosspay` | 토스페이 | `false` |
+| `method_kakaopay` | 카카오페이 | `false` |
+| `method_naverpay` | 네이버페이 | `false` |
+| `method_payco` | 페이코 | `false` |
+| `method_samsungpay` | 삼성페이 | `false` |
+| `vbank_valid_hours` | 가상계좌 입금기한(시간) | `24` |
+| `vbank_cash_receipt_type` | 가상계좌 현금영수증 유형 | - |
+| `use_escrow` | 에스크로 사용 | `off` |
+| `webhook_secret_verify` | 웹훅 secret 검증 | `true` |
+
+개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
+
+
+
+`order_sheet_mode`를 켜야 `method_*` 개별 결제수단 플래그가 체크아웃 화면에 실제로
+반영됩니다 — 꺼둔 상태(기본값)에서는 `method_*` 값을 바꿔도 통합결제창이 결제수단을
+전부 처리하므로 화면에 변화가 없습니다. 라이브 키(클라이언트 키·시크릿 키)는 외부에
+노출하지 마세요.
+
+**웹훅 URL 등록** — 토스페이먼츠 개발자센터에 아래 URL을 실제 운영 도메인으로 등록합니다.
+
+```text
+https://your-domain.com/plugins/sirsoft-tosspayments/webhook/deposit
+```
+
+`webhook_secret_verify`(기본값 켜짐)를 끄면 이 웹훅의 위조 방지 수단이 사라지므로 특별한
+이유가 없는 한 켜둡니다.
+
+
+## 사용 방법
+
+
+**결제창형으로 시작하기**: 별도 설정 없이 활성화만 하면 통합결제창 카드 하나로 카드·계좌이체·
+가상계좌·휴대폰결제가 전부 처리됩니다. 간편결제(토스페이/카카오페이/네이버페이 등)를
+개별 버튼으로 노출하고 싶다면 `order_sheet_mode`를 켜고 해당 `method_*` 플래그를
+활성화하세요.
+
+**결제 취소/부분취소**: 관리자가 주문 취소를 요청(`cancel_pg=true`)하면 코어가
+`sirsoft-ecommerce.payment.refund` 필터 훅을 발화하고, `PaymentRefundListener`가
+토스페이먼츠 취소 API를 호출합니다. `after_cancel`은 취소 API 가 **성공했을 때만**
+발화합니다 — 실패하면 `TossPaymentsApiException`이 던져지고 코어가 이를 받아 취소 요청
+자체를 실패로 응답합니다(이 플러그인에는 나이스페이먼츠의 `refund_failed` 같은 별도
+실패 훅이 없습니다).
+
+**가상계좌 확인**: 테스트 모드에서 가상계좌 웹훅이 도착하지 않으면 §웹훅 URL 등록이
+완료됐는지, `webhook_secret_verify`가 켜져 있다면 저장된 secret 이 정상 발급됐는지
+확인합니다.
+
+전체 API 목록은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은
+[docs/extension-points.md](docs/extension-points.md) 를 참고하세요.
+
+
+## 다른 확장과의 연동
+
+
+**이 확장이 의존하는 확장**
+
+| 확장 | 유형 | 버전 제약 | 번들 |
+|---|---|---|---|
+| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ |
+
+**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
+
+없음.
+
+
+## 문서
+
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
+| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
+| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
+| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
+
+## 트러블슈팅
+
+
+| 증상 | 원인 | 조치 |
+|---|---|---|
+| 결제는 성공했는데 체크아웃으로 실패 리다이렉트됨 | 콜백 `amount`가 서버 재계산 금액과 불일치 | 의도된 안전장치 — 쿠폰/재고 변경 등으로 주문 금액이 결제 시점과 달라졌는지 확인 |
+| 가상계좌 입금통보가 반영되지 않음 | 웹훅 URL 미등록, 또는 secret 불일치로 조용히 무시됨 | §웹훅 URL 등록 확인 + 로그의 `deposit webhook secret mismatch` 경고 확인 |
+| 주문서형으로 켰는데 개별 결제수단 버튼이 안 보임 | `order_sheet_mode`는 켰지만 해당 `method_*` 플래그 비활성 | 노출하려는 결제수단의 `method_*`를 개별로 활성화 |
+| 설정 저장 시 422 오류 | `vbank_valid_hours` 범위(1~2160) 또는 `use_escrow` 값(`off`/`on`/`buyer_choice`) 위반 | `ValidateTossSettingsListener`가 의도적으로 차단한 것 — 값을 허용 범위로 수정 |
+| 결제 성공했는데 간편결제 버튼 클릭 시 오류 | 토스페이먼츠 계약이 없는 결제수단을 활성화 | 계약이 완료된 결제수단만 관리자 설정에서 활성화 |
+
+
+## 변경 이력
+
+[CHANGELOG.md](CHANGELOG.md)
+
+## 라이선스
+
+MIT
diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/README.md b/plugins/_bundled/sirsoft-tosspayments/docs/README.md
new file mode 100644
index 00000000..a724a00d
--- /dev/null
+++ b/plugins/_bundled/sirsoft-tosspayments/docs/README.md
@@ -0,0 +1,22 @@
+# 토스페이먼츠 개발자 문서
+
+> plugins/_bundled/sirsoft-tosspayments · 플러그인
+
+
+**훅 수**: 4 · **구독 훅 수**: 9 · **라우트 수**: 4 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 1
+
+
+## 문서 목차
+
+
+| 문서 | 내용 |
+|---|---|
+| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
+| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
+| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
+| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
+| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
+| [api/](api/README.md) | API 레퍼런스 |
+| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
+| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
+
diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/architecture.md b/plugins/_bundled/sirsoft-tosspayments/docs/architecture.md
new file mode 100644
index 00000000..1ee4a9f7
--- /dev/null
+++ b/plugins/_bundled/sirsoft-tosspayments/docs/architecture.md
@@ -0,0 +1,58 @@
+# 토스페이먼츠 — 아키텍처
+
+> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 설계 의도
+
+
+토스페이먼츠 통합결제창을 `sirsoft-ecommerce`에 연결하는 어댑터입니다. 다른 PG
+플러그인(iframe 팝업, CLI 실행, SOAP)과 달리 이 플러그인은 순수 리다이렉트 기반입니다 —
+결제창이 브라우저를 콜백 URL로 돌려보내고, 서버는 그 쿼리 파라미터로 승인을 확인합니다.
+이 단순함의 대가로 콜백 파라미터(특히 `amount`)를 신뢰하지 않고 서버가 재검증해야
+합니다(§AGENTS.md "의도적으로 하지 않는 것").
+
+가상계좌 웹훅 검증도 다른 PG 와 다릅니다 — IP 화이트리스트가 아니라 결제 승인 응답에만
+실리는 secret 값 대조입니다. 토스가 notify IP 목록이나 서명을 제공하지 않기 때문입니다.
+
+
+## 계층 지도
+
+
+```text
+Controller (PaymentCallbackController / WebhookController)
+ → TossPaymentsApiService (승인 확인 · 취소 API 호출)
+ → sirsoft-ecommerce 의 Order/OrderPayment 모델 (직접 참조 — 이 플러그인 소유 모델 없음)
+
+Listener (RegisterPgProviderListener / RegisterTossPaymentMethodsListener / ValidateTossSettingsListener 등)
+ → sirsoft-ecommerce 의 필터 훅 + 코어 설정 저장 훅에 등록 (컴파일 타임 결합 없음)
+```
+
+`ValidateTossSettingsListener`가 `core.plugin_settings.before_save`(코어 설정 저장 훅)를
+구독하는 것은 이 플러그인만의 계층 특징입니다 — 결제 도메인 훅(`sirsoft-ecommerce.payment.*`)
+뿐 아니라 코어 설정 저장 경로에도 개입해 저장 시점에 값을 검증합니다.
+
+
+## 디렉토리
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
+| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
+| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
+| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
+| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
+| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
+| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
+| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
+| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-tosspayments --force` (빌드 불필요) |
+| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-tosspayments --force` |
+| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-tosspayments --force` |
+| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
+| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-tosspayments --force` |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
+
diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/data-model.md b/plugins/_bundled/sirsoft-tosspayments/docs/data-model.md
new file mode 100644
index 00000000..139f3397
--- /dev/null
+++ b/plugins/_bundled/sirsoft-tosspayments/docs/data-model.md
@@ -0,0 +1,64 @@
+# 토스페이먼츠 — 데이터 모델
+
+> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 모델
+
+
+_소유 모델이 없습니다._
+
+
+
+결제 상태는 이 플러그인이 아니라 `sirsoft-ecommerce`의 `Order`/`OrderPayment` 모델이
+소유합니다(§AGENTS.md "설계 원칙"). 가상계좌 웹훅 검증에 쓰는 secret 조차 별도 테이블이
+아니라 `OrderPayment.payment_meta`(JSON 컬럼)의 `toss_secret` 키에 저장됩니다 — 이 값은
+그 주문 하나에만 의미가 있어 독립 테이블을 둘 이유가 없습니다.
+
+
+## 소유 테이블
+
+
+_소유 테이블이 없습니다._
+
+
+
+가상계좌 발급 정보와 토스 거래키(`paymentKey`)는 이커머스 `OrderPayment` 테이블의 기존
+컬럼/메타에 저장됩니다 — PG 마다 별도 결제상세 테이블을 두면 관리자 주문 상세가 PG
+종류에 따라 다른 테이블을 조인해야 합니다.
+
+
+## 마이그레이션
+
+
+_마이그레이션이 없습니다._
+
+
+
+소유 테이블이 없으므로(§소유 테이블) 스키마 변경 자체가 발생하지 않습니다. 설정 스키마
+변경(§settings.md)은 `config/settings/defaults.json` 갱신만으로 끝나며 DB 마이그레이션
+대상이 아닙니다.
+
+
+## Enum
+
+
+_Enum 이 없습니다._
+
+
+
+`use_escrow`의 3-상태(`off`/`on`/`buyer_choice`)는 Enum이 아니라
+`ValidateTossSettingsListener::USE_ESCROW_VALUES` 상수 배열로 검증합니다 — 설정값 하나에만
+쓰이는 닫힌 어휘라 Enum 승격의 이득(여러 곳에서 타입으로 재사용)이 없습니다.
+
+
+## Repository
+
+
+_Repository 가 없습니다._
+
+
+
+이 플러그인이 이커머스 `Order`/`OrderPayment`를 읽고 쓰는 지점(컨트롤러·리스너)은 모두
+이커머스가 이미 노출한 Eloquent 모델을 직접 참조합니다 — 자기 소유 테이블이 없는 상태에서
+남의 모델을 감싸는 Repository 를 새로 만드는 것은 위임만 하는 빈 계층입니다.
+
diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/extension-points.md b/plugins/_bundled/sirsoft-tosspayments/docs/extension-points.md
new file mode 100644
index 00000000..8ee88279
--- /dev/null
+++ b/plugins/_bundled/sirsoft-tosspayments/docs/extension-points.md
@@ -0,0 +1,135 @@
+# 토스페이먼츠 — 확장점
+
+> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 발행 훅
+
+
+발행 훅 4종 / 호출 지점 4곳.
+
+| 훅 이름 | 유형 | 설명 | 발행 위치 |
+|---|---|---|---|
+| `sirsoft-tosspayments.payment.after_cancel` | action | 토스페이먼츠 결제 취소 완료 후 | `src/Services/TossPaymentsApiService.php:102` |
+| `sirsoft-tosspayments.payment.after_confirm` | action | 토스페이먼츠 결제 승인 완료 후 | `src/Controllers/PaymentCallbackController.php:98` |
+| `sirsoft-tosspayments.payment.before_cancel` | action | 토스페이먼츠 결제 취소 API 호출 전 | `src/Services/TossPaymentsApiService.php:98` |
+| `sirsoft-tosspayments.payment.before_confirm` | action | 토스페이먼츠 결제 승인 API 호출 전 | `src/Controllers/PaymentCallbackController.php:94` |
+
+
+
+`before_confirm`/`before_cancel`은 API 호출 **전** 개입 지점이라 예외를 던지면 실제 토스
+호출이 일어나지 않습니다. `after_confirm`은 승인 확인 응답을 받은 뒤(§AGENTS.md "핵심 흐름")
+발화하므로, 이 시점에는 아직 금액 대조가 끝나지 않았을 수 있습니다 — 구독하는 확장은
+이커머스가 최종 완료 처리를 마쳤는지 별도로 확인해야 합니다.
+
+
+## 구독 훅
+
+
+| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
+|---|---|---|---|---|
+| `core.plugin_settings.before_save` | action (미선언) | `ValidateTossSettingsListener` | `validateBeforeSave` | 10 |
+| `core.plugins.updated` | action | `RestoreLayoutExtensionsAfterUpdateListener` | `restoreCurrentExtensionsAfterUpdate` | 20 |
+| `sirsoft-ecommerce.cash_receipt.cancel` | filter | `RegisterCashReceiptProviderListener` | `cancel` | 10 |
+| `sirsoft-ecommerce.cash_receipt.issue` | filter | `RegisterCashReceiptProviderListener` | `issue` | 10 |
+| `sirsoft-ecommerce.cash_receipt.registered_providers` | filter | `RegisterCashReceiptProviderListener` | `registerProvider` | 10 |
+| `sirsoft-ecommerce.payment.get_client_config` | filter | `RegisterPgProviderListener` | `getClientConfig` | 10 |
+| `sirsoft-ecommerce.payment.refund` | filter | `PaymentRefundListener` | `processRefund` | 10 |
+| `sirsoft-ecommerce.payment.registered_pg_providers` | filter | `RegisterPgProviderListener` | `registerProvider` | 10 |
+| `sirsoft-ecommerce.settings.filter_available_payment_methods` | filter | `RegisterTossPaymentMethodsListener` | `injectTossMethods` | 20 |
+
+
+
+`core.plugin_settings.before_save`는 다른 PG 플러그인에는 없는 구독입니다 — 이 플러그인만
+설정 저장 시점에 서버측 범위 검증(`vbank_valid_hours`, `use_escrow`)을 강제합니다
+(§AGENTS.md "핵심 흐름"). `RegisterTossPaymentMethodsListener`가 `order_sheet_mode`가
+꺼져 있으면 아무것도 주입하지 않는 것은 결제창형에서는 토스 결제수단 전부가 통합결제창
+카드 하나로 처리되기 때문입니다 — 개별 버튼을 만들 필요가 없습니다. 3종의 현금영수증 훅
+(`cash_receipt.*`)은 카드/계좌이체 발급, 취소, 프로바이더 등록을 각각 담당하며 모두
+`RegisterCashReceiptProviderListener` 하나가 처리합니다 — PG 선택과 현금영수증 발급사
+선택이 이커머스에서 독립적인 개념이라 별도 등록입니다.
+
+
+## 훅 리스너
+
+
+| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
+|---|---|---|---|---|
+| `PaymentRefundListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/PaymentRefundListener.php` |
+| `RegisterCashReceiptProviderListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/RegisterCashReceiptProviderListener.php` |
+| `RegisterPgProviderListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/RegisterPgProviderListener.php` |
+| `RegisterTossPaymentMethodsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterTossPaymentMethodsListener.php` |
+| `RestoreLayoutExtensionsAfterUpdateListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RestoreLayoutExtensionsAfterUpdateListener.php` |
+| `ValidateTossSettingsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/ValidateTossSettingsListener.php` |
+
+
+
+`ValidateTossSettingsListener`는 구독 훅이 `sirsoft-ecommerce.*`가 아니라
+`core.plugin_settings.before_save`라는 점에서 이 표의 다른 5개 리스너와 성격이 다릅니다 —
+결제 도메인이 아니라 코어 설정 저장 파이프라인에 개입합니다. `getSubscribedHooks()`에서
+`sync: true`를 선언하는 것도 이 리스너뿐입니다(§AGENTS.md "금지 패턴").
+
+
+## 레이아웃 확장
+
+
+| 대상 | 설명 |
+|---|---|
+| `resources/extensions/admin_order_payment.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/checkout-payment-error.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/user_order_show.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+
+
+
+`checkout-payment-error.json`은 다른 PG 플러그인에는 없는 조각입니다 — 승인 확인 실패
+(`amount_mismatch`, 서명 오류 등)가 `?error=` 쿼리로 체크아웃에 되돌아왔을 때 그 오류를
+사용자에게 보여주는 전용 UI입니다. 다른 PG는 오류 안내를 체크아웃 레이아웃이 이미 가진
+공용 오류 처리에 맡기지만, 이 플러그인은 리다이렉트 기반 승인이라 오류 사유가 다양해
+전용 조각을 둡니다.
+
+
+## 미들웨어
+
+
+_등록하는 미들웨어가 없습니다._
+
+
+
+다른 PG 플러그인은 가상계좌 입금통보에 IP 화이트리스트 미들웨어를 부착하지만, 이
+플러그인은 미들웨어가 없습니다 — 토스가 notify IP 목록을 제공하지 않아 IP 기반 검증
+자체가 불가능하기 때문입니다. 대신 §핵심 흐름의 secret 대조(`WebhookController` 안의
+애플리케이션 레벨 검증)가 그 역할을 대신합니다.
+
+
+## 브로드캐스트 채널
+
+
+_등록하는 브로드캐스트 채널이 없습니다._
+
+
+
+결제 승인·웹훅은 전부 동기 HTTP 요청/응답 안에서 끝나는 흐름이라 실시간 브로드캐스트가
+필요한 지점이 없습니다.
+
+
+## 스케줄
+
+
+_등록하는 스케줄이 없습니다._
+
+
+
+가상계좌 만료는 이 플러그인이 크론으로 스캔하지 않고, 만료 이후 도착하는 토스 입금 웹훅을
+거부하는 방식으로 처리됩니다.
+
+
+## 알림 정의
+
+
+_등록하는 알림 정의가 없습니다._
+
+
+
+결제 완료/실패 알림은 이커머스 모듈이 주문 상태 변화를 기준으로 발송하는 공용 알림에 이미
+포함됩니다 — PG 마다 별도 알림 정의를 만들면 같은 이벤트에 대해 PG 수만큼 중복 정의가
+생깁니다.
+
diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/frontend.md b/plugins/_bundled/sirsoft-tosspayments/docs/frontend.md
new file mode 100644
index 00000000..e626408d
--- /dev/null
+++ b/plugins/_bundled/sirsoft-tosspayments/docs/frontend.md
@@ -0,0 +1,79 @@
+# 토스페이먼츠 — 프론트엔드
+
+> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 레이아웃
+
+
+레이아웃 1개 (루트: `resources/layouts`).
+
+| 그룹 | 개수 |
+|---|---|
+| `admin` | 1개 |
+
+| 레이아웃 | 그룹 | 종류 | extends |
+|---|---|---|---|
+| `plugin_settings` | `admin` | 화면 | `_admin_base` |
+
+
+
+다른 PG 플러그인들과 마찬가지로 이 플러그인이 소유한 화면 레이아웃은 관리자 설정 화면
+하나뿐입니다 — 체크아웃·주문상세의 결제 UI는 이 플러그인 소유가 아니라 §레이아웃 확장
+(다른 확장/템플릿 레이아웃에 주입되는 조각)으로 존재합니다.
+
+
+## 액션 핸들러
+
+
+핸들러 1개 (정의: `resources/js/handlers/index.ts`).
+
+| 핸들러 | 레이아웃에서 부르는 이름 |
+|---|---|
+| `requestPayment` | `sirsoft-tosspayments.requestPayment` |
+
+
+
+핸들러가 이것 하나뿐인 이유는 이 플러그인이 통합결제창 SDK 호출 이후를 전부 브라우저
+리다이렉트에 위임하기 때문입니다(§AGENTS.md "1. 이 확장은 무엇인가") — 다른 PG처럼 결제
+수단 선택 UI를 DOM으로 직접 조작할 필요가 없습니다. `order_sheet_mode`에 따라 통합결제창
+하나(카드 한 장)를 열지, 사용자가 고른 개별 토스 결제수단(`params.paymentMethodId`)을
+지정해 열지가 갈리지만(`params.paymentMethod`, 미지정 시 `_local.paymentMethod` 참조) 그
+분기도 이 핸들러 하나 안에서 처리합니다.
+
+
+## 전역 진입점
+
+
+| 항목 | 값 |
+|---|---|
+| 엔트리 파일 | `resources/js/index.ts` |
+| 전역 객체 | `window.__SirsoftTosspayments` |
+| 재등록 진입점 | `initPlugin()` |
+
+로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.
+
+
+
+`window.__SirsoftTosspayments`로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로 재등록
+진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). 토스 SDK(`js.tosspayments.com/v2/standard`)
+자체는 이 진입점이 미리 로드하지 않습니다 — `requestPayment` 핸들러가 결제 시도 시점에
+동적으로 로드합니다.
+
+
+## 에셋
+
+
+| 경로 | 구분 |
+|---|---|
+| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) |
+
+로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
+
+
+
+토스 SDK 자체는 이 목록에 없습니다 — `requestPayment` 핸들러가 결제 시도 시점에 동적으로
+로드하는 제3자 자산이라, 이 플러그인이 빌드 시 번들링하는 `dist/` 산출물과는 다른 층입니다.
+다른 PG 플러그인과 달리 `editor-spec.json`이 없는 것은 이 플러그인이 레이아웃 편집기에서
+커스터마이즈 가능한 전용 컴포넌트를 노출하지 않기 때문입니다 — 결제 UI는 코어 기본
+컴포넌트와 §레이아웃 확장 조각만으로 구성됩니다.
+
diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/settings.md b/plugins/_bundled/sirsoft-tosspayments/docs/settings.md
new file mode 100644
index 00000000..6a79bd8a
--- /dev/null
+++ b/plugins/_bundled/sirsoft-tosspayments/docs/settings.md
@@ -0,0 +1,105 @@
+# 토스페이먼츠 — 설정·권한·라우트
+
+> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 설정 스키마
+
+
+| 키 | 타입 | 기본값 | 설명 |
+|---|---|---|---|
+| `is_test_mode` | `boolean` | `true` | 테스트 모드 |
+| `test_client_key` | `string` | - | 테스트 클라이언트 키 |
+| `test_secret_key` | `string` | - | 테스트 시크릿 키 |
+| `live_client_key` | `string` | - | 라이브 클라이언트 키 |
+| `live_secret_key` | `string` | - | 라이브 시크릿 키 |
+| `redirect_success_url` | `string` | `{shopBase}/orders/{orderId}/complete` | 결제 성공 리다이렉트 URL |
+| `redirect_fail_url` | `string` | `{shopBase}/checkout` | 결제 실패 리다이렉트 URL |
+| `order_sheet_mode` | `boolean` | `false` | 주문서형 결제 |
+| `method_card` | `boolean` | `true` | 카드 |
+| `method_virtual_account` | `boolean` | `false` | 가상계좌 |
+| `method_transfer` | `boolean` | `false` | 계좌이체 |
+| `method_mobile_phone` | `boolean` | `false` | 휴대폰 |
+| `method_tosspay` | `boolean` | `false` | 토스페이 |
+| `method_kakaopay` | `boolean` | `false` | 카카오페이 |
+| `method_naverpay` | `boolean` | `false` | 네이버페이 |
+| `method_payco` | `boolean` | `false` | 페이코 |
+| `method_samsungpay` | `boolean` | `false` | 삼성페이 |
+| `vbank_valid_hours` | `integer` | `24` | 가상계좌 입금기한(시간) |
+| `vbank_cash_receipt_type` | `string` | - | 가상계좌 현금영수증 유형 |
+| `use_escrow` | `string` | `off` | 에스크로 사용 |
+| `webhook_secret_verify` | `boolean` | `true` | 웹훅 secret 검증 |
+
+기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json`
+
+
+
+`order_sheet_mode`가 이 스키마의 분기점입니다 — `false`(결제창형)면 `method_*` 8개 플래그는
+읽히지 않고 통합결제창이 결제수단 선택을 전담합니다. `true`(주문서형)로 켜야 `method_*`
+플래그가 실제로 체크아웃 화면의 개별 버튼 노출 여부를 결정합니다(§AGENTS.md "이 확장은
+무엇인가"). `vbank_valid_hours`(1~2160시간)와 `use_escrow`(`off`/`on`/`buyer_choice`)는
+`ValidateTossSettingsListener`가 저장 시점에 범위를 강제합니다 — UI 의 input 힌트만으로는
+관리자 설정 저장 API 직접 호출을 막을 수 없기 때문입니다. `webhook_secret_verify`를 끄면
+가상계좌 웹훅의 유일한 위조 방지 수단이 사라지므로 기본값 `true`를 유지해야 합니다.
+
+
+## 권한
+
+
+_선언된 권한이 없습니다._
+
+
+
+결제 설정 접근 권한은 이커머스의 관리자 권한 체계 안에서 다뤄집니다 — PG 마다 별도 권한을
+선언하면 PG 를 여러 개 설치했을 때 "결제 설정을 볼 수 있는 사람"이라는 하나의 개념이
+플러그인 수만큼 중복 정의됩니다.
+
+
+## 메뉴
+
+
+_등록하는 메뉴가 없습니다._
+
+
+
+설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해
+접근합니다 — PG 플러그인마다 전용 사이드바 메뉴를 만들면 PG 를 여러 개 설치했을 때 메뉴가
+난립합니다.
+
+
+## 라우트
+
+
+| 종류 | 파일 | URL prefix |
+|---|---|---|
+| `web` | `src/routes/web.php` | `/plugins/sirsoft-tosspayments/...` |
+
+확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
+
+
+
+다른 PG 플러그인과 달리 `api` 라우트 파일이 없습니다 — 이 플러그인은 로그인 사용자가
+Bearer 토큰으로 직접 호출하는 엔드포인트(예: 관리자 거래조회)를 두지 않습니다. 승인
+콜백·가상계좌 웹훅 모두 결제창 리다이렉트나 토스 서버가 도달하는 경로라 `web`에만
+있습니다.
+
+
+## 의존 관계
+
+
+**이 확장이 의존하는 확장**
+
+| 확장 | 유형 | 버전 제약 | 번들 |
+|---|---|---|---|
+| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ |
+
+**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
+
+없음.
+
+
+
+`sirsoft-ecommerce >=1.1.0` 하드 의존은 §data-model.md 에서 설명한 구조(결제 상태는
+이커머스가 소유, 이 플러그인은 절차만 소유)의 직접적 결과입니다 — 이커머스 없이는 이
+플러그인이 다룰 주문 자체가 존재하지 않습니다. 이커머스의 PG 등록 훅이나 `Order` 모델
+구조가 바뀌면 이 최소 버전을 올려야 합니다(§CLAUDE.md "확장 → 확장 동기화").
+
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/AGENTS.md b/plugins/_bundled/sirsoft-verification_kginicis/AGENTS.md
new file mode 100644
index 00000000..e2740e79
--- /dev/null
+++ b/plugins/_bundled/sirsoft-verification_kginicis/AGENTS.md
@@ -0,0 +1,187 @@
+# KG이니시스 본인인증 — 에이전트 가이드
+
+> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
+
+## TL;DR (5초 요약)
+
+```text
+1. 유형: 플러그인 (sirsoft-verification_kginicis) — KG이니시스 본인확인(reqSvcCd=03) IDV Provider. 코어 `IdentityVerificationInterface` 12메서드 구현, PII 레코드 소유 (payment 플러그인과 달리 소유 테이블 있음)
+2. 확장 방식: `RegisterInicisProviderListener` 로 코어 `core.identity.registered_providers` 필터에 등록 — 코어는 이 플러그인의 존재를 모른다
+3. 건드리면 안 되는 것: 비로그인 사용자 PII 캐시 stash(`inicis:pending_record:` 접두) 로직 우회, 라이브 MID `SRB` 프리픽스 정책값 상수(`LIVE_MID_PREFIX`) 미참조, 중복가입 차단(`AssertNoDuplicateInicisIdentity`) 우회
+4. 작업 위치: `plugins/_bundled/sirsoft-verification_kginicis` — 활성 디렉토리 직접 수정 금지
+5. 반영: `php artisan plugin:update sirsoft-verification_kginicis --force`
+```
+
+## 1. 이 확장은 무엇인가
+
+
+KG이니시스 본인확인(휴대폰 인증, `reqSvcCd=03`)을 코어 본인인증(IDV) 체계에 연결하는
+Provider 입니다. 코어 `IdentityVerificationInterface`(표준 12메서드)를 구현해, 회원가입·
+비밀번호 찾기·민감작업 등 코어가 정의한 모든 IDV 강제 지점에서 이메일 인증 대신 이니시스
+팝업이 대신 동작하게 합니다. `sirsoft-verification_nhnkcp`도 같은 인터페이스를 구현하며,
+운영자는 둘 중 어느 것이든(또는 둘 다) 설치해 사용할 수 있습니다 — 코어는 등록된
+provider ID로만 구분하고 어느 PG사인지 모릅니다.
+
+**결제 PG 플러그인과의 결정적 차이**: 이 플러그인은 실제 PII(개인식별정보) 레코드를
+소유합니다(§data-model.md — `inicis_identity_records` 테이블, `InicisIdentityRecord`
+모델). 결제 플러그인들이 "상태는 남의 것, 절차만 내 것"이었던 것과 달리, 본인확인은 그
+확인 결과(이름·생년월일·성별·CI/DI 등)를 이 플러그인이 직접 보관해야 이후 재확인 없이
+"본인확인 완료 여부"를 판단할 수 있습니다.
+
+**의도적으로 하지 않는 것**: 비로그인 사용자(예: 회원가입 도중)의 PII는 확인 즉시
+DB 에 쓰지 않고 Cache 에 임시 저장(`inicis:pending_record:` 접두)했다가, 가입이 실제로
+완료된 뒤(`core.auth.after_register` 훅)에야 레코드로 흡수합니다 — 가입을 완료하지 않은
+방문자의 PII 를 DB 에 영구 저장하지 않기 위함입니다. 사용자가 탈퇴하거나 계정이 삭제되면
+`core.user.after_withdraw`/`core.user.before_delete` 훅에서 관련 레코드를 정리합니다.
+
+
+## 2. 디렉토리 지도
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
+| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
+| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
+| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
+| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
+| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
+| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
+| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
+| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
+| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
+| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
+| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
+| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
+| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-verification_kginicis --force` (빌드 불필요) |
+| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-verification_kginicis --force` |
+| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-verification_kginicis --force` |
+| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-verification_kginicis --force` |
+| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
+| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-verification_kginicis --force` |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
+
+
+## 3. 핵심 흐름
+
+
+**본인확인 시작~완료**: 코어가 IDV 를 요구하는 지점(회원가입 등)에서 428 응답 →
+프론트 `startAuth` 핸들러가 사용자 클릭 컨텍스트 안에서 `window.open`으로 빈 팝업 생성
+(Chrome popup blocker 회피 — 자동 호출은 차단되지만 클릭 직후 호출은 통과) → 코어
+challenge 시작 응답의 `mid`/`mtxid`/`authHash`로 팝업에 이니시스 인증 폼 제출 → 이니시스
+인증 완료 후 `InicisChallengeMappingRepository`가 mTxId ↔ challenge_id 매핑을 저장 →
+인증 결과는 postMessage 또는 팝업 종료 감지로 회수 → `InicisIdentityProvider::verify()`가
+SEED 복호화 후 결과를 반환. 성인인증(`inicis.adult_verification` purpose)으로 발행된
+challenge 는 만 19세 이상만 통과시킵니다.
+
+**비로그인 사용자(회원가입 도중) 처리**: `verify()` 시점에 로그인 사용자가 없으면 PII 를
+Cache 에 stash(`inicis:pending_record:{key}`) → 회원가입 완료 → `core.auth.after_register`
+훅 → `CompleteInicisRecordAfterRegister`가 같은 캐시 키로 PII 를 회수해
+`InicisIdentityRecord`로 흡수.
+
+**중복가입 차단**: `core.auth.before_register` 훅 → `AssertNoDuplicateInicisIdentity`가
+`duplicate_block_enabled` 설정이 켜져 있으면 `duplicate_field`(DI 또는 CI) 기준으로
+`InicisIdentityLogQueryRepository`를 조회해 이미 가입된 동일인이 있는지 확인 → 있으면
+가입을 차단.
+
+**사용자 삭제/탈퇴 시 PII 정리**: `core.user.before_delete`/`core.user.after_withdraw` 훅 →
+`CleanInicisRecordOnUserDelete`/`CleanInicisRecordOnUserWithdraw`가 해당 사용자의
+`inicis_identity_records`를 정리.
+
+
+## 4. 확장점
+
+
+| 확장점 | 수 | 상세 |
+|---|---|---|
+| 발행 훅 | 3개 | [발행 훅](docs/extension-points.md#발행-훅) |
+| 구독 훅 | 6개 | [구독 훅](docs/extension-points.md#구독-훅) |
+| 훅 리스너 | 6개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
+| 레이아웃 확장 | 2개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
+| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) |
+| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
+| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
+| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
+
+
+
+`core.identity.registered_providers` 는 코어가 등록된 IDV provider 목록을 모으는 필터
+훅입니다 — 새 IDV PG 를 추가하려는 확장은 이 훅에 자기 provider 를 등록하면 됩니다
+(`sirsoft-verification_nhnkcp`가 동일 패턴). `core.plugin_settings.update_validation_rules`는
+`ValidateInicisSettingsListener`가 `is_test_mode=false`(라이브 모드) 진입 시
+`live_mid`/`live_api_key`에 `required` 규칙을 동적으로 부여하는 자리입니다 — 코어
+`UpdatePluginSettingsRequest`의 정적 스키마는 "테스트 모드일 땐 선택, 라이브 모드일 땐
+필수" 같은 조건부 검증을 표현할 수 없기 때문입니다. `live_mid`의 `SRB` 프리픽스는 이
+필터가 아니라 `InicisIdentityProvider::buildLiveMid()`가 그 값을 실제로 쓸 때(요청 조립
+시점) 동적으로 부착하므로 별도 형식 검증을 두지 않습니다 — DB 에는 운영자가 입력한 원본
+값이 그대로 저장됩니다.
+
+
+## 5. 수정 시 동반 의무
+
+- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-verification_kginicis --force` 로 반영
+- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
+- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝
+- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
+- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-verification_kginicis` 재실행 + `docs/api/**` 갱신
+- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
+- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
+- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
+- [ ] PII 컬럼(이름·생년월일·성별·CI/DI 등)을 다루는 코드 변경 시 GDPR 삭제/탈퇴 정리 리스너(`CleanInicisRecordOnUserDelete`/`CleanInicisRecordOnUserWithdraw`)가 여전히 그 컬럼을 정리하는지 확인
+- [ ] 팝업 기반 인증 흐름(`startAuth`)을 고칠 때 `window.open`을 사용자 클릭 컨텍스트 밖으로 옮기지 않는다 — Chrome popup blocker 회피가 깨진다
+- [ ] `duplicate_field`/`duplicate_block_enabled` 로직을 고치면 `InicisDuplicateField` Enum 과 `AssertNoDuplicateInicisIdentity`를 함께 갱신
+
+## 6. 금지 패턴
+
+
+| 금지 | 올바른 사용 | 이유 |
+|---|---|---|
+| 비로그인 사용자의 PII 를 verify 즉시 DB 에 저장 | Cache 에 stash(`inicis:pending_record:` 접두) 후 가입 완료 시 흡수 | 가입을 완료하지 않은 방문자의 PII 를 DB 에 영구 저장하면 불필요한 개인정보 보유가 된다 |
+| 사용자 삭제/탈퇴 리스너 없이 PII 컬럼 추가 | `CleanInicisRecordOnUserDelete`/`CleanInicisRecordOnUserWithdraw`에 정리 로직 동반 | 정리 누락 시 탈퇴한 사용자의 PII 가 무기한 남는다 |
+| `LIVE_MID_PREFIX` 상수를 참조하지 않고 `'SRB'`를 문자열로 재작성 | `InicisIdentityProvider::LIVE_MID_PREFIX` 참조 | 이니시스 프리픽스 정책이 바뀌면 상수 1곳만 갱신해야 런타임 로직 전체에 반영된다 — 문자열 재작성은 사각을 만든다 |
+| 팝업을 사용자 클릭 이벤트 핸들러 밖(비동기 콜백 등)에서 `window.open` | 사용자 제스처 컨텍스트 안에서 직접 호출 | Chrome 등 브라우저는 사용자 제스처 없이 열리는 팝업을 자동 차단한다 |
+| 라이브 API 키를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 본인확인 API 를 위조 호출할 수 있다 |
+
+
+## 7. 테스트 실행
+
+
+| 종류 | 개수 | 위치 |
+|---|---|---|
+| PHPUnit | 22개 | `plugins/_bundled/sirsoft-verification_kginicis/tests` |
+| Vitest | 8개 | `vitest.config.ts` |
+| Playwright | 0개 | — |
+| 시나리오 매니페스트 | 8개 | `tests/scenarios` |
+
+기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
+
+```bash
+# PHPUnit (변경 범위만) (Bash)
+php vendor/bin/phpunit plugins/_bundled/sirsoft-verification_kginicis/tests --filter='<대상클래스>'
+
+# Vitest (확장 디렉토리에서) (PowerShell)
+cd plugins/_bundled/sirsoft-verification_kginicis && powershell -Command "npm run test:run -- <대상>"
+
+```
+
+무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
+
+
+## 8. 문서 목차
+
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
+| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
+| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
+| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/README.md b/plugins/_bundled/sirsoft-verification_kginicis/README.md
new file mode 100644
index 00000000..a8e980d8
--- /dev/null
+++ b/plugins/_bundled/sirsoft-verification_kginicis/README.md
@@ -0,0 +1,182 @@
+# KG이니시스 본인인증
+
+**G7 플러그인 · sirsoft-verification_kginicis**
+KG이니시스 통합인증의 본인확인(reqSvcCd=03)을 G7 코어 IDV 인프라에 Provider 로 등록하는 플러그인
+
+
+
+
+
+
+
+
+
+
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+KG이니시스 본인확인(휴대폰 인증, reqSvcCd=03)을 G7 코어의 본인인증(IDV) 체계에 연결하는
+플러그인입니다. 코어가 정의한 표준 인터페이스를 구현해, 회원가입·비밀번호 찾기·민감작업
+등 코어가 IDV 를 요구하는 모든 지점에서 이메일 인증 대신 이니시스 팝업이 동작하게
+합니다.
+
+결제 PG 플러그인들과 달리 이 플러그인은 실제 개인식별정보(PII — 이름·생년월일·성별·CI/DI
+등)를 직접 보관합니다. 본인확인 결과를 저장해 두어야 이후 재확인 없이 "이 사용자가
+본인확인을 완료했는가"를 즉시 판단할 수 있기 때문입니다. `sirsoft-ecommerce`를 비롯한
+어떤 다른 확장에도 의존하지 않고 코어만으로 동작합니다.
+
+
+## 주요 기능
+
+
+| 영역 | 설명 |
+|---|---|
+| 본인확인 | KG이니시스 통합인증(reqSvcCd=03) 팝업 기반 본인확인 |
+| 성인인증 | 만 19세 이상 여부만 확인하는 별도 purpose |
+| 중복가입 차단 | DI 또는 CI 기준으로 동일인 재가입 차단 (선택) |
+| 게스트 처리 | 비로그인 사용자의 본인확인 결과를 임시 보관 후 가입 완료 시 흡수 |
+| 개인정보 정리 | 사용자 탈퇴/삭제 시 보관 중인 PII 레코드 자동 파기 |
+| 마이페이지 | 본인확인 완료 상태 카드 노출 |
+| 관리자 설정 | 테스트/라이브 모드 전환, 중복가입 판정 기준 설정 |
+
+
+## 동작 방식
+
+
+```mermaid
+flowchart LR
+ A[코어가 IDV 요구 · 428] --> B[사용자 클릭 → 팝업 오픈]
+ B --> C[이니시스 인증 폼 제출]
+ C --> D[인증 완료 → 결과 회수]
+ D --> E[SEED 복호화 → PII 확보]
+ E -->|로그인 사용자| F[레코드 즉시 저장]
+ E -->|비로그인 사용자| G[Cache 임시 보관]
+ G --> H[가입 완료 시 레코드로 흡수]
+```
+
+팝업은 반드시 사용자 클릭 이벤트 안에서 열립니다 — 비동기 콜백 이후에 열면 브라우저
+팝업 차단기에 걸립니다. 비로그인 사용자(회원가입 도중)의 본인확인 결과는 가입이 실제로
+완료되기 전까지 DB 가 아니라 Cache 에만 임시 보관됩니다.
+
+
+## 요구 사항
+
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=7.0.8` |
+| PHP | `^8.2` |
+
+
+## 설치
+
+
+```bash
+# 번들 설치 (코어에 동봉된 소스에서 설치)
+php artisan plugin:install sirsoft-verification_kginicis
+
+# 활성화
+php artisan plugin:activate sirsoft-verification_kginicis
+
+# 업데이트 (번들 소스 기준 강제 반영)
+php artisan plugin:update sirsoft-verification_kginicis --force
+```
+
+저장소: https://github.com/gnuboard/g7-plugin-sirsoft-verification_kginicis
+
+
+## 관리자 설정
+
+
+| 키 | 의미 | 기본값 |
+|---|---|---|
+| `is_test_mode` | 테스트 모드 | `true` |
+| `test_mid` | 테스트 MID | `INIiasTest` |
+| `test_api_key` | 테스트 API 키 | `TGdxb2l3enJDWFRTbTgvREU3MGYwUT09` |
+| `live_mid` | 라이브 MID | - |
+| `live_api_key` | 라이브 API 키 | - |
+| `duplicate_field` | 중복 판정 필드 | `di` |
+| `duplicate_block_enabled` | 중복 가입 차단 | `true` |
+
+개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
+
+
+
+`live_mid`는 실제 사용 시점(요청 조립 시)에 `SRB` 프리픽스가 자동으로 붙으므로 프리픽스
+없이 입력해도 됩니다.
+`live_mid`/`live_api_key`는 `is_test_mode`를 끄는(라이브 모드) 순간부터 필수가 됩니다 —
+테스트 모드에서는 비워둘 수 있습니다. `duplicate_field`(`di` 또는 `ci`)와
+`duplicate_block_enabled`는 다른 IDV provider(예: `sirsoft-verification_nhnkcp`)와 별개로
+이 provider 를 통해 확인한 사용자에게만 적용됩니다. 라이브 API 키는 외부에 노출하지
+마세요.
+
+
+## 사용 방법
+
+
+**활성화하기**: 플러그인을 활성화하면 자동으로 코어 IDV provider 목록에 등록됩니다.
+별도 화면 배치 작업 없이 코어가 이미 정의한 IDV 강제 지점(회원가입 등)에서 즉시
+동작합니다. 여러 IDV provider 를 동시에 설치했다면 코어 본인인증 정책 화면에서 어느
+provider 를 쓸지 선택합니다.
+
+**중복가입 차단 켜기**: 동일인이 여러 계정을 만드는 것을 막고 싶다면
+`duplicate_block_enabled`를 켜고 `duplicate_field`로 DI/CI 중 판정 기준을 고릅니다.
+
+**개인정보 보관 정책 확인**: 사용자가 탈퇴하거나 관리자가 계정을 삭제하면 보관 중인
+본인확인 PII 가 자동으로 파기됩니다 — 별도 운영 작업이 필요 없습니다.
+
+전체 API 목록은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은
+[docs/extension-points.md](docs/extension-points.md) 를 참고하세요.
+
+
+## 다른 확장과의 연동
+
+
+**이 확장이 의존하는 확장**
+
+없음 — 코어만으로 동작합니다.
+
+**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
+
+없음.
+
+
+## 문서
+
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
+| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
+| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
+| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
+
+## 트러블슈팅
+
+
+| 증상 | 원인 | 조치 |
+|---|---|---|
+| 인증 버튼을 눌러도 팝업이 안 뜸 | 브라우저 팝업 차단기 | `startAuth`가 클릭 이벤트 핸들러 안에서 직접 호출되는지 확인 — 비동기 콜백 뒤로 옮기면 차단됨 |
+| 설정 저장 시 422 오류 | 라이브 모드인데 `live_mid`/`live_api_key` 미입력 | `is_test_mode`를 켜거나 라이브 자격증명을 입력 |
+| 이미 가입된 사용자인데 중복 오류 없이 재가입됨 | `duplicate_block_enabled`가 꺼져 있거나 `duplicate_field` 기준이 실제 판정과 다름 | 관리자 설정에서 두 값을 확인 |
+| 탈퇴한 사용자의 본인확인 정보가 남아있는 것으로 보임 | 정리 리스너 실행 여부를 별도로 확인하지 않음 | `CleanInicisRecordOnUserWithdraw`/`CleanInicisRecordOnUserDelete` 정상 등록 여부를 훅 캐시에서 확인 |
+
+
+## 변경 이력
+
+[CHANGELOG.md](CHANGELOG.md)
+
+## 라이선스
+
+MIT
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/README.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/README.md
new file mode 100644
index 00000000..0cc88202
--- /dev/null
+++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/README.md
@@ -0,0 +1,22 @@
+# KG이니시스 본인인증 개발자 문서
+
+> plugins/_bundled/sirsoft-verification_kginicis · 플러그인
+
+
+**훅 수**: 3 · **구독 훅 수**: 6 · **라우트 수**: 2 · **모델 수**: 2 · **테이블 수**: 2 · **마이그레이션 수**: 3 · **레이아웃 수**: 1 · **핸들러 수**: 1
+
+
+## 문서 목차
+
+
+| 문서 | 내용 |
+|---|---|
+| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
+| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
+| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
+| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
+| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
+| [api/](api/README.md) | API 레퍼런스 |
+| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
+| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
+
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/architecture.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/architecture.md
new file mode 100644
index 00000000..ddf01782
--- /dev/null
+++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/architecture.md
@@ -0,0 +1,68 @@
+# KG이니시스 본인인증 — 아키텍처
+
+> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 설계 의도
+
+
+KG이니시스 본인확인을 코어 IDV(본인인증) 체계에 연결하는 Provider 입니다. 코어
+`IdentityVerificationInterface`를 구현하는 것이 이 플러그인의 유일한 계약이며, 코어는
+`core.identity.registered_providers` 필터로 등록된 provider 목록만 알고 어느 PG사의
+구현인지는 모릅니다.
+
+결제 PG 플러그인들과 달리 이 플러그인은 실제 PII 를 소유합니다(§data-model.md). 본인확인
+결과(CI/DI 등)를 재확인 없이 판단하려면 그 결과를 어딘가 보관해야 하고, 그 보관 책임은
+Provider 자신에게 있습니다 — 코어는 "본인확인 완료 여부"만 알면 되고 원본 PII 를 알 필요가
+없습니다.
+
+
+## 계층 지도
+
+
+```text
+Controller (Http/Controllers) → FormRequest (Http/Requests)
+ → InicisIdentityProvider (IdentityVerificationInterface 구현 — verify/challenge 표준 진입점)
+ → InicisGatewayInterface (외부 통신 + SEED 복호화)
+ → InicisChallengeMappingRepositoryInterface (mTxId ↔ challenge_id)
+ → InicisIdentityRecordRepositoryInterface (PII record)
+ → CacheInterface (비로그인 verify PII 임시 stash)
+
+Listener (RegisterInicisProviderListener 등)
+ → 코어 identity/auth/user 훅에 등록 (컴파일 타임 결합 없음)
+```
+
+`InicisIdentityProvider`가 4개의 협력자(게이트웨이·Repository 2종·캐시)를 생성자 주입받는
+것은 그 각각이 서로 다른 관심사(외부 API 통신, DB 영속, 임시 캐시)이기 때문입니다 — 하나로
+합치면 단위 테스트에서 외부 API 를 모킹할 때 DB/캐시까지 함께 모킹해야 합니다.
+
+
+## 디렉토리
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
+| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
+| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
+| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
+| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
+| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
+| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
+| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
+| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
+| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
+| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
+| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
+| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
+| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-verification_kginicis --force` (빌드 불필요) |
+| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-verification_kginicis --force` |
+| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-verification_kginicis --force` |
+| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-verification_kginicis --force` |
+| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
+| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-verification_kginicis --force` |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
+
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/data-model.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/data-model.md
new file mode 100644
index 00000000..6c8be445
--- /dev/null
+++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/data-model.md
@@ -0,0 +1,95 @@
+# KG이니시스 본인인증 — 데이터 모델
+
+> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 모델
+
+
+| 모델 | 테이블 | fillable | 관계 | 특성 |
+|---|---|---|---|---|
+| `InicisChallengeMapping` | `inicis_challenge_mappings` | 2 | challenge→IdentityVerificationLog | - |
+| `InicisIdentityRecord` | `inicis_identity_records` | 17 | user→User | - |
+
+
+
+`InicisIdentityRecord`가 PII(이름·생년월일·성별·CI/DI 등)를 직접 보관하는 것이 결제
+플러그인들과의 근본적 차이입니다(§AGENTS.md "1. 이 확장은 무엇인가") — 이 레코드가 없으면
+"이 사용자가 본인확인을 완료했는가"를 매번 이니시스에 재조회해야 합니다.
+`InicisChallengeMapping`은 별도 모델입니다 — 진행 중인 인증 시도(mTxId ↔ challenge_id)와
+완료된 확인 결과(PII record)는 생명주기가 다르기 때문입니다(전자는 인증 세션 하나,
+후자는 사용자당 최신 1건).
+
+
+## 소유 테이블
+
+
+| 테이블 | 모델 |
+|---|---|
+| `inicis_challenge_mappings` | `InicisChallengeMapping` |
+| `inicis_identity_records` | `InicisIdentityRecord` |
+
+
+
+`inicis_identity_records.user_id`는 `unique` 제약을 갖습니다 — 한 사용자는 본인확인 결과를
+1건만 보유하며, 재인증 시 기존 레코드를 갱신합니다(새 레코드를 추가하지 않습니다). 이
+설계 덕분에 "이 사용자가 본인확인을 완료했는가"는 단순 존재 조회 하나로 판정됩니다.
+
+
+## 마이그레이션
+
+
+마이그레이션 3개.
+
+| 파일 | 생성 테이블 | 변경 테이블 | down() |
+|---|---|---|---|
+| `2026_05_08_000001_create_inicis_identity_records_table.php` | `inicis_identity_records` | `inicis_identity_records` | ✅ |
+| `2026_05_08_000002_create_inicis_challenge_mappings_table.php` | `inicis_challenge_mappings` | `inicis_challenge_mappings` | ✅ |
+| `2026_06_22_000001_make_inicis_record_aux_fields_nullable.php` | - | `inicis_identity_records` | ✅ |
+
+
+
+세 번째 마이그레이션이 `name`/`phone`/`birthday` 암호화 컬럼을 nullable 로 완화한 이유는
+누락 값을 `null`로 저장하기 위함입니다 — `Crypt::encryptString('')`로 "암호화된 빈
+문자열"을 저장하면 복호화 시 빈 칸이 나오는 오염 레코드가 됩니다. 정상 경로에서는
+`verify()` 가드(`INCOMPLETE_IDENTITY`)가 이 신원 핵심값의 누락을 이미 차단하므로 항상
+채워지며, 이 nullable 화는 가드를 우회한 비정상 입력이 암호화된 빈 문자열로 오염
+저장되는 것을 막는 방어적 통일입니다.
+
+
+## Enum
+
+
+| Enum | backing | case 수 | case |
+|---|---|---|---|
+| `InicisDuplicateField` | `string` | 2 | `di`, `ci` |
+
+
+
+`di`(연계정보)와 `ci`(연계정보의 상위 개념 — 사이트 간 동일인 식별용) 중 어느 필드로 중복
+가입을 판정할지는 운영자가 설정으로 고릅니다(§settings.md `duplicate_field`). Enum 으로
+닫힌 것은 이 값이 `AssertNoDuplicateInicisIdentity`의 조회 조건과
+`InicisIdentityLogQueryRepository`의 컬럼 화이트리스트 양쪽에서 타입 안전하게 재사용되기
+때문입니다 — 문자열 리터럴이었다면 오타가 조용히 "중복 없음"으로 판정될 위험이 있습니다.
+
+
+## Repository
+
+
+| 클래스 | 종류 | 설명 |
+|---|---|---|
+| `InicisChallengeMappingRepository` | 구현 | `inicis_challenge_mappings` 테이블 Repository 구현체. |
+| `InicisChallengeMappingRepositoryInterface` | 인터페이스 | 이니시스 mTxId ↔ challenge_id 매핑 Repository 인터페이스. |
+| `InicisIdentityLogQueryRepository` | 구현 | InicisIdentityLogQueryRepositoryInterface 구현체. |
+| `InicisIdentityLogQueryRepositoryInterface` | 인터페이스 | 본 plugin 의 동일인 검증 listener 전용 IdentityVerificationLog 조회 Repository. |
+| `InicisIdentityRecordRepository` | 구현 | `inicis_identity_records` 테이블 Repository 구현체. |
+| `InicisIdentityRecordRepositoryInterface` | 인터페이스 | KG이니시스 본인확인 PII 레코드 Repository 인터페이스. |
+
+
+
+3쌍(인터페이스+구현체)으로 나뉜 것은 각자 다른 데이터를 다루기 때문입니다 —
+`InicisChallengeMappingRepository`(진행 중인 인증 세션), `InicisIdentityRecordRepository`
+(완료된 PII), `InicisIdentityLogQueryRepository`(동일인 검증 전용 `IdentityVerificationLog`
+조회, 코어 로그 테이블을 이 플러그인 관점으로 좁혀 읽는 어댑터). 결제 플러그인들이
+Repository 를 하나도 두지 않는 것과 대조적으로, 이 플러그인은 실제 PII 를 소유하므로
+Repository 인터페이스 주입 원칙(§CLAUDE.md "Service-Repository 패턴")이 그대로 적용됩니다.
+
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/extension-points.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/extension-points.md
new file mode 100644
index 00000000..f1dc27fb
--- /dev/null
+++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/extension-points.md
@@ -0,0 +1,130 @@
+# KG이니시스 본인인증 — 확장점
+
+> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 발행 훅
+
+
+발행 훅 3종 / 호출 지점 3곳. 이 중 3종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다.
+
+| 훅 이름 | 유형 | 설명 | 발행 위치 |
+|---|---|---|---|
+| `core.auth.after_register` | action | — | `src/Listeners/CompleteInicisRecordAfterRegister.php:64` |
+| `core.user.after_withdraw` | action | — | `src/Listeners/CleanInicisRecordOnUserWithdraw.php:18` |
+| `core.user.before_delete` | action | — | `src/Listeners/CleanInicisRecordOnUserDelete.php:23` |
+
+
+
+이 3종은 이 플러그인이 실제로 발행하는 훅이 아닙니다 — 세 리스너 파일 모두 코어가 그
+훅을 어떻게 호출하는지 보여주는 **docblock 예시**(`HookManager::doAction('core.user.before_delete', $user);`
+형태의 주석)를 갖고 있는데, 소스 자동 감지가 그 주석 텍스트를 실제 발행 호출로 오인해
+잡아낸 결과입니다. 실제 발행 주체는 코어이며, 이 플러그인은 §구독 훅에서 같은 이름으로
+**구독**만 합니다. 이 확장의 리스너에 이런 docblock 예시를 새로 추가할 때는 이 표에
+가짜 항목이 늘어난다는 점을 감안합니다.
+
+
+## 구독 훅
+
+
+| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
+|---|---|---|---|---|
+| `core.auth.after_register` | action (미선언) | `CompleteInicisRecordAfterRegister` | `handle` | 50 |
+| `core.auth.before_register` | action (미선언) | `AssertNoDuplicateInicisIdentity` | `handle` | 20 |
+| `core.identity.registered_providers` | filter | `RegisterInicisProviderListener` | `register` | 20 |
+| `core.plugin_settings.update_validation_rules` | filter | `ValidateInicisSettingsListener` | `addLiveModeRules` | 10 |
+| `core.user.after_withdraw` | action (미선언) | `CleanInicisRecordOnUserWithdraw` | `handle` | 50 |
+| `core.user.before_delete` | action (미선언) | `CleanInicisRecordOnUserDelete` | `handle` | 50 |
+
+
+
+`AssertNoDuplicateInicisIdentity`가 `core.auth.before_register`(가입 **전**)을 구독하는
+것은 중복 가입을 막으려면 가입 트랜잭션이 커밋되기 전에 차단해야 하기 때문입니다 —
+`after_register`에서 잡으면 이미 중복 계정이 생성된 뒤라 롤백이 더 복잡해집니다.
+`core.user.before_delete`는 `sync: true`로 동기 실행됩니다 — 이 시점 정리가 실패하면
+FK 제약(1451)으로 사용자 삭제 자체가 실패해야 하므로, 비동기 큐로 미뤄지면 삭제가 먼저
+끝나버릴 수 있습니다.
+
+
+## 훅 리스너
+
+
+| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
+|---|---|---|---|---|
+| `AssertNoDuplicateInicisIdentity` | 1개 | 명시 등록 | ✅ | `src/Listeners/AssertNoDuplicateInicisIdentity.php` |
+| `CleanInicisRecordOnUserDelete` | 1개 | 명시 등록 | ✅ | `src/Listeners/CleanInicisRecordOnUserDelete.php` |
+| `CleanInicisRecordOnUserWithdraw` | 1개 | 명시 등록 | ✅ | `src/Listeners/CleanInicisRecordOnUserWithdraw.php` |
+| `CompleteInicisRecordAfterRegister` | 1개 | 명시 등록 | ✅ | `src/Listeners/CompleteInicisRecordAfterRegister.php` |
+| `RegisterInicisProviderListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterInicisProviderListener.php` |
+| `ValidateInicisSettingsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/ValidateInicisSettingsListener.php` |
+
+
+
+6개 리스너 중 3개(`CompleteInicisRecordAfterRegister`, `CleanInicisRecordOnUserWithdraw`,
+`CleanInicisRecordOnUserDelete`)는 사용자 생명주기 각 단계(가입 완료·탈퇴·삭제)에 맞춰
+PII 레코드를 흡수하거나 정리하는 대칭 구조입니다 — 하나를 고칠 때 나머지 둘도 같은 PII
+필드를 다루고 있는지 확인해야 합니다.
+
+
+## 레이아웃 확장
+
+
+| 대상 | 설명 |
+|---|---|
+| `resources/extensions/identity_provider_inicis.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/mypage_identity_card.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+
+
+
+`identity_provider_inicis.json`은 코어 IDV 팝업(§CLAUDE.md "본인인증(IDV) 공통 UI 가이드")이
+provider 별로 다른 안내 문구·로고를 보여줘야 할 때 이 플러그인이 자기 몫을 주입하는
+조각입니다. `mypage_identity_card.json`은 마이페이지에 "본인확인 완료" 상태 카드를
+보여주는 조각으로, `sirsoft-verification_nhnkcp`도 동일한 명명 규칙의 자기 조각을 갖습니다
+— 여러 IDV provider 가 동시에 설치돼도 각자 자기 카드만 주입하므로 충돌하지 않습니다.
+
+
+## 미들웨어
+
+
+_등록하는 미들웨어가 없습니다._
+
+
+
+결제 플러그인들과 달리 이 플러그인은 PG 서버가 직접 호출하는 웹훅/통보 엔드포인트가
+없습니다 — 본인확인 결과는 팝업 콜백(사용자 브라우저 경유)으로만 도달하므로 IP
+화이트리스트 같은 서버간 통신 검증이 필요 없습니다.
+
+
+## 브로드캐스트 채널
+
+
+_등록하는 브로드캐스트 채널이 없습니다._
+
+
+
+인증 결과는 팝업의 postMessage 또는 팝업 종료 감지로 프론트가 직접 회수합니다(§AGENTS.md
+"핵심 흐름") — 서버가 다른 클라이언트에 실시간으로 알려야 할 상태 변화가 없어 브로드캐스트
+채널이 필요 없습니다.
+
+
+## 스케줄
+
+
+_등록하는 스케줄이 없습니다._
+
+
+
+mTxId ↔ challenge_id 매핑(`inicis_challenge_mappings`)이나 pending PII 캐시는 만료된
+항목을 별도 배치로 청소하지 않습니다 — 캐시는 TTL 로 자연 소멸하고, 매핑 테이블의 정리는
+사용자 삭제/탈퇴 시점 리스너가 담당합니다(§훅 리스너).
+
+
+## 알림 정의
+
+
+_등록하는 알림 정의가 없습니다._
+
+
+
+본인확인 성공/실패는 사용자가 팝업 화면에서 즉시 확인하는 동기적 상호작용이라, 별도
+알림(이메일/SMS 등)을 발송할 지점이 없습니다.
+
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/frontend.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/frontend.md
new file mode 100644
index 00000000..0fc4631e
--- /dev/null
+++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/frontend.md
@@ -0,0 +1,81 @@
+# KG이니시스 본인인증 — 프론트엔드
+
+> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 레이아웃
+
+
+레이아웃 1개 (루트: `resources/layouts`).
+
+| 그룹 | 개수 |
+|---|---|
+| `admin` | 1개 |
+
+| 레이아웃 | 그룹 | 종류 | extends |
+|---|---|---|---|
+| `plugin_settings` | `admin` | 화면 | `_admin_base` |
+
+
+
+결제 PG 플러그인들과 마찬가지로 이 플러그인이 소유한 화면 레이아웃은 관리자 설정 화면
+하나뿐입니다 — 회원가입·마이페이지의 본인확인 UI는 이 플러그인 소유가 아니라
+§레이아웃 확장(다른 확장/템플릿 레이아웃에 주입되는 조각) 및 코어 IDV 공통 팝업 UI로
+존재합니다.
+
+
+## 액션 핸들러
+
+
+핸들러 1개 (정의: `resources/js/index.ts`).
+
+| 핸들러 | 레이아웃에서 부르는 이름 |
+|---|---|
+| `startAuth` | `sirsoft-verification_kginicis.startAuth` |
+
+
+
+`startAuth`는 반드시 사용자 클릭 이벤트 핸들러 안에서(동기적으로) 호출돼야 합니다 —
+`window.open`으로 빈 팝업을 먼저 연 뒤 그 팝업에 이니시스 인증 폼을 제출하는 방식인데,
+`window.open`이 사용자 제스처 컨텍스트 밖(예: API 응답을 기다린 뒤의 비동기 콜백)에서
+호출되면 Chrome 등 브라우저의 팝업 차단기에 걸립니다. 과거 `setLauncher`로 코어 IDV
+런처를 덮어써 챌린지 발급 이후 시점에 팝업을 여는 방식을 썼다가 이 문제로 폐기됐습니다
+(소스 주석 "Phase E′-revert").
+
+
+## 전역 진입점
+
+
+| 항목 | 값 |
+|---|---|
+| 엔트리 파일 | `resources/js/index.ts` |
+| 전역 객체 | `window.__SirsoftVerificationKginicis` |
+| 재등록 진입점 | `initPlugin()` |
+
+로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.
+
+
+
+`window.__SirsoftVerificationKginicis`로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로
+재등록 진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). 이 플러그인은 결제 PG
+플러그인들과 달리 결제창 SDK 를 동적 로드하지 않습니다 — 이니시스 인증 폼은 팝업
+안에서 서버가 렌더링한 페이지로 제출되므로 프론트가 별도 스크립트를 불러올 필요가
+없습니다.
+
+
+## 에셋
+
+
+| 경로 | 구분 |
+|---|---|
+| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) |
+| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
+
+로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
+
+
+
+제3자 SDK 스크립트가 목록에 없는 것은 §전역 진입점에서 설명한 대로 이 플러그인이 그런
+자산을 동적 로드하지 않기 때문입니다 — 인증 폼 자체가 서버 렌더링 페이지라 프론트는
+팝업을 열고 결과를 회수하는 역할만 합니다. CSS 산출물이 없는 것은 이 플러그인의 UI가
+버튼·상태 카드 같은 최소한의 코어 컴포넌트로만 구성되기 때문입니다.
+
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/settings.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/settings.md
new file mode 100644
index 00000000..5947bf7e
--- /dev/null
+++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/settings.md
@@ -0,0 +1,93 @@
+# KG이니시스 본인인증 — 설정·권한·라우트
+
+> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 설정 스키마
+
+
+| 키 | 타입 | 기본값 | 설명 |
+|---|---|---|---|
+| `is_test_mode` | `boolean` | `true` | 테스트 모드 |
+| `test_mid` | `string` | `INIiasTest` | 테스트 MID |
+| `test_api_key` | `string` | `TGdxb2l3enJDWFRTbTgvREU3MGYwUT09` | 테스트 API 키 |
+| `live_mid` | `string` | - | 라이브 MID |
+| `live_api_key` | `string` | - | 라이브 API 키 |
+| `duplicate_field` | `enum` | `di` | 중복 판정 필드 |
+| `duplicate_block_enabled` | `boolean` | `true` | 중복 가입 차단 |
+
+기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json`
+
+
+
+결제 PG 플러그인들의 `test_*`/`live_*` 쌍과 같은 구조이지만 필드는 단 2개(`test_mid`/
+`test_api_key`, `live_mid`/`live_api_key`)뿐입니다 — 본인확인 API 는 결제와 달리 사인키·
+INIAPI 키·모바일 해시키처럼 기능별로 분리된 자격증명이 필요 없습니다. `live_mid`는
+`InicisIdentityProvider::buildLiveMid()`가 그 값을 요청 조립 시점에 쓸 때 `SRB` 프리픽스를
+동적으로 부착하므로(DB 저장값은 원본 그대로) 운영자가 프리픽스를 직접 입력할 필요가
+없습니다. `live_mid`/`live_api_key`는 `is_test_mode=false`일 때만
+`ValidateInicisSettingsListener`가 `required`로 강제합니다(§AGENTS.md "핵심 흐름").
+`duplicate_field`/`duplicate_block_enabled`는 `sirsoft-ecommerce`와 무관한 코어 레벨
+회원가입 규칙입니다 — 이 플러그인은 이커머스에 의존하지 않습니다(§의존 관계).
+
+
+## 권한
+
+
+_선언된 권한이 없습니다._
+
+
+
+본인인증 설정 접근 권한은 코어의 관리자 권한 체계 안에서 다뤄집니다 — IDV provider 마다
+별도 권한을 선언하면 provider 를 여러 개 설치했을 때 "본인인증 설정을 볼 수 있는 사람"이
+provider 수만큼 중복 정의됩니다.
+
+
+## 메뉴
+
+
+_등록하는 메뉴가 없습니다._
+
+
+
+설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해
+접근합니다 — IDV provider 마다 전용 사이드바 메뉴를 만들면 provider 를 여러 개 설치했을
+때 메뉴가 난립합니다.
+
+
+## 라우트
+
+
+| 종류 | 파일 | URL prefix |
+|---|---|---|
+| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-verification_kginicis/...` |
+| `web` | `src/routes/web.php` | `/plugins/sirsoft-verification_kginicis/...` |
+
+확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
+
+
+
+`web` 라우트는 CSRF 검증을 명시적으로 제외합니다(`ValidateCsrfToken` 제외 그룹) — 이니시스
+결제창이 팝업 안에서 우리 서버로 폼을 직접 POST 하는데, 그 요청은 우리 CSRF 토큰을 모르기
+때문입니다. `popup-bridge`는 팝업과 opener 창 사이의 postMessage 중계용 정적 페이지라
+별도 인증이 필요 없습니다. `api`는 로그인 사용자가 Bearer 토큰으로 자기 본인확인 상태를
+조회하는 마이페이지 엔드포인트(`GET /me/identity/inicis`)입니다 — 챌린지 시작 자체는
+코어 `/api/identity/challenges`가 담당하므로 이 플러그인의 `api` 라우트에는 없습니다.
+
+
+## 의존 관계
+
+
+**이 확장이 의존하는 확장**
+
+없음 — 코어만으로 동작합니다.
+
+**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
+
+없음.
+
+
+
+결제 PG 플러그인들과 달리 이 플러그인은 `sirsoft-ecommerce`에 의존하지 않습니다 — 본인확인은
+결제와 무관하게 회원가입·비밀번호 찾기 등 코어 인증 흐름 전반에 쓰이는 기능이라, 이커머스가
+설치되지 않은 사이트(게시판만 운영하는 등)에서도 단독으로 동작해야 합니다.
+
diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/AGENTS.md b/plugins/_bundled/sirsoft-verification_nhnkcp/AGENTS.md
new file mode 100644
index 00000000..f909084f
--- /dev/null
+++ b/plugins/_bundled/sirsoft-verification_nhnkcp/AGENTS.md
@@ -0,0 +1,189 @@
+# NHN KCP 휴대폰 본인확인 — 에이전트 가이드
+
+> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
+
+## TL;DR (5초 요약)
+
+```text
+1. 유형: 플러그인 (sirsoft-verification_nhnkcp) — NHN KCP 휴대폰 본인확인 IDV Provider. 코어 `IdentityVerificationInterface` 구현, PII 레코드 소유 (`sirsoft-verification_kginicis`와 같은 부류·다른 벤더)
+2. 확장 방식: `RegisterKcpProviderListener` 로 코어 `core.identity.registered_providers` 필터에 등록 — 코어는 이 플러그인의 존재를 모른다
+3. 건드리면 안 되는 것: 데스크톱 팝업/모바일 리다이렉트 분기(`isMobileEnvironment()`) 우회, 라이브 사이트코드 `SM` 프리픽스 정책값 상수(`LIVE_SITE_CD_PREFIX`) 미참조, 중복가입 차단(`AssertNoDuplicateKcpIdentity`) 우회
+4. 작업 위치: `plugins/_bundled/sirsoft-verification_nhnkcp` — 활성 디렉토리 직접 수정 금지
+5. 반영: `php artisan plugin:update sirsoft-verification_nhnkcp --force`
+```
+
+## 1. 이 확장은 무엇인가
+
+
+NHN KCP 휴대폰 본인확인을 코어 본인인증(IDV) 체계에 연결하는 Provider 입니다.
+`sirsoft-verification_kginicis`와 같은 부류(코어 `IdentityVerificationInterface` 12메서드
+구현)지만 다른 벤더이며, 운영자는 둘 중 하나 또는 둘 다 설치해 사용할 수 있습니다 —
+코어는 등록된 provider ID로만 구분합니다.
+
+**이 플러그인만의 차이**: 인증 화면 진입 방식이 기기별로 갈립니다 — 데스크톱은
+`sirsoft-verification_kginicis`와 동일하게 사용자 클릭 컨텍스트 안에서 빈 팝업을 열고
+그 안에서 인증을 진행하지만, 모바일은 팝업 대신 **전체 페이지 리다이렉트**로 KCP 인증
+화면으로 이동한 뒤 콜백으로 복귀합니다(`isMobileEnvironment()` 분기) — 모바일 브라우저는
+팝업 UX 가 나쁘고 앱 전환(문자 인증 등)이 얽히면 팝업 컨텍스트 자체가 끊기기 쉽기
+때문입니다. 리다이렉트 복귀 시 원래 상태를 되살리기 위해 `sessionStorage`에
+`g7.identity.redirectStash` 키로 복귀 정보를 임시 저장합니다.
+
+**설계 원칙**: 이 플러그인도 실제 PII 를 소유합니다(§data-model.md — `kginicis`와 같은
+구조: 완료된 확인 결과 레코드 + 진행 중인 인증 거래 매핑을 별도 테이블로 분리).
+
+**의도적으로 하지 않는 것**: `kginicis`와 동일하게, 비로그인 사용자의 PII 는 확인 즉시
+DB 에 쓰지 않고 임시 보관했다가 가입 완료 시에만 레코드로 흡수합니다. 사용자 탈퇴/삭제
+시 관련 PII 를 자동 정리합니다.
+
+
+## 2. 디렉토리 지도
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
+| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
+| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
+| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
+| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
+| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
+| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
+| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
+| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
+| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
+| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
+| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
+| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-verification_nhnkcp --force` (빌드 불필요) |
+| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-verification_nhnkcp --force` |
+| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-verification_nhnkcp --force` |
+| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-verification_nhnkcp --force` |
+| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
+| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-verification_nhnkcp --force` |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
+
+
+## 3. 핵심 흐름
+
+
+**본인확인 시작~완료(데스크톱)**: 코어 428 응답 → `startAuth`가 사용자 클릭 컨텍스트
+안에서 `window.open`으로 빈 팝업 생성 → KCP 인증 폼 제출 → 인증 완료 후
+`KcpCertTransactionRepository`가 거래 매핑을 저장 → 팝업 종료 감지로 결과 회수 →
+`KcpIdentityProvider::verify()`가 결과를 반환. 성인인증(`nhnkcp.adult_verification`
+purpose)으로 발행된 challenge 는 만 19세 이상만 통과시킵니다.
+
+**본인확인 시작~완료(모바일)**: `isMobileEnvironment()`가 참이면 팝업 대신 전체 페이지
+리다이렉트로 KCP 인증 화면으로 이동 → 복귀 정보를 `sessionStorage`(`g7.identity.redirectStash`)에
+저장 → 인증 완료 후 콜백 URL로 복귀 → 저장된 stash 로 원래 challenge 컨텍스트를 복원.
+
+**비로그인 사용자 처리 / 중복가입 차단 / 사용자 삭제·탈퇴 시 정리**:
+`sirsoft-verification_kginicis`와 동일한 3단계 패턴입니다 — `verify()` 시 Cache
+stash(`nhnkcp:pending_record:`) → `core.auth.after_register`에서 흡수,
+`core.auth.before_register`에서 `AssertNoDuplicateKcpIdentity`가 중복 차단,
+`core.user.before_delete`/`after_withdraw`에서 PII 정리.
+
+**설정 저장 시 검증 + 라이브 사이트코드 정규화**: 관리자가 설정 저장 요청 → FormRequest
+검증 단계에서 `core.plugin_settings.update_validation_rules` 훅 →
+`ValidateKcpSettingsListener::addLiveModeRules()`가 라이브 모드일 때 `live_site_cd`/
+`live_enc_key`를 필수로 강제(값이 비어있지 않은지만 확인 — 아직 프리픽스는 보지 않음) →
+검증 통과 후 `PluginSettingsService`가 실제 저장하는 단계에서
+`core.plugin_settings.filter_save_data` 훅 → 같은 리스너의 `normalizeLiveSiteCd()`가
+`live_site_cd`에 `SM` 프리픽스가 없으면 자동으로 붙여 저장. 두 훅이 분리된 이유는 코어의
+FormRequest 검증과 Service 저장이 서로 다른 파이프라인 단계이기 때문입니다 — "필수값이
+비어있지 않은가"는 검증 단계에서, "저장될 값의 형식을 교정"은 저장 단계에서 처리합니다.
+
+
+## 4. 확장점
+
+
+| 확장점 | 수 | 상세 |
+|---|---|---|
+| 발행 훅 | 0개 | [발행 훅](docs/extension-points.md#발행-훅) |
+| 구독 훅 | 7개 | [구독 훅](docs/extension-points.md#구독-훅) |
+| 훅 리스너 | 6개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
+| 레이아웃 확장 | 2개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
+| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) |
+| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
+| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
+| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
+
+
+
+발행 훅이 0개인 것은 `sirsoft-verification_kginicis`와의 실제 차이입니다 — kginicis 쪽
+3건은 리스너 docblock 의 코어 호출 예시 주석을 소스 자동 감지가 오인한 결과였는데(§data-model.md
+계열의 동일 패턴), 이 플러그인의 리스너 docblock 은 그런 예시 서술 방식을 쓰지 않아
+오탐이 없습니다. `core.identity.registered_providers`는 코어가 등록된 IDV provider 목록을
+모으는 필터 훅입니다.
+
+
+## 5. 수정 시 동반 의무
+
+- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-verification_nhnkcp --force` 로 반영
+- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
+- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝
+- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-verification_nhnkcp` 재실행 + `docs/api/**` 갱신
+- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
+- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
+- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
+- [ ] PII 컬럼을 다루는 코드 변경 시 GDPR 삭제/탈퇴 정리 리스너(`CleanKcpRecordOnUserDelete`/`CleanKcpRecordOnUserWithdraw`)가 여전히 그 컬럼을 정리하는지 확인
+- [ ] 데스크톱/모바일 분기(`isMobileEnvironment()`)를 고칠 때 양쪽 복귀 경로(팝업 종료 감지 / `redirectStash` 복원)를 함께 테스트
+- [ ] `duplicate_field`/`duplicate_block_enabled` 로직을 고치면 `KcpDuplicateField` Enum 과 `AssertNoDuplicateKcpIdentity`를 함께 갱신
+- [ ] `normalizeLiveSiteCd()`/`addLiveModeRules()`를 고칠 때 두 훅(`filter_save_data`/`update_validation_rules`)의 실행 순서(검증 먼저, 정규화는 저장 시점)를 유지
+
+## 6. 금지 패턴
+
+
+| 금지 | 올바른 사용 | 이유 |
+|---|---|---|
+| 비로그인 사용자의 PII 를 verify 즉시 DB 에 저장 | Cache 에 stash(`nhnkcp:pending_record:` 접두) 후 가입 완료 시 흡수 | 가입을 완료하지 않은 방문자의 PII 를 DB 에 영구 저장하면 불필요한 개인정보 보유가 된다 |
+| 사용자 삭제/탈퇴 리스너 없이 PII 컬럼 추가 | `CleanKcpRecordOnUserDelete`/`CleanKcpRecordOnUserWithdraw`에 정리 로직 동반 | 정리 누락 시 탈퇴한 사용자의 PII 가 무기한 남는다 |
+| `LIVE_SITE_CD_PREFIX` 상수를 참조하지 않고 `'SM'`을 문자열로 재작성 | `KcpIdentityProvider::LIVE_SITE_CD_PREFIX` 참조 | KCP 사이트코드 정책이 바뀌면 상수 1곳만 갱신해야 런타임 로직 전체에 반영된다 |
+| 모바일 리다이렉트 복귀 시 `redirectStash`를 검증 없이 신뢰 | 복귀 정보의 challenge 컨텍스트를 서버측 상태와 대조 후 사용 | `sessionStorage`는 클라이언트가 임의로 조작할 수 있는 저장소다 |
+| 팝업을 사용자 클릭 이벤트 핸들러 밖에서 `window.open` | 사용자 제스처 컨텍스트 안에서 직접 호출 (데스크톱 경로) | Chrome 등 브라우저는 사용자 제스처 없이 열리는 팝업을 자동 차단한다 |
+| 라이브 암호화 키를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 본인확인 API 를 위조 호출할 수 있다 |
+
+
+## 7. 테스트 실행
+
+
+| 종류 | 개수 | 위치 |
+|---|---|---|
+| PHPUnit | 20개 | `plugins/_bundled/sirsoft-verification_nhnkcp/tests` |
+| Vitest | 7개 | `vitest.config.ts` |
+| Playwright | 2개 | `tests/Playwright` |
+| 시나리오 매니페스트 | 14개 | `tests/scenarios` |
+
+기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
+
+```bash
+# PHPUnit (변경 범위만) (Bash)
+php vendor/bin/phpunit plugins/_bundled/sirsoft-verification_nhnkcp/tests --filter='<대상클래스>'
+
+# Vitest (확장 디렉토리에서) (PowerShell)
+cd plugins/_bundled/sirsoft-verification_nhnkcp && powershell -Command "npm run test:run -- <대상>"
+
+# Playwright E2E (Bash)
+npx playwright test plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/specs/<대상>.spec.ts
+
+```
+
+무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
+
+
+## 8. 문서 목차
+
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
+| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
+| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
+| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/README.md b/plugins/_bundled/sirsoft-verification_nhnkcp/README.md
new file mode 100644
index 00000000..098a7197
--- /dev/null
+++ b/plugins/_bundled/sirsoft-verification_nhnkcp/README.md
@@ -0,0 +1,186 @@
+# NHN KCP 휴대폰 본인확인
+
+**G7 플러그인 · sirsoft-verification_nhnkcp**
+NHN KCP 휴대폰 본인확인(V2 REST)을 G7 코어 IDV 인프라에 Provider 로 등록하는 플러그인
+
+
+
+
+
+
+
+
+
+
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+NHN KCP 휴대폰 본인확인을 G7 코어의 본인인증(IDV) 체계에 연결하는 플러그인입니다.
+`sirsoft-verification_kginicis`와 같은 부류(코어 표준 인터페이스 구현)지만 다른 벤더이며,
+운영자는 둘 중 하나 또는 둘 다 설치해 사용할 수 있습니다.
+
+이 플러그인만의 특징은 인증 화면 진입 방식이 기기별로 갈린다는 점입니다 — 데스크톱은
+팝업, 모바일은 전체 페이지 리다이렉트를 씁니다. 모바일 브라우저는 팝업 UX 가 나쁘고
+문자 인증 같은 앱 전환이 얽히면 팝업 컨텍스트가 끊기기 쉽기 때문입니다.
+
+kginicis 와 마찬가지로 이 플러그인은 실제 개인식별정보(PII)를 직접 보관하며, 결제 PG
+플러그인들과 달리 `sirsoft-ecommerce`를 비롯한 어떤 확장에도 의존하지 않습니다.
+
+
+## 주요 기능
+
+
+| 영역 | 설명 |
+|---|---|
+| 본인확인 | NHN KCP 휴대폰 본인확인 팝업(데스크톱)/리다이렉트(모바일) |
+| 성인인증 | 만 19세 이상 여부만 확인하는 별도 purpose |
+| 중복가입 차단 | DI 또는 CI 기준으로 동일인 재가입 차단 (선택) |
+| 게스트 처리 | 비로그인 사용자의 본인확인 결과를 임시 보관 후 가입 완료 시 흡수 |
+| 개인정보 정리 | 사용자 탈퇴/삭제 시 보관 중인 PII 레코드 자동 파기 |
+| 마이페이지 | 본인확인 완료 상태 카드 노출 |
+| 관리자 설정 | 테스트/라이브 모드 전환, 중복가입 판정 기준 설정 |
+
+
+## 동작 방식
+
+
+```mermaid
+flowchart LR
+ A[코어가 IDV 요구 · 428] --> B{기기 판별}
+ B -->|데스크톱| C[사용자 클릭 → 팝업 오픈]
+ B -->|모바일| D[복귀 정보 저장 → 페이지 리다이렉트]
+ C --> E[KCP 인증 완료 → 팝업 종료 감지]
+ D --> F[KCP 인증 완료 → 콜백 복귀]
+ E --> G[결과 회수 → PII 확보]
+ F --> G
+ G -->|로그인 사용자| H[레코드 즉시 저장]
+ G -->|비로그인 사용자| I[Cache 임시 보관 → 가입 완료 시 흡수]
+```
+
+데스크톱 팝업은 반드시 사용자 클릭 이벤트 안에서 열립니다 — 비동기 콜백 이후에 열면
+브라우저 팝업 차단기에 걸립니다. 모바일 리다이렉트는 이 제약이 없는 대신, 복귀 후
+원래 상태를 되살리기 위한 정보를 브라우저에 임시 저장합니다.
+
+
+## 요구 사항
+
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=7.0.6` |
+| PHP | `^8.2` |
+
+
+## 설치
+
+
+```bash
+# 번들 설치 (코어에 동봉된 소스에서 설치)
+php artisan plugin:install sirsoft-verification_nhnkcp
+
+# 활성화
+php artisan plugin:activate sirsoft-verification_nhnkcp
+
+# 업데이트 (번들 소스 기준 강제 반영)
+php artisan plugin:update sirsoft-verification_nhnkcp --force
+```
+
+저장소: https://github.com/gnuboard/g7-plugin-sirsoft-verification_nhnkcp
+
+
+## 관리자 설정
+
+
+| 키 | 의미 | 기본값 |
+|---|---|---|
+| `is_test_mode` | 테스트 모드 | `true` |
+| `test_site_cd` | 테스트 사이트코드 | `AO7F3` |
+| `test_enc_key` | 테스트 암호화 키 | `c2a22fa3ebe4698075bcac6b433d52e351c881b02fb83488d4283a43385b1f8e` |
+| `live_site_cd` | 운영 사이트코드 | - |
+| `live_enc_key` | 운영 암호화 키 | - |
+| `web_siteid` | 웹사이트 ID | - |
+| `duplicate_field` | 중복 판정 필드 | `di` |
+| `duplicate_block_enabled` | 중복 가입 차단 | `true` |
+
+개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
+
+
+
+`live_site_cd`는 저장 시 `SM` 프리픽스가 자동으로 붙으므로 프리픽스 없이 입력해도 됩니다.
+`live_site_cd`/`live_enc_key`는 `is_test_mode`를 끄는(라이브 모드) 순간부터 필수가
+됩니다 — 테스트 모드에서는 비워둘 수 있습니다. `web_siteid`는 테스트/라이브 모드 공통으로
+쓰이는 웹사이트 식별자입니다. `duplicate_field`(`di` 또는 `ci`)와
+`duplicate_block_enabled`는 다른 IDV provider(예: `sirsoft-verification_kginicis`)와
+별개로 이 provider 를 통해 확인한 사용자에게만 적용됩니다. 라이브 암호화 키는 외부에
+노출하지 마세요.
+
+
+## 사용 방법
+
+
+**활성화하기**: 플러그인을 활성화하면 자동으로 코어 IDV provider 목록에 등록됩니다.
+별도 화면 배치 작업 없이 코어가 이미 정의한 IDV 강제 지점(회원가입 등)에서 즉시
+동작합니다.
+
+**중복가입 차단 켜기**: 동일인이 여러 계정을 만드는 것을 막고 싶다면
+`duplicate_block_enabled`를 켜고 `duplicate_field`로 DI/CI 중 판정 기준을 고릅니다.
+
+**모바일 동작 확인**: 데스크톱에서는 정상인데 모바일에서만 인증이 실패한다면 리다이렉트
+복귀 경로(`redirectStash`)가 원인일 가능성이 높습니다 — §트러블슈팅을 확인하세요.
+
+전체 API 목록은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은
+[docs/extension-points.md](docs/extension-points.md) 를 참고하세요.
+
+
+## 다른 확장과의 연동
+
+
+**이 확장이 의존하는 확장**
+
+없음 — 코어만으로 동작합니다.
+
+**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
+
+없음.
+
+
+## 문서
+
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
+| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
+| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
+| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
+
+## 트러블슈팅
+
+
+| 증상 | 원인 | 조치 |
+|---|---|---|
+| 데스크톱에서 인증 버튼을 눌러도 팝업이 안 뜸 | 브라우저 팝업 차단기 | `startAuth`가 클릭 이벤트 핸들러 안에서 직접 호출되는지 확인 |
+| 모바일에서만 인증 완료 후 원래 화면으로 돌아오지 않음 | 리다이렉트 복귀 정보(`sessionStorage`의 `g7.identity.redirectStash`)가 유실됨 | 리다이렉트 도중 다른 탭/앱으로 완전히 전환되어 세션 스토리지가 초기화됐는지 확인 (동일 브라우저 탭 안에서 왕복해야 함) |
+| 설정 저장 시 422 오류 | 라이브 모드인데 `live_site_cd`/`live_enc_key` 미입력 | `is_test_mode`를 켜거나 라이브 자격증명을 입력 |
+| 이미 가입된 사용자인데 중복 오류 없이 재가입됨 | `duplicate_block_enabled`가 꺼져 있거나 `duplicate_field` 기준이 실제 판정과 다름 | 관리자 설정에서 두 값을 확인 |
+| 탈퇴한 사용자의 본인확인 정보가 남아있는 것으로 보임 | 정리 리스너 실행 여부를 별도로 확인하지 않음 | `CleanKcpRecordOnUserWithdraw`/`CleanKcpRecordOnUserDelete` 정상 등록 여부를 훅 캐시에서 확인 |
+
+
+## 변경 이력
+
+[CHANGELOG.md](CHANGELOG.md)
+
+## 라이선스
+
+MIT
diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md
new file mode 100644
index 00000000..a9c0e86d
--- /dev/null
+++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md
@@ -0,0 +1,22 @@
+# NHN KCP 휴대폰 본인확인 개발자 문서
+
+> plugins/_bundled/sirsoft-verification_nhnkcp · 플러그인
+
+
+**훅 수**: 0 · **구독 훅 수**: 7 · **라우트 수**: 2 · **모델 수**: 2 · **테이블 수**: 2 · **마이그레이션 수**: 2 · **레이아웃 수**: 1 · **핸들러 수**: 1
+
+
+## 문서 목차
+
+
+| 문서 | 내용 |
+|---|---|
+| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
+| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
+| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
+| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
+| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
+| [api/](api/README.md) | API 레퍼런스 |
+| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
+| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
+
diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/architecture.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/architecture.md
new file mode 100644
index 00000000..9f838741
--- /dev/null
+++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/architecture.md
@@ -0,0 +1,70 @@
+# NHN KCP 휴대폰 본인확인 — 아키텍처
+
+> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 설계 의도
+
+
+NHN KCP 휴대폰 본인확인을 코어 IDV 체계에 연결하는 Provider 입니다.
+`sirsoft-verification_kginicis`와 계약(코어 `IdentityVerificationInterface`)·PII 소유
+구조는 같지만, 인증 화면 진입 방식이 기기별로 갈립니다 — 데스크톱은 팝업, 모바일은 전체
+페이지 리다이렉트입니다. 이 분기가 이 플러그인 아키텍처 전반(프론트 상태 복원,
+`sessionStorage` 사용)에 스며 있습니다.
+
+
+## 계층 지도
+
+
+```text
+Controller (Http/Controllers) → FormRequest (Http/Requests)
+ → KcpIdentityProvider (IdentityVerificationInterface 구현 — verify/challenge 표준 진입점)
+ → KcpCertClientInterface (외부 통신 + 암호화/복호화)
+ → KcpCertTransactionRepositoryInterface (진행 중인 인증 거래)
+ → KcpIdentityRecordRepositoryInterface (완료된 PII record)
+ → IdentityVerificationLogRepositoryInterface (코어 IDV 로그 조회)
+ → KcpDuplicateIdentityChecker (중복가입 판정 로직)
+ → CacheInterface (비로그인 verify PII 임시 stash)
+
+Listener (RegisterKcpProviderListener 등)
+ → 코어 identity/auth/user/settings 훅에 등록 (컴파일 타임 결합 없음)
+```
+
+`KcpDuplicateIdentityChecker`가 별도 협력자로 분리된 것은 `sirsoft-verification_kginicis`와의
+작은 차이입니다 — kginicis 는 그 판정 로직을 `AssertNoDuplicateInicisIdentity` 리스너
+안에 두는 반면, 이 플러그인은 Provider 자신도 같은 판정 로직을 재사용할 수 있도록 별도
+클래스로 뽑았습니다.
+
+`sirsoft-verification_kginicis`와 계층 구조가 거의 동일한 것은 둘 다 같은 코어 계약을
+구현하기 때문입니다 — 새 IDV provider 를 추가할 때 이 두 플러그인을 참조 구현으로 삼을 수
+있습니다.
+
+
+## 디렉토리
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
+| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
+| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
+| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
+| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
+| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
+| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
+| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
+| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
+| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
+| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
+| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
+| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-verification_nhnkcp --force` (빌드 불필요) |
+| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-verification_nhnkcp --force` |
+| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-verification_nhnkcp --force` |
+| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-verification_nhnkcp --force` |
+| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
+| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-verification_nhnkcp --force` |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
+
diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/data-model.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/data-model.md
new file mode 100644
index 00000000..9f8dd713
--- /dev/null
+++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/data-model.md
@@ -0,0 +1,89 @@
+# NHN KCP 휴대폰 본인확인 — 데이터 모델
+
+> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 모델
+
+
+| 모델 | 테이블 | fillable | 관계 | 특성 |
+|---|---|---|---|---|
+| `KcpCertTransaction` | `nhnkcp_cert_transactions` | 5 | challenge→IdentityVerificationLog | - |
+| `KcpIdentityRecord` | `nhnkcp_identity_records` | 15 | user→User | - |
+
+
+
+`sirsoft-verification_kginicis`의 `InicisIdentityRecord`/`InicisChallengeMapping`과 대칭인
+구조입니다 — `KcpIdentityRecord`가 PII 를 직접 보관하고(결제 플러그인들과의 근본적 차이),
+`KcpCertTransaction`은 진행 중인 인증 시도만 다룹니다. 두 모델을 분리한 이유도 동일합니다
+— 인증 세션(거래 하나)과 확인 결과(사용자당 최신 1건)는 생명주기가 다릅니다.
+
+
+## 소유 테이블
+
+
+| 테이블 | 모델 |
+|---|---|
+| `nhnkcp_cert_transactions` | `KcpCertTransaction` |
+| `nhnkcp_identity_records` | `KcpIdentityRecord` |
+
+
+
+`nhnkcp_identity_records.user_id`는 `unique` 제약을 갖습니다 — 한 사용자는 본인확인 결과를
+1건만 보유하며, 재인증 시 기존 레코드를 갱신합니다. `sirsoft-verification_kginicis`와
+동일한 설계로, "이 사용자가 본인확인을 완료했는가"를 단순 존재 조회 하나로 판정합니다.
+
+
+## 마이그레이션
+
+
+마이그레이션 2개.
+
+| 파일 | 생성 테이블 | 변경 테이블 | down() |
+|---|---|---|---|
+| `2026_07_28_000001_create_nhnkcp_identity_records_table.php` | `nhnkcp_identity_records` | `nhnkcp_identity_records` | ✅ |
+| `2026_07_28_000002_create_nhnkcp_cert_transactions_table.php` | `nhnkcp_cert_transactions` | `nhnkcp_cert_transactions` | ✅ |
+
+
+
+`sirsoft-verification_kginicis`가 세 번째 마이그레이션으로 부가 필드를 nullable 완화한
+것과 달리, 이 플러그인은 두 테이블 생성만으로 끝났습니다 — 아직 같은 종류의 스키마 보정이
+필요했던 적이 없습니다. 향후 유사한 nullable 완화가 필요해지면 kginicis 의 사례(§CLAUDE.md
+"소스 교정만으로는 기설치본이 낫지 않는다")를 참고합니다.
+
+
+## Enum
+
+
+| Enum | backing | case 수 | case |
+|---|---|---|---|
+| `KcpDuplicateField` | `string` | 2 | `di`, `ci` |
+
+
+
+`sirsoft-verification_kginicis`의 `InicisDuplicateField`와 case 구성이 동일합니다(`di`/`ci`)
+— 두 벤더 모두 같은 개념(연계정보/연계정보 상위값)을 제공하기 때문입니다. `duplicate_field`
+설정값과 `KcpDuplicateIdentityChecker`의 판정 컬럼 화이트리스트 양쪽에서 재사용되므로 Enum
+으로 닫아 오타로 인한 "중복 없음" 오판정을 방지합니다.
+
+
+## Repository
+
+
+| 클래스 | 종류 | 설명 |
+|---|---|---|
+| `KcpCertTransactionRepository` | 구현 | `nhnkcp_cert_transactions` 테이블 Repository 구현체. |
+| `KcpCertTransactionRepositoryInterface` | 인터페이스 | `nhnkcp_cert_transactions` 테이블 Repository 계약. |
+| `KcpIdentityLogQueryRepository` | 구현 | KcpIdentityLogQueryRepositoryInterface 구현체. |
+| `KcpIdentityLogQueryRepositoryInterface` | 인터페이스 | 본 plugin 이 발행한 코어 IDV 로그의 보조 조회/갱신 계약. |
+| `KcpIdentityRecordRepository` | 구현 | `nhnkcp_identity_records` 테이블 Repository 구현체. |
+| `KcpIdentityRecordRepositoryInterface` | 인터페이스 | `nhnkcp_identity_records` 테이블 Repository 계약. |
+
+
+
+3쌍으로 나뉜 것은 `sirsoft-verification_kginicis`와 같은 이유입니다 —
+`KcpCertTransactionRepository`(진행 중인 인증 거래), `KcpIdentityRecordRepository`(완료된
+PII), `KcpIdentityLogQueryRepository`(코어 IDV 로그를 이 플러그인 관점으로 좁혀 읽는
+어댑터)가 각자 다른 데이터를 다룹니다. `KcpDuplicateIdentityChecker`(§architecture.md)는
+Repository 가 아니라 판정 로직 클래스입니다 — 두 Repository 를 조합해 판정만 하고
+데이터를 소유하지 않습니다.
+
diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/extension-points.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/extension-points.md
new file mode 100644
index 00000000..576fee13
--- /dev/null
+++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/extension-points.md
@@ -0,0 +1,123 @@
+# NHN KCP 휴대폰 본인확인 — 확장점
+
+> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 발행 훅
+
+
+_이 확장은 훅을 발행하지 않습니다._
+
+
+
+`sirsoft-verification_kginicis`는 같은 표에 3건이 잡히지만 실제로는 발행하는 훅이
+아닙니다(코어 호출부를 보여주는 docblock 예시를 소스 자동 감지가 오인한 결과) — 이
+플러그인은 그런 예시 서술 방식을 쓰지 않아 표가 정확히 비어 있습니다. 두 플러그인 모두
+실제로 발행하는 도메인 전용 훅은 없습니다.
+
+
+## 구독 훅
+
+
+| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
+|---|---|---|---|---|
+| `core.auth.after_register` | action (미선언) | `CompleteKcpRecordAfterRegister` | `handle` | 50 |
+| `core.auth.before_register` | action (미선언) | `AssertNoDuplicateKcpIdentity` | `handle` | 20 |
+| `core.identity.registered_providers` | filter | `RegisterKcpProviderListener` | `register` | 20 |
+| `core.plugin_settings.filter_save_data` | filter | `ValidateKcpSettingsListener` | `normalizeLiveSiteCd` | 10 |
+| `core.plugin_settings.update_validation_rules` | filter | `ValidateKcpSettingsListener` | `addLiveModeRules` | 10 |
+| `core.user.after_withdraw` | action (미선언) | `CleanKcpRecordOnUserWithdraw` | `handle` | 50 |
+| `core.user.before_delete` | action (미선언) | `CleanKcpRecordOnUserDelete` | `handle` | 50 |
+
+
+
+`ValidateKcpSettingsListener`가 2개 훅(`filter_save_data`/`update_validation_rules`)을
+동시에 구독하는 것은 `sirsoft-verification_kginicis`의 `ValidateInicisSettingsListener`(1개
+훅만 구독)와의 차이입니다 — kginicis 는 라이브 MID 프리픽스를 Provider 가 값을 쓸 때
+동적으로 붙이는 반면, 이 플러그인은 저장 시점에 정규화해 DB 에 프리픽스 붙은 값을
+남깁니다(§AGENTS.md "핵심 흐름"). `AssertNoDuplicateKcpIdentity`가
+`core.auth.before_register`(가입 **전**)을 구독하는 이유와 `core.user.before_delete`가
+`sync: true`인 이유는 kginicis 쪽과 동일합니다.
+
+
+## 훅 리스너
+
+
+| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
+|---|---|---|---|---|
+| `AssertNoDuplicateKcpIdentity` | 1개 | 명시 등록 | ✅ | `src/Listeners/AssertNoDuplicateKcpIdentity.php` |
+| `CleanKcpRecordOnUserDelete` | 1개 | 명시 등록 | ✅ | `src/Listeners/CleanKcpRecordOnUserDelete.php` |
+| `CleanKcpRecordOnUserWithdraw` | 1개 | 명시 등록 | ✅ | `src/Listeners/CleanKcpRecordOnUserWithdraw.php` |
+| `CompleteKcpRecordAfterRegister` | 1개 | 명시 등록 | ✅ | `src/Listeners/CompleteKcpRecordAfterRegister.php` |
+| `RegisterKcpProviderListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterKcpProviderListener.php` |
+| `ValidateKcpSettingsListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/ValidateKcpSettingsListener.php` |
+
+
+
+6개 리스너 중 3개(`CompleteKcpRecordAfterRegister`, `CleanKcpRecordOnUserWithdraw`,
+`CleanKcpRecordOnUserDelete`)는 `sirsoft-verification_kginicis`와 동일하게 사용자
+생명주기 각 단계에 맞춰 PII 레코드를 흡수하거나 정리하는 대칭 구조입니다 — 하나를 고칠
+때 나머지 둘도 같은 PII 필드를 다루고 있는지 확인해야 합니다.
+
+
+## 레이아웃 확장
+
+
+| 대상 | 설명 |
+|---|---|
+| `resources/extensions/identity_provider_nhnkcp.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+| `resources/extensions/mypage_identity_card.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
+
+
+
+`mypage_identity_card.json`은 `sirsoft-verification_kginicis`의 같은 이름 조각과 동일한
+역할(마이페이지 "본인확인 완료" 상태 카드)입니다 — 여러 IDV provider 가 동시에 설치돼도
+각자 자기 카드만 주입하므로 충돌하지 않습니다. `identity_provider_nhnkcp.json`은 코어
+IDV 팝업이 이 provider 고유의 안내 문구·로고를 보여줄 때 쓰는 조각입니다.
+
+
+## 미들웨어
+
+
+_등록하는 미들웨어가 없습니다._
+
+
+
+결제 플러그인들과 달리 이 플러그인은 PG 서버가 직접 호출하는 웹훅/통보 엔드포인트가
+없습니다 — 본인확인 결과는 팝업 콜백 또는 모바일 리다이렉트 콜백(둘 다 사용자 브라우저
+경유)으로만 도달하므로 IP 화이트리스트 같은 서버간 통신 검증이 필요 없습니다.
+
+
+## 브로드캐스트 채널
+
+
+_등록하는 브로드캐스트 채널이 없습니다._
+
+
+
+인증 결과는 팝업의 종료 감지 또는 모바일 리다이렉트 복귀로 프론트가 직접 회수합니다
+(§AGENTS.md "핵심 흐름") — 서버가 다른 클라이언트에 실시간으로 알려야 할 상태 변화가
+없어 브로드캐스트 채널이 필요 없습니다.
+
+
+## 스케줄
+
+
+_등록하는 스케줄이 없습니다._
+
+
+
+`nhnkcp_cert_transactions`의 만료된 인증 거래나 `redirectStash`의 잔존 세션 데이터를
+별도 배치로 청소하지 않습니다 — `sessionStorage`는 브라우저 세션 종료로 자연 소멸하고,
+서버측 거래 레코드 정리는 사용자 삭제/탈퇴 시점 리스너가 담당합니다(§훅 리스너).
+
+
+## 알림 정의
+
+
+_등록하는 알림 정의가 없습니다._
+
+
+
+본인확인 성공/실패는 사용자가 즉시 확인하는 동기적 상호작용이라, 별도 알림(이메일/SMS
+등)을 발송할 지점이 없습니다.
+
diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/frontend.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/frontend.md
new file mode 100644
index 00000000..b2e5cfe1
--- /dev/null
+++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/frontend.md
@@ -0,0 +1,80 @@
+# NHN KCP 휴대폰 본인확인 — 프론트엔드
+
+> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 레이아웃
+
+
+레이아웃 1개 (루트: `resources/layouts`).
+
+| 그룹 | 개수 |
+|---|---|
+| `admin` | 1개 |
+
+| 레이아웃 | 그룹 | 종류 | extends |
+|---|---|---|---|
+| `plugin_settings` | `admin` | 화면 | `_admin_base` |
+
+
+
+`sirsoft-verification_kginicis`와 마찬가지로 이 플러그인이 소유한 화면 레이아웃은 관리자
+설정 화면 하나뿐입니다 — 회원가입·마이페이지의 본인확인 UI는 §레이아웃 확장 조각 및 코어
+IDV 공통 팝업 UI로 존재합니다.
+
+
+## 액션 핸들러
+
+
+핸들러 1개 (정의: `resources/js/index.ts`).
+
+| 핸들러 | 레이아웃에서 부르는 이름 |
+|---|---|
+| `startAuth` | `sirsoft-verification_nhnkcp.startAuth` |
+
+
+
+`startAuth`는 `isMobileEnvironment()`로 기기를 판별해 데스크톱에서는
+`sirsoft-verification_kginicis`와 같은 팝업 패턴(사용자 클릭 컨텍스트 안에서
+`window.open`)을, 모바일에서는 `sessionStorage`(`g7.identity.redirectStash`)에 복귀
+정보를 저장한 뒤 전체 페이지 리다이렉트를 씁니다. 반드시 클릭 이벤트 핸들러 안에서
+호출돼야 팝업 차단을 피할 수 있다는 제약은 데스크톱 경로에만 해당합니다 — 모바일
+리다이렉트는 팝업 차단과 무관합니다.
+
+
+## 전역 진입점
+
+
+| 항목 | 값 |
+|---|---|
+| 엔트리 파일 | `resources/js/index.ts` |
+| 전역 객체 | `window.__SirsoftVerificationNhnkcp` |
+| 재등록 진입점 | `initPlugin()` |
+
+로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.
+
+
+
+`window.__SirsoftVerificationNhnkcp`로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로
+재등록 진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). `sirsoft-verification_kginicis`와
+마찬가지로 이 플러그인은 결제창 SDK 를 동적 로드하지 않습니다 — 인증 화면 자체가 서버
+렌더링 페이지(팝업 안) 또는 리다이렉트 대상(모바일)이므로 프론트가 별도 스크립트를 불러올
+필요가 없습니다.
+
+
+## 에셋
+
+
+| 경로 | 구분 |
+|---|---|
+| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) |
+| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
+
+로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
+
+
+
+제3자 SDK 스크립트가 목록에 없는 이유는 §전역 진입점과 동일합니다 — 인증 화면은 팝업
+안의 서버 렌더링 페이지이거나 모바일 리다이렉트 대상이라 프론트가 동적 로드할 자산이
+없습니다. CSS 산출물이 없는 것은 UI가 버튼·상태 카드 같은 최소한의 코어 컴포넌트로만
+구성되기 때문입니다.
+
diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/settings.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/settings.md
new file mode 100644
index 00000000..7b3e68e2
--- /dev/null
+++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/settings.md
@@ -0,0 +1,93 @@
+# NHN KCP 휴대폰 본인확인 — 설정·권한·라우트
+
+> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 설정 스키마
+
+
+| 키 | 타입 | 기본값 | 설명 |
+|---|---|---|---|
+| `is_test_mode` | `boolean` | `true` | 테스트 모드 |
+| `test_site_cd` | `string` | `AO7F3` | 테스트 사이트코드 |
+| `test_enc_key` | `string` | `c2a22fa3ebe4698075bcac6b433d52e351c881b02fb83488d4283a43385b1f8e` | 테스트 암호화 키 |
+| `live_site_cd` | `string` | - | 운영 사이트코드 |
+| `live_enc_key` | `string` | - | 운영 암호화 키 |
+| `web_siteid` | `string` | - | 웹사이트 ID |
+| `duplicate_field` | `enum` | `di` | 중복 판정 필드 |
+| `duplicate_block_enabled` | `boolean` | `true` | 중복 가입 차단 |
+
+기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json`
+
+
+
+`sirsoft-verification_kginicis`가 `mid`+`api_key` 2종 키 쌍을 쓰는 것과 달리 이 플러그인은
+`site_cd`+`enc_key`(대칭 암호화 키 1개)만 씁니다 — NHN KCP 본인확인 API 는 서명 검증용
+별도 API 키가 없고 site_cd/enc_key 조합만으로 인증합니다. `web_siteid`는 두 자격증명
+쌍(`test_*`/`live_*`)과 별개로 테스트·라이브 모드 공통으로 쓰이는 웹사이트 식별자입니다.
+`live_site_cd`는 `is_test_mode=false`일 때만 `ValidateKcpSettingsListener`가 필수로
+강제하고, 저장 시점에 `SM` 프리픽스를 자동으로 붙입니다(§AGENTS.md "핵심 흐름" — kginicis
+와 달리 DB 에 프리픽스가 붙은 값이 저장됩니다). `duplicate_field`/`duplicate_block_enabled`는
+kginicis 와 동일한 개념이지만 서로 다른 provider 를 통해 확인한 사용자에게 각자 독립
+적용됩니다.
+
+
+## 권한
+
+
+_선언된 권한이 없습니다._
+
+
+
+본인인증 설정 접근 권한은 코어의 관리자 권한 체계 안에서 다뤄집니다 — IDV provider 마다
+별도 권한을 선언하면 provider 를 여러 개 설치했을 때 "본인인증 설정을 볼 수 있는 사람"이
+provider 수만큼 중복 정의됩니다.
+
+
+## 메뉴
+
+
+_등록하는 메뉴가 없습니다._
+
+
+
+설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해
+접근합니다 — IDV provider 마다 전용 사이드바 메뉴를 만들면 provider 를 여러 개 설치했을
+때 메뉴가 난립합니다.
+
+
+## 라우트
+
+
+| 종류 | 파일 | URL prefix |
+|---|---|---|
+| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-verification_nhnkcp/...` |
+| `web` | `src/routes/web.php` | `/plugins/sirsoft-verification_nhnkcp/...` |
+
+확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
+
+
+
+`sirsoft-verification_kginicis`와 동일한 3-엔드포인트 구조입니다 — `web`의 콜백(CSRF 제외,
+KCP 인증 화면이 직접 POST)은 데스크톱 팝업과 모바일 리다이렉트 양쪽 경로가 **같은
+엔드포인트로 수렴**합니다(§AGENTS.md "핵심 흐름" — 기기별 진입 방식만 다를 뿐 콜백 처리는
+공통). `bridge`는 팝업-opener 간 postMessage 중계 페이지, `api`는 로그인 사용자가 자기
+본인확인 상태를 조회하는 마이페이지 엔드포인트입니다.
+
+
+## 의존 관계
+
+
+**이 확장이 의존하는 확장**
+
+없음 — 코어만으로 동작합니다.
+
+**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
+
+없음.
+
+
+
+`sirsoft-verification_kginicis`와 마찬가지로 이 플러그인은 `sirsoft-ecommerce`를 비롯한
+어떤 확장에도 의존하지 않습니다 — 본인확인은 결제와 무관하게 회원가입 등 코어 인증 흐름
+전반에 쓰이는 기능이라, 이커머스가 설치되지 않은 사이트에서도 단독으로 동작해야 합니다.
+
diff --git a/templates/_bundled/sirsoft-admin_basic/AGENTS.md b/templates/_bundled/sirsoft-admin_basic/AGENTS.md
new file mode 100644
index 00000000..27548884
--- /dev/null
+++ b/templates/_bundled/sirsoft-admin_basic/AGENTS.md
@@ -0,0 +1,150 @@
+# Admin Basic — 에이전트 가이드
+
+> 이 문서는 이 템플릿을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
+
+## TL;DR (5초 요약)
+
+```text
+1. 유형: 템플릿 (sirsoft-admin_basic) — 코어·모든 번들 확장의 관리자 화면을 그리는 유일한 admin 템플릿
+2. 확장 방식: 필수 컴포넌트(35개, config/template.php)만 써서 레이아웃 JSON 작성 — 다른 admin 템플릿으로 교체돼도 동작 보장
+3. 건드리면 안 되는 것: `Icon` 컴포넌트로 AdminSidebar 아이콘 렌더(반드시 `I`+FontAwesome 클래스), `_admin_base` 슬롯 구조를 임의로 재배치
+4. 작업 위치: `templates/_bundled/sirsoft-admin_basic` — 활성 디렉토리 직접 수정 금지
+5. 반영: `php artisan template:update sirsoft-admin_basic --force`
+```
+
+## 1. 이 확장은 무엇인가
+
+
+G7 이 기본 제공하는 유일한 admin 타입 템플릿입니다 — 코어 관리자 화면(대시보드·사용자·역할·
+설정·확장 관리 등)뿐 아니라 **모든 번들 모듈/플러그인의 관리자 레이아웃**(`resources/layouts/
+admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템플릿의 컴포넌트로 그려집니다.
+그래서 이 템플릿의 공개 계약(필수 컴포넌트 35개, `_admin_base` 슬롯 구조)을 깨면 코어가
+아니라 **전체 번들 확장의 관리자 화면**이 동시에 영향을 받습니다 — 다른 번들 확장 하나를
+고치는 것과는 파급 범위가 다릅니다.
+
+**설계 원칙**: 모듈/플러그인 개발자가 "이 컴포넌트만 쓰면 다른 admin 템플릿으로 바꿔도
+안전하다"는 보장을 받도록, 필수 컴포넌트 목록(config/template.php)을 이 템플릿 하나가 아니라
+**admin 템플릿이라면 지켜야 할 계약**으로 취급합니다 — 이 템플릿에만 있는 편의 컴포넌트를
+필수 목록에 넣지 않습니다.
+
+**의도적으로 하지 않는 것**: 방문자용(user) 화면은 이 템플릿의 범위가 아닙니다(`sirsoft-basic`
+소관). 또한 컴포넌트 Props 전체 레퍼런스는 이 문서가 다시 나열하지 않습니다 — 코어
+`docs/frontend/component-props*.md` 가 SSoT 이고, 이 문서는 이 템플릿에서만 유효한 계약
+(필수 컴포넌트·AdminSidebar·SlotContainer·베이스 레이아웃 구조)만 다룹니다.
+
+
+## 2. 디렉토리 지도
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `template.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json 동기화 |
+| `routes.json` | 라우트 → 레이아웃 매핑 | `php artisan template:update sirsoft-admin_basic --force` |
+| `layouts/` | 레이아웃 JSON | `php artisan template:update sirsoft-admin_basic --force` (빌드 불필요) |
+| `src/components/` | React 컴포넌트 | `php artisan template:build` → `php artisan template:update sirsoft-admin_basic --force` |
+| `src/handlers/` | 템플릿 전용 액션 핸들러 | `php artisan template:build` → `php artisan template:update sirsoft-admin_basic --force` |
+| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
+| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan template:update sirsoft-admin_basic --force` |
+| `editor-spec/` | 분할 편집기 스펙 | `php artisan template:update sirsoft-admin_basic --force` |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan template:update sirsoft-admin_basic --force` |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
+
+
+## 3. 핵심 흐름
+
+
+**새 관리자 화면 렌더**: 방문자가 `/admin/{path}` 접근 → `routes.json` 이 경로를 레이아웃
+이름으로 매핑 → 그 레이아웃이 `extends: _admin_base` 로 사이드바·헤더·Toast·콘텐츠 슬롯을
+상속 → `slots.content` 에 정의된 컴포넌트(대개 `PageHeader`+`DataGrid`/`Card`/`Form`
+조합)가 데이터소스 API 를 호출해 화면을 채웁니다. 이 흐름은 코어 화면과 번들 확장 화면이
+완전히 동일합니다 — 확장이 관리자 화면을 추가할 때 이 템플릿을 복제하지 않고 자기
+`resources/layouts/admin/*.json` 에서 그대로 `_admin_base` 를 extends 합니다.
+
+**사이드바 접힘 상태 복원**: 페이지 로드 → `src/index.ts` 부트스트랩이 `initSidebar()` 직접
+호출(레이아웃 `init_actions` 아님) → localStorage 값을 `_global.sidebarCollapsed` 에 반영 →
+`_admin_base.json` 의 사이드바 영역 className 표현식이 그 값을 읽어 접힘 스타일 적용.
+
+**로케일 전환**: 코어가 로케일을 바꾸면 이 템플릿의 `initTemplate()` 재등록 진입점이 호출되어
+핸들러 맵을 다시 등록합니다(§docs/handlers.md "부트스트랩"). 사이드바 접힘 등 1회성 부팅
+작업은 이 진입점에 섞지 않습니다 — 로케일 전환마다 중복 실행되면 안 되기 때문입니다.
+
+
+## 4. 확장점
+
+
+| 확장점 | 수 | 상세 |
+|---|---|---|
+| 제공 컴포넌트 | 125개 | [제공 컴포넌트](docs/components.md#제공-컴포넌트) |
+| 레이아웃 | 145개 | [레이아웃 목록](docs/layouts.md#레이아웃-목록) |
+| 전용 핸들러 | 17개 | [템플릿 전용 핸들러](docs/handlers.md#템플릿-전용-핸들러) |
+| 확장 오버라이드 | 0개 | [확장 오버라이드](docs/layouts.md#확장-오버라이드) |
+
+
+
+이 템플릿은 훅을 발행/구독하지 않습니다 — 관리자 화면 확장은 훅이 아니라 **레이아웃 확장
+오버라이드**(`extensions/{module-identifier}/*.json`, 지금은 0개)와 **컴포넌트 재사용**
+(공통 컴포넌트를 그대로 쓰는 것) 두 경로로만 이뤄집니다. 다른 확장의 관리자 화면 UI 를
+이 템플릿에서만 다르게 그리고 싶다면 첫 번째 경로를, 새 화면 유형(예: 새로운 카드 스타일)이
+필요하면 `src/components/composite/` 에 컴포넌트를 추가하고 `components.json` 에 등록하는
+쪽을 씁니다.
+
+
+## 5. 수정 시 동반 의무
+
+- [ ] `_bundled` 에서만 수정하고 `php artisan template:update sirsoft-admin_basic --force` 로 반영
+- [ ] manifest version 상향 시 `package.json` · `package-lock.json` 동기화 + CHANGELOG 기재
+- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
+- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
+- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
+- [ ] 필수 컴포넌트(35개) 의 Props 시그니처를 깨는 변경은 전체 번들 확장 관리자 화면에 영향 — 변경 전 `src/components/{basic,composite}/` 의 실사용처를 넓게 확인
+- [ ] `_admin_base.json` 슬롯 구조(`content` 슬롯 등) 변경 시 그 슬롯에 의존하는 모든 화면(145개 레이아웃 대다수) 영향 검토
+- [ ] AdminSidebar 의 `MenuItem`/`AdminSidebarProps` 인터페이스 확장 시 이 문서의 §docs/components.md "AdminSidebar 상세" 동기화
+
+## 6. 금지 패턴
+
+
+| 금지 | 올바른 사용 | 이유 |
+|---|---|---|
+| `AdminSidebar` 메뉴 아이콘을 `Icon name="home"` 식으로 렌더 | `I` 컴포넌트 + FontAwesome 클래스 문자열(`icon` 필드가 이미 `"fas fa-home"` 형태) | 메뉴 API 가 클래스 문자열을 내려주므로 `Icon`(IconName enum) 으로 받으면 렌더되지 않는다 |
+| 필수 컴포넌트 목록 밖의 이 템플릿 전용 컴포넌트를 모듈 레이아웃에서 사용 | 필수 컴포넌트(config/template.php) 만 사용 | 다른 admin 템플릿으로 교체 시 그 화면만 깨진다 |
+| 사이드바 접힘 상태를 레이아웃 `init_actions` 로 매번 복원 | 템플릿 부트스트랩(`src/index.ts`)에서 1회 복원 | `init_actions` 는 화면 진입마다 재실행되어 불필요한 반복 처리가 된다 |
+| `_admin_base` 를 상속하는데 로그인 화면처럼 `initTheme`/메뉴 초기화를 다시 호출 | `_admin_base` 상속 화면은 이미 초기화된 전역 상태를 그대로 사용 | 중복 호출은 낭비이며, 두 초기화 지점의 결과가 어긋나면 화면 간 상태 불일치가 생긴다 |
+
+
+## 7. 테스트 실행
+
+
+| 종류 | 개수 | 위치 |
+|---|---|---|
+| PHPUnit | 0개 | — |
+| Vitest | 203개 | `vitest.config.ts` |
+| Playwright | 8개 | `tests/Playwright` |
+| 시나리오 매니페스트 | 2개 | `tests/scenarios` |
+
+```bash
+# Vitest (확장 디렉토리에서) (PowerShell)
+cd templates/_bundled/sirsoft-admin_basic && powershell -Command "npm run test:run -- <대상>"
+
+# Playwright E2E (Bash)
+npx playwright test templates/_bundled/sirsoft-admin_basic/tests/Playwright/specs/<대상>.spec.ts
+
+```
+
+무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
+
+
+## 8. 문서 목차
+
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/components.md](docs/components.md) | 템플릿이 제공하는 컴포넌트 | ✅ |
+| [docs/layouts.md](docs/layouts.md) | 레이아웃 목록과 라우트 매핑 | ✅ |
+| [docs/handlers.md](docs/handlers.md) | 템플릿 전용 핸들러와 부트스트랩 | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
diff --git a/templates/_bundled/sirsoft-admin_basic/README.md b/templates/_bundled/sirsoft-admin_basic/README.md
new file mode 100644
index 00000000..669ff4fb
--- /dev/null
+++ b/templates/_bundled/sirsoft-admin_basic/README.md
@@ -0,0 +1,180 @@
+# Admin Basic
+
+**G7 템플릿 · sirsoft-admin_basic**
+그누보드7 기본 관리자 템플릿
+
+
+
+
+
+
+
+
+
+
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [제공 컴포넌트](#제공-컴포넌트) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+G7 이 기본 제공하는 관리자(admin) 템플릿입니다. 코어 관리자 화면뿐 아니라 설치된 모든
+모듈/플러그인의 관리자 화면이 이 템플릿의 컴포넌트와 베이스 레이아웃을 그대로 사용합니다 —
+확장을 설치하면 그 확장의 관리자 UI 도 자동으로 이 템플릿의 디자인(사이드바·헤더·색상·다크
+모드)을 따릅니다.
+
+이 템플릿은 사용자(방문자)용 화면을 그리지 않습니다 — 방문자 화면은 `sirsoft-basic` 템플릿의
+몫입니다. 또한 완전히 다른 디자인의 관리자 화면이 필요하면 이 템플릿을 고치는 대신 같은
+컴포넌트 계약(필수 컴포넌트 35개)을 구현하는 새 admin 템플릿을 만드는 것이 원칙입니다 — 그래야
+기존 확장들의 관리자 화면이 새 템플릿에서도 깨지지 않습니다.
+
+
+## 주요 기능
+
+
+| 영역 | 설명 |
+|---|---|
+| 관리자 셸 | 사이드바(계층형 메뉴, 접힘 지원)·헤더·다크 모드·다국어 전환을 갖춘 공통 화면 골격(`_admin_base`) |
+| 컴포넌트 125개 | HTML 래핑 39개(basic) + UI 패턴 캡슐화 80개(composite) + 페이지 구조 5개(layout) + 모달 1개 |
+| 필수 컴포넌트 계약 | 35개 컴포넌트만 사용하면 다른 admin 템플릿으로 교체해도 화면이 보장되는 모듈 호환성 기준 |
+| 레이아웃 145개 | 대시보드·사용자·역할·메뉴·설정·확장 관리·스케줄·활동/알림 로그 등 코어 관리자 화면 전체 |
+| 확장 관리 UI | 모듈/플러그인/템플릿 설치·활성화·업데이트·삭제와 레이아웃 편집기(코드 편집 + 실시간 미리보기) |
+| 본인인증(IDV) 챌린지 | 관리자 민감 작업에 걸리는 본인인증 화면 |
+
+
+## 동작 방식
+
+
+```mermaid
+flowchart TD
+ base["_admin_base (사이드바·헤더·Toast·콘텐츠 슬롯)"] --> list["목록 화면 (PageHeader+DataGrid+Pagination)"]
+ base --> detail["상세 화면 (PageHeader+Card)"]
+ base --> form["폼 화면 (Form+FormField)"]
+ base --> settings["설정 화면 (TabNavigation+_tab_*)"]
+ auth["인증 화면 (admin_login 등, _admin_base 미상속)"]
+ errors["에러 화면 (errors/*, 독립 레이아웃)"]
+```
+
+인증 화면(로그인/비밀번호 찾기·재설정)과 에러 화면은 의도적으로 `_admin_base` 를 상속하지
+않습니다 — 로그인 전이거나 정상 화면 렌더링 자체가 불가능한 상황이라 사이드바·헤더 같은
+"이미 로그인된 관리자" 전제의 UI 를 보여줄 수 없기 때문입니다.
+
+
+## 요구 사항
+
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=7.0.10` |
+| PHP | `^8.2` |
+
+
+## 설치
+
+
+```bash
+# 번들 설치 (코어에 동봉된 소스에서 설치)
+php artisan template:install sirsoft-admin_basic
+
+# 활성화
+php artisan template:activate sirsoft-admin_basic
+
+# 업데이트 (번들 소스 기준 강제 반영)
+php artisan template:update sirsoft-admin_basic --force
+```
+
+저장소: https://github.com/gnuboard/g7-template-sirsoft-admin_basic
+
+
+## 제공 컴포넌트
+
+
+컴포넌트 125개 (루트: `src/components`).
+
+| 분류 | 개수 |
+|---|---|
+| `basic` | 39개 |
+| `composite` | 80개 |
+| `layout` | 5개 |
+| `modals` | 1개 |
+
+
+
+운영자가 직접 켜고 끄는 설정 항목은 없습니다 — 이 표는 확장(모듈/플러그인) 개발자가 레이아웃을
+만들 때 참고하는 컴포넌트 인벤토리입니다. 전체 목록·Props 상세는
+[docs/components.md](docs/components.md) 와 코어
+[component-props.md](../../../docs/frontend/component-props.md) 를 참고하세요.
+
+
+## 사용 방법
+
+
+**새 확장의 관리자 화면 만들기**: 확장의 `resources/layouts/admin/*.json` 에서
+`"extends": "_admin_base"` 로 베이스를 상속하고, `slots.content` 에 필수 컴포넌트만으로 화면을
+구성합니다. 필수 컴포넌트 목록 밖의 컴포넌트를 쓰면 다른 admin 템플릿으로 교체됐을 때 그
+화면만 깨집니다.
+
+**사이드바 메뉴 등록**: 확장이 `getAdminMenus()` 로 메뉴를 선언하면 이 템플릿의 `AdminSidebar`
+가 자동으로 계층에 반영합니다 — 이 템플릿을 직접 수정할 필요가 없습니다.
+
+**레이아웃 실시간 편집**: `/admin/templates/sirsoft-admin_basic/edit` 에서 레이아웃 편집기로
+관리자 화면 자체를 코드 편집 + 실시간 미리보기로 수정할 수 있습니다(운영 환경에서는 신중하게
+사용).
+
+
+## 다른 확장과의 연동
+
+
+**이 확장이 의존하는 확장**
+
+없음 — 코어만으로 동작합니다.
+
+**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
+
+없음.
+
+
+
+formal 의존성이 양쪽 다 "없음"인 것은 이 템플릿이 코어만으로 동작하기 때문이지만, 실질적으로는
+**모든 번들 모듈/플러그인의 관리자 화면**이 이 템플릿의 필수 컴포넌트·`_admin_base` 계약에
+암묵적으로 의존합니다. 이 의존은 manifest 로 선언되지 않습니다 — 어느 admin 템플릿이든 같은
+계약(필수 컴포넌트 35개)만 구현하면 되므로, 확장이 "이 템플릿"이 아니라 "이 계약"에 의존하는
+형태이기 때문입니다. 이 템플릿의 필수 컴포넌트 Props 를 바꿀 때 영향 범위를 이 템플릿
+자신의 `dependencies` 목록으로는 알 수 없다는 뜻이며, 실제로는 활성 모듈/플러그인 전수의
+관리자 레이아웃을 확인해야 합니다.
+
+
+## 문서
+
+
+| 문서 | 내용 | 상태 |
+|---|---|---|
+| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
+| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
+| [docs/components.md](docs/components.md) | 템플릿이 제공하는 컴포넌트 | ✅ |
+| [docs/layouts.md](docs/layouts.md) | 레이아웃 목록과 라우트 매핑 | ✅ |
+| [docs/handlers.md](docs/handlers.md) | 템플릿 전용 핸들러와 부트스트랩 | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
+
+## 트러블슈팅
+
+
+| 증상 | 원인 | 조치 |
+|---|---|---|
+| 사이드바 메뉴 아이콘이 안 보임 | `Icon` 컴포넌트로 렌더 시도(API 는 FontAwesome 클래스 문자열을 내려줌) | `I` 컴포넌트로 교체 (`docs/components.md` "AdminSidebar 상세" 참고) |
+| 다른 admin 템플릿으로 교체 후 특정 확장 화면이 깨짐 | 그 확장이 필수 컴포넌트 목록 밖의 컴포넌트를 사용 | 그 확장의 레이아웃을 필수 컴포넌트(35개)만으로 재작성하거나, 새 템플릿에 같은 컴포넌트를 구현 |
+| 사이드바 접힘 상태가 새로고침 후 풀림 | localStorage 접근 실패(시크릿 모드 등) 또는 부트스트랩 순서 문제 | `initSidebar()` 가 템플릿 부트스트랩에서 호출되는지 확인 (`src/index.ts`) |
+| 로그인 화면에 다크 모드/언어 전환이 적용 안 됨 | 로그인 화면은 `_admin_base` 를 상속하지 않아 초기화 경로가 다름 | `admin_login.json` 자체의 초기화 액션을 확인 — `_admin_base` 수정으로는 반영되지 않는다 |
+
+
+## 변경 이력
+
+[CHANGELOG.md](CHANGELOG.md)
+
+## 라이선스
+
+MIT
diff --git a/templates/_bundled/sirsoft-admin_basic/docs/README.md b/templates/_bundled/sirsoft-admin_basic/docs/README.md
new file mode 100644
index 00000000..91fef5af
--- /dev/null
+++ b/templates/_bundled/sirsoft-admin_basic/docs/README.md
@@ -0,0 +1,20 @@
+# Admin Basic 개발자 문서
+
+> templates/_bundled/sirsoft-admin_basic · 템플릿
+
+
+**훅 수**: 0 · **구독 훅 수**: 0 · **라우트 수**: 29 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 145 · **핸들러 수**: 17
+
+
+## 문서 목차
+
+
+| 문서 | 내용 |
+|---|---|
+| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
+| [components.md](components.md) | 템플릿이 제공하는 컴포넌트 |
+| [layouts.md](layouts.md) | 레이아웃 목록과 라우트 매핑 |
+| [handlers.md](handlers.md) | 템플릿 전용 핸들러와 부트스트랩 |
+| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
+| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
+
diff --git a/templates/_bundled/sirsoft-admin_basic/docs/architecture.md b/templates/_bundled/sirsoft-admin_basic/docs/architecture.md
new file mode 100644
index 00000000..73f9841d
--- /dev/null
+++ b/templates/_bundled/sirsoft-admin_basic/docs/architecture.md
@@ -0,0 +1,60 @@
+# Admin Basic — 아키텍처
+
+> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 설계 의도
+
+
+이 템플릿의 설계는 "관리자 화면 디자인"과 "관리자 화면 구성"을 분리하는 데 집중합니다.
+컴포넌트(디자인 구현)는 이 템플릿이 소유하지만, 어떤 화면을 어떻게 구성할지(레이아웃 JSON)는
+코어와 모든 번들 확장이 **각자 소유**합니다 — 이 템플릿은 그 구성을 그릴 부품만 제공합니다.
+그래서 이 템플릿의 실질 소스는 컴포넌트(`src/components/`)와 코어 화면 레이아웃뿐이고, 확장
+관리자 화면(모듈/플러그인이 소유)은 이 템플릿 디렉토리 밖(`modules/`, `plugins/`)에 있습니다.
+
+필수 컴포넌트 계약(§AGENTS.md)이 이 분리를 지탱합니다 — 계약이 없으면 확장 개발자가 이
+템플릿에만 있는 컴포넌트를 무심코 써버려 "관리자 화면 디자인 교체"가 사실상 불가능해집니다.
+
+
+## 계층 지도
+
+
+```
+routes.json (URL → 레이아웃 이름 매핑)
+ │
+ ▼
+layouts/*.json (extends: _admin_base)
+ │
+ ▼
+_admin_base.json (사이드바·헤더·Toast·PageTransitionIndicator·콘텐츠 슬롯·푸터)
+ │
+ ▼
+src/components/{basic,composite,layout}/*.tsx (필수 컴포넌트 35개 포함 125개)
+ │
+ ▼
+src/handlers/*.ts (이 템플릿 전용 핸들러 17개 — 범용 핸들러는 코어 ActionDispatcher)
+```
+
+이 트리는 코어 화면에만 적용되는 것이 아닙니다 — 모듈/플러그인의 관리자 레이아웃도 같은
+`_admin_base` → 컴포넌트 경로를 그대로 타므로, 이 템플릿을 고치면 그 확장들의 화면도 같은
+방향으로 바뀝니다(§AGENTS.md "수정 시 동반 의무" 참고).
+
+
+## 디렉토리
+
+
+| 경로 | 역할 | 수정 시 필요한 절차 |
+|---|---|---|
+| `template.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json 동기화 |
+| `routes.json` | 라우트 → 레이아웃 매핑 | `php artisan template:update sirsoft-admin_basic --force` |
+| `layouts/` | 레이아웃 JSON | `php artisan template:update sirsoft-admin_basic --force` (빌드 불필요) |
+| `src/components/` | React 컴포넌트 | `php artisan template:build` → `php artisan template:update sirsoft-admin_basic --force` |
+| `src/handlers/` | 템플릿 전용 액션 핸들러 | `php artisan template:build` → `php artisan template:update sirsoft-admin_basic --force` |
+| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
+| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan template:update sirsoft-admin_basic --force` |
+| `editor-spec/` | 분할 편집기 스펙 | `php artisan template:update sirsoft-admin_basic --force` |
+| `tests/` | 테스트 | 변경 범위만 필터 실행 |
+| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
+| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan template:update sirsoft-admin_basic --force` |
+| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
+| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
+
diff --git a/docs/frontend/templates/sirsoft-admin_basic/components.md b/templates/_bundled/sirsoft-admin_basic/docs/components.md
similarity index 66%
rename from docs/frontend/templates/sirsoft-admin_basic/components.md
rename to templates/_bundled/sirsoft-admin_basic/docs/components.md
index a1478fff..51e75884 100644
--- a/docs/frontend/templates/sirsoft-admin_basic/components.md
+++ b/templates/_bundled/sirsoft-admin_basic/docs/components.md
@@ -1,35 +1,122 @@
-# sirsoft-admin_basic 컴포넌트
+# Admin Basic — 컴포넌트
-> **템플릿 식별자**: `sirsoft-admin_basic` (type: admin, v0.2.14)
-> **관련 문서**: [핸들러](./handlers.md) | [레이아웃](./layouts.md) | [컴포넌트 Props 레퍼런스](../../component-props.md)
+> 템플릿이 제공하는 컴포넌트 · 진입점: [AGENTS.md](../AGENTS.md)
----
+## 제공 컴포넌트
-## TL;DR (5초 요약)
+
+컴포넌트 125개 (루트: `src/components`).
+
+| 분류 | 개수 |
+|---|---|
+| `basic` | 39개 |
+| `composite` | 80개 |
+| `layout` | 5개 |
+| `modals` | 1개 |
+
+
+
+세 분류(basic/composite/layout)는 "얼마나 많이 조합됐는가"로 나뉩니다 — basic 은 HTML 태그를
+그대로 래핑(`Div`→``)한 최소 단위, composite 는 basic 을 여러 개 조합해 UI 패턴을 캡슐화한
+것(`DataGrid`가 `Table`+`Pagination`+정렬 로직을 감싸는 식), layout 은 페이지 구조 자체(Grid/
+Flex/Container)를 정의합니다. 레이아웃 JSON 저작자는 이 구분을 몰라도 되지만("HTML 태그
+직접 사용 금지" 규정만 지키면 됨), 컴포넌트를 새로 추가하는 쪽은 이 구분에 맞는 디렉토리
+(`src/components/{basic,composite,layout}/`)에 넣어야 `components.json` 카탈로그와
+`editor-spec.json` 팔레트 분류가 어긋나지 않습니다.
+
+위 개수는 코드에서 실측되므로 시간이 지나면 달라집니다 — 이 문서에 구체적인 개수를 하드코딩해
+적지 않습니다(과거 버전 문서가 37/66/8 로 적었다가 실제로는 39/80/5+modals 1 이 되어 있었던
+사례가 있습니다). 정확한 전체 목록·Props 는 [component-props.md](../../../../docs/frontend/component-props.md)
+· [component-props-composite.md](../../../../docs/frontend/component-props-composite.md) 를
+따라갑니다 — 이 문서는 "이 템플릿에서만" 유효한 계약(필수 컴포넌트·AdminSidebar·SlotContainer)
+만 다룹니다.
+
+
+## 필수 컴포넌트 (모듈 호환성)
+
+모듈 개발자가 아래 컴포넌트만 사용하면 **다른 Admin 템플릿으로 교체해도 화면이 깨지지
+않습니다**. 목록의 SSoT 는 `config/template.php` 의 `required_admin_components` 이며, 아래
+표는 그 값을 그대로 옮긴 것입니다(코드가 SSoT — 표와 설정이 어긋나면 설정이 맞습니다).
+
+| 분류 | 컴포넌트 |
+|---|---|
+| Basic (27개) | `A`, `Button`, `Checkbox`, `Div`, `Form`, `H1`, `H2`, `H3`, `Icon`, `Img`, `Input`, `Label`, `Li`, `Nav`, `P`, `Section`, `Select`, `Span`, `Svg`, `Table`, `Tbody`, `Td`, `Textarea`, `Th`, `Thead`, `Tr`, `Ul` |
+| Composite (8개) | `Alert`, `Badge`, `Card`, `DataTable`, `FormField`, `Modal`, `PageHeader`, `Pagination` |
```text
-1. Basic 37개: HTML 래핑 (Div, Button, Input, Select, Form, A, H1~H4, Table 등)
-2. Composite 66개: UI 패턴 캡슐화 (DataGrid, Modal, PageHeader, Card, FileUploader 등)
-3. Layout 8개: 페이지 구조 (Container, Grid, Flex, SectionLayout, ThreeColumnLayout 등)
-4. 모듈은 필수 컴포넌트(27 basic + 15 composite)만 사용 → 모든 Admin 템플릿 호환 보장
-5. components.json에 등록 필수, Props는 이 문서 + component-props.md 참조
+✅ 필수 컴포넌트 목록에 있는 것만 사용 (다른 Admin 템플릿 호환 보장)
+✅ 템플릿의 베이스 레이아웃(`_admin_base`)을 extends
+❌ 이 템플릿에만 있는 커스텀 컴포넌트 사용 금지
```
----
+기본은 `validate_on_install: false` — 설치 시 필수 컴포넌트 검증은 기본적으로 수행되지 않고
+경고에 그칩니다(`block_on_failure: false`). 검증을 강제하려면 두 설정을 함께 켭니다.
-## 목차
+## AdminSidebar 상세
-1. [컴포넌트 개요](#컴포넌트-개요)
-2. [Basic Components (37개)](#basic-components-37개)
-3. [Composite Components (66개)](#composite-components-66개)
-4. [Layout Components (8개)](#layout-components-8개)
-5. [필수 컴포넌트 (모듈 호환성)](#필수-컴포넌트-모듈-호환성)
-6. [모듈 개발자 가이드](#모듈-개발자-가이드)
-7. [템플릿 개발자 가이드](#템플릿-개발자-가이드)
+계층형 관리자 메뉴를 그리는 컴포넌트로, 데이터 소스로 받은 메뉴 트리를 그대로 넘기면 됩니다.
----
+```typescript
+interface MenuItem {
+ id: string | number;
+ name: string | { ko: string; en: string };
+ slug: string;
+ url?: string | null;
+ icon?: string; // FontAwesome 클래스 (예: "fas fa-home")
+ children?: MenuItem[];
+ is_active?: boolean;
+ module_id?: number | null; // 모듈이 등록한 메뉴인지 식별
+}
-## 컴포넌트 개요
+interface AdminSidebarProps {
+ logo?: string;
+ logoAlt?: string;
+ menu: MenuItem[]; // 필수
+ collapsed?: boolean;
+ onToggleCollapse?: () => void;
+ className?: string;
+ currentLocale?: string; // 미지정 시 G7Core.locale.current() 자동 사용
+ id?: string; // 레이아웃 편집기 코어 일괄 ID
+}
+```
+
+아이콘은 `Icon` 컴포넌트(IconName enum)가 아니라 `I` 컴포넌트 + FontAwesome 클래스 문자열로
+렌더링됩니다 — 메뉴 API 가 `icon` 필드를 이미 `"fas fa-home"` 형태 클래스 문자열로 내려주기
+때문입니다. 새 메뉴 아이콘을 추가할 때 `Icon name="home"` 식으로 쓰면 렌더되지 않습니다.
+
+## SlotContainer 상세
+
+동적 슬롯 렌더링 컨테이너입니다 — 다른 컴포넌트가 `slot` prop 으로 자신이 속할 슬롯 ID 를
+표현식으로 지정하면(예: `"{{_local.isVisible ? 'basic_filters' : 'detail_filters'}}"`), 그
+표현식이 상태 변화에 따라 재평가되어 컴포넌트가 슬롯 사이를 동적으로 이동합니다.
+
+```typescript
+interface SlotContainerProps {
+ slotId: string; // 필수 — 렌더링할 슬롯 ID
+ className?: string;
+}
+```
+
+```json
+{ "id": "category_filter", "type": "basic", "name": "Div", "slot": "{{_local.isVisible ? 'basic_filters' : 'detail_filters'}}", "slotOrder": 1, "children": [] }
+```
+
+```json
+{ "id": "basic_filters_container", "type": "composite", "name": "SlotContainer", "props": { "slotId": "basic_filters" } }
+```
+
+## 이관 원문 상세
+
+> 아래는 코어 `docs/frontend/templates/sirsoft-admin_basic/components.md` 에 있던 원문을
+> 이 문서로 옮긴 것입니다(#601). 이관 시점 그대로 보존하되, **코드가 SSoT 인 값과 어긋나는
+> 부분에는 정정 주석**을 달았습니다 — 실측 총계는 위 「제공 컴포넌트」 블록이, 필수 컴포넌트
+> 목록은 `config/template.php` 의 `required_admin_components` 가 SSoT 입니다.
+
+### 컴포넌트 개요
+
+> **정정(#601)**: 아래 개수는 이관 시점(2026-03) 문서 값입니다. 코드 실측은 basic 39 · composite 80 ·
+> layout 5 · modals 1 = **125종**이며 위 「제공 컴포넌트」 블록이 SSoT 입니다. 아래 목록에 없는
+> 컴포넌트가 있을 수 있습니다.
| 타입 | 개수 | 설명 |
|------|------|------|
@@ -43,11 +130,13 @@
---
-## Basic Components (37개)
+### Basic Components (37개)
+
+> **정정(#601)**: 제목의 개수는 이관 시점 문서 값입니다(코드 실측 39종). 목록 자체는 원문 그대로입니다.
HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic 컴포넌트는 `className`, `children` props를 공통으로 지원합니다.
-### 텍스트/링크
+#### 텍스트/링크
| 컴포넌트 | 설명 | 주요 Props | 바인딩 |
|----------|------|-----------|--------|
@@ -62,7 +151,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
| `Pre` | 서식 유지 텍스트 래퍼 | - | - |
| `Code` | 인라인 코드 래퍼 | - | - |
-### 컨테이너
+#### 컨테이너
| 컴포넌트 | 설명 | 주요 Props | 바인딩 |
|----------|------|-----------|--------|
@@ -72,7 +161,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
| `Form` | 폼 컨테이너 (자동 바인딩 지원) | - | - |
| `Fragment` | React.Fragment — iterator에서 DOM 래퍼 없이 사용 | - | - |
-### 폼 입력
+#### 폼 입력
| 컴포넌트 | 설명 | 주요 Props | 바인딩 |
|----------|------|-----------|--------|
@@ -85,7 +174,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
| `FileInput` | 파일 입력 (검증 포함) | accept, maxSize, onChange, onError, buttonText, placeholder, disabled | - |
| `Button` | 버튼 | variant (`primary`\|`secondary`\|`danger`\|`success`), size (`sm`\|`md`\|`lg`) | - |
-### 테이블
+#### 테이블
| 컴포넌트 | 설명 |
|----------|------|
@@ -97,7 +186,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
| `Th` | 테이블 헤더 셀 |
| `Td` | 테이블 데이터 셀 |
-### 리스트
+#### 리스트
| 컴포넌트 | 설명 |
|----------|------|
@@ -105,7 +194,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
| `Ol` | 순서 있는 리스트 |
| `Li` | 리스트 아이템 |
-### 미디어/아이콘
+#### 미디어/아이콘
| 컴포넌트 | 설명 | 주요 Props |
|----------|------|-----------|
@@ -116,11 +205,13 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
---
-## Composite Components (66개)
+### Composite Components (66개)
+
+> **정정(#601)**: 제목의 개수는 이관 시점 문서 값입니다(코드 실측 80종). 목록 자체는 원문 그대로입니다.
기본 컴포넌트를 조합하여 UI 패턴을 캡슐화한 복합 컴포넌트입니다.
-### 데이터 표시
+#### 데이터 표시
| 컴포넌트 | 설명 | 주요 Props |
|----------|------|-----------|
@@ -133,7 +224,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
| `EmptyState` | 데이터 없음 상태 표시 | title, description, iconName, illustrationSrc, ... |
| `HtmlContent` | HTML 안전 렌더링 (DOMPurify XSS 방지) | content, isHtml, purifyConfig, text |
-### 폼/입력
+#### 폼/입력
| 컴포넌트 | 설명 | 주요 Props |
|----------|------|-----------|
@@ -152,14 +243,14 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
| `FileUploader` | 파일 업로드 (드래그앤드롭, 이미지 압축) | attachmentableType, attachmentableId, collection, maxFiles, maxSize, ... |
| `IconSelect` | 아이콘 선택 드롭다운 | value, onChange, options, placeholder, searchPlaceholder, ... |
-### 에디터
+#### 에디터
| 컴포넌트 | 설명 | 주요 Props |
|----------|------|-----------|
| `HtmlEditor` | HTML/텍스트 편집기 (편집/미리보기 토글) | content, onChange, isHtml, onHtmlModeChange, previewMode, ... |
| `CodeEditor` | JSON 코드 편집기 (Monaco Editor) | value, onChange, language, height, readOnly, ... |
-### 네비게이션
+#### 네비게이션
| 컴포넌트 | 설명 | 주요 Props |
|----------|------|-----------|
@@ -169,7 +260,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
| `ActionMenu` | 드롭다운 액션 메뉴 | items, triggerLabel, triggerIconName, position, style |
| `Dropdown` | 드롭다운 메뉴 | label, items, onItemClick, position, style |
-### 모달/다이얼로그
+#### 모달/다이얼로그
| 컴포넌트 | 설명 | 주요 Props |
|----------|------|-----------|
@@ -180,7 +271,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
| `Toast` | 토스트 알림 | toasts, position, onRemove |
| `Alert` | 알림 메시지 | type, message, dismissible, onDismiss |
-### 관리자 UI
+#### 관리자 UI
| 컴포넌트 | 설명 | 주요 Props |
|----------|------|-----------|
@@ -195,7 +286,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
| `LanguageSelector` | 언어 선택 드롭다운 | availableLocales, languageText, apiEndpoint, onLanguageChange, inline |
| `PageTransitionIndicator` | 페이지 전환 로딩 표시 | style |
-### 레이아웃 편집
+#### 레이아웃 편집
| 컴포넌트 | 설명 | 주요 Props |
|----------|------|-----------|
@@ -205,7 +296,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
| `LayoutWarnings` | 레이아웃 경고 표시 | warnings |
| `VersionList` | 버전 목록 아이템 | versions, selectedId, onSelect |
-### 확장 관리
+#### 확장 관리
| 컴포넌트 | 설명 | 주요 Props |
|----------|------|-----------|
@@ -213,7 +304,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
| `ExtensionBadge` | 확장 섹션 뱃지 (identifier로 이름 자동 조회) | type, identifier, name, installedModules, installedPlugins, ... |
| `ProductCard` | 상품 카드 (이미지, 제목, 가격, 액션) | imageUrl, imageAlt, title, subtitle, description, ... |
-### 고급 기능
+#### 고급 기능
| 컴포넌트 | 설명 | 주요 Props |
|----------|------|-----------|
@@ -233,7 +324,9 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
---
-## Layout Components (8개)
+### Layout Components (8개)
+
+> **정정(#601)**: 제목의 개수는 이관 시점 문서 값입니다(코드 실측 5종). 목록 자체는 원문 그대로입니다.
페이지 구조를 정의하는 레이아웃 컴포넌트입니다.
@@ -250,11 +343,11 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
---
-## 필수 컴포넌트 (모듈 호환성)
+### 필수 컴포넌트 (모듈 호환성)
모듈 개발자가 이 컴포넌트들만 사용하면 **모든 Admin 템플릿에서 동작이 보장**됩니다.
-### 필수 Basic (27개)
+#### 필수 Basic (27개)
| 카테고리 | 컴포넌트 |
|----------|----------|
@@ -265,7 +358,12 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
| 리스트 | `Ul`, `Li` |
| 미디어 | `Icon`, `Img`, `Svg` |
-### 필수 Composite (15개)
+#### 필수 Composite (15개)
+
+> **정정(#601)**: 아래 표는 `config/template.php` 의 `required_admin_components` 와 일치하지 않습니다 —
+> 실제 필수 Composite 는 위 「필수 컴포넌트 (모듈 호환성)」 절의 8종(`Alert` `Badge` `Card`
+> `DataTable` `FormField` `Modal` `PageHeader` `Pagination`)이며 설정 파일이 SSoT 입니다.
+> 아래 목록은 이관 시점 문서를 보존한 것이므로 필수 여부의 근거로 쓰지 않습니다.
| 카테고리 | 컴포넌트 | 설명 |
|----------|----------|------|
@@ -285,7 +383,10 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
| | `AdminSidebar` | 관리자 사이드바 |
| | `SlotContainer` | 동적 슬롯 렌더링 |
-### 설정 파일
+#### 설정 파일
+
+> **정정(#601)**: 아래 PHP 스니펫은 이관 시점 값입니다. 현재 `config/template.php` 의 실제 배열은 위
+> 「필수 컴포넌트 (모듈 호환성)」 절의 표(Basic 27 + Composite 8)와 일치합니다.
필수 컴포넌트 목록은 `config/template.php`에 정의:
@@ -300,9 +401,9 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
---
-## 모듈 개발자 가이드
+### 모듈 개발자 가이드
-### 핵심 원칙
+#### 핵심 원칙
```text
✅ 필수 컴포넌트 목록에 있는 컴포넌트만 사용
@@ -310,7 +411,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
❌ 특정 템플릿에만 존재하는 커스텀 컴포넌트 사용 금지
```
-### 레이아웃 작성 예시
+#### 레이아웃 작성 예시
```json
{
@@ -335,11 +436,11 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
---
-## 템플릿 개발자 가이드
+### 템플릿 개발자 가이드
Admin 타입 템플릿 개발 시 필수 컴포넌트를 반드시 구현해야 합니다.
-### 구현 체크리스트
+#### 구현 체크리스트
```text
□ DataGrid - 정렬, 필터링, 페이지네이션 지원
@@ -358,18 +459,18 @@ Admin 타입 템플릿 개발 시 필수 컴포넌트를 반드시 구현해야
□ AdminSidebar - 계층형 메뉴, 다국어 지원
```
-### Props 인터페이스 일관성
+#### Props 인터페이스 일관성
모든 Admin 템플릿은 동일한 Props 인터페이스를 구현해야 합니다. 상세 Props는 다음 문서를 참조:
-- [컴포넌트 Props 레퍼런스 (Basic)](../../component-props.md)
-- [컴포넌트 Props 레퍼런스 (Composite)](../../component-props-composite.md)
+- [컴포넌트 Props 레퍼런스 (Basic)](../../../../docs/frontend/component-props.md)
+- [컴포넌트 Props 레퍼런스 (Composite)](../../../../docs/frontend/component-props-composite.md)
---
-## AdminSidebar 상세
+### AdminSidebar 상세
-### MenuItem 인터페이스
+#### MenuItem 인터페이스
```typescript
interface MenuItem {
@@ -383,7 +484,7 @@ interface MenuItem {
}
```
-### AdminSidebarProps
+#### AdminSidebarProps
```typescript
interface AdminSidebarProps {
@@ -397,14 +498,14 @@ interface AdminSidebarProps {
}
```
-### 아이콘 처리
+#### 아이콘 처리
```text
✅ I 컴포넌트 + FontAwesome 클래스 (
)
❌ Icon 컴포넌트 + IconName enum (금지 — API가 FontAwesome 클래스 문자열 직접 제공)
```
-### 레이아웃 JSON 사용 예시
+#### 레이아웃 JSON 사용 예시
```json
{
@@ -433,9 +534,9 @@ interface AdminSidebarProps {
---
-## SlotContainer 상세
+### SlotContainer 상세
-### SlotContainerProps
+#### SlotContainerProps
```typescript
interface SlotContainerProps {
@@ -444,7 +545,7 @@ interface SlotContainerProps {
}
```
-### 슬롯 시스템 동작
+#### 슬롯 시스템 동작
```text
1. slot 속성 컴포넌트 → SlotContext에 등록
@@ -452,7 +553,7 @@ interface SlotContainerProps {
3. 상태 변화 시 slot 표현식 재평가로 동적 이동
```
-### 사용 예시
+#### 사용 예시
```json
{
@@ -476,11 +577,11 @@ interface SlotContainerProps {
---
-## 관련 문서
+### 관련 문서
-- [sirsoft-admin_basic 핸들러](./handlers.md)
-- [sirsoft-admin_basic 레이아웃](./layouts.md)
-- [sirsoft-basic 컴포넌트](../sirsoft-basic/components.md)
-- [컴포넌트 개발 규칙](../../components.md)
-- [컴포넌트 Props 레퍼런스](../../component-props.md)
-- [컴포넌트 Props 레퍼런스 - Composite](../../component-props-composite.md)
+- [sirsoft-admin_basic 핸들러](handlers.md)
+- [sirsoft-admin_basic 레이아웃](layouts.md)
+- [sirsoft-basic 컴포넌트](../../../../docs/frontend/templates/sirsoft-basic/components.md)
+- [컴포넌트 개발 규칙](../../../../docs/frontend/components.md)
+- [컴포넌트 Props 레퍼런스](../../../../docs/frontend/component-props.md)
+- [컴포넌트 Props 레퍼런스 - Composite](../../../../docs/frontend/component-props-composite.md)
diff --git a/templates/_bundled/sirsoft-admin_basic/docs/handlers.md b/templates/_bundled/sirsoft-admin_basic/docs/handlers.md
new file mode 100644
index 00000000..58780c4f
--- /dev/null
+++ b/templates/_bundled/sirsoft-admin_basic/docs/handlers.md
@@ -0,0 +1,584 @@
+# Admin Basic — 핸들러
+
+> 템플릿 전용 핸들러와 부트스트랩 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 템플릿 전용 핸들러
+
+
+핸들러 17개 (정의: `src/handlers/index.ts`).
+
+| 핸들러 | 레이아웃에서 부르는 이름 |
+|---|---|
+| `detectAssetUrlMode` | (템플릿 전용 — 네임스페이스 없음) |
+| `checkAssetUrlModeDrift` | (템플릿 전용 — 네임스페이스 없음) |
+| `setTheme` | (템플릿 전용 — 네임스페이스 없음) |
+| `initTheme` | (템플릿 전용 — 네임스페이스 없음) |
+| `scrollToSection` | (템플릿 전용 — 네임스페이스 없음) |
+| `initMenuFromUrl` | (템플릿 전용 — 네임스페이스 없음) |
+| `initFilterVisibility` | (템플릿 전용 — 네임스페이스 없음) |
+| `saveFilterVisibility` | (템플릿 전용 — 네임스페이스 없음) |
+| `toggleFilterVisibility` | (템플릿 전용 — 네임스페이스 없음) |
+| `resetFilterVisibility` | (템플릿 전용 — 네임스페이스 없음) |
+| `saveMultilingualTag` | (템플릿 전용 — 네임스페이스 없음) |
+| `cancelMultilingualTag` | (템플릿 전용 — 네임스페이스 없음) |
+| `updateMultilingualTagValue` | (템플릿 전용 — 네임스페이스 없음) |
+| `setDateRange` | (템플릿 전용 — 네임스페이스 없음) |
+| `toggleSidebar` | (템플릿 전용 — 네임스페이스 없음) |
+| `initSidebar` | (템플릿 전용 — 네임스페이스 없음) |
+| `downloadAttachment` | (템플릿 전용 — 네임스페이스 없음) |
+
+
+
+`setLocale` 이 이 목록에 없는 것이 정상입니다 — 로케일 전환은 엔진 레벨(ActionDispatcher)
+빌트인으로 승격되어 더 이상 이 템플릿이 등록하지 않습니다(`src/handlers/index.ts` 상단 주석
+참고). 과거 버전 문서가 `setLocale` 을 이 템플릿의 핸들러로 적었다면 그것은 이관 전 버전
+기준이므로 따르지 않습니다. 새 핸들러를 추가할 때는 `src/handlers/index.ts` 의 맵 객체 한
+곳에만 등록하면 자동으로 ActionDispatcher 에 반영됩니다 — 다른 곳에 흩어 등록하지 않습니다.
+
+이 핸들러들은 **이 템플릿에서만** 등록되므로 다른 템플릿에서는 미지원일 수 있습니다. 범용
+핸들러(`navigate`/`apiCall`/`setState` 등)는 [actions-handlers.md](../../../../docs/frontend/actions-handlers.md)
+를 참고하고, 여기서는 이 템플릿 전용 핸들러만 다룹니다.
+
+### setTheme / initTheme
+
+다크/라이트/자동(`auto`, 시스템 설정 따름) 테마를 전환·복원합니다. `setTheme` 은 localStorage
+저장 + `document.documentElement` 클래스 적용(Tailwind `dark:` variant 활성화)을,
+`initTheme` 은 params 없이 `init_actions` 에서 호출해 저장된 테마를 앱 시작 시 복원합니다.
+
+```json
+{ "type": "click", "handler": "setTheme", "params": { "theme": "{{_global.theme === 'dark' ? 'light' : 'dark'}}" } }
+```
+
+### scrollToSection
+
+`params.selector`(CSS 선택자, 필수) 로 지정한 요소로 부드럽게 스크롤합니다. `params.offset`
+(기본 `0`, 음수면 위로)은 고정 헤더 높이를 보상할 때 씁니다.
+
+```json
+{ "type": "click", "handler": "scrollToSection", "params": { "selector": "#features", "offset": -80 } }
+```
+
+### initMenuFromUrl
+
+현재 URL 경로를 사이드바 메뉴 항목과 매칭해 활성 메뉴(및 부모 메뉴의 펼침 상태)를 자동
+설정합니다. params 없이 `_admin_base.json` 의 `init_actions` 에서 호출합니다.
+
+### 필터 가시성 핸들러 4종
+
+목록 화면 필터 패널의 표시/숨김을 localStorage 에 저장해 새로고침 후에도 유지합니다.
+
+| 핸들러 | params | 설명 |
+|---|---|---|
+| `initFilterVisibility` | 없음 | localStorage → `_local` 복원 (`init_actions`에서 호출) |
+| `saveFilterVisibility` | `{ filters }` | `_local` → localStorage 저장 |
+| `toggleFilterVisibility` | `{ key }` | 특정 필터 키 가시성 토글 |
+| `resetFilterVisibility` | 없음 | 전체 초기화 |
+
+### 다국어 태그 핸들러 3종
+
+`MultilingualInput` 컴포넌트가 쓰는 태그 편집 핸들러입니다.
+
+| 핸들러 | params | 설명 |
+|---|---|---|
+| `saveMultilingualTag` | `{ field, locale }` | 태그 저장 |
+| `cancelMultilingualTag` | 없음 | 편집 취소 |
+| `updateMultilingualTagValue` | `{ field, locale, value }` | 값 업데이트 |
+
+### setDateRange
+
+날짜 필터 프리셋 버튼(`today`/`week`/`month`/`3months`/`6months`/`1year`) 클릭 시 시작·종료일을
+`YYYY-MM-DDTHH:mm:ss` 형식으로 계산해 **반환**합니다(자체적으로 상태를 갱신하지 않음) — 레이아웃
+JSON 이 `sequence` + `setState` 로 반환값(`$prev.startDate` 등)을 원하는 필드에 반영합니다.
+
+```json
+{ "type": "click", "handler": "sequence", "actions": [
+ { "handler": "setDateRange", "params": { "preset": "today" } },
+ { "handler": "setState", "params": { "target": "local", "filter.dateFrom": "{{$prev.startDate}}", "filter.dateTo": "{{$prev.endDate}}" } }
+] }
+```
+
+### 사이드바 접힘 핸들러 2종
+
+데스크톱 좌측 사이드바 접힘 상태를 `localStorage`(`g7_admin_sidebar_collapsed`) 와
+`_global.sidebarCollapsed` 에 반영합니다. 모바일 슬라이드 사이드바 상태(`_global.sidebarOpen`)와는
+**독립된 상태**이므로 데스크톱 접힘과 모바일 열림/닫힘을 같은 상태로 착각하지 않습니다.
+
+| 핸들러 | params | 설명 |
+|---|---|---|
+| `initSidebar` | 없음 | 저장된 접힘 상태 복원 (`init_actions`에서 호출) |
+| `toggleSidebar` | 없음 | 접힘 상태 반전 + 저장 |
+
+### downloadAttachment
+
+관리자 화면에서 첨부파일을 `
` 직접 링크가 아니라 `G7Core.api.get(url, {responseType:
+'blob'})` 로 요청한 뒤 objectURL 로 변환해 다운로드합니다. `` 네비게이션은 Authorization
+헤더가 실리지 않아 요청이 guest 로 통과하고 활동이력의 행위자(`user_id`)가 비게 되므로, 코어
+ApiClient 경유로 토큰을 자동 첨부해야 다운로드 행위가 관리자 본인 이력으로 정확히 남습니다.
+
+```json
+{ "type": "click", "handler": "downloadAttachment", "params": { "url": "{{attachment.download_url}}", "filename": "{{attachment.original_name}}" } }
+```
+
+### 자산 URL 방식 재감지 2종
+
+관리자 환경설정 화면에서 정적 자산 URL 방식(확장자 있음/없음)을 **브라우저에서** 재감지합니다.
+서버가 자기 자신에게 curl 하면 nginx vhost·프록시 체인을 우회해 오판할 수 있어, 실제 방문자
+경로를 재현하려면 브라우저가 프로브를 던져야 합니다.
+
+| 핸들러 | 호출 시점 | 동작 |
+|---|---|---|
+| `detectAssetUrlMode` | 관리자가 "재감지" 버튼 클릭 | 프로브 쌍(확장자 있음/없음)을 던져 판정한 뒤 폼 상태(`general.asset_url_mode`)와 감지 상태 문구를 갱신 — **저장은 하지 않음**(관리자가 결과를 보고 직접 저장) |
+| `checkAssetUrlModeDrift` | 대시보드 진입 시 자동 | 저장된 설정값과 실제 감지 결과를 대조해 어긋나면 전역 상태(`assetUrlModeDrift`)에 실어 대시보드에 경고 노출 |
+
+봇은 JavaScript 를 실행하지 않으므로 클라이언트측 자가 복구가 있어도 저장값이 틀린 채로
+남으면 SEO 렌더링은 계속 깨집니다 — `checkAssetUrlModeDrift` 가 그 간극을 관리자에게 드러내는
+유일한 지점입니다. 판정 불가(`unavailable`)일 때는 아무 것도 표시하지 않습니다 — 일시적
+네트워크 장애를 결함 신호로 오인시키지 않기 위함입니다.
+
+
+## 부트스트랩
+
+
+| 항목 | 값 |
+|---|---|
+| 엔트리 파일 | `src/index.ts` |
+| 전역 객체 | **미노출** |
+| 재등록 진입점 | `initTemplate()` |
+
+재등록 진입점이 전역에 고정 이름으로 노출되지 않으면 로케일 전환 후 이 확장의 액션이 전부 무반응이 됩니다 (오류·토스트 없음).
+
+
+
+전역 객체가 "미노출"인 것은 실수가 아니라 템플릿과 모듈/플러그인의 재등록 규약 차이입니다 —
+모듈/플러그인은 여러 개가 동시에 활성화되므로 서로를 식별할 `window.__[Name]` 고정 이름이
+필요하지만, 템플릿은 사이트에 활성 템플릿이 항상 하나뿐이라 그런 식별 필요가 없습니다.
+`initTemplate()` 을 고칠 때는 핸들러 재등록
+외의 1회성 부팅 작업(예: 사이드바 초기 상태 복원)을 섞지 않습니다 — 로케일 전환마다 재실행되면
+안 되는 작업이기 때문입니다(사이드바 복원은 `initSidebar` 를 레이아웃 `init_actions` 에서
+별도로 호출하는 이유이기도 합니다).
+
+
+## 이관 원문 상세
+
+> 아래는 코어 `docs/frontend/templates/sirsoft-admin_basic/handlers.md` 에 있던 원문을
+> 이 문서로 옮긴 것입니다(#601). 핸들러별 params·동작·사용 예시가 여기에 있습니다.
+> 이관 시점 그대로 보존하되, 현재 코드와 어긋나는 부분에는 정정 주석을 달았습니다 —
+> 등록 핸들러의 SSoT 는 위 「템플릿 전용 핸들러」 블록입니다.
+
+### setLocale
+
+> **정정(#601)**: `setLocale` 은 더 이상 이 템플릿이 등록하는 핸들러가 아닙니다 — 엔진(ActionDispatcher)
+> 빌트인으로 승격되어 모든 템플릿에서 동작합니다. 아래 서술은 이관 시점 기록이며, 동작·파라미터는
+> 같지만 **소유 주체가 템플릿이 아니라 엔진**입니다.
+
+앱 언어를 변경합니다. 번역 파일을 다시 로드하고 UI를 갱신합니다.
+
+**소스**: `src/handlers/setLocaleHandler.ts`
+
+```json
+{
+ "type": "click",
+ "handler": "setLocale",
+ "params": {
+ "locale": "en"
+ }
+}
+```
+
+#### params
+
+| 필드 | 타입 | 필수 | 설명 |
+|------|------|------|------|
+| `locale` | string | ✅ | 변경할 로케일 코드 (예: `"ko"`, `"en"`, `"ja"`) |
+
+#### 동작
+
+```text
+1. 로케일 변경 → 번역 파일 다시 로드
+2. _global.locale 자동 업데이트
+3. 모든 $t: 표현식 재평가
+```
+
+#### 사용 예시
+
+```json
+{
+ "id": "lang_en_btn",
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "text": "English"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setLocale",
+ "params": {
+ "locale": "en"
+ }
+ }
+ ]
+}
+```
+
+---
+
+### setTheme / initTheme
+
+#### setTheme
+
+다크/라이트 모드를 전환합니다.
+
+**소스**: `src/handlers/setThemeHandler.ts`
+
+```json
+{
+ "type": "click",
+ "handler": "setTheme",
+ "params": {
+ "theme": "dark"
+ }
+}
+```
+
+#### setTheme params
+
+| 필드 | 타입 | 필수 | 설명 |
+|------|------|------|------|
+| `theme` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) |
+
+#### 동작
+
+```text
+1. localStorage에 테마 설정 저장
+2. document.documentElement에 class 적용 (dark/light)
+3. Tailwind dark: variant 활성화/비활성화
+```
+
+#### initTheme
+
+앱 시작 시 저장된 테마 설정을 적용합니다. 주로 `init_actions`에서 사용합니다.
+
+```json
+{
+ "init_actions": [
+ {
+ "handler": "initTheme"
+ }
+ ]
+}
+```
+
+params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원합니다.
+
+#### 사용 예시
+
+```json
+{
+ "id": "theme_toggle",
+ "type": "composite",
+ "name": "ThemeToggle",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setTheme",
+ "params": {
+ "theme": "{{_global.theme === 'dark' ? 'light' : 'dark'}}"
+ }
+ }
+ ]
+}
+```
+
+---
+
+### scrollToSection
+
+특정 섹션으로 스크롤합니다. 오프셋 지원에 특화되어 있습니다.
+
+**소스**: `src/handlers/scrollToSectionHandler.ts`
+
+```json
+{
+ "type": "click",
+ "handler": "scrollToSection",
+ "params": {
+ "selector": "#features",
+ "offset": -80
+ }
+}
+```
+
+#### params
+
+| 필드 | 타입 | 필수 | 기본값 | 설명 |
+|------|------|------|--------|------|
+| `selector` | string | ✅ | - | CSS 선택자 (예: `"#section-id"`, `".class-name"`) |
+| `offset` | number | ❌ | `0` | 스크롤 오프셋 (음수: 위로, 양수: 아래로). 고정 헤더 높이 보상에 사용 |
+
+#### 동작
+
+```text
+1. document.querySelector(selector)로 대상 요소 검색
+2. 요소의 위치 계산 + offset 적용
+3. window.scrollTo({ top, behavior: 'smooth' })로 부드러운 스크롤
+```
+
+#### 사용 예시
+
+```json
+{
+ "id": "nav_features",
+ "type": "basic",
+ "name": "A",
+ "props": {
+ "text": "$t:common.features",
+ "className": "cursor-pointer"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "scrollToSection",
+ "params": {
+ "selector": "#features",
+ "offset": -80
+ }
+ }
+ ]
+}
+```
+
+---
+
+### initMenuFromUrl
+
+현재 URL을 기반으로 사이드바/네비게이션 메뉴의 활성 상태를 초기화합니다. 주로 관리자 템플릿의 `init_actions`에서 사용합니다.
+
+**소스**: `src/handlers/initMenuFromUrlHandler.ts`
+
+```json
+{
+ "init_actions": [
+ {
+ "handler": "initMenuFromUrl"
+ }
+ ]
+}
+```
+
+#### params
+
+없음. 현재 URL 경로를 메뉴 항목과 매칭하여 활성 메뉴를 자동 설정합니다.
+
+#### 동작
+
+```text
+1. 현재 URL 경로 (window.location.pathname) 추출
+2. 사이드바 메뉴 데이터에서 URL 매칭
+3. 매칭된 메뉴 항목의 is_active 상태 설정
+4. 부모 메뉴도 자동으로 펼침 상태 설정
+```
+
+#### 사용 예시 (_admin_base.json)
+
+```json
+{
+ "init_actions": [
+ { "handler": "initTheme" },
+ { "handler": "initMenuFromUrl" }
+ ]
+}
+```
+
+---
+
+### 필터 가시성 핸들러
+
+목록 화면에서 필터 패널의 가시성(표시/숨김)을 관리합니다. localStorage에 상태를 저장하여 새로고침 후에도 유지합니다.
+
+**소스**: `src/handlers/filterVisibilityHandler.ts`
+
+#### initFilterVisibility
+
+저장된 필터 가시성 상태를 `_local`에 복원합니다.
+
+```json
+{
+ "init_actions": [
+ {
+ "handler": "initFilterVisibility"
+ }
+ ]
+}
+```
+
+#### saveFilterVisibility
+
+현재 필터 가시성 상태를 localStorage에 저장합니다.
+
+```json
+{
+ "handler": "saveFilterVisibility",
+ "params": {
+ "filters": "{{_local.filterVisibility}}"
+ }
+}
+```
+
+#### toggleFilterVisibility
+
+특정 필터 키의 가시성을 토글합니다.
+
+```json
+{
+ "type": "click",
+ "handler": "toggleFilterVisibility",
+ "params": {
+ "key": "advancedFilters"
+ }
+}
+```
+
+#### resetFilterVisibility
+
+모든 필터 가시성을 초기 상태로 리셋합니다.
+
+```json
+{
+ "type": "click",
+ "handler": "resetFilterVisibility"
+}
+```
+
+#### 핸들러 params 요약
+
+| 핸들러 | params | 설명 |
+|--------|--------|------|
+| `initFilterVisibility` | 없음 | localStorage → `_local` 복원 |
+| `saveFilterVisibility` | `{ filters }` | `_local` → localStorage 저장 |
+| `toggleFilterVisibility` | `{ key }` | 특정 키 토글 |
+| `resetFilterVisibility` | 없음 | 전체 초기화 |
+
+#### 사용 예시 (목록 페이지)
+
+```json
+{
+ "init_actions": [
+ { "handler": "initFilterVisibility" }
+ ],
+ "components": [
+ {
+ "id": "filter_toggle_btn",
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "text": "$t:common.toggle_filters"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "toggleFilterVisibility",
+ "params": { "key": "advancedFilters" }
+ }
+ ]
+ },
+ {
+ "id": "filter_section",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{_local.filterVisibility?.advancedFilters}}",
+ "children": [
+ { "comment": "필터 컴포넌트들" }
+ ]
+ }
+ ]
+}
+```
+
+---
+
+### 다국어 태그 핸들러
+
+다국어 입력 컴포넌트(MultilingualInput)에서 사용하는 태그 관리 핸들러입니다.
+
+**소스**: `src/handlers/multilingualTagHandler.ts`
+
+#### saveMultilingualTag
+
+다국어 태그를 저장합니다.
+
+```json
+{
+ "handler": "saveMultilingualTag",
+ "params": {
+ "field": "tags",
+ "locale": "{{_global.locale}}"
+ }
+}
+```
+
+#### cancelMultilingualTag
+
+다국어 태그 편집을 취소합니다.
+
+```json
+{
+ "handler": "cancelMultilingualTag"
+}
+```
+
+#### updateMultilingualTagValue
+
+다국어 태그 값을 업데이트합니다.
+
+```json
+{
+ "handler": "updateMultilingualTagValue",
+ "params": {
+ "field": "tags",
+ "locale": "ko",
+ "value": "{{$event.target.value}}"
+ }
+}
+```
+
+#### 태그 핸들러 params 요약
+
+| 핸들러 | params | 설명 |
+|--------|--------|------|
+| `saveMultilingualTag` | `{ field, locale }` | 태그 저장 |
+| `cancelMultilingualTag` | 없음 | 편집 취소 |
+| `updateMultilingualTagValue` | `{ field, locale, value }` | 값 업데이트 |
+
+---
+
+### 핸들러 소스 파일 매핑
+
+| 핸들러명 | 소스 파일 | 등록 함수 |
+|---------|----------|----------|
+| `setLocale` | `src/handlers/setLocaleHandler.ts` | `setLocaleHandler` |
+| `setTheme`, `initTheme` | `src/handlers/setThemeHandler.ts` | `initTheme` |
+| `scrollToSection` | `src/handlers/scrollToSectionHandler.ts` | `scrollToSectionHandler` |
+| `initMenuFromUrl` | `src/handlers/initMenuFromUrlHandler.ts` | `initMenuFromUrlHandler` |
+| `initFilterVisibility`, `saveFilterVisibility`, `toggleFilterVisibility`, `resetFilterVisibility` | `src/handlers/filterVisibilityHandler.ts` | `initFilterVisibilityHandler` |
+| `saveMultilingualTag`, `cancelMultilingualTag`, `updateMultilingualTagValue` | `src/handlers/multilingualTagHandler.ts` | `saveMultilingualTagHandler` |
+
+---
+
+### 주의사항
+
+```text
+이 핸들러들은 sirsoft-admin_basic 템플릿에서만 등록됨 (다른 템플릿에서 미지원 가능)
+범용 핸들러(navigate, apiCall, setState 등)와 달리 템플릿 의존적
+✅ 커스텀 핸들러이므로 template.json의 핸들러 등록 확인 필요
+✅ 범용 핸들러는 actions-handlers.md 참조
+```
+
+---
+
+### 관련 문서
+
+- [액션 핸들러 개요](../../../../docs/frontend/actions-handlers.md)
+- [sirsoft-admin_basic 컴포넌트](components.md)
+- [sirsoft-admin_basic 레이아웃](layouts.md)
+- [sirsoft-basic 핸들러](../../../../docs/frontend/templates/sirsoft-basic/handlers.md)
diff --git a/templates/_bundled/sirsoft-admin_basic/docs/layouts.md b/templates/_bundled/sirsoft-admin_basic/docs/layouts.md
new file mode 100644
index 00000000..a701e0f0
--- /dev/null
+++ b/templates/_bundled/sirsoft-admin_basic/docs/layouts.md
@@ -0,0 +1,621 @@
+# Admin Basic — 레이아웃
+
+> 레이아웃 목록과 라우트 매핑 · 진입점: [AGENTS.md](../AGENTS.md)
+
+## 레이아웃 목록
+
+
+레이아웃 145개 (루트: `layouts`).
+
+| 그룹 | 개수 |
+|---|---|
+| `(root)` | 27개 |
+| `auth` | 1개 |
+| `errors` | 6개 |
+| `overrides` | 1개 |
+| `partials` | 110개 |
+
+| 레이아웃 | 그룹 | 종류 | extends |
+|---|---|---|---|
+| `_admin_base` | `(root)` | partial | - |
+| `admin_activity_log_list` | `(root)` | 화면 | `_admin_base` |
+| `admin_dashboard` | `(root)` | 화면 | `_admin_base` |
+| `admin_forgot_password` | `(root)` | 화면 | - |
+| `admin_identity_logs` | `(root)` | 화면 | `_admin_base` |
+| `admin_language_pack_list` | `(root)` | 화면 | `_admin_base` |
+| `admin_language_packs` | `(root)` | 화면 | `admin_language_pack_list` |
+| `admin_language_packs_install_modal` | `(root)` | 화면 | - |
+| `admin_login` | `(root)` | 화면 | - |
+| `admin_menu_list` | `(root)` | 화면 | `_admin_base` |
+| `admin_module_language_packs` | `(root)` | 화면 | `admin_language_pack_list` |
+| `admin_module_list` | `(root)` | 화면 | `_admin_base` |
+| `admin_notification_log_list` | `(root)` | 화면 | `_admin_base` |
+| `admin_plugin_language_packs` | `(root)` | 화면 | `admin_language_pack_list` |
+| `admin_plugin_list` | `(root)` | 화면 | `_admin_base` |
+| `admin_reset_password` | `(root)` | 화면 | - |
+| `admin_role_form` | `(root)` | 화면 | `_admin_base` |
+| `admin_role_list` | `(root)` | 화면 | `_admin_base` |
+| `admin_schedule_list` | `(root)` | 화면 | `_admin_base` |
+| `admin_settings` | `(root)` | 화면 | `_admin_base` |
+| `admin_template_language_packs` | `(root)` | 화면 | `admin_language_pack_list` |
+| `admin_template_layout_edit` | `(root)` | 화면 | `_admin_base` |
+| `admin_template_list` | `(root)` | 화면 | `_admin_base` |
+| `admin_user_detail` | `(root)` | 화면 | `_admin_base` |
+| `admin_user_form` | `(root)` | 화면 | `_admin_base` |
+| `admin_user_list` | `(root)` | 화면 | `_admin_base` |
+| `identity_challenge` | `auth` | 화면 | `_admin_base` |
+| `401` | `errors` | 화면 | `_admin_base` |
+| `403` | `errors` | 화면 | `_admin_base` |
+| `404` | `errors` | 화면 | `_admin_base` |
+| `500` | `errors` | 화면 | `_admin_base` |
+| `503` | `errors` | 화면 | `_admin_base` |
+| `maintenance` | `errors` | 화면 | `_admin_base` |
+| `index` | `overrides` | 화면 | `_admin_base` |
+| `_identity_challenge_modal` | `partials` | partial | - |
+| `_modal_changelog` | `partials` | partial | - |
+| `_modal_license` | `partials` | partial | - |
+| `_modal_notification_delete_all_confirm` | `partials` | partial | - |
+| `_partial_datagrid` | `partials` | partial | - |
+| `_partial_filter` | `partials` | partial | - |
+| `_content` | `partials` | partial | - |
+| `_modal_log_detail` | `partials` | partial | - |
+| `_modal_purge_confirm` | `partials` | partial | - |
+| `_partial_datagrid` | `partials` | partial | - |
+| `_partial_filter` | `partials` | partial | - |
+| `_content` | `partials` | partial | - |
+| `_drawer_manifest_preview` | `partials` | partial | - |
+| `_modal_detail` | `partials` | partial | - |
+| `_modal_install` | `partials` | partial | - |
+| `_modal_install_bundled` | `partials` | partial | - |
+| `_modal_refresh_cache` | `partials` | partial | - |
+| `_modal_slot_conflict` | `partials` | partial | - |
+| `_modal_uninstall` | `partials` | partial | - |
+| `_modal_update` | `partials` | partial | - |
+| `_modal_delete` | `partials` | partial | - |
+| `_panel_detail` | `partials` | partial | - |
+| `_panel_form` | `partials` | partial | - |
+| `_panel_menu_list` | `partials` | partial | - |
+| `_panel_view` | `partials` | partial | - |
+| `_drawer_manifest_preview` | `partials` | partial | - |
+| `_modal_deactivate_warning` | `partials` | partial | - |
+| `_modal_detail` | `partials` | partial | - |
+| `_modal_extension_license` | `partials` | partial | - |
+| `_modal_force_activate` | `partials` | partial | - |
+| `_modal_force_deactivate` | `partials` | partial | - |
+| `_modal_install` | `partials` | partial | - |
+| `_modal_manual_install` | `partials` | partial | - |
+| `_modal_reactivate_language_packs` | `partials` | partial | - |
+| `_modal_refresh_layouts` | `partials` | partial | - |
+| `_modal_uninstall` | `partials` | partial | - |
+| `_modal_update` | `partials` | partial | - |
+| `_modal_log_detail` | `partials` | partial | - |
+| `_partial_datagrid` | `partials` | partial | - |
+| `_partial_filter` | `partials` | partial | - |
+| `_drawer_manifest_preview` | `partials` | partial | - |
+| `_modal_detail` | `partials` | partial | - |
+| `_modal_extension_license` | `partials` | partial | - |
+| `_modal_force_activate` | `partials` | partial | - |
+| `_modal_force_deactivate` | `partials` | partial | - |
+| `_modal_install` | `partials` | partial | - |
+| `_modal_manual_install` | `partials` | partial | - |
+| `_modal_reactivate_language_packs` | `partials` | partial | - |
+| `_modal_refresh_layouts` | `partials` | partial | - |
+| `_modal_uninstall` | `partials` | partial | - |
+| `_modal_update` | `partials` | partial | - |
+| `_modal_delete` | `partials` | partial | - |
+| `_modal_delete` | `partials` | partial | - |
+| `_modal_duplicate` | `partials` | partial | - |
+| `_modal_form` | `partials` | partial | - |
+| `_modal_history` | `partials` | partial | - |
+| `_modal_run` | `partials` | partial | - |
+| `_tab_schedules` | `partials` | partial | - |
+| `_modal_cache_delete` | `partials` | partial | - |
+| `_modal_core_changelog` | `partials` | partial | - |
+| `_modal_core_update_guide` | `partials` | partial | - |
+| `_modal_core_update_result` | `partials` | partial | - |
+| `_modal_identity_message_definition_add` | `partials` | partial | - |
+| `_modal_identity_message_definition_delete` | `partials` | partial | - |
+| `_modal_identity_message_definition_reset` | `partials` | partial | - |
+| `_modal_identity_message_template_form` | `partials` | partial | - |
+| `_modal_identity_message_template_preview` | `partials` | partial | - |
+| `_modal_identity_policy_delete` | `partials` | partial | - |
+| `_modal_identity_policy_form` | `partials` | partial | - |
+| `_modal_mail_template_form` | `partials` | partial | - |
+| `_modal_notification_definition_reset` | `partials` | partial | - |
+| `_modal_notification_template_form` | `partials` | partial | - |
+| `_modal_notification_template_preview` | `partials` | partial | - |
+| `_modal_password_confirm` | `partials` | partial | - |
+| `_tab_advanced` | `partials` | partial | - |
+| `_tab_drivers` | `partials` | partial | - |
+| `_tab_general` | `partials` | partial | - |
+| `_tab_identity` | `partials` | partial | - |
+| `_tab_identity_basic` | `partials` | partial | - |
+| `_tab_identity_messages` | `partials` | partial | - |
+| `_tab_identity_policies` | `partials` | partial | - |
+| `_tab_identity_providers` | `partials` | partial | - |
+| `_tab_info` | `partials` | partial | - |
+| `_tab_language_packs` | `partials` | partial | - |
+| `_tab_mail` | `partials` | partial | - |
+| `_tab_mail_templates` | `partials` | partial | - |
+| `_tab_notification_definitions` | `partials` | partial | - |
+| `_tab_security` | `partials` | partial | - |
+| `_tab_seo` | `partials` | partial | - |
+| `_tab_upload` | `partials` | partial | - |
+| `_modal_extension_preview_layout` | `partials` | partial | - |
+| `_modal_extension_version_history` | `partials` | partial | - |
+| `_modal_version_history` | `partials` | partial | - |
+| `_drawer_manifest_preview` | `partials` | partial | - |
+| `_modal_activate` | `partials` | partial | - |
+| `_modal_deactivate` | `partials` | partial | - |
+| `_modal_detail` | `partials` | partial | - |
+| `_modal_extension_license` | `partials` | partial | - |
+| `_modal_force_activate` | `partials` | partial | - |
+| `_modal_install` | `partials` | partial | - |
+| `_modal_manual_install` | `partials` | partial | - |
+| `_modal_reactivate_language_packs` | `partials` | partial | - |
+| `_modal_refresh_layouts` | `partials` | partial | - |
+| `_modal_uninstall` | `partials` | partial | - |
+| `_modal_update` | `partials` | partial | - |
+| `_tab_admin` | `partials` | partial | - |
+| `_tab_user` | `partials` | partial | - |
+| `_content_section` | `partials` | partial | - |
+| `_header_section` | `partials` | partial | - |
+| `_info_card` | `partials` | partial | - |
+| `template_partial_test` | `(root)` | 화면 | `_admin_base` |
+
+
+
+그룹은 "이 파일이 독립된 화면 URL을 갖는가"로 나뉩니다 — `(root)`/`auth`/`errors`/`overrides` 는
+라우트에 직접 매핑되는 화면이고, `partials`(110개, 전체의 76%)는 화면에 `extends`/포함되는
+조각(탭·모달·패널)이라 그 자체로는 URL이 없습니다. partial 비중이 압도적으로 큰 것은 관리자
+화면 하나가 보통 탭 여러 개 + 모달 여러 개로 구성되기 때문입니다(예: 확장 관리 화면 하나가
+설치/삭제/업데이트/강제활성화 등 모달을 5~10개씩 갖습니다).
+
+이 목록은 코드에서 실측되므로 구체적인 개수·파일명을 프로즈에 하드코딩해 반복하지 않습니다
+— 과거 버전 문서가 손으로 그린 전체 페이지 트리(약 20개 화면 기준)는 이미 145개로 늘어난
+현재 상태와 크게 어긋나 있었습니다. 전체 목록은 항상 위 생성 표를 신뢰합니다.
+
+### 화면 유형별 구성 패턴
+
+새 관리자 화면을 만들 때 아래 패턴 중 가장 가까운 것을 참고합니다(구체적 파일명이 아니라
+**구조**를 재사용하는 것이 목적입니다 — 실제 예시 파일은 위 표에서 비슷한 이름을 찾습니다).
+
+**목록 화면**: `extends: _admin_base`, `slots.content` 에 `PageHeader`(제목·액션) →
+`FilterGroup`(선택) → `DataGrid`/`CardGrid`(목록) → `Pagination`. data_sources 는
+`auto_fetch: true` + `params`에 `_local.page`/`per_page` 바인딩. 삭제·상태변경은 `apiCall`,
+상세/수정 이동은 `navigate`, 필터·페이지네이션은 `setState`. 목록 컨텍스트 왕복 규약
+(`mergeQuery`)을 지킵니다(§CLAUDE.md).
+
+**상세 화면**: `extends: _admin_base`, `data_sources`에 `route.id` 기반 상세 API,
+`slots.content`에 `PageHeader` + `Card` 여러 개(기본 정보/활동 내역/권한 정보 등 섹션별 분리).
+
+**폼 화면(생성/수정 겸용)**: `route.id` 존재 여부로 생성/수정을 분기, `Form` 안에
+`FormField`+`Input`/`Select`/`Toggle` 조합. `Button` 은 `type="button"` 명시(submit 방지),
+서버 검증 에러는 `FormField` 의 `error` prop 으로 표시.
+
+**설정(탭) 화면**: `TabNavigation` + `activeTab` 상태에 따라 `_tab_*.json` partial 을
+조건부 렌더링. 탭 전환은 `setState`, 저장은 `apiCall`, 파괴적 동작 확인은 `openModal`.
+
+**확장 관리 화면**(모듈/플러그인/템플릿 목록): `DataGrid`/`CardGrid` + `StatusBadge`(상태) +
+`ActionMenu`(설치/활성화/비활성화/삭제/업데이트) + `ExtensionBadge`. 설치·삭제·업데이트마다
+전용 확인 모달(`_modal_install`/`_modal_uninstall`/`_modal_update` 등)을 개별 파일로 분리 —
+한 모달에 여러 동작을 조건 분기로 몰아넣지 않습니다(모달마다 문구·부작용이 다릅니다).
+
+**에러 화면**(`errors/*.json`): `extends` 없는 독립 레이아웃. `Div`(중앙 정렬) 안에
+`Icon`+`H1`(코드)+`P`(메시지)+`Button`(홈 이동). 독립 레이아웃이므로 `Toast`/`Modal` 같은
+전역 호스트 컴포넌트가 필요하면 직접 마운트해야 합니다(§CLAUDE.md "독립 레이아웃의 글로벌
+호스트 컴포넌트").
+
+### `_admin_base.json` 에 대한 정정
+
+과거 버전 문서는 `_admin_base.json` 이 `init_actions: [initTheme, initMenuFromUrl]` 를 갖는다고
+적었으나, 현재 `_admin_base.json` 에는 `init_actions` 키 자체가 없습니다 — 두 핸들러는 현재
+`_admin_base` 를 상속하지 않는 인증 화면(`admin_login`/`admin_forgot_password`/
+`admin_reset_password`)에서만 호출됩니다. 사이드바 접힘 상태 복원(`initSidebar`)은 레이아웃이
+아니라 템플릿 부트스트랩(`src/index.ts`)에서 직접 호출됩니다. `_admin_base` 를 고칠 때 이
+문서의 낡은 구조를 그대로 믿지 말고 실제 JSON 을 확인하세요.
+
+
+## 라우트 매핑
+
+
+| 경로 | 레이아웃 | 이름 |
+|---|---|---|
+| `*/admin` | `-` | - |
+| `*/admin/login` | `admin_login` | - |
+| `*/admin/forgot-password` | `admin_forgot_password` | - |
+| `*/admin/reset-password` | `admin_reset_password` | - |
+| `*/admin/dashboard` | `admin_dashboard` | - |
+| `*/admin/users` | `admin_user_list` | - |
+| `*/admin/users/create` | `admin_user_form` | - |
+| `*/admin/users/:id` | `admin_user_detail` | - |
+| `*/admin/users/:id/edit` | `admin_user_form` | - |
+| `*/admin/modules` | `admin_module_list` | - |
+| `*/admin/settings/language-packs` | `-` | - |
+| `*/admin/modules/:identifier/language-packs` | `admin_module_language_packs` | - |
+| `*/admin/plugins/:identifier/language-packs` | `admin_plugin_language_packs` | - |
+| `*/admin/templates/:identifier/language-packs` | `admin_template_language_packs` | - |
+| `*/admin/plugins` | `admin_plugin_list` | - |
+| `*/admin/menus` | `admin_menu_list` | - |
+| `*/admin/templates` | `-` | - |
+| `*/admin/templates/:identifier/edit` | `admin_template_layout_edit` | - |
+| `*/admin/templates/:type` | `admin_template_list` | - |
+| `*/admin/template/partial` | `template_partial_test` | - |
+| `*/admin/activity-logs` | `admin_activity_log_list` | - |
+| `*/admin/notification-logs` | `admin_notification_log_list` | - |
+| `*/admin/settings` | `admin_settings` | - |
+| `*/admin/roles` | `admin_role_list` | - |
+| `*/admin/roles/create` | `admin_role_form` | - |
+| `*/admin/roles/:id/edit` | `admin_role_form` | - |
+| `*/admin/schedules` | `admin_schedule_list` | - |
+| `*/admin/identity/logs` | `admin_identity_logs` | - |
+| `*/admin/identity/challenge` | `auth/identity_challenge` | - |
+
+
+
+`레이아웃` 열이 `-` 인 두 행(`*/admin`, `*/admin/settings/language-packs`)은 레이아웃이
+없다는 뜻이 아니라 **다른 라우트로 리다이렉트되는 진입점**입니다 — 예를 들어 `/admin` 은
+로그인 여부에 따라 `/admin/login` 또는 `/admin/dashboard` 로 넘어가는 게이트 라우트입니다.
+새 화면을 추가할 때 이 표에 라우트를 등록하는 것만으로 끝나지 않습니다 — 사이드바 메뉴에서
+그 화면으로 이동하는 진입점도 함께 추가해야 실제로 도달 가능해집니다(라우트만 있고 메뉴
+항목이 없으면 URL을 직접 입력해야만 닿는 화면이 됩니다).
+
+
+## 확장 오버라이드
+
+
+_오버라이드하는 레이아웃 확장 조각이 없습니다._
+
+
+
+이 템플릿은 현재 어떤 모듈/플러그인의 레이아웃 확장 조각도 오버라이드하지 않습니다 —
+`sirsoft-basic` 템플릿과 달리 관리자 화면은 확장이 끼워 넣는 조각(예: 이커머스 문의 설정,
+GDPR 배너)을 코어 대시보드 위젯 형태로만 받고, 이 템플릿이 그 조각을 대체할 필요가 아직
+없었기 때문입니다. 특정 확장의 관리자 UI 를 이 템플릿에서만 다르게 보이게 하려면
+`extensions/{module-identifier}/*.json` 을 신설합니다(§docs/extension/layout-extensions.md
+"템플릿 오버라이드").
+
+
+## 이관 원문 상세
+
+> 아래는 코어 `docs/frontend/templates/sirsoft-admin_basic/layouts.md` 에 있던 원문을
+> 이 문서로 옮긴 것입니다(#601). 페이지 맵 트리와 화면 유형별 패턴 상세가 여기에 있습니다.
+> 이관 시점 그대로 보존하되, 현재 코드와 어긋나는 부분에는 정정 주석을 달았습니다 —
+> 레이아웃·라우트의 SSoT 는 위 「레이아웃 목록」·「라우트 매핑」 블록입니다.
+
+### 페이지 맵 (트리 구조)
+
+```text
+_admin_base.json (베이스 레이아웃)
+│
+├── admin_dashboard.json (대시보드)
+├── admin_login.json (로그인 — _admin_base 미상속)
+│
+├── 사용자 관리
+│ ├── admin_user_list.json (목록)
+│ ├── admin_user_form.json (생성/수정)
+│ └── admin_user_detail.json (상세)
+│
+├── 역할 관리
+│ ├── admin_role_list.json (목록)
+│ │ └── partials/admin_role_list/_modal_delete.json
+│ └── admin_role_form.json (생성/수정)
+│
+├── 메뉴 관리
+│ └── admin_menu_list.json (3패널 레이아웃)
+│ └── partials/admin_menu_list/
+│ ├── _panel_menu_list.json (좌측: 메뉴 트리)
+│ ├── _panel_form.json (중앙: 편집 폼)
+│ ├── _panel_detail.json (중앙: 상세 보기)
+│ ├── _panel_view.json (우측: 미리보기)
+│ └── _modal_delete.json
+│
+├── 환경 설정
+│ └── admin_settings.json (탭 네비게이션)
+│ └── partials/admin_settings/
+│ ├── _tab_general.json (일반)
+│ ├── _tab_mail.json (메일 발송 SMTP 설정)
+│ ├── _tab_notification_definitions.json (알림 정의)
+│ ├── _tab_security.json (보안)
+│ ├── _tab_upload.json (업로드)
+│ ├── _tab_drivers.json (드라이버)
+│ ├── _tab_seo.json (SEO)
+│ ├── _tab_advanced.json (고급)
+│ ├── _tab_info.json (시스템 정보)
+│ ├── _modal_cache_delete.json
+│ ├── _modal_core_changelog.json
+│ ├── _modal_core_update_guide.json
+│ ├── _modal_core_update_result.json
+│ ├── _modal_notification_template_form.json
+│ ├── _modal_notification_template_preview.json
+│ └── _modal_password_confirm.json
+│
+├── 모듈 관리
+│ └── admin_module_list.json
+│ └── partials/admin_module_list/
+│ ├── _modal_detail.json
+│ ├── _modal_install.json
+│ ├── _modal_manual_install.json
+│ ├── _modal_uninstall.json
+│ ├── _modal_update.json
+│ ├── _modal_deactivate_warning.json
+│ ├── _modal_force_activate.json
+│ ├── _modal_force_deactivate.json
+│ ├── _modal_extension_license.json
+│ └── _modal_refresh_layouts.json
+│
+├── 플러그인 관리
+│ └── admin_plugin_list.json
+│ └── partials/admin_plugin_list/
+│ ├── (모듈과 동일 구조 — 10개 모달)
+│ └── ...
+│
+├── 템플릿 관리
+│ ├── admin_template_list.json
+│ │ └── partials/admin_template_list/
+│ │ ├── _tab_admin.json (Admin 템플릿 탭)
+│ │ ├── _tab_user.json (User 템플릿 탭)
+│ │ ├── _modal_detail.json
+│ │ ├── _modal_install.json
+│ │ ├── _modal_manual_install.json
+│ │ ├── _modal_uninstall.json
+│ │ ├── _modal_update.json
+│ │ ├── _modal_activate.json
+│ │ ├── _modal_deactivate.json
+│ │ ├── _modal_force_activate.json
+│ │ ├── _modal_extension_license.json
+│ │ └── _modal_refresh_layouts.json
+│ └── admin_template_layout_edit.json (레이아웃 편집기)
+│ └── partials/admin_template_layout_edit/_modal_version_history.json
+│
+├── 스케줄 관리
+│ └── admin_schedule_list.json
+│ └── partials/admin_schedule_list/
+│ ├── _tab_schedules.json
+│ ├── _modal_form.json
+│ ├── _modal_delete.json
+│ ├── _modal_duplicate.json
+│ ├── _modal_history.json
+│ └── _modal_run.json
+│
+├── 메일 발송 로그
+│ └── admin_mail_send_log_list.json
+│ └── partials/admin_mail_send_log_list/
+│ ├── _partial_datagrid.json
+│ └── _partial_filter.json
+│
+├── 공통 Partial
+│ ├── partials/_modal_changelog.json
+│ └── partials/_modal_license.json
+│
+├── 에러 페이지
+│ └── errors/
+│ ├── 401.json (인증 필요)
+│ ├── 403.json (접근 거부)
+│ ├── 404.json (페이지 없음)
+│ ├── 500.json (서버 오류)
+│ ├── 503.json (서비스 불가)
+│ └── maintenance.json (점검 중)
+│
+├── 오버라이드
+│ └── overrides/sirsoft-sample/index.json
+│
+└── 테스트
+ └── template_partial_test.json
+ └── partials/template_partial_test/
+ ├── _content_section.json
+ ├── _header_section.json
+ └── _info_card.json
+```
+
+---
+
+### 카테고리별 가이드
+
+#### 목록 페이지 패턴
+
+**대표**: `admin_user_list.json`, `admin_role_list.json`
+
+**구성**:
+```text
+extends: _admin_base
+slots.content:
+ └── PageHeader (제목, 액션 버튼)
+ └── FilterGroup (선택적)
+ └── DataGrid (columns, data, pagination)
+ └── Pagination
+```
+
+**data_sources**:
+```json
+{
+ "id": "users",
+ "endpoint": "/api/admin/users",
+ "method": "GET",
+ "auto_fetch": true,
+ "params": { "page": "{{_local.page ?? 1}}", "per_page": "{{_local.per_page ?? 15}}" }
+}
+```
+
+**핸들러 패턴**:
+- `apiCall` — 삭제, 상태 변경
+- `navigate` — 상세/수정 페이지 이동
+- `setState` — 필터, 페이지네이션 상태
+
+**Partial 구조**: 모달 (삭제 확인, 상세 보기 등)
+
+---
+
+#### 상세 페이지 패턴
+
+**대표**: `admin_user_detail.json`
+
+**구성**:
+```text
+extends: _admin_base
+data_sources: [상세 API (route.id 기반)]
+slots.content:
+ └── PageHeader
+ └── Card (기본 정보 섹션)
+ └── Card (활동 내역 섹션)
+ └── Card (권한 정보 섹션)
+```
+
+**data_sources**:
+```json
+{
+ "id": "user",
+ "endpoint": "/api/admin/users/{{route.id}}",
+ "method": "GET",
+ "auto_fetch": true
+}
+```
+
+**핸들러 패턴**:
+- `apiCall` — 상태 변경 (활성화/비활성화, 역할 변경)
+- `navigate` — 목록으로 이동, 수정 페이지 이동
+
+---
+
+#### 폼 페이지 패턴
+
+**대표**: `admin_user_form.json`, `admin_role_form.json`
+
+**구성**:
+```text
+extends: _admin_base
+data_sources: [상세 API (수정 시), 참조 데이터 (Select 옵션)]
+slots.content:
+ └── PageHeader
+ └── Form
+ ├── FormField + Input (텍스트)
+ ├── FormField + Select (선택)
+ ├── FormField + Toggle (토글)
+ └── Button (저장/취소)
+```
+
+**핸들러 패턴**:
+- `apiCall` — 생성 (POST) / 수정 (PUT)
+- `navigate` — 성공 후 목록/상세로 이동
+
+**주의사항**:
+```text
+✅ Form 내 Button에 type="button" 명시 (submit 방지)
+✅ 수정 폼은 route.id 존재 여부로 생성/수정 구분
+✅ FormField에 error prop으로 서버 검증 에러 표시
+```
+
+---
+
+#### 설정 페이지 패턴
+
+**대표**: `admin_settings.json`
+
+**구성**:
+```text
+extends: _admin_base
+data_sources: [설정 API]
+slots.content:
+ └── PageHeader
+ └── TabNavigation (tabs)
+ └── Div (탭별 partial 조건부 렌더링)
+ ├── if: activeTab === 'general' → partial: _tab_general.json
+ ├── if: activeTab === 'mail' → partial: _tab_mail.json
+ ├── if: activeTab === 'security' → partial: _tab_security.json
+ └── ...
+```
+
+**Partial 구조**: `partials/admin_settings/_tab_*.json` (9개 탭)
+
+**핸들러 패턴**:
+- `setState` — 탭 전환
+- `apiCall` — 설정 저장
+- `openModal` — 확인 다이얼로그
+
+---
+
+#### 확장 관리 패턴
+
+**대표**: `admin_module_list.json`, `admin_plugin_list.json`, `admin_template_list.json`
+
+**구성**:
+```text
+extends: _admin_base
+data_sources: [확장 목록 API]
+slots.content:
+ └── PageHeader (새로고침, 수동 설치 버튼)
+ └── DataGrid/CardGrid (확장 목록)
+ ├── StatusBadge (상태)
+ ├── ActionMenu (설치/활성화/비활성화/삭제/업데이트)
+ └── ExtensionBadge (모듈 식별)
+modals:
+ ├── _modal_detail.json (상세 정보)
+ ├── _modal_install.json (설치 확인)
+ ├── _modal_uninstall.json (삭제 확인)
+ ├── _modal_update.json (업데이트)
+ └── _modal_force_activate.json 등
+```
+
+**핸들러 패턴**:
+- `apiCall` — 설치, 활성화, 비활성화, 삭제, 업데이트
+- `openModal` — 확인 다이얼로그
+- `setState` — 선택된 확장 정보 저장
+
+**특수사항**:
+- 템플릿 관리는 Admin/User 탭 분리 (`_tab_admin.json`, `_tab_user.json`)
+- 레이아웃 편집기 (`admin_template_layout_edit.json`)는 CodeEditor + 실시간 미리보기
+
+---
+
+#### 에러 페이지 패턴
+
+**대표**: `errors/404.json`
+
+**구성**:
+```text
+(extends 없음 — 독립 레이아웃)
+components:
+ └── Div (전체 화면 중앙 정렬)
+ ├── Icon (에러 아이콘)
+ ├── H1 (에러 코드)
+ ├── P (에러 메시지)
+ └── Button (홈으로 이동)
+```
+
+**핸들러 패턴**:
+- `navigate` — 대시보드/홈으로 이동
+
+---
+
+### 베이스 레이아웃 구조
+
+#### _admin_base.json
+
+모든 관리자 페이지의 공통 구조를 정의합니다.
+
+```text
+_admin_base.json
+├── init_actions: [initTheme, initMenuFromUrl]
+├── data_sources: [admin_menu, notifications]
+├── components:
+│ ├── AdminSidebar (menu: admin_menu.data)
+│ ├── AdminHeader (user, notifications)
+│ ├── Toast
+│ ├── PageTransitionIndicator
+│ └── Div (content area)
+│ └── slot: "content" (← 하위 레이아웃이 채움)
+└── AdminFooter
+```
+
+**슬롯**:
+- `content` — 각 페이지의 메인 콘텐츠가 삽입되는 위치
+
+---
+
+### 관련 문서
+
+- [sirsoft-admin_basic 컴포넌트](components.md)
+- [sirsoft-admin_basic 핸들러](handlers.md)
+- [레이아웃 JSON 스키마](../../../../docs/frontend/layout-json.md)
+- [레이아웃 상속](../../../../docs/frontend/layout-json-inheritance.md)
+- [sirsoft-basic 레이아웃](../../../../docs/frontend/templates/sirsoft-basic/layouts.md)
diff --git a/tests/Feature/Documentation/ExtensionDocContractTest.php b/tests/Feature/Documentation/ExtensionDocContractTest.php
index 1edde8fa..a73253ed 100644
--- a/tests/Feature/Documentation/ExtensionDocContractTest.php
+++ b/tests/Feature/Documentation/ExtensionDocContractTest.php
@@ -37,24 +37,15 @@ class ExtensionDocContractTest extends TestCase
*/
private const DOC_BACKLOG = [
'module/gnuboard7-hello_module',
- 'module/sirsoft-board',
'module/sirsoft-ecommerce',
'module/sirsoft-page',
'plugin/gnuboard7-hello_plugin',
'plugin/sirsoft-ckeditor5',
'plugin/sirsoft-daum_postcode',
- 'plugin/sirsoft-gdpr',
'plugin/sirsoft-marketing',
'plugin/sirsoft-message_bizppurio',
- 'plugin/sirsoft-pay_kginicis',
- 'plugin/sirsoft-pay_nhnkcp',
- 'plugin/sirsoft-pay_nicepayments',
- 'plugin/sirsoft-tosspayments',
- 'plugin/sirsoft-verification_kginicis',
- 'plugin/sirsoft-verification_nhnkcp',
'template/gnuboard7-hello_admin_template',
'template/gnuboard7-hello_user_template',
- 'template/sirsoft-admin_basic',
'template/sirsoft-basic',
];
@@ -66,7 +57,7 @@ class ExtensionDocContractTest extends TestCase
* 늘리려면 이 숫자를 올리는 **눈에 보이는 행위**를 거치게 합니다. 집필이 진행되면 이
* 숫자도 함께 내려갑니다 (백로그는 줄어들기만 하므로 상한도 단조 감소한다).
*/
- private const DOC_BACKLOG_CEILING = 20;
+ private const DOC_BACKLOG_CEILING = 11;
/**
* `_bundled` 스캔이 번들 확장 전수를 발견하는지 확인합니다.
@@ -910,6 +901,58 @@ class ExtensionDocContractTest extends TestCase
$ctx['frontend']['layoutExtensions'],
"{$record['id']}: {$extRoot}/ 가 실재하는데 레이아웃 확장 조각이 수집되지 않았습니다.",
);
+
+ // getLayoutExtensions() 기본 구현은 위와 같은 디렉토리를 glob() 한 절대경로라
+ // 파일 목록과 100% 중복이다. 정규화 없이 실으면 같은 파일이 두 번(상대·절대) 나오고
+ // 로컬 머신 절대경로가 커밋되는 확장 저장소에 그대로 남는다.
+ // 템플릿은 'layout-extensions' 가 아니라 'template-overrides' 블록(docs/layouts.md)이다
+ // — 그 개념(오버라이드)이 모듈/플러그인의 발행과 반대 방향이라 문서 자리가 다르다.
+ $blockKey = $record['type'] === ExtensionInventory::TYPE_TEMPLATE ? 'template-overrides' : 'layout-extensions';
+ $block = $scaffolder->renderBlocks($ctx)[$blockKey] ?? '';
+ $this->assertNotSame('', $block, "{$record['id']}: {$blockKey} 블록이 렌더되지 않았습니다.");
+ $this->assertStringNotContainsString(
+ str_replace('\\', '/', $record['path']),
+ str_replace('\\', '/', $block),
+ "{$record['id']}: 레이아웃 확장 표에 로컬 절대경로가 남아 있습니다.",
+ );
+ $rowCount = substr_count($block, "\n| `");
+ $this->assertSame(
+ count($ctx['frontend']['layoutExtensions']),
+ $rowCount,
+ "{$record['id']}: 레이아웃 확장 표 행 수가 실제 파일 수와 다릅니다 (중복 행 의심).",
+ );
+ }
+
+ // getNotificationDefinitions() 의 표준 계약은 리스트 배열 + 원소별 'type' 키다
+ // (NotificationSyncHelper::sync() 소비 형태). 'key'/'event' 로 읽으면 정수 인덱스
+ // 배열에서 이름을 못 찾아 모든 행이 '-' 로 찍힌다 — 표가 있으나 마나 해진다.
+ $declaredNotifications = $ctx['surface']['values']['getNotificationDefinitions'] ?? [];
+ if (is_array($declaredNotifications) && $declaredNotifications !== []) {
+ $notificationsBlock = $scaffolder->renderBlocks($ctx)['notifications'] ?? '';
+ $this->assertStringNotContainsString(
+ "\n| `-` |",
+ $notificationsBlock,
+ "{$record['id']}: 알림 정의 표의 알림 키가 비어 있습니다 ('type' 필드 매핑 확인).",
+ );
+ }
+
+ // getSettingsLayout() 은 getModulePath()/getPluginPath() 기준 절대경로를 돌려준다
+ // (§레이아웃 확장과 같은 결함군) — relativeToExtension() 을 거치지 않으면 로컬 머신
+ // 절대경로가 그대로 커밋 문서(docs/settings.md · README.md)에 실린다.
+ $settingsLayout = $ctx['surface']['values']['getSettingsLayout'] ?? null;
+ if (is_string($settingsLayout) && $settingsLayout !== '') {
+ $rendered = $scaffolder->renderBlocks($ctx);
+ // record['path'] 는 Symfony Finder 산출물(슬래시)과 코드 조립 문자열(백슬래시)이
+ // 섞인 혼합 구분자다 — 양쪽을 슬래시로 정규화하지 않으면 정규화된 needle 이
+ // 정규화되지 않은 haystack 안의 진짜 누출을 놓친다.
+ $needle = str_replace('\\', '/', $record['path']);
+ foreach (['settings-schema', 'settings-summary'] as $settingsBlockKey) {
+ $this->assertStringNotContainsString(
+ $needle,
+ str_replace('\\', '/', $rendered[$settingsBlockKey] ?? ''),
+ "{$record['id']}: {$settingsBlockKey} 블록에 로컬 절대경로가 남아 있습니다.",
+ );
+ }
}
}