# @iyulab/enterprise

iyulab 프레임워크의 엔터프라이즈 통합 패키지. 폼 레이아웃 컴포넌트·API 설정·도메인 헬퍼를 제공합니다.

## Installation

```bash
npm install @iyulab/enterprise @iyulab/components
```

`@iyulab/components` 는 필수 peer 다(로케일·포맷·디자인 토큰). React 폼 레이아웃(`/react`)을 쓰면 `react` 도 필요하다.

| 진입점 | 내용 |
|--------|------|
| `@iyulab/enterprise` | 아래 표의 고유 export |
| `@iyulab/enterprise/react` | `FormSection` · `FormRow` |
| `@iyulab/enterprise/icons` | `house` 아이콘 라이브러리 등록(부수효과 import) |

## Quick Start

React 앱의 첫 페이지 — 폼 섹션 하나(Vite 기준, `index.html` 이 `src/main.tsx` 를 모듈로 싣는다).

```bash
npm install @iyulab/enterprise @iyulab/components @lit/react react react-dom
```

```tsx
// src/main.tsx
import '@iyulab/components/styles/tokens.css';
import { createRoot } from 'react-dom/client';
import { UInput, UTextarea, UButton } from '@iyulab/components/react';
import { FormSection, FormRow } from '@iyulab/enterprise/react';
import { DateHelper } from '@iyulab/enterprise';

function OrderForm() {
  return (
    <form style={{ maxWidth: 640, margin: 32 }} onSubmit={(e) => e.preventDefault()}>
      <FormSection title="Order">
        <FormRow>
          <UInput name="customer" label="Customer" required />
          <UInput name="due" label="Due date" value={DateHelper.formatDate(new Date())} />
        </FormRow>
        <FormRow full>
          <UTextarea name="note" label="Note" />
        </FormRow>
      </FormSection>
      <UButton type="submit" color="primary">Save</UButton>
    </form>
  );
}

createRoot(document.body.appendChild(document.createElement('div'))).render(<OrderForm />);
```

데이터 서비스(`createODataService`)·인증(`createAuthClient`)·권한 store 는 아래 [Usage](#usage) 에 있습니다.

## 무엇을 제공하나

### 1. 고유 export (enterprise에서만 제공)

| Export | 종류 | 용도 |
|--------|------|------|
| `FormSection` | React (`/react`) | 제목 + 세로 스택 폼 섹션 |
| `FormRow` | React (`/react`) | 2컬럼 그리드 폼 행(`full`로 1컬럼) |
| `u-list-page` | 커스텀 엘리먼트 (`/list-page`) | 목록 화면 골격 — 머리·필터·툴바·뷰·페이저·빈/오류 슬롯, 소스를 뷰·페이저에 묶고 `view` 로 표↔카드 전환. 표는 그리지 않는다. 상세: `skills/iyulab-enterprise/references/list.md` |
| `ListPage` | React (`/react`) | `u-list-page` 의 얇은 래퍼(`@lit/react` — 선택 peer) |
| `ApiConfig` | class | baseUrl/OData·API prefix·dev 판별 중앙 설정 |
| `createODataService` | factory | OData v4 + custom REST CRUD 서비스(401·토스트·에러파싱) |
| `ApiError` | class | HTTP status + OData `error.details`(필드별 검증 상세)를 실은 API 호출 실패 에러 |
| `applyFieldErrors` · `clearFieldErrors` | function | 서버 거절의 필드 상세(`ApiError.details` 의 `target`)를 폼 안 그 `name` 의 컨트롤에 — 칸 옆 오류 · 고치면 걷힘 · 못 내려간 항목은 폼 단위로 돌려준다 |
| `wasNotified` | function | 이 실패를 서비스가 `notify.error` 로 이미 알렸는가 — 경계의 이중 토스트 방지 |
| `createAuthClient` | factory | 쿠키 세션 인증(fetchMe/login/logout) — 제네릭 user/자격증명 |
| `createPermissionStore` · `hasPermission` 외 | store | 권한 스냅샷 store + 판정 free 함수 |
| `bindSource` · `SourceBinding` | function · Lit controller | 데이터 소스(`@iyulab/flex-table` 의 `createODataSource`·`createArraySource`)를 «뷰 어휘» 를 말하는 요소(표 · 카드 뷰 · 페이저 · 검색 칸)에 묶는다 — 상태를 속성에 쓰고 `sort-change`·`page-change`·`search` 를 소스 조작으로. 상세: `skills/iyulab-enterprise/references/list.md` |
| `DateHelper` | class | 날짜 포맷·날짜 계산(로컬 달력 — `'YYYY-MM-DD'` 는 그 날로 읽는다) |
| `ProgressHelper` | class | 진행률 계산 |
| `UrgencyHelper` | class | 긴급도 계산 |

### 2. 심볼 출처 (v0.3.0부터 하위 패키지 재노출 제거됨)

`@iyulab/enterprise`는 **자기 고유 export만** 제공합니다(위 표). `@iyulab/components`(컴포넌트), `@iyulab/data-components`(데이터 그리드/시트), `@iyulab/modern-app`(앱 프레임워크, 라우팅)은 각 패키지에서 **직접 import**하세요.

> v0.2.x까지는 위 3개 패키지를 `export *`로 재노출했으나, 심볼 출처 혼란과 exact-pin(v0.2.2 이전) 조합 시 이중 인스턴스 위험 때문에 v0.3.0에서 제거했습니다. 재노출에 의존하던 코드는 `@iyulab/enterprise`가 아닌 각 하위 패키지에서 직접 import하도록 수정해야 합니다.

## Usage

### 폼 레이아웃 (React)

React 컴포넌트는 `@iyulab/enterprise/react` 서브패스에 있습니다. `react` 는 선택 peer 의존이라, React 를 쓰지 않는 앱(프리셋·데이터 서비스·인증 클라이언트만 쓰는 앱)에는 설치되지 않습니다.

```tsx
import { FormSection, FormRow } from '@iyulab/enterprise/react'
import { UInput, UTextarea } from '@iyulab/components/react'

<FormSection title="기본 정보">
  <FormRow>
    <UInput label="이름" />
    <UInput label="코드" />
  </FormRow>
  <FormRow full>
    <UTextarea label="비고" />
  </FormRow>
</FormSection>
```

- `FormRow`는 기본 2컬럼 그리드입니다. 한 칸을 차지하려면 `full`을, 다른 열 수가 필요하면 `columns`를 씁니다.
- **열 폭 — 선언한 열은 실제로 균등합니다.** 트랙은 `1fr`이 아니라 `minmax(0, 1fr)`입니다. 그리드 아이템의 기본 `min-width: auto`는 내용의 min-content 아래로 줄어들기를 거부해서, 순수 `1fr`로 두면 긴 내용이 든 칸만 부풀고 나머지가 찌그러집니다(400px 컨테이너 실측: 2열 **425/8**, 3열 **425/8/8**). `minmax(0, 1fr)`은 트랙 최소를 0으로 만들어 같은 경우를 **196/196**·**128/128/128**로 만듭니다.
- ⚠**긴 불가분 문자열 자체의 넘침은 별개 축입니다.** 칸이 균등해져도 그 안의 긴 문자열은 자기 칸을 넘습니다 — 셀 쪽에서 `overflow-wrap`으로 다룹니다(`@iyulab/components`의 엘리먼트는 이미 그 값을 갖습니다).
- 두 컴포넌트 모두 `className`·`style`을 받아 기본값 **뒤에** 병합합니다 — 블록을 복제하지 않고 조정하는 정규 경로입니다. `FormSection`은 제목 줄만 바꾸는 `titleStyle`도 받습니다.

```tsx
<FormRow columns={3}>…</FormRow>
<FormSection title="기본 정보" style={{ marginBottom: 32 }}>…</FormSection>
```

> 이 계층이 무엇을 소유하고 무엇을 소유하지 않는지, 새 패턴이 언제 추가되는지는
> **[LOB 계층 헌장](./docs/lob-layers.md)** 이 정합니다. 패턴을 제안하기 전에 읽어 주세요.

### API 설정

```typescript
import { ApiConfig } from '@iyulab/enterprise'

ApiConfig.initialize({ baseUrl: '/', odataPrefix: '$data', apiPrefix: 'api' })

ApiConfig.getODataUrl('Orders')          // → /$data/Orders
ApiConfig.getApiUrl('auth/me')           // → /api/auth/me
ApiConfig.getUrlWithParams('report', { year: 2026, active: true })
ApiConfig.isDevelopment                   // 환경 판별
```

### OData 서비스 (`createODataService`)

OData v4 + custom REST CRUD 를 한 번에 구성한다. 401 세션 처리·에러 메시지 추출·성공 토스트·204 빈 바디 안전 파싱이 내장돼 있고, **도메인/로케일 요소는 전부 주입**으로 앱 adapter 에 남긴다.

```typescript
import { createODataService } from '@iyulab/enterprise'
import { app } from '@iyulab/modern-app'

// 앱 adapter (예: src/lib/odata.ts) — 라이브러리는 이 배선만 받는다.
export const svc = createODataService({
  baseUrl: window.location.origin,
  onUnauthorized: () => { window.location.href = '/' },        // 세션 만료 리다이렉트는 앱이 결정
  notify: { success: (m) => app.success(m), error: (m) => app.error(m) },
  messages: {                                                   // 기본은 영어 — 로케일 오버라이드
    saved: '저장되었습니다', updated: '수정되었습니다', deleted: '삭제되었습니다',
    sessionExpired: '세션이 만료되었습니다. 다시 로그인하세요.',
  },
})

await svc.odataGet<Order>('Orders', { $top: '20' })   // value 배열 언랩

// 서버가 페이지를 자르면(@odata.nextLink) odataGet 은 끝까지 따라가 전량을 모은다.
// maxRows 를 주면 그 수를 넘는 순간 남은 페이지를 읽지 않고 RangeError 를 던진다(자르지 않는다).
await svc.odataGet<Order>('Orders', undefined, { maxRows: 5000 })
// 페이지 단위로 읽어야 하는 화면은 페이지 API 를 쓴다 — nextLink 는 조립하지 말고 그대로 넘긴다.
const page = await svc.odataGetPage<Order>('Orders', { $count: 'true' })  // { value, nextLink?, count? }
if (page.nextLink) await svc.odataGetNextPage<Order>(page.nextLink)
await svc.odataPost<Order>('Orders', { name: 'A', note: '' })  // '' → null 정규화 + 성공 토스트
svc.odataUrl('Orders')                                 // flex-table useODataSource 엔드포인트
svc.sourceDefaults                                     // { baseUrl, onUnauthorized } 주입용

// custom REST — GET/POST/PUT/PATCH/DELETE 전부, 204 빈 바디 안전 파싱 포함
await svc.apiGet<Order>('reports/summary')
await svc.apiPost<Order>('orders', { name: 'A' })
await svc.apiPut<Order>('orders/7', { name: 'A (revised)' })    // 리소스 전체 교체
await svc.apiPatch<Order>('orders/7', { note: 'urgent' })
await svc.apiDelete('orders/7')                                 // 204 안전

// 로그인 — 401 이 「세션 만료」가 아니라 「자격 증명 틀림」인 유일한 호출이다.
// 전역 onUnauthorized 를 이 호출에서만 끄면, 서버가 준 사유가 그대로 ApiError.message 로 온다.
await svc.apiPost('auth/login', creds, { onUnauthorized: false })

// 연결 프로브 — 「응답이 왔는가」만 묻는다. fetchRaw 는 던지지도, 401 훅을 부르지도 않는다.
const probe = await svc.fetchRaw(svc.apiUrl('ping'))
const reachable = true          // 여기 닿았다는 것 자체가 답이다(probe.status 는 볼 필요 없다)

// body가 FormData 인스턴스면 그대로(직렬화 없이) 멀티파트로 전송된다 —
// Content-Type은 브라우저가 boundary와 함께 자동 설정한다. apiPost/apiPut/apiPatch 전부 동일.
const form = new FormData()
form.append('file', file)
await svc.apiPost<Order>('orders/7/attachments', form)
```

주입 항목:

| config | 용도 |
|--------|------|
| `baseUrl` | 모든 요청의 오리진 (필수) |
| `odataPrefix` / `apiPrefix` | 엔드포인트 prefix (기본 `$data` / `api`) |
| `onUnauthorized(status)` | 401 시 호출 — 리다이렉트/재진입 가드는 앱이 처리 |
| `notify.success/error` | 토스트 훅 (생략 시 토스트 없음 — 순수). **메서드마다 걸리는 방식이 다르다 — 바로 아래 표 참조** |
| `onMutated(mutation)` | 쓰기가 성공(2xx)한 뒤 한 번 — 캐시 무효화 자리. 아래 절 참조 (`0.19.0~`) |
| `messages` | 사용자 대면 문구 (기본 영어, 지정 키만 대체) |
| `formatError(info)` | 에러 메시지 포매팅 오버라이드 (앱별 정책) — `info` 는 `status`/`statusText`/`rawMessage`/`code`(서버의 거절 코드)/`details`(검증된 `error.details`)/`body` 를 받는다 |

#### `onMutated` — 쓰기 뒤 무효화

목록·상세·대시보드가 같은 데이터를 따로 들고 있으면, 한쪽에서 저장한 뒤 다른 쪽이 낡는다
(예: 목록 위 오버레이에서 상세를 저장하고 닫으면 목록은 재마운트되지 않는다). 서비스가 쓰기를
전부 소유하므로 «무엇이 쓰였는가» 도 서비스가 알린다 — 쓰기 메서드를 한 겹씩 감쌀 필요가 없다.

```typescript
export const svc = createODataService({
  baseUrl: window.location.origin,
  onMutated: ({ method, source, target, id }) => invalidate(source === 'odata' ? target : 'api'),
})
```

- **성공한 쓰기마다 한 번** — `odataPost`·`odataPatch`·`odataDelete`, `apiPost`·`apiPut`·`apiPatch`·`apiDelete` 와 그 `*Quiet` 변형 전부(`odataPut` 은 없다).
  토스트 여부와 무관하다.
- **실패한 쓰기·읽기(`fetchRaw` 포함)에는 부르지 않는다.**
- `mutation` = `{ method, source: 'odata' | 'api', target, id? }` — `target` 은 `odata` 면 엔티티 셋
  이름, `api` 면 넘긴 경로 그대로. `id` 는 키로 지정한 쓰기(`odataPatch`·`odataDelete`)에만.
- 콜백이 던져도 쓰기는 성공으로 끝난다(예외는 `reportError` 로 보고) — 관찰자의 실패가
  «저장 실패» 로 보이면 안 된다.

#### `notify` 가 걸리는 자리 — 축은 «쓰기 ↔ 읽기» 다

| 메서드 | 실패 시 `error` | 성공 시 `success` |
|---|---|---|
| `odataPost` · `odataPatch` · `odataDelete` | ✅ (401 제외) | ✅ `saved`·`updated`·`deleted` |
| `apiPost` · `apiPut` · `apiPatch` · `apiDelete` | ✅ (401 제외) | ❌ |
| `*Quiet` 전부 (`odataPostQuiet` … `apiDeleteQuiet`) | ❌ | ❌ |
| `odataGet` · `odataGetPage` · `odataGetNextPage` · `odataGetById` · `odataCount` · `apiGet` · `fetchRaw` | ❌ | ❌ |

- **조회는 통지하지 않는다.** 빈 화면 자체가 신호이고, 목록을 열 때마다 토스트가 뜨면 읽을 수 없다.
- **쓰기는 통지한다.** 결과가 화면에 안 보일 수 있기 때문이다 — 서버가 409 와 사유를 돌려줘도,
  통지가 없으면 아무 일도 일어나지 않은 화면과 구별되지 않는다.
- **401 은 통지하지 않는다** — `onUnauthorized` 가 이미 안내하므로 겹친다.
- **`api*` 에는 성공 토스트가 없다.** 임의의 RPC(상태 전이·발행·업로드)를 태우는 경로라
  «저장되었습니다» 같은 문구를 라이브러리가 지어낼 수 없다. 실패 메시지는 서버가 주므로 어느
  엔드포인트에서나 뜻이 통하지만, 성공 문구는 그렇지 않다 — 필요하면 호출한 쪽이 띄운다.
- **이미 자기 래퍼로 통지하고 있다면 `*Quiet` 로 바꾼다.** 이중 토스트를 막는 탈출구이고,
  `odata*Quiet` 와 같은 관용구다(한 사용자 액션이 여러 요청을 낼 때도 같은 것을 쓴다).

#### 401 축 — 전역 정책과 그 호출 단위 예외

`onUnauthorized` 는 **서비스 전역 정책**이다: 401 이 오면 훅을 부르고 `messages.sessionExpired`
로 단락한다. 이 기본값은 거의 항상 옳지만 **두 자리에서 틀린다.**

| 자리 | 왜 틀리는가 | 해법 |
|---|---|---|
| **로그인 자체** | 401 이 「세션이 끊겼다」가 아니라 「자격 증명이 틀렸다」를 뜻한다. 전역 훅이 발화하면 로그인 화면에서 로그인 화면으로 리다이렉트되고, 던져지는 메시지가 `sessionExpired` 라 화면이 실패 사유를 **지어내야** 한다 | `{ onUnauthorized: false }` |
| **연결 프로브** | *「응답이 왔는가」* 만 묻는 호출이라, 401 은 **서버가 살아서 거절한 것** = 「닿았다」다. 훅+예외를 타면 로그인하지 않은 사용자에게 늘 「서버에 연결할 수 없습니다」가 뜬다 | `fetchRaw` |

```ts
// 401 을 «그 호출의 결과» 로 받는다 — 훅 없음, 메시지 덮어쓰기 없음.
await svc.apiPost('auth/login', creds, { onUnauthorized: false })
```

- **`fetchRaw` 를 뺀 모든 요청 메서드가 이 옵션을 받는다**(마지막 선택 인자 — `fetchRaw` 는 애초에 401 훅을 태우지 않는다). 부분집합으로 두면 다음 소비자가
  다른 메서드에서 같은 벽을 만난다.
- **끄는 축만 있다.** 새 동작을 켜는 스위치가 아니라 전역 정책의 탈출구이므로, 옵션을 생략한
  호출은 이 옵션이 생기기 전과 **한 글자도 다르게 동작하지 않는다.**
- **`authenticate()` 같은 이름 있는 프리미티브는 두지 않는다** — 그것은 엔드포인트 경로·바디
  모양·토큰 처리 규약을 라이브러리가 안다고 가정하는 **도메인 이름**이다. 그 규약은 앱마다
  다르므로 adapter 에 남기고, 라이브러리는 범용 축만 연다.

#### `fetchRaw` — 「raw」 는 정책을 태우지 않는다는 뜻이다

응답을 **그대로** 돌려준다. **비-2xx 에도 던지지 않고 `onUnauthorized` 도 부르지 않는다** —
상태 판단은 호출부의 몫이다. 상태에 따라 예외·토스트·세션 처리를 원하면 `apiGet` 을 쓴다.

> ⚠**0.15.0 이전에는 이 문장이 선언에 적혀 있으면서 실제로는 비-2xx 에 던졌다.** 선언이 옳고
> 구현이 틀렸던 자리라 구현을 고쳤다. `fetchRaw` 의 throw 에 기대고 있었다면 호출부에서
> `if (!res.ok) throw …` 로 바꾸거나 `apiGet` 으로 옮긴다.

> 도메인 액션(상태 전이 등)·엔티티 목록·권한 코드는 라이브러리에 넣지 말고 앱 adapter 에 둔다.

#### 실패 응답 — `ApiError`

모든 메서드는 실패 시 `ApiError`(`Error` 상속)를 던진다. `status` 로 상태별 분기하고,
서버가 OData v4 오류 봉투에 필드별 검증 상세(`error.details`)를 실어 보내면 `details` 로
읽어 폼의 필드별 오류 표시에 바로 연결할 수 있다.

```typescript
import { applyFieldErrors } from '@iyulab/enterprise'

try {
  await svc.odataPost('Roles', draft)
} catch (e) {
  // [{ code: 'ValidationFailed', message: 'The Name field is required.', target: 'Name' }, …]
  const { applied, formLevel } = applyFieldErrors(form, e)
  // applied: 칸에 내려간 항목(control · message) — 요약에 칸으로 가는 링크로
  // formLevel: target 이 없거나 그 이름의 칸이 없는 항목 — 요약에 링크 없는 줄로
}
```

`applyFieldErrors(root, error, { message? })` 는 `target` 과 같은 `name` 의 컨트롤(네이티브 폼 요소 ·
`@iyulab/components` 폼 컨트롤)에 `setCustomValidity(message)` 를 하고, components 컨트롤이면 `validate()` 로
오류를 보인다. 사용자가 그 칸을 고치면(`input`·`change`) 그 메시지를 걷고, 다시 부르면 앞의 것을 먼저 걷는다
(`clearFieldErrors(root)` 로도 걷는다). `message` 로 문장을 바꾼다 — `detail.code` 로 지역화할 때(예: 값을 그
타입으로 읽지 못한 `Unconvertible` 만 앱의 언어로). `ApiError` 가 아닌 실패는 빈 결과다.

| 필드 | 설명 |
|------|------|
| `message` | 사용자 대면 메시지 (`formatError` → 서버 raw → status 폴백 순으로 결정) |
| `status` | HTTP status |
| `code` | 서버가 정한 거절 코드 — OData `error.code`(봉투가 없으면 최상위 `code` → `Code`). 같은 상태 안의 실패를 가른다(예: 403 중 «비밀번호 변경 필요»). 없거나 빈 문자열이면 `undefined` |
| `details` | `error.details` 항목 배열 — 규격상 `code`/`message` 는 필수, `target`(속성 이름)은 선택. 상세가 없거나 규격 형태가 아니면 `undefined` |
| `body` | 실패 응답의 본문 — JSON 이면 파싱한 값, 아니면 텍스트, 비었으면 `undefined`. 거절이 싣는 «충돌의 현재 상태»(409 의 새 판정, 412 의 현재 표현)를 같은 요청을 다시 보내지 않고 읽는다. 모양은 서버 계약이라 `unknown` — 호출부가 좁힌다. `formatError` 가 받는 `body` 와 같은 값. 표준 `cause` 처럼 열거되지 않는다(직렬화·로그에 실리지 않음) |
| `cause` | 표준 `Error.cause` — 응답 없이 실패했을 때(인증 클라이언트의 네트워크 오류, `status` 0) 전송이 던진 예외. 진단용 |
| `notified` | 서비스가 이 실패를 `notify.error` 로 **이미 사용자에게 알렸는가**(읽기 전용). 쓰기 실패를 알렸으면 `true`, 읽기·`*Quiet`·401 이나 `notify.error` 미설정이면 `false` |

#### 경계에서 «알리지 않은 것만» 알리기 — `notified` · `wasNotified`

쓰기 실패는 서비스가 알린 **뒤** 같은 에러를 다시 던진다(호출부 흐름을 멈추게 하려고). 그 에러가 전역
`unhandledrejection` 핸들러나 error boundary 까지 올라오면, 거기서 다시 토스트를 띄우면 이중 통지가 되고
안 띄우면 읽기 실패가 조용해진다. 어느 쪽인지는 에러가 들고 온다:

```typescript
import { ApiError, wasNotified } from '@iyulab/enterprise'

window.addEventListener('unhandledrejection', (ev) => {
  const err = ev.reason
  if (err instanceof ApiError || wasNotified(err)) ev.preventDefault()
  if (err instanceof ApiError && !err.notified) app.error(err.message)   // 읽기 실패 등
})
```

`wasNotified(err)` 는 `ApiError` 가 아닌 실패(네트워크 오류 등)에도 같은 답을 준다 — 쓰기 경로에서 알렸다면
`true`. 표식은 서비스만 세울 수 있다(에러 객체의 필드가 아니다).

#### OData v4 적합성 (conformance)

서비스가 OData v4 의 어느 조항을 따르는지 — «구현» 행은 전부 `tests/odata-conformance.test.ts` 가
**명세의 예시 응답 모양**으로 잰다. 서버가 새 조항을 쓰기 시작하는 날(서버 주도 페이징 등) 소비자가
먼저 깨지지 않게 하려는 표다.

| 조항 | 상태 | 메모 |
|---|---|---|
| 서버 주도 페이징 — `@odata.nextLink`(상대·절대) | 구현 | `odataGet` 은 끝까지 따라가고, `odataGetPage` 는 `nextLink` 를 준다. 서비스 오리진 밖 · 이미 읽은 링크로의 순환은 던진다 |
| `nextLink` 를 불투명하게 따라가기(Protocol §11.2.6.7) | 구현 | 서버가 쓴 쿼리가 바이트 그대로 나간다(`@iyulab/http-client` 0.10.3 이상 — 범위 `^0.10.0` 이면 재설치로 받는다) |
| `$count=true` → `@odata.count` · `odataCount` | 구현 | `odataCount` 는 `$top=0&$count=true` 로 행 없이 건수만 |
| 오류 봉투 — `error.code`·`message`·`details[]`(`code`·`message`·`target`) | 구현 | `ApiError.message`·`ApiError.code`·`ApiError.details` · `innererror` 는 해석하지 않는다(서버의 디버깅용이지 계약이 아니다) — 원문 `body` 에만 있고, `body` 는 열거되지 않아 에러를 직렬화해도 따라 나가지 않는다 |
| 생성 201 · 수정/삭제 204 No Content | 구현 | 빈 바디를 파싱하지 않는다 |
| 키 리터럴(URL Conventions §4.3.1) | 호출자 | `id` 는 URL 에 쓰이는 리터럴 그대로다 — GUID·숫자는 그대로, 문자열 키는 호출자가 `'…'` 로 감싸고 `'` 를 `''` 로. 클라이언트는 메타데이터 없이 둘을 가를 수 없다 |
| 낙관적 동시성 — ETag · `If-Match` · 412 | 미구현 | 서버 지원 확인 뒤에 연다 |
| `Prefer`(`return=…` · `odata.maxpagesize`) · `$batch` · `@odata.deltaLink` | 미구현 | |
| `429` · `Retry-After` | 미구현 | 다른 오류와 같이 `ApiError` 로 던진다(재시도하지 않는다) |


### 인증 + 권한 (`createAuthClient` · 권한 store)

쿠키 세션 인증 흐름과 권한 스냅샷을 승격. 사용자·자격증명 **형태는 앱이 제네릭으로 정의**하고, 권한 코드는 불투명 문자열로만 다룬다. `getPermissions` 를 주면 로그인/세션 조회 성공 시 권한 store 가 자동 갱신된다.

```typescript
import { createAuthClient, hasPermission, setPermissions } from '@iyulab/enterprise'

interface User { Id: string; Permissions: string[] }   // 도메인 형태 = 앱 소유

export const auth = createAuthClient<User, { Username: string; Password: string }>({
  meUrl: '/api/auth/me', loginUrl: '/api/auth/login', logoutUrl: '/api/auth/logout',
  getPermissions: (u) => u.Permissions,                 // 성공 시 권한 store 자동 set
  messages: { invalidCredentials: '사용자명 또는 비밀번호가 올바르지 않습니다.' },
})

// 부팅 게이트 — 답은 셋이다
const session = await auth.fetchMe()
if (session.status === 'anonymous') showLogin()                    // 401 — 로그인 화면
else if (session.status === 'unknown') showOffline(session.error)  // 답을 못 받음 — 로그인으로 보내지 않는다

// @iyulab/modern-app 의 부팅 게이트는 이 답을 그대로 받는다
await app.load({ auth: { me: () => auth.fetchMe(), renderLogin, renderUnavailable }, /* … */ })

// 로그아웃도 결과를 본다 — 서버가 세션을 끊지 못했으면(2xx·401 이 아닌 답 · 응답 없음) 쿠키가 살아 있다
const out = await auth.logout()
if (out.ok) showLogin()
else showError(out.message)                                        // 화면을 지키고 다시 시도하게 한다

// 어디서나 권한 판정(부팅 스냅샷)
if (hasPermission('orders.write')) { /* 저장 버튼 노출 */ }
```

주입 항목(위 예시가 쓴 것 외 나머지):

| config | 용도 |
|--------|------|
| `baseUrl` | 상대 URL 앞에 붙일 오리진 (기본 `''` = same-origin) |
| `credentials` | fetch `credentials` 모드 (기본 `'same-origin'` — 쿠키 세션) |
| `extractLoginError` | 로그인 실패(non-401) 응답 바디에서 사용자 대면 메시지 추출 오버라이드 (기본: 봉투 `error.message` → `message` → `Message`) |
| `permissionStore` | 권한 자동 갱신 대상 store (기본 `defaultPermissionStore`) — 격리가 필요하면 `createPermissionStore()`로 별도 store를 만들어 주입 |
| `loginUser` | 로그인 성공 뒤 세션 사용자를 어디서 얻는가 — `'body'`(기본: 로그인 응답 본문) · `'me'`(`meUrl` 재조회 — 로그인 응답이 `{ Success, User, Token }` 같은 봉투인 서버). `LoginResult` 성공에는 응답 원문 `body` 도 실린다 |

- `fetchMe()` 는 던지지 않고 `{ status: 'authenticated', user }` · `{ status: 'anonymous' }`(401) · `{ status: 'unknown', error }`(그 밖의 비-2xx · 네트워크 · 읽을 수 없는 본문) 중 하나를 준다. 로그인 게이트는 `anonymous` 에서만 연다 — `unknown` 을 미인증으로 읽으면 서버가 잠깐 503 을 낸 순간 모두가 로그인 화면으로 간다. 라이브러리는 리다이렉트하지 않는다.
- `login()` 실패는 `{ ok: false, message, error }` — `error` 는 `ApiError`(`status` · `code` · `details`, 응답이 없으면 `status: 0`)라 429 · 403 거절 코드 같은 갈래로 화면을 나눌 수 있다.
- 권한 store 는 «모름» 과 «없음» 을 가른다(`isKnown()` · `permissionsKnown()`). `unknown` 세션은 마지막으로 알던 권한을 건드리지 않는다.
- 도메인 판정(`isPortalUser` 등)·권한 코드 상수는 라이브러리가 아니라 앱 adapter 에 둔다.

### 도메인 헬퍼

```typescript
import { DateHelper } from '@iyulab/enterprise'
import { formatCurrency } from '@iyulab/components/dist/utilities/format.js'   // 통화·숫자 포맷은 components 의 것(배럴은 컴포넌트를 전부 등록한다)

DateHelper.formatDate('2026-10-09')   // 그 날(로컬 달력)로 읽는다
formatCurrency(1234000, 'KRW')        // ₩1,234,000
```

## Development

```bash
npm run build
```

## License

MIT
