diff --git a/FEATURE_TODO.md b/FEATURE_TODO.md
new file mode 100644
index 00000000..070ddeaf
--- /dev/null
+++ b/FEATURE_TODO.md
@@ -0,0 +1,201 @@
+# Auth Wallet Feature
+
+Adding NUT-21/22 authentication support (CAT + BAT) to coco-cashu.
+
+## Background
+
+BAT (Blind Auth Token) is a standard ecash proof with `unit:'auth', amount:1`.
+cashu-ts provides `AuthManager` which handles the full CAT/BAT lifecycle internally:
+- Auth keysets are fetched from `/v1/auth/blind/keysets` (not `/v1/keysets`)
+- BAT pool is managed internally by `AuthManager` (mint/consume/top-up)
+- `exportPool()` / `importPool()` API for external persistence
+
+BAT uses independent storage — not shared with regular keyset/proof repositories.
+This avoids contaminating balance calculations and proof state tracking, and matches
+the lifecycle: auth session + BAT pool are always created/deleted together.
+
+## Completed
+
+### 1. AuthSession Model
+- [x] `models/AuthSession.ts` — session data structure
+- [x] `models/index.ts` export
+
+### 2. Repository
+- [x] `repositories/index.ts` — `AuthSessionRepository` interface
+- [x] `RepositoriesBase` — `authSessionRepository` field
+- [x] `repositories/memory/MemoryAuthSessionRepository.ts` — in-memory implementation for tests
+- [x] `repositories/memory/index.ts` export
+- [x] `MemoryRepositories.ts` wiring
+
+### 3. Errors
+- [x] `models/Error.ts` — `AuthSessionError`, `AuthSessionExpiredError`
+
+### 4. Events
+- [x] `events/types.ts` CoreEvents:
+ - `auth-session:updated`
+ - `auth-session:deleted`
+ - `auth-session:expired`
+
+### 5. Service
+- [x] `services/AuthSessionService.ts` — session CRUD + expiration validation
+- [x] `services/index.ts` export
+
+### 6. Tests
+- [x] `test/unit/AuthSessionService.test.ts` — unit tests
+
+### 7. API Integration (AuthApi + Manager + MintAdapter)
+- [x] `api/AuthApi.ts` — orchestrates cashu-ts AuthManager per mint
+ - `startDeviceAuth(mintUrl)` — OIDC Device Code Flow
+ - `login(mintUrl, tokens)` — manual login with externally obtained tokens
+ - `restore(mintUrl)` — restore session on app restart
+ - `logout(mintUrl)` — delete session + disconnect AuthProvider
+ - `getSession(mintUrl)` / `hasSession(mintUrl)` — session queries
+ - `getAuthProvider(mintUrl)` — access cashu-ts AuthProvider
+- [x] `Manager.ts` — `readonly auth: AuthApi` property
+- [x] `MintAdapter.ts` — `setAuthProvider`/`clearAuthProvider`
+ - Passes `authProvider` to cashu-ts `Mint` constructor
+- [x] `api/index.ts` export
+- [x] `services/AuthSessionService.ts` — fixed Logger import (`@nestjs/common` → `@core/logging`)
+- [x] `test/unit/AuthApi.test.ts` — 9 unit tests
+- [x] `test/integration/auth-session.test.ts` — end-to-end via `mgr.auth.startDeviceAuth()`
+
+### 8. Phase 1: BAT Pool Persistence
+- [x] `models/AuthSession.ts` — `batPool?: Proof[]` 필드 추가
+- [x] `services/AuthSessionService.ts` — `saveSession()` 3번째 파라미터 `batPool` 추가
+- [x] `api/AuthApi.ts` — `saveSessionWithPool()` private helper 추가
+ - `startDeviceAuth` — `onTokens` + `poll()` 에서 `exportPool()` 포함 저장
+ - `login` — `saveSessionWithPool()` 사용
+ - `restore` — `session.batPool` 있으면 `importPool(proofs, 'replace')`
+ - `attachOIDC` — `onTokens` 콜백에서 `exportPool()` 포함 저장
+ - `logout` — 변경 없음 (session 삭제 시 batPool도 함께 삭제)
+- [x] `test/unit/AuthSessionService.test.ts` — batPool round-trip + backward compat 테스트 2개
+- [x] `test/unit/AuthApi.test.ts` — restore+importPool, restore without pool, login batPool 테스트 3개
+
+### 9. Phase 2: Storage Adapters (All Platforms)
+- [x] `packages/sqlite3/`
+ - `src/repositories/AuthSessionRepository.ts` — SQLite 구현 (UPSERT, batPoolJson TEXT)
+ - `src/schema.ts` — migration `012_auth_sessions` 추가
+ - `src/index.ts` — `SqliteRepositories` + `withTransaction` 연결
+- [x] `packages/expo-sqlite/`
+ - `src/repositories/AuthSessionRepository.ts` — Expo SQLite 구현
+ - `src/schema.ts` — migration `012_auth_sessions` 추가
+ - `src/index.ts` — `ExpoSqliteRepositories` + `withTransaction` 연결
+- [x] `packages/indexeddb/`
+ - `src/repositories/AuthSessionRepository.ts` — Dexie 구현
+ - `src/lib/db.ts` — `AuthSessionRow` 타입 추가
+ - `src/lib/schema.ts` — Dexie version 10 (`coco_cashu_auth_sessions: '&mintUrl'`)
+ - `src/index.ts` — `IndexedDbRepositories` + `withTransaction` 연결
+
+### 10. BAT TopUp Integration Tests
+- [x] `test/unit/AuthManager.topUp.test.ts` — 실제 secp256k1 암호화 mock mint 기반 5개 테스트
+ - 빈 pool → 자동 topUp 트리거 (init + `/v1/auth/blind/mint` 호출 검증)
+ - pool에 토큰 있을 때 → topUp 미발생 확인
+ - pool 소진 후 → 재 topUp 트리거
+ - `bat_max_mint` 제한 준수
+ - `exportPool()` → `importPool()` round-trip으로 topUp 회피
+
+### 11. WalletService Auth Integration
+- [x] `MintAdapter.getAuthProvider()` — auth provider 읽기 접근자
+- [x] `WalletService` — optional `authProviderGetter`, `buildWallet()`에서 `authProvider` 전달
+- [x] `Manager.ts` — getter 클로저 연결 + 이벤트 기반 캐시 무효화
+- [x] Wallet 기반 작업 (createMintQuote, melt, swap, send)에 BAT/CAT 헤더 포함
+
+### 12. BAT checkstate / spend (non-standard cdk extension)
+- [x] `types.ts` — `AuthProof`, `CheckBlindAuthStateRequest/Response`, `SpendBlindAuthRequest/Response`, `BlindAuthProofState` 타입 추가
+- [x] `types.ts` — `toAuthProof(proof)` 유틸 (Proof → AuthProof, amount/witness 제거, dleq {e,s,r} 보존)
+- [x] `infra/MintAdapter.ts` — `checkBlindAuthState()`, `spendBlindAuth()` 메서드 추가
+ - cashu-ts Mint에 없는 비표준 엔드포인트 → `requestProvider.getRequestFn()` 직접 사용
+ - 기존 rate-limiting 자동 적용
+- [x] `api/AuthApi.ts` — `checkBlindAuthState(mintUrl, proofs)`, `spendBlindAuth(mintUrl, proof)` public 메서드
+ - `Proof[] → AuthProof[]` 변환은 AuthApi에서 수행 (MintAdapter는 wire type만 다룸)
+ - mintUrl 정규화, local pool 미수정 원칙
+- [x] `test/unit/AuthApi.test.ts` — 4개 테스트 추가 (Proof→AuthProof 변환, URL 정규화, 에러 전파)
+- [x] `test/integration/auth-bat.test.ts` — CAT + BAT 통합 테스트 4개
+ - T1: CAT-protected endpoint (createMintQuote) 성공, pool 0 유지
+ - T2: ensure() → BAT 발급, pool ≥ 3
+ - T3: session restore → CAT 동작 + BAT 재발급 가능
+ - T4: flush → re-issue → checkBlindAuthState(UNSPENT) → spendBlindAuth → checkBlindAuthState(SPENT)
+- [x] `docs/auth-architecture.md` — Flow 6 (checkstate/spend), 권장 민트 설정, 테스트 스위트 문서화
+
+## Remaining Work
+
+### Phase 3: React Wrapper (optional)
+
+- [ ] `packages/react/` auth hooks
+ - `useAuthSession()`
+ - `useBatPool()`
+
+## Architecture (Current)
+
+```
+mgr.auth.startDeviceAuth(mintUrl)
+ → Creates AuthManager + OIDCAuth (cashu-ts)
+ → oidc.startDeviceAuth() → user authorizes → poll()
+ → AuthSessionService.saveSession() (persistence)
+ → MintAdapter.setAuthProvider() (injects authProvider into Mint)
+ → All subsequent Mint requests auto-include CAT/BAT headers
+
+AuthManager (built into cashu-ts):
+ - CAT storage / retrieval / auto-refresh via OIDCAuth
+ - BAT auto-minting / pool management / DLEQ validation
+ - Auth keysets fetched from /v1/auth/blind/keysets (managed internally)
+ - Auto-detects NUT-21 (CAT) vs NUT-22 (BAT) per endpoint
+ - exportPool() → Proof[] (snapshot for persistence)
+ - importPool(proofs, 'replace'|'merge') (restore from persistence)
+```
+
+### Storage Strategy: Independent (Not Shared)
+
+```
+Regular ecash: MintService → /v1/keysets → KeysetRepository
+ WalletService → ProofRepository (unit:'sat')
+
+Auth (BAT): AuthManager → /v1/auth/blind/keysets (internal)
+ AuthManager.exportPool() → AuthSession.batPool (Proof[])
+ ↕ saved/restored via AuthSessionRepository
+```
+
+- Auth keysets: cashu-ts internal, coco가 별도 저장할 필요 없음
+- BAT proofs: `AuthSession.batPool`에 JSON으로 직렬화, session과 동일 lifecycle
+- 기존 KeysetRepository/ProofRepository와 완전 분리 → 잔고 오염 없음
+
+## Reference Patterns
+
+| New | Existing Pattern |
+|-----|-----------------|
+| AuthSessionRepository | MintQuoteRepository |
+| AuthSessionService | MintQuoteService |
+
+## Conventions
+
+- **Normalize mint URLs**: always pass through `normalizeMintUrl()` before storage/comparison
+- **Emit events**: emit EventBus events on every state change
+- **Domain errors**: use `models/Error.ts` classes instead of plain `new Error()`
+- **Include cause**: preserve original error when wrapping
+
+## Running Tests
+
+```sh
+# All core tests (452 pass expected)
+bun run --filter='coco-cashu-core' test
+
+# AuthSessionService only
+cd packages/core
+bun test test/unit/AuthSessionService.test.ts
+
+# AuthApi only
+bun test test/unit/AuthApi.test.ts
+
+# BAT topUp tests (mock mint with real DLEQ)
+bun test test/unit/AuthManager.topUp.test.ts
+
+# sqlite3 contract tests
+cd packages/sqlite3
+bun test
+
+# Integration test — auth session (requires running mint + manual OIDC authorization)
+MINT_URL=http://localhost:8085 bun test test/integration/auth-session.test.ts --timeout 300000
+
+# Integration test — CAT + BAT + checkstate/spend
+MINT_URL=http://localhost:8085 bun test test/integration/auth-bat.test.ts --timeout 300000
+```
diff --git a/bun.lock b/bun.lock
index 2a9c0144..32fe5335 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
- "configVersion": 1,
"workspaces": {
"": {
"name": "coco-cashu",
@@ -13,7 +12,7 @@
},
"packages/adapter-tests": {
"name": "coco-cashu-adapter-tests",
- "version": "1.1.2-rc.42",
+ "version": "1.1.2-rc.43",
"dependencies": {
"fake-bolt11": "^0.1.0",
},
@@ -23,13 +22,13 @@
},
"peerDependencies": {
"@cashu/cashu-ts": "^3.3.0",
- "coco-cashu-core": "^1.1.2-rc.42",
+ "coco-cashu-core": "^1.1.2-rc.43",
"typescript": "^5",
},
},
"packages/core": {
"name": "coco-cashu-core",
- "version": "1.1.2-rc.42",
+ "version": "1.1.2-rc.43",
"dependencies": {
"@cashu/cashu-ts": "3.3.0",
"@noble/curves": "^2.0.1",
@@ -38,7 +37,7 @@
},
"devDependencies": {
"@types/bun": "latest",
- "coco-cashu-adapter-tests": "1.1.2-rc.42",
+ "coco-cashu-adapter-tests": "1.1.2-rc.43",
"esbuild": "latest",
"tsdown": "0.20.1",
"typescript": "^5.9.2",
@@ -60,7 +59,7 @@
},
"packages/expo-sqlite": {
"name": "coco-cashu-expo-sqlite",
- "version": "1.1.2-rc.42",
+ "version": "1.1.2-rc.43",
"devDependencies": {
"coco-cashu-adapter-tests": "1.1.2-rc.42",
"tsdown": "0.20.1",
@@ -73,7 +72,7 @@
},
"packages/indexeddb": {
"name": "coco-cashu-indexeddb",
- "version": "1.1.2-rc.42",
+ "version": "1.1.2-rc.43",
"dependencies": {
"dexie": "^4.0.8",
},
@@ -92,14 +91,14 @@
},
"packages/react": {
"name": "coco-cashu-react",
- "version": "1.1.2-rc.42",
+ "version": "1.1.2-rc.43",
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"ajv": "^8.17.1",
- "coco-cashu-core": "1.1.2-rc.42",
+ "coco-cashu-core": "1.1.2-rc.43",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
@@ -111,13 +110,13 @@
"vite-plugin-dts": "^4.5.4",
},
"peerDependencies": {
- "coco-cashu-core": "1.1.2-rc.42",
+ "coco-cashu-core": "1.1.2-rc.43",
"react": "^19",
},
},
"packages/sqlite3": {
"name": "coco-cashu-sqlite3",
- "version": "1.1.2-rc.42",
+ "version": "1.1.2-rc.43",
"devDependencies": {
"@types/sqlite3": "^3.1.8",
"coco-cashu-adapter-tests": "1.1.2-rc.42",
@@ -411,57 +410,57 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
- "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
- "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
- "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
- "@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
- "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
- "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
- "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
- "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
- "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
- "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
- "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
- "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
- "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
- "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
- "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
- "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
- "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
- "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
- "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
- "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
- "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
- "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
- "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
- "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
- "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
- "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
@@ -1217,7 +1216,7 @@
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
- "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
+ "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -2585,6 +2584,8 @@
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
+ "vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
+
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
@@ -2825,6 +2826,58 @@
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
+ "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
+
+ "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
+
+ "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
+
+ "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
+
+ "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
+
+ "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
+
+ "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
+
+ "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
+
+ "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
+
+ "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
+
+ "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
+
+ "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
+
+ "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
+
+ "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
+
+ "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
+
+ "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
+
+ "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
+
+ "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
+
+ "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
+
+ "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
+
+ "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
+
+ "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
+
+ "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
+
+ "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
+
+ "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
+
+ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
+
"@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"@babel/highlight/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
diff --git a/docs/auth-architecture.md b/docs/auth-architecture.md
new file mode 100644
index 00000000..65b4f405
--- /dev/null
+++ b/docs/auth-architecture.md
@@ -0,0 +1,327 @@
+# NUT-21/22 Auth System Architecture
+
+## Core Concepts
+
+```
+CAT (Client Auth Token) = OIDC access_token. User identity proof.
+BAT (Blind Auth Token) = ecash proof (unit:'auth', amount:1). Consumed per request.
+ Blind signature prevents mint from tracking "who made the request".
+```
+
+## Layer Structure
+
+```
+Manager (entry point)
+ ├── mgr.auth.* AuthApi
+ ├── mgr.quotes.* QuotesApi
+ ├── mgr.wallet.* WalletApi
+ └── mgr.mint.* MintApi
+
+Services (business logic)
+ ├── AuthSessionService Session CRUD + expiry validation
+ ├── WalletService Wallet creation / caching
+ └── MintService Mint info management
+
+Infra (external communication)
+ ├── MintAdapter HTTP -> Mint object management
+ └── MintRequestProvider Request rate limiting
+
+Repositories (storage)
+ └── AuthSessionRepository memory / sqlite / indexeddb
+
+cashu-ts (external library)
+ ├── AuthManager CAT/BAT lifecycle management
+ ├── OIDCAuth OIDC Device Code Flow
+ ├── Mint Mint HTTP client
+ └── Wallet ecash operations (swap, melt, etc.)
+```
+
+## Storage Separation
+
+```
+Regular ecash: /v1/keysets -> KeysetRepository
+ Wallet -> ProofRepository (unit:'sat')
+
+Auth (BAT): /v1/auth/blind/keysets -> cashu-ts internal
+ AuthManager.exportPool() -> AuthSession.batPool (Proof[])
+ <-> AuthSessionRepository (JSON serialized)
+```
+
+BAT is `unit:'auth'` and must not mix with balance.
+When a session is deleted, BATs are deleted together.
+
+---
+
+## Flow 1: Initial Authentication (`startDeviceAuth`)
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant AuthApi
+ participant cashu_ts as cashu-ts
(AuthManager + OIDCAuth)
+ participant Mint as Mint Server
+ participant DB as AuthSessionRepository
+
+ User->>AuthApi: mgr.auth.startDeviceAuth(mintUrl)
+ AuthApi->>cashu_ts: new AuthManager(mintUrl)
+ AuthApi->>cashu_ts: mint.oidcAuth({ onTokens })
+ cashu_ts->>Mint: GET /v1/auth/info
+ Mint-->>cashu_ts: OIDC metadata
+ AuthApi->>cashu_ts: oidc.startDeviceAuth()
+ cashu_ts->>Mint: POST /device/auth
+ Mint-->>cashu_ts: device_code + user_code
+ AuthApi-->>User: verification_uri + user_code
+
+ Note over User: User authorizes in browser
+
+ User->>AuthApi: device.poll()
+ AuthApi->>cashu_ts: poll()
+ cashu_ts->>Mint: POST /token (polling)
+ Mint-->>cashu_ts: access_token + refresh_token
+
+ AuthApi->>DB: saveSession(tokens + batPool)
+ AuthApi->>AuthApi: managers.set(mintUrl, auth)
+ AuthApi->>AuthApi: mintAdapter.setAuthProvider
(PersistingProvider wrapper)
+ AuthApi-->>User: tokens
+```
+
+## Flow 2: Authenticated Request (`createMintQuote`)
+
+```mermaid
+sequenceDiagram
+ participant App
+ participant WalletService
+ participant Wallet as Wallet / Mint
(cashu-ts)
+ participant PP as PersistingProvider
(wrapper)
+ participant AM as AuthManager
(cashu-ts)
+ participant Mint as Mint Server
+ participant DB as AuthSessionRepository
+
+ App->>WalletService: getWallet(mintUrl)
+ WalletService->>WalletService: buildWallet()
new Mint(url, { authProvider: PP })
+
+ App->>Wallet: createMintQuote()
+ Wallet->>PP: getBlindAuthToken({ method, path })
+ PP->>AM: auth.getBlindAuthToken()
+
+ alt pool is empty
+ AM->>Mint: POST /v1/auth/blind/mint
(topUp - mint new BATs using CAT)
+ Mint-->>AM: blind-signed BAT proofs
+ end
+
+ AM->>AM: pool.pop() (consume 1 BAT)
+ AM-->>PP: BAT token string
+
+ PP-)DB: persistPool() (fire & forget)
updateBatPool(mintUrl, exportPool())
+
+ PP-->>Wallet: BAT token string
+ Wallet->>Mint: POST /v1/mint/quote/bolt11
Authorization: BAT xxxxxx
+ Mint-->>Wallet: quote response
+ Wallet-->>App: quote
+```
+
+## Flow 3: App Restart (`restore`)
+
+```mermaid
+sequenceDiagram
+ participant App
+ participant AuthApi
+ participant DB as AuthSessionRepository
+ participant AM as AuthManager
(cashu-ts)
+ participant MintAdapter
+
+ App->>AuthApi: mgr.auth.restore(mintUrl)
+ AuthApi->>DB: getSession(mintUrl)
+ DB-->>AuthApi: session
(accessToken, refreshToken, batPool)
+
+ AuthApi->>AM: new AuthManager(mintUrl)
+ AuthApi->>AM: setCAT(accessToken)
+
+ alt batPool exists
+ AuthApi->>AM: importPool(proofs, 'replace')
+ end
+
+ alt refreshToken exists
+ AuthApi->>AM: attachOIDC(oidc)
(enables automatic CAT refresh)
+ end
+
+ AuthApi->>AuthApi: managers.set(mintUrl, auth)
+ AuthApi->>MintAdapter: setAuthProvider
(PersistingProvider wrapper)
+ AuthApi-->>App: true
+```
+
+## Flow 4: Cache Invalidation
+
+```mermaid
+sequenceDiagram
+ participant AuthApi
+ participant EventBus
+ participant Manager
+ participant WalletService
+ participant MintAdapter
+
+ Note over AuthApi: login / logout / token refresh
+
+ AuthApi->>MintAdapter: setAuthProvider() or clearAuthProvider()
+ MintAdapter->>MintAdapter: delete cached Mint instance
+
+ AuthApi->>EventBus: emit('auth-session:updated') or
emit('auth-session:deleted')
+ EventBus->>Manager: event handler
+ Manager->>WalletService: clearCache(mintUrl)
+
+ Note over WalletService: Next getWallet() call rebuilds
Wallet with new authProvider
+```
+
+## PersistingProvider Wrapper
+
+cashu-ts `AuthManager` has no pool-change callback.
+The wrapper intercepts `getBlindAuthToken()` and `ensure()` to auto-save pool to DB.
+
+```mermaid
+flowchart LR
+ subgraph PersistingProvider
+ A[getBlindAuthToken] --> B[auth.getBlindAuthToken]
+ B --> C[persistPool - fire & forget]
+ D[ensure] --> E[auth.ensure]
+ E --> C
+ F[getCAT / setCAT / ensureCAT] --> G[delegate as-is]
+ end
+
+ C --> H[(AuthSessionRepository
updateBatPool)]
+```
+
+## Flow 5: Logout
+
+```mermaid
+sequenceDiagram
+ participant App
+ participant AuthApi
+ participant DB as AuthSessionRepository
+ participant EventBus
+ participant MintAdapter
+ participant Manager
+ participant WalletService
+
+ App->>AuthApi: mgr.auth.logout(mintUrl)
+ AuthApi->>DB: deleteSession(mintUrl)
+ AuthApi->>AuthApi: managers.delete(mintUrl)
+ AuthApi->>AuthApi: oidcClients.delete(mintUrl)
+ AuthApi->>MintAdapter: clearAuthProvider(mintUrl)
+ MintAdapter->>MintAdapter: delete cached Mint
+
+ AuthApi->>EventBus: emit('auth-session:deleted')
+ EventBus->>Manager: handler
+ Manager->>WalletService: clearCache(mintUrl)
+
+ Note over WalletService: Next Wallet will be
created without authProvider
+```
+
+## Flow 6: BAT State Query & Spend (non-standard cdk extension)
+
+cashu-ts `Mint` class has no corresponding methods for these endpoints.
+`MintAdapter` calls `requestProvider.getRequestFn()` directly.
+
+```mermaid
+sequenceDiagram
+ participant App
+ participant AuthApi
+ participant MintAdapter
+ participant Mint as Mint Server
+
+ Note over App: checkBlindAuthState — read-only state query
+
+ App->>AuthApi: mgr.auth.checkBlindAuthState(mintUrl, proofs)
+ AuthApi->>AuthApi: proofs.map(toAuthProof)
strip amount/witness → {id, secret, C, dleq?}
+ AuthApi->>MintAdapter: checkBlindAuthState(mintUrl, { auth_proofs })
+ MintAdapter->>Mint: POST /v1/auth/blind/checkstate
(via requestFn — rate-limited)
+ Mint-->>MintAdapter: { states: [{ Y, state, witness? }] }
+ MintAdapter-->>App: CheckBlindAuthStateResponse
+
+ Note over App: spendBlindAuth — mark BAT as spent
+
+ App->>AuthApi: mgr.auth.spendBlindAuth(mintUrl, proof)
+ AuthApi->>AuthApi: toAuthProof(proof)
+ AuthApi->>MintAdapter: spendBlindAuth(mintUrl, { auth_proof })
+ MintAdapter->>Mint: POST /v1/auth/blind/spend
(via requestFn — rate-limited)
+ Mint-->>MintAdapter: { state: { Y, state: "SPENT" } }
+ MintAdapter-->>App: SpendBlindAuthResponse
+
+ Note over App: Local BAT pool is NOT modified.
Caller is responsible for pool management.
+```
+
+### Wire Types (`packages/core/types.ts`)
+
+```
+Proof (cashu-ts) AuthProof (wire)
+┌──────────────────┐ ┌──────────────────┐
+│ id │ ──── │ id │
+│ amount │ ✗ │ secret │
+│ secret │ ──── │ C │
+│ C │ ──── │ dleq? {e, s, r} │
+│ witness │ ✗ └──────────────────┘
+│ dleq? {e, s, r} │ ──── toAuthProof() strips amount + witness
+└──────────────────┘
+```
+
+## Recommended Mint Auth Configuration
+
+```
+# Entry points — authenticated users only
+mint: Blind # Token minting requires BAT
+get_mint_quote: Clear # Quote creation requires CAT (lightweight)
+check_mint_quote: Blind # Quote status requires BAT
+
+# Exit points — open (external recipients must redeem)
+melt: None # Anyone with tokens can withdraw
+get_melt_quote: None # Anyone can create withdrawal quotes
+check_melt_quote: None # Anyone can check withdrawal status
+
+# Token operations — open (receivers need swap to claim)
+swap: None # Receiving tokens requires swap
+check_proof_state: None # Anyone can verify token validity
+
+# Recovery — protected (computationally expensive, DoS vector)
+restore: Blind # Token recovery requires BAT
+```
+
+Rationale: Mint serves two user types.
+**Internal** (authenticated) users deposit funds via mint endpoints.
+**External** users receive ecash and must be able to redeem (melt/swap) without authentication.
+
+## Integration Test Suite
+
+File: `packages/core/test/integration/auth-bat.test.ts`
+
+```bash
+MINT_URL=http://localhost:8085 bun test packages/core/test/integration/auth-bat.test.ts --timeout 300000
+```
+
+Requires OIDC Device Code authorization in browser during `beforeAll`.
+
+```
+beforeAll
+ OIDC Device Code Flow → browser authorization → CAT acquired
+
+T1 CAT-protected endpoint succeeds without consuming BATs
+ createMintQuote (get_mint_quote = Clear)
+ → quote returned, pool stays 0
+ Verifies: CAT header auth works, BAT pool untouched
+
+T2 ensure() mints BATs via CAT and populates pool
+ provider.ensure(3)
+ → pool ≥ 3
+ Verifies: CAT → POST /v1/auth/blind/mint → BAT minting works
+
+T3 session restore → CAT works, BAT re-mintable
+ new Manager + restore() from same repository
+ → createMintQuote succeeds (CAT restored)
+ → ensure(2) succeeds (BAT re-mintable with restored CAT)
+ Verifies: session persistence, CAT + BAT capability after restart
+
+T4 flush → re-issue → checkBlindAuthState → spendBlindAuth
+ importPool([], 'replace') → ensure(3) → fresh pool
+ → checkBlindAuthState: all UNSPENT, pool size unchanged (read-only)
+ → spendBlindAuth(pool[0]): returns SPENT
+ → checkBlindAuthState: pool[0] SPENT, rest UNSPENT
+ Verifies: checkstate/spend endpoints, state transitions, read-only semantics
+```
diff --git a/packages/core/Manager.ts b/packages/core/Manager.ts
index 2b85d932..fc8c120f 100644
--- a/packages/core/Manager.ts
+++ b/packages/core/Manager.ts
@@ -20,6 +20,7 @@ import {
KeyRingService,
TransactionService,
PaymentRequestService,
+ AuthSessionService,
} from './services';
import { SendOperationService } from './operations/send/SendOperationService';
import { MeltOperationService } from './operations/melt/MeltOperationService';
@@ -34,7 +35,7 @@ import {
} from './infra';
import { EventBus, type CoreEvents } from './events';
import { type Logger, NullLogger } from './logging';
-import { MintApi, WalletApi, QuotesApi, HistoryApi, KeyRingApi, SendApi } from './api';
+import { MintApi, WalletApi, QuotesApi, HistoryApi, KeyRingApi, SendApi, AuthApi } from './api';
import { SubscriptionApi } from './api/SubscriptionApi.ts';
import { PluginHost } from './plugins/PluginHost.ts';
import type { Plugin, ServiceMap, PluginExtensions } from './plugins/types.ts';
@@ -164,6 +165,7 @@ export class Manager {
readonly subscription: SubscriptionApi;
readonly history: HistoryApi;
readonly send: SendApi;
+ readonly auth: AuthApi;
readonly ext: PluginExtensions;
private mintService: MintService;
private walletService: WalletService;
@@ -184,6 +186,7 @@ export class Manager {
private counterService: CounterService;
private transactionService: TransactionService;
private paymentRequestService: PaymentRequestService;
+ private authSessionService: AuthSessionService;
private sendOperationService: SendOperationService;
private sendOperationRepository: SendOperationRepository;
private meltOperationService: MeltOperationService;
@@ -241,6 +244,7 @@ export class Manager {
this.sendOperationRepository = core.sendOperationRepository;
this.meltOperationService = core.meltOperationService;
this.meltOperationRepository = core.meltOperationRepository;
+ this.authSessionService = core.authSessionService;
this.proofRepository = repositories.proofRepository;
const apis = this.buildApis();
this.mint = apis.mint;
@@ -250,6 +254,7 @@ export class Manager {
this.subscription = apis.subscription;
this.history = apis.history;
this.send = apis.send;
+ this.auth = apis.auth;
// Point ext to pluginHost's extensions storage
this.ext = this.pluginHost.getExtensions() as PluginExtensions;
@@ -260,6 +265,14 @@ export class Manager {
this.subscriptions.closeMint(mintUrl);
});
+ // Invalidate wallet cache when auth state changes so next getWallet() picks up the new authProvider
+ this.eventBus.on('auth-session:updated', ({ mintUrl }) => {
+ this.walletService.clearCache(mintUrl);
+ });
+ this.eventBus.on('auth-session:deleted', ({ mintUrl }) => {
+ this.walletService.clearCache(mintUrl);
+ });
+
// Initialize plugins asynchronously to keep constructor sync
const services: ServiceMap = {
mintService: this.mintService,
@@ -549,6 +562,7 @@ export class Manager {
sendOperationRepository: SendOperationRepository;
meltOperationService: MeltOperationService;
meltOperationRepository: MeltOperationRepository;
+ authSessionService: AuthSessionService;
} {
const mintLogger = this.getChildLogger('MintService');
const walletLogger = this.getChildLogger('WalletService');
@@ -577,6 +591,7 @@ export class Manager {
seedService,
this.mintRequestProvider,
walletLogger,
+ (mintUrl: string) => this.mintAdapter.getAuthProvider(mintUrl),
);
const counterService = new CounterService(
repositories.counterRepository,
@@ -672,6 +687,13 @@ export class Manager {
paymentRequestLogger,
);
+ const authSessionLogger = this.getChildLogger('AuthSessionService');
+ const authSessionService = new AuthSessionService(
+ repositories.authSessionRepository,
+ this.eventBus,
+ authSessionLogger,
+ );
+
return {
mintService,
seedService,
@@ -690,6 +712,7 @@ export class Manager {
sendOperationRepository,
meltOperationService,
meltOperationRepository,
+ authSessionService,
};
}
@@ -701,6 +724,7 @@ export class Manager {
subscription: SubscriptionApi;
history: HistoryApi;
send: SendApi;
+ auth: AuthApi;
} {
const walletApiLogger = this.getChildLogger('WalletApi');
const subscriptionApiLogger = this.getChildLogger('SubscriptionApi');
@@ -724,6 +748,8 @@ export class Manager {
const subscription = new SubscriptionApi(this.subscriptions, subscriptionApiLogger);
const history = new HistoryApi(this.historyService);
const send = new SendApi(this.sendOperationService);
- return { mint, wallet, quotes, keyring, subscription, history, send };
+ const authApiLogger = this.getChildLogger('AuthApi');
+ const auth = new AuthApi(this.authSessionService, this.mintAdapter, authApiLogger);
+ return { mint, wallet, quotes, keyring, subscription, history, send, auth };
}
}
diff --git a/packages/core/api/AuthApi.ts b/packages/core/api/AuthApi.ts
new file mode 100644
index 00000000..b1e2e04b
--- /dev/null
+++ b/packages/core/api/AuthApi.ts
@@ -0,0 +1,316 @@
+import {
+ AuthManager,
+ Mint,
+ type OIDCAuth,
+ type AuthProvider,
+ type TokenResponse,
+} from '@cashu/cashu-ts';
+import type { Proof } from '@cashu/cashu-ts';
+import type { AuthSessionService } from '@core/services';
+import type { AuthSession } from '@core/models';
+import type { MintAdapter } from '@core/infra/MintAdapter';
+import type { Logger } from '@core/logging';
+import { normalizeMintUrl } from '@core/utils';
+import { toAuthProof } from '@core/types';
+
+/**
+ * Public API for NUT-21/22 authentication.
+ *
+ * Orchestrates cashu-ts AuthManager (CAT/BAT lifecycle) and
+ * AuthSessionService (token persistence) so callers only need
+ * `mgr.auth.*` to authenticate with mints.
+ */
+export class AuthApi {
+ /** Per-mint AuthManager (always present after login/restore). */
+ private readonly managers = new Map();
+ /** Per-mint OIDCAuth (present when refresh_token is available). */
+ private readonly oidcClients = new Map();
+
+ constructor(
+ private readonly authSessionService: AuthSessionService,
+ private readonly mintAdapter: MintAdapter,
+ private readonly logger?: Logger,
+ ) {}
+
+ // ---------------------------------------------------------------------------
+ // OIDC Device Code flow
+ // ---------------------------------------------------------------------------
+
+ /**
+ * Start an OIDC Device Code authorization flow for a mint.
+ *
+ * Returns the device-code fields (verification_uri, user_code, etc.)
+ * plus a `poll()` helper that resolves once the user authorizes.
+ * After `poll()` succeeds the session is persisted and the
+ * AuthProvider is wired into MintAdapter automatically.
+ */
+ async startDeviceAuth(mintUrl: string) {
+ mintUrl = normalizeMintUrl(mintUrl);
+
+ const auth = new AuthManager(mintUrl);
+ const mint = new Mint(mintUrl, { authProvider: auth });
+ const oidc = await mint.oidcAuth({
+ onTokens: (t: TokenResponse) => {
+ auth.setCAT(t.access_token);
+ if (t.access_token) {
+ this.saveSessionWithPool(mintUrl, auth, {
+ access_token: t.access_token,
+ refresh_token: t.refresh_token,
+ expires_in: t.expires_in,
+ }).catch((err) => {
+ this.logger?.error('Failed to persist session in onTokens', {
+ mintUrl,
+ cause: err instanceof Error ? err.message : String(err),
+ });
+ });
+ }
+ },
+ });
+ auth.attachOIDC(oidc);
+
+ const device = await oidc.startDeviceAuth();
+
+ return {
+ verification_uri: device.verification_uri,
+ verification_uri_complete: device.verification_uri_complete,
+ user_code: device.user_code,
+ /** Poll until the user authorizes; resolves with the OIDC tokens. */
+ poll: async (): Promise => {
+ const tokens = await device.poll();
+ await this.saveSessionWithPool(mintUrl, auth, {
+ access_token: tokens.access_token!,
+ refresh_token: tokens.refresh_token,
+ expires_in: tokens.expires_in,
+ });
+ this.managers.set(mintUrl, auth);
+ this.oidcClients.set(mintUrl, oidc);
+ this.mintAdapter.setAuthProvider(mintUrl, this.createPersistingProvider(mintUrl, auth));
+ this.logger?.info('Auth session established', { mintUrl });
+ return tokens;
+ },
+ /** Cancel the pending device-code poll. */
+ cancel: device.cancel,
+ };
+ }
+
+ // ---------------------------------------------------------------------------
+ // Manual login (caller already has tokens, e.g. from auth-code flow)
+ // ---------------------------------------------------------------------------
+
+ /**
+ * Save OIDC tokens as an auth session and wire the AuthProvider.
+ *
+ * Use this when the caller already obtained tokens externally
+ * (e.g. via Authorization Code + PKCE or password grant).
+ */
+ async login(
+ mintUrl: string,
+ tokens: {
+ access_token: string;
+ refresh_token?: string;
+ expires_in?: number;
+ scope?: string;
+ },
+ ): Promise {
+ mintUrl = normalizeMintUrl(mintUrl);
+
+ const auth = new AuthManager(mintUrl);
+ auth.setCAT(tokens.access_token);
+
+ if (tokens.refresh_token) {
+ await this.attachOIDC(mintUrl, auth);
+ }
+
+ const session = await this.saveSessionWithPool(mintUrl, auth, tokens);
+
+ this.managers.set(mintUrl, auth);
+ this.mintAdapter.setAuthProvider(mintUrl, this.createPersistingProvider(mintUrl, auth));
+ this.logger?.info('Auth login completed', { mintUrl });
+ return session;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Restore (app restart)
+ // ---------------------------------------------------------------------------
+
+ /**
+ * Restore a persisted auth session and wire the AuthProvider.
+ *
+ * Call this on app startup for each mint that has a stored session.
+ * Returns true if a valid session was found and restored.
+ */
+ async restore(mintUrl: string): Promise {
+ mintUrl = normalizeMintUrl(mintUrl);
+
+ let session: AuthSession;
+ try {
+ session = await this.authSessionService.getValidSession(mintUrl);
+ } catch {
+ return false;
+ }
+
+ const auth = new AuthManager(mintUrl);
+ auth.setCAT(session.accessToken);
+
+ if (session.batPool?.length) {
+ auth.importPool(session.batPool, 'replace');
+ }
+
+ if (session.refreshToken) {
+ try {
+ await this.attachOIDC(mintUrl, auth);
+ } catch (err) {
+ this.logger?.warn('Failed to attach OIDC for refresh during restore', {
+ mintUrl,
+ cause: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+
+ this.managers.set(mintUrl, auth);
+ this.mintAdapter.setAuthProvider(mintUrl, this.createPersistingProvider(mintUrl, auth));
+ this.logger?.info('Auth session restored', { mintUrl });
+ return true;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Logout
+ // ---------------------------------------------------------------------------
+
+ /** Delete the auth session and disconnect the AuthProvider. */
+ async logout(mintUrl: string): Promise {
+ mintUrl = normalizeMintUrl(mintUrl);
+ await this.authSessionService.deleteSession(mintUrl);
+ this.managers.delete(mintUrl);
+ this.oidcClients.delete(mintUrl);
+ this.mintAdapter.clearAuthProvider(mintUrl);
+ this.logger?.info('Auth logout completed', { mintUrl });
+ }
+
+ // ---------------------------------------------------------------------------
+ // Session queries
+ // ---------------------------------------------------------------------------
+
+ /** Get a valid (non-expired) session; throws if missing or expired. */
+ async getSession(mintUrl: string): Promise {
+ return this.authSessionService.getValidSession(mintUrl);
+ }
+
+ /** Check whether a session exists for the given mint. */
+ async hasSession(mintUrl: string): Promise {
+ return this.authSessionService.hasSession(mintUrl);
+ }
+
+ // ---------------------------------------------------------------------------
+ // AuthProvider access (for advanced use)
+ // ---------------------------------------------------------------------------
+
+ /** Get the AuthProvider for a mint, or undefined if not authenticated. */
+ getAuthProvider(mintUrl: string): AuthProvider | undefined {
+ mintUrl = normalizeMintUrl(mintUrl);
+ return this.managers.get(mintUrl);
+ }
+
+ // ---------------------------------------------------------------------------
+ // BAT state queries (non-standard cdk extension)
+ // ---------------------------------------------------------------------------
+
+ /**
+ * Check whether BATs are valid and unspent without consuming them.
+ * Calls the mint's POST /v1/auth/blind/checkstate endpoint.
+ */
+ async checkBlindAuthState(mintUrl: string, proofs: Proof[]) {
+ mintUrl = normalizeMintUrl(mintUrl);
+ return this.mintAdapter.checkBlindAuthState(mintUrl, {
+ auth_proofs: proofs.map(toAuthProof),
+ });
+ }
+
+ /**
+ * Mark a single BAT as spent on the mint.
+ * Calls the mint's POST /v1/auth/blind/spend endpoint.
+ * Does not modify the local BAT pool — caller is responsible for pool management.
+ */
+ async spendBlindAuth(mintUrl: string, proof: Proof) {
+ mintUrl = normalizeMintUrl(mintUrl);
+ return this.mintAdapter.spendBlindAuth(mintUrl, {
+ auth_proof: toAuthProof(proof),
+ });
+ }
+
+ // ---------------------------------------------------------------------------
+ // Internal helpers
+ // ---------------------------------------------------------------------------
+
+ /**
+ * Create an OIDCAuth instance from the mint's NUT-21 metadata,
+ * attach it to the AuthManager for automatic CAT refresh, and
+ * register the onTokens callback for persistence.
+ */
+ private async attachOIDC(mintUrl: string, auth: AuthManager): Promise {
+ const mint = new Mint(mintUrl, { authProvider: auth });
+ const oidc = await mint.oidcAuth({
+ onTokens: (t: TokenResponse) => {
+ auth.setCAT(t.access_token);
+ if (t.access_token) {
+ this.saveSessionWithPool(mintUrl, auth, {
+ access_token: t.access_token,
+ refresh_token: t.refresh_token,
+ expires_in: t.expires_in,
+ }).catch((err) => {
+ this.logger?.error('Failed to persist session in onTokens', {
+ mintUrl,
+ cause: err instanceof Error ? err.message : String(err),
+ });
+ });
+ }
+ },
+ });
+ auth.attachOIDC(oidc);
+ this.oidcClients.set(mintUrl, oidc);
+ }
+
+ /**
+ * Wrap an AuthManager so that every BAT consumption/topUp automatically
+ * persists the updated pool to the session store.
+ */
+ private createPersistingProvider(mintUrl: string, auth: AuthManager): AuthProvider {
+ return {
+ getBlindAuthToken: async (input) => {
+ const token = await auth.getBlindAuthToken(input);
+ this.persistPool(mintUrl, auth);
+ return token;
+ },
+ ensure: async (minTokens: number) => {
+ await auth.ensure?.(minTokens);
+ this.persistPool(mintUrl, auth);
+ },
+ getCAT: () => auth.getCAT(),
+ setCAT: (cat) => auth.setCAT(cat),
+ ensureCAT: (minValiditySec) => auth.ensureCAT?.(minValiditySec),
+ };
+ }
+
+ private persistPool(mintUrl: string, auth: AuthManager): void {
+ const pool = auth.exportPool();
+ this.authSessionService.updateBatPool(mintUrl, pool.length > 0 ? pool : undefined).catch((err) => {
+ this.logger?.error('Failed to persist BAT pool after change', {
+ mintUrl,
+ cause: err instanceof Error ? err.message : String(err),
+ });
+ });
+ }
+
+ private async saveSessionWithPool(
+ mintUrl: string,
+ auth: AuthManager,
+ tokens: { access_token: string; refresh_token?: string; expires_in?: number; scope?: string },
+ ): Promise {
+ const batPool = auth.exportPool();
+ return this.authSessionService.saveSession(
+ mintUrl,
+ tokens,
+ batPool.length > 0 ? batPool : undefined,
+ );
+ }
+}
diff --git a/packages/core/api/index.ts b/packages/core/api/index.ts
index 1914bd43..47dc4cfa 100644
--- a/packages/core/api/index.ts
+++ b/packages/core/api/index.ts
@@ -5,3 +5,4 @@ export * from './KeyRingApi.ts';
export * from './SubscriptionApi.ts';
export * from './HistoryApi.ts';
export * from './SendApi.ts';
+export * from './AuthApi.ts';
diff --git a/packages/core/events/types.ts b/packages/core/events/types.ts
index f7b912c6..0d9e3c8a 100644
--- a/packages/core/events/types.ts
+++ b/packages/core/events/types.ts
@@ -1,6 +1,3 @@
-import type { Mint } from '../models/Mint';
-import type { Keyset } from '../models/Keyset';
-import type { Counter } from '../models/Counter';
import type {
MeltQuoteBolt11Response,
MeltQuoteState,
@@ -8,10 +5,13 @@ import type {
MintQuoteState,
Token,
} from '@cashu/cashu-ts';
-import type { CoreProof, ProofState } from '../types';
+import type { MeltOperation } from '@core/operations/melt';
+import type { Counter } from '../models/Counter';
import type { HistoryEntry } from '../models/History';
+import type { Keyset } from '../models/Keyset';
+import type { Mint } from '../models/Mint';
import type { SendOperation } from '../operations/send/SendOperation';
-import type { MeltOperation } from '@core/operations/melt';
+import type { CoreProof, ProofState } from '../types';
export interface CoreEvents {
'mint:added': { mint: Mint; keysets: Keyset[] };
@@ -59,4 +59,7 @@ export interface CoreEvents {
'melt-op:rolled-back': { mintUrl: string; operationId: string; operation: MeltOperation };
'subscriptions:paused': void;
'subscriptions:resumed': void;
+ 'auth-session:updated': {mintUrl:string};
+ 'auth-session:deleted': {mintUrl:string};
+ 'auth-session:expired': {mintUrl: string};
}
diff --git a/packages/core/infra/MintAdapter.ts b/packages/core/infra/MintAdapter.ts
index f66a8865..c4e790a3 100644
--- a/packages/core/infra/MintAdapter.ts
+++ b/packages/core/infra/MintAdapter.ts
@@ -6,8 +6,15 @@ import {
type MeltQuoteBolt11Response,
type MeltQuoteBolt12Response,
type GetKeysetsResponse,
+ type AuthProvider,
} from '@cashu/cashu-ts';
-import type { MintInfo } from '../types';
+import type {
+ MintInfo,
+ CheckBlindAuthStateRequest,
+ CheckBlindAuthStateResponse,
+ SpendBlindAuthRequest,
+ SpendBlindAuthResponse,
+} from '../types';
import type { MintRequestProvider } from './MintRequestProvider.ts';
/**
@@ -19,11 +26,29 @@ import type { MintRequestProvider } from './MintRequestProvider.ts';
export class MintAdapter {
private cashuMints: Record = {};
private readonly requestProvider: MintRequestProvider;
+ private readonly authProviders = new Map();
constructor(requestProvider: MintRequestProvider) {
this.requestProvider = requestProvider;
}
+ /** Register an AuthProvider for a mint (NUT-21/22). Invalidates the cached Mint instance. */
+ setAuthProvider(mintUrl: string, provider: AuthProvider): void {
+ this.authProviders.set(mintUrl, provider);
+ delete this.cashuMints[mintUrl];
+ }
+
+ /** Get the AuthProvider for a mint (if registered). */
+ getAuthProvider(mintUrl: string): AuthProvider | undefined {
+ return this.authProviders.get(mintUrl);
+ }
+
+ /** Remove the AuthProvider for a mint. Invalidates the cached Mint instance. */
+ clearAuthProvider(mintUrl: string): void {
+ this.authProviders.delete(mintUrl);
+ delete this.cashuMints[mintUrl];
+ }
+
async fetchMintInfo(mintUrl: string): Promise {
const cashuMint = this.getCashuMint(mintUrl);
return await cashuMint.getInfo();
@@ -46,7 +71,8 @@ export class MintAdapter {
private getCashuMint(mintUrl: string): Mint {
if (!this.cashuMints[mintUrl]) {
const requestFn = this.requestProvider.getRequestFn(mintUrl);
- this.cashuMints[mintUrl] = new Mint(mintUrl, { customRequest: requestFn });
+ const authProvider = this.authProviders.get(mintUrl);
+ this.cashuMints[mintUrl] = new Mint(mintUrl, { customRequest: requestFn, authProvider });
}
return this.cashuMints[mintUrl];
}
@@ -98,4 +124,30 @@ export class MintAdapter {
const blindedMessages = changeOutputs.map((output) => output.blindedMessage);
return cashuMint.meltBolt12({ quote: quoteId, inputs: proofsToSend, outputs: blindedMessages });
}
+
+ // ---- Non-standard cdk endpoints (not in cashu-ts Mint) ----
+
+ async checkBlindAuthState(
+ mintUrl: string,
+ payload: CheckBlindAuthStateRequest,
+ ): Promise {
+ const requestFn = this.requestProvider.getRequestFn(mintUrl);
+ return requestFn({
+ endpoint: `${mintUrl}/v1/auth/blind/checkstate`,
+ requestBody: payload as unknown as Record,
+ method: 'POST',
+ });
+ }
+
+ async spendBlindAuth(
+ mintUrl: string,
+ payload: SpendBlindAuthRequest,
+ ): Promise {
+ const requestFn = this.requestProvider.getRequestFn(mintUrl);
+ return requestFn({
+ endpoint: `${mintUrl}/v1/auth/blind/spend`,
+ requestBody: payload as unknown as Record,
+ method: 'POST',
+ });
+ }
}
diff --git a/packages/core/models/AuthSession.ts b/packages/core/models/AuthSession.ts
new file mode 100644
index 00000000..42852b06
--- /dev/null
+++ b/packages/core/models/AuthSession.ts
@@ -0,0 +1,10 @@
+import type { Proof } from '@cashu/cashu-ts';
+
+export interface AuthSession {
+ mintUrl: string;
+ accessToken: string;
+ refreshToken?: string;
+ expiresAt: number;
+ scope?: string;
+ batPool?: Proof[];
+}
\ No newline at end of file
diff --git a/packages/core/models/Error.ts b/packages/core/models/Error.ts
index 7b50fa5b..0bb4adc1 100644
--- a/packages/core/models/Error.ts
+++ b/packages/core/models/Error.ts
@@ -115,3 +115,20 @@ export class OperationInProgressError extends Error {
this.operationId = operationId;
}
}
+
+export class AuthSessionError extends Error {
+ readonly mintUrl: string;
+ constructor(mintUrl:string, message?: string, cause?: unknown) {
+ super(message ?? `Auth session error for mint ${mintUrl}`);
+ this.name = 'AuthSessionError';
+ this.mintUrl = mintUrl;
+ (this as unknown as {cause?: unknown}).cause = cause;
+ }
+}
+
+export class AuthSessionExpiredError extends AuthSessionError{
+ constructor(mintUrl: string) {
+ super(mintUrl, `Auth session expired for mint ${mintUrl}`);
+ this.name = `AuthSessionExpiredError`;
+ }
+}
diff --git a/packages/core/models/index.ts b/packages/core/models/index.ts
index f2f2d1c3..cb6066e4 100644
--- a/packages/core/models/index.ts
+++ b/packages/core/models/index.ts
@@ -1,8 +1,9 @@
+export * from './AuthSession';
export * from './Counter';
export * from './Error';
+export * from './History';
export * from './Keypair';
export * from './Keyset';
+export * from './MeltQuote';
export * from './Mint';
export * from './MintQuote';
-export * from './MeltQuote';
-export * from './History';
diff --git a/packages/core/repositories/index.ts b/packages/core/repositories/index.ts
index 7444724c..b5783226 100644
--- a/packages/core/repositories/index.ts
+++ b/packages/core/repositories/index.ts
@@ -1,9 +1,4 @@
-import type { Mint } from '../models/Mint';
-import type { Keyset } from '../models/Keyset';
-import type { Counter } from '../models/Counter';
-import type { CoreProof, ProofState } from '../types';
-import type { MintQuote } from '@core/models/MintQuote';
-import type { MeltQuote } from '@core/models/MeltQuote';
+import type { AuthSession } from '@core/models/AuthSession';
import type {
HistoryEntry,
MeltHistoryEntry,
@@ -11,11 +6,15 @@ import type {
SendHistoryEntry,
SendHistoryState,
} from '@core/models/History';
-import type { MeltQuoteState, MintQuoteState } from '@cashu/cashu-ts';
import type { Keypair } from '@core/models/Keypair';
-import type { SendOperation, SendOperationState } from '../operations/send/SendOperation';
+import type { MeltQuote } from '@core/models/MeltQuote';
+import type { MintQuote } from '@core/models/MintQuote';
import type { MeltOperation, MeltOperationState } from '@core/operations/melt/MeltOperation';
-
+import type { Counter } from '../models/Counter';
+import type { Keyset } from '../models/Keyset';
+import type { Mint } from '../models/Mint';
+import type { SendOperation, SendOperationState } from '../operations/send/SendOperation';
+import type { CoreProof, ProofState } from '../types';
export interface MintRepository {
isTrustedMint(mintUrl: string): Promise;
getMintByUrl(mintUrl: string): Promise;
@@ -180,7 +179,15 @@ export interface MeltOperationRepository {
/** Delete a melt operation */
delete(id: string): Promise;
}
+export interface AuthSessionRepository{
+ getSession(mintUrl: string): Promise;
+
+ saveSession(session:AuthSession): Promise;
+ deleteSession(mintUrl: string): Promise;
+
+ getAllSessions(): Promise;
+}
interface RepositoriesBase {
mintRepository: MintRepository;
keyRingRepository: KeyRingRepository;
@@ -192,6 +199,7 @@ interface RepositoriesBase {
historyRepository: HistoryRepository;
sendOperationRepository: SendOperationRepository;
meltOperationRepository: MeltOperationRepository;
+ authSessionRepository: AuthSessionRepository;
}
export interface Repositories extends RepositoriesBase {
diff --git a/packages/core/repositories/memory/MemoryAuthSessionRepository.ts b/packages/core/repositories/memory/MemoryAuthSessionRepository.ts
new file mode 100644
index 00000000..25ddcda2
--- /dev/null
+++ b/packages/core/repositories/memory/MemoryAuthSessionRepository.ts
@@ -0,0 +1,22 @@
+import type { AuthSession } from '@core/models/AuthSession';
+import type { AuthSessionRepository } from '..';
+
+export class MemoryAuthSessionRepository implements AuthSessionRepository {
+ private readonly sessions = new Map();
+
+ async getSession(mintUrl: string): Promise {
+ return this.sessions.get(mintUrl) ?? null;
+ }
+
+ async saveSession(session: AuthSession): Promise {
+ this.sessions.set(session.mintUrl, session);
+ }
+
+ async deleteSession(mintUrl: string): Promise {
+ this.sessions.delete(mintUrl);
+ }
+
+ async getAllSessions(): Promise {
+ return [...this.sessions.values()];
+ }
+}
diff --git a/packages/core/repositories/memory/MemoryRepositories.ts b/packages/core/repositories/memory/MemoryRepositories.ts
index f6092b36..abe250a9 100644
--- a/packages/core/repositories/memory/MemoryRepositories.ts
+++ b/packages/core/repositories/memory/MemoryRepositories.ts
@@ -1,27 +1,29 @@
import type {
- Repositories,
- RepositoryTransactionScope,
- MintRepository,
- KeysetRepository,
- KeyRingRepository,
+ AuthSessionRepository,
CounterRepository,
- ProofRepository,
- MintQuoteRepository,
- MeltQuoteRepository,
HistoryRepository,
- SendOperationRepository,
+ KeyRingRepository,
+ KeysetRepository,
MeltOperationRepository,
+ MeltQuoteRepository,
+ MintQuoteRepository,
+ MintRepository,
+ ProofRepository,
+ Repositories,
+ RepositoryTransactionScope,
+ SendOperationRepository,
} from '..';
-import { MemoryMintRepository } from './MemoryMintRepository';
-import { MemoryKeysetRepository } from './MemoryKeysetRepository';
-import { MemoryKeyRingRepository } from './MemoryKeyRingRepository';
+import { MemoryAuthSessionRepository } from './MemoryAuthSessionRepository';
import { MemoryCounterRepository } from './MemoryCounterRepository';
-import { MemoryProofRepository } from './MemoryProofRepository';
-import { MemoryMintQuoteRepository } from './MemoryMintQuoteRepository';
-import { MemoryMeltQuoteRepository } from './MemoryMeltQuoteRepository';
import { MemoryHistoryRepository } from './MemoryHistoryRepository';
-import { MemorySendOperationRepository } from './MemorySendOperationRepository';
+import { MemoryKeyRingRepository } from './MemoryKeyRingRepository';
+import { MemoryKeysetRepository } from './MemoryKeysetRepository';
import { MemoryMeltOperationRepository } from './MemoryMeltOperationRepository';
+import { MemoryMeltQuoteRepository } from './MemoryMeltQuoteRepository';
+import { MemoryMintQuoteRepository } from './MemoryMintQuoteRepository';
+import { MemoryMintRepository } from './MemoryMintRepository';
+import { MemoryProofRepository } from './MemoryProofRepository';
+import { MemorySendOperationRepository } from './MemorySendOperationRepository';
export class MemoryRepositories implements Repositories {
mintRepository: MintRepository;
@@ -34,6 +36,7 @@ export class MemoryRepositories implements Repositories {
historyRepository: HistoryRepository;
sendOperationRepository: SendOperationRepository;
meltOperationRepository: MeltOperationRepository;
+ authSessionRepository: AuthSessionRepository;
constructor() {
this.mintRepository = new MemoryMintRepository();
@@ -46,6 +49,7 @@ export class MemoryRepositories implements Repositories {
this.historyRepository = new MemoryHistoryRepository();
this.sendOperationRepository = new MemorySendOperationRepository();
this.meltOperationRepository = new MemoryMeltOperationRepository();
+ this.authSessionRepository = new MemoryAuthSessionRepository();
}
async init(): Promise {
diff --git a/packages/core/repositories/memory/index.ts b/packages/core/repositories/memory/index.ts
index 6f232f7f..781e599d 100644
--- a/packages/core/repositories/memory/index.ts
+++ b/packages/core/repositories/memory/index.ts
@@ -1,11 +1,13 @@
+export * from './MemoryAuthSessionRepository';
export * from './MemoryCounterRepository';
-export * from './MemoryKeysetRepository';
+export * from './MemoryHistoryRepository';
export * from './MemoryKeyRingRepository';
+export * from './MemoryKeysetRepository';
+export * from './MemoryMeltOperationRepository';
+export * from './MemoryMeltQuoteRepository';
+export * from './MemoryMintQuoteRepository';
export * from './MemoryMintRepository';
export * from './MemoryProofRepository';
export * from './MemoryRepositories';
-export * from './MemoryMintQuoteRepository';
-export * from './MemoryMeltQuoteRepository';
-export * from './MemoryHistoryRepository';
export * from './MemorySendOperationRepository';
-export * from './MemoryMeltOperationRepository';
+
diff --git a/packages/core/services/AuthSessionService.ts b/packages/core/services/AuthSessionService.ts
new file mode 100644
index 00000000..4c3229a3
--- /dev/null
+++ b/packages/core/services/AuthSessionService.ts
@@ -0,0 +1,112 @@
+import type { CoreEvents, EventBus } from "@core/events";
+import { AuthSessionError, AuthSessionExpiredError } from "@core/models";
+import type { AuthSessionRepository } from "@core/repositories";
+import { normalizeMintUrl } from "@core/utils";
+import type { Logger } from "@core/logging";
+import type { Proof } from '@cashu/cashu-ts';
+import type { AuthSession } from '../models/AuthSession';
+
+export class AuthSessionService {
+ private readonly repo: AuthSessionRepository;
+ private readonly eventBus: EventBus;
+ private readonly logger?: Logger;
+
+ constructor(
+ repo: AuthSessionRepository,
+ eventBus: EventBus,
+ logger?: Logger,
+ ) {
+ this.repo = repo;
+ this.eventBus = eventBus;
+ this.logger = logger;
+ }
+
+ /** Get a valid (non-expired) session; throws if missing or expired. */
+ async getValidSession(mintUrl: string): Promise {
+ mintUrl = normalizeMintUrl(mintUrl);
+ try {
+ const session = await this.repo.getSession(mintUrl);
+ if (!session) {
+ throw new AuthSessionError(mintUrl, 'No auth session found');
+ }
+ const now = Math.floor(Date.now() / 1000);
+ if (session.expiresAt <= now) {
+ await this.eventBus.emit('auth-session:expired', { mintUrl });
+ throw new AuthSessionExpiredError(mintUrl);
+ }
+ return session;
+ } catch (err) {
+ this.logger?.error('Failed to get valid session', { mintUrl, err });
+ throw err;
+ }
+ }
+
+ /** Save OIDC tokens as a session. */
+ async saveSession(
+ mintUrl: string,
+ tokens: { access_token: string; refresh_token?: string; expires_in?: number; scope?: string },
+ batPool?: Proof[],
+ ): Promise {
+ mintUrl = normalizeMintUrl(mintUrl);
+ try {
+ const now = Math.floor(Date.now() / 1000);
+ const session: AuthSession = {
+ mintUrl,
+ accessToken: tokens.access_token,
+ refreshToken: tokens.refresh_token,
+ expiresAt: now + (tokens.expires_in ?? 3600),
+ scope: tokens.scope,
+ batPool,
+ };
+ await this.repo.saveSession(session);
+ await this.eventBus.emit('auth-session:updated', { mintUrl });
+ this.logger?.info('Auth session saved', { mintUrl, expiresAt: session.expiresAt });
+ return session;
+ } catch (err) {
+ this.logger?.error('Failed to save session', { mintUrl, err });
+ throw err;
+ }
+ }
+
+ /** Update only the BAT pool of an existing session (no expiry recalculation, no event). */
+ async updateBatPool(mintUrl: string, batPool?: Proof[]): Promise {
+ mintUrl = normalizeMintUrl(mintUrl);
+ try {
+ const session = await this.repo.getSession(mintUrl);
+ if (!session) return;
+ session.batPool = batPool;
+ await this.repo.saveSession(session);
+ this.logger?.debug('BAT pool updated', { mintUrl, poolSize: batPool?.length ?? 0 });
+ } catch (err) {
+ this.logger?.error('Failed to update BAT pool', { mintUrl, err });
+ throw err;
+ }
+ }
+
+ /** Delete (logout) a session. */
+ async deleteSession(mintUrl: string): Promise {
+ mintUrl = normalizeMintUrl(mintUrl);
+ try {
+ await this.repo.deleteSession(mintUrl);
+ await this.eventBus.emit('auth-session:deleted', { mintUrl });
+ this.logger?.info('Auth session deleted', { mintUrl });
+ } catch (err) {
+ this.logger?.error('Failed to delete session', { mintUrl, err });
+ throw err;
+ }
+ }
+
+ /** Check whether a valid (non-expired) session exists for the given mint. */
+ async hasSession(mintUrl: string): Promise {
+ mintUrl = normalizeMintUrl(mintUrl);
+ try {
+ const session = await this.repo.getSession(mintUrl);
+ if (!session) return false;
+ const now = Math.floor(Date.now() / 1000);
+ return session.expiresAt > now;
+ } catch (err) {
+ this.logger?.error('Failed to check session', { mintUrl, err });
+ throw err;
+ }
+ }
+}
diff --git a/packages/core/services/WalletService.ts b/packages/core/services/WalletService.ts
index 762a9460..f831b5f6 100644
--- a/packages/core/services/WalletService.ts
+++ b/packages/core/services/WalletService.ts
@@ -1,4 +1,4 @@
-import { Mint, Wallet, type MintKeys, type MintKeyset, type KeyChainCache } from '@cashu/cashu-ts';
+import { Mint, Wallet, type MintKeys, type MintKeyset, type KeyChainCache, type AuthProvider } from '@cashu/cashu-ts';
import type { MintService } from './MintService';
import type { Logger } from '../logging/Logger.ts';
import type { SeedService } from './SeedService.ts';
@@ -20,17 +20,20 @@ export class WalletService {
private inFlight: Map> = new Map();
private readonly logger?: Logger;
private readonly requestProvider: MintRequestProvider;
+ private readonly authProviderGetter?: (mintUrl: string) => AuthProvider | undefined;
constructor(
mintService: MintService,
seedService: SeedService,
requestProvider: MintRequestProvider,
logger?: Logger,
+ authProviderGetter?: (mintUrl: string) => AuthProvider | undefined,
) {
this.mintService = mintService;
this.seedService = seedService;
this.requestProvider = requestProvider;
this.logger = logger;
+ this.authProviderGetter = authProviderGetter;
}
async getWallet(mintUrl: string): Promise {
@@ -134,7 +137,8 @@ export class WalletService {
const seed = await this.seedService.getSeed();
const requestFn = this.requestProvider.getRequestFn(mintUrl);
- const wallet = new Wallet(new Mint(mintUrl, { customRequest: requestFn }), {
+ const authProvider = this.authProviderGetter?.(mintUrl);
+ const wallet = new Wallet(new Mint(mintUrl, { customRequest: requestFn, authProvider }), {
unit: DEFAULT_UNIT,
// @ts-ignore
logger:
diff --git a/packages/core/services/index.ts b/packages/core/services/index.ts
index 7743a99a..92f45bec 100644
--- a/packages/core/services/index.ts
+++ b/packages/core/services/index.ts
@@ -1,13 +1,14 @@
+export * from './AuthSessionService';
export * from './CounterService';
+export * from './HistoryService';
export * from './KeyRingService';
+export * from './MeltQuoteService';
+export * from './MintQuoteService';
export * from './MintService';
-export * from './WalletService';
+export * from './PaymentRequestService';
export * from './ProofService';
-export * from './MintQuoteService';
-export * from './watchers';
export * from './SeedService';
-export * from './WalletRestoreService';
-export * from './MeltQuoteService';
-export * from './HistoryService';
export * from './TransactionService';
-export * from './PaymentRequestService';
+export * from './WalletRestoreService';
+export * from './WalletService';
+export * from './watchers';
diff --git a/packages/core/test/integration/auth-bat.test.ts b/packages/core/test/integration/auth-bat.test.ts
new file mode 100644
index 00000000..4f04b594
--- /dev/null
+++ b/packages/core/test/integration/auth-bat.test.ts
@@ -0,0 +1,167 @@
+import { describe, it, expect, beforeAll, setDefaultTimeout } from 'bun:test';
+
+setDefaultTimeout(300_000); // 5 min – manual browser authorization needed
+import { initializeCoco, type Manager } from '../../Manager';
+import { MemoryRepositories } from '../../repositories/memory';
+
+const mintUrl = process.env.MINT_URL;
+
+if (!mintUrl) {
+ throw new Error('MINT_URL is not set');
+}
+
+/**
+ * Mint endpoint auth configuration assumed by this suite:
+ *
+ * get_mint_quote: Clear (CAT)
+ * mint: Blind (BAT)
+ * check_mint_quote: Blind (BAT)
+ * restore: Blind (BAT)
+ * melt / swap / …: None (open)
+ */
+describe('Auth Integration (CAT + BAT)', () => {
+ let mgr: Manager;
+ let repositories: MemoryRepositories;
+
+ beforeAll(async () => {
+ repositories = new MemoryRepositories();
+ await repositories.init();
+
+ mgr = await initializeCoco({
+ repo: repositories,
+ seedGetter: async () => new Uint8Array(64),
+ watchers: {
+ mintQuoteWatcher: { disabled: true },
+ proofStateWatcher: { disabled: true },
+ },
+ processors: {
+ mintQuoteProcessor: { disabled: true },
+ },
+ });
+
+ // Add the mint (GET endpoints don't require auth)
+ await mgr.mint.addMint(mintUrl, { trusted: true });
+
+ // Start Device Code Flow
+ const device = await mgr.auth.startDeviceAuth(mintUrl);
+
+ console.log('\n========================================');
+ console.log(' OIDC Device Code Authorization');
+ console.log('========================================');
+ console.log(` Visit: ${device.verification_uri_complete || device.verification_uri}`);
+ console.log(` Code: ${device.user_code}`);
+ console.log(' Waiting for authorization...');
+ console.log('========================================\n');
+
+ const tokens = await device.poll();
+ expect(tokens.access_token).toBeDefined();
+ console.log('Authorization successful - access_token received');
+ });
+
+ // ---------------------------------------------------------------------------
+ // CAT (Clear Auth Token)
+ // ---------------------------------------------------------------------------
+
+ it('T1: CAT-protected endpoint succeeds without consuming BATs', async () => {
+ const provider = mgr.auth.getAuthProvider(mintUrl);
+ expect(provider).toBeDefined();
+ expect(provider!.poolSize).toBe(0);
+
+ // get_mint_quote = Clear → uses CAT header, no BAT needed
+ const quote = await mgr.quotes.createMintQuote(mintUrl, 1);
+ expect(quote).toBeDefined();
+ expect(quote.quote).toBeDefined();
+
+ // Pool stays empty — CAT auth does not touch BATs
+ expect(provider!.poolSize).toBe(0);
+ });
+
+ // ---------------------------------------------------------------------------
+ // BAT (Blind Auth Token) — pool lifecycle
+ // ---------------------------------------------------------------------------
+
+ it('T2: ensure() mints BATs via CAT and populates pool', async () => {
+ const provider = mgr.auth.getAuthProvider(mintUrl);
+ expect(provider).toBeDefined();
+ expect(provider!.poolSize).toBe(0);
+
+ // Explicitly mint BATs (uses CAT to call /v1/auth/blind/mint)
+ await provider!.ensure!(3);
+ expect(provider!.poolSize).toBeGreaterThanOrEqual(3);
+ });
+
+ it('T3: session restore → CAT works, BAT re-mintable', async () => {
+ // Create a new Manager instance sharing the same repositories
+ const mgr2 = await initializeCoco({
+ repo: repositories,
+ seedGetter: async () => new Uint8Array(64),
+ watchers: {
+ mintQuoteWatcher: { disabled: true },
+ proofStateWatcher: { disabled: true },
+ },
+ processors: {
+ mintQuoteProcessor: { disabled: true },
+ },
+ });
+
+ // Restore session from repository
+ const restored = await mgr2.auth.restore(mintUrl);
+ expect(restored).toBe(true);
+
+ const provider2 = mgr2.auth.getAuthProvider(mintUrl);
+ expect(provider2).toBeDefined();
+
+ // CAT works after restore — createMintQuote (Clear-protected)
+ const quote = await mgr2.quotes.createMintQuote(mintUrl, 1);
+ expect(quote).toBeDefined();
+ expect(quote.quote).toBeDefined();
+
+ // BAT re-mintable after restore — ensure() uses restored CAT
+ await provider2!.ensure!(2);
+ expect(provider2!.poolSize).toBeGreaterThanOrEqual(2);
+ });
+
+ // ---------------------------------------------------------------------------
+ // checkBlindAuthState / spendBlindAuth (non-standard cdk extension)
+ // ---------------------------------------------------------------------------
+
+ it('T4: flush pool, re-issue, checkBlindAuthState all UNSPENT, spend one, verify SPENT', async () => {
+ const provider = mgr.auth.getAuthProvider(mintUrl);
+ expect(provider).toBeDefined();
+
+ // Flush stale BATs and issue fresh ones
+ provider!.importPool([], 'replace');
+ expect(provider!.poolSize).toBe(0);
+
+ await provider!.ensure!(3);
+ const pool = provider!.exportPool();
+ expect(pool.length).toBeGreaterThanOrEqual(3);
+
+ // All fresh BATs should be UNSPENT
+ const checkResult = await mgr.auth.checkBlindAuthState(mintUrl, pool);
+ expect(checkResult.states).toHaveLength(pool.length);
+ for (const s of checkResult.states) {
+ expect(s.Y).toBeDefined();
+ expect(s.state).toBe('UNSPENT');
+ }
+
+ // Pool unchanged after read-only checkstate
+ expect(provider!.exportPool().length).toBe(pool.length);
+
+ // Spend one BAT
+ const target = pool[0];
+ const spendResult = await mgr.auth.spendBlindAuth(mintUrl, target);
+ expect(spendResult.state).toBeDefined();
+ expect(spendResult.state.state).toBe('SPENT');
+
+ // Verify it's SPENT, others still UNSPENT
+ const recheck = await mgr.auth.checkBlindAuthState(mintUrl, pool);
+ const targetState = recheck.states[0];
+ expect(targetState.state).toBe('SPENT');
+
+ const rest = recheck.states.slice(1);
+ for (const s of rest) {
+ expect(s.state).toBe('UNSPENT');
+ }
+ });
+});
diff --git a/packages/core/test/integration/auth-session.test.ts b/packages/core/test/integration/auth-session.test.ts
new file mode 100644
index 00000000..2446eb2d
--- /dev/null
+++ b/packages/core/test/integration/auth-session.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect } from 'bun:test';
+import { initializeCoco } from '../../Manager';
+import { MemoryRepositories } from '../../repositories/memory';
+
+const mintUrl = process.env.MINT_URL;
+
+if (!mintUrl) {
+ throw new Error('MINT_URL is not set');
+}
+
+describe('Auth Session (Device Code Flow)', () => {
+ it('should authenticate via OIDC device code and wire AuthProvider', async () => {
+ const repositories = new MemoryRepositories();
+ await repositories.init();
+
+ const mgr = await initializeCoco({
+ repo: repositories,
+ seedGetter: async () => new Uint8Array(32),
+ watchers: {
+ mintQuoteWatcher: { disabled: true },
+ proofStateWatcher: { disabled: true },
+ },
+ processors: {
+ mintQuoteProcessor: { disabled: true },
+ },
+ });
+
+ // Start Device Code Flow via mgr.auth
+ const device = await mgr.auth.startDeviceAuth(mintUrl);
+
+ // Display authorization instructions
+ console.log('\n========================================');
+ console.log(' OIDC Device Code Authorization');
+ console.log('========================================');
+ console.log(` Visit: ${device.verification_uri_complete || device.verification_uri}`);
+ console.log(` Code: ${device.user_code}`);
+ console.log(' Waiting for authorization...');
+ console.log('========================================\n');
+
+ // Wait for user to authorize in browser
+ const tokens = await device.poll();
+
+ expect(tokens.access_token).toBeDefined();
+ console.log('Authorization successful - access_token received');
+
+ // Verify session was persisted
+ const session = await mgr.auth.getSession(mintUrl);
+ expect(session.accessToken).toBe(tokens.access_token!);
+ expect(session.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000));
+
+ // Verify AuthProvider is wired
+ const provider = mgr.auth.getAuthProvider(mintUrl);
+ expect(provider).toBeDefined();
+ expect(provider!.getCAT()).toBe(tokens.access_token!);
+
+ // Verify hasSession
+ expect(await mgr.auth.hasSession(mintUrl)).toBe(true);
+
+ // Verify refresh_token if present
+ if (tokens.refresh_token) {
+ expect(session.refreshToken).toBe(tokens.refresh_token);
+ }
+
+ console.log('Auth session established and AuthProvider wired');
+
+ // Logout
+ await mgr.auth.logout(mintUrl);
+ expect(await mgr.auth.hasSession(mintUrl)).toBe(false);
+ expect(mgr.auth.getAuthProvider(mintUrl)).toBeUndefined();
+ console.log('Logout verified');
+ }, 300000); // 5 min timeout for manual authorization
+});
diff --git a/packages/core/test/unit/AuthApi.test.ts b/packages/core/test/unit/AuthApi.test.ts
new file mode 100644
index 00000000..a60787cd
--- /dev/null
+++ b/packages/core/test/unit/AuthApi.test.ts
@@ -0,0 +1,251 @@
+import { describe, it, expect, mock, beforeEach } from 'bun:test';
+import { AuthApi } from '../../api/AuthApi.ts';
+import type { AuthSessionService } from '../../services/AuthSessionService.ts';
+import type { MintAdapter } from '../../infra/MintAdapter.ts';
+import type { AuthSession } from '../../models/AuthSession.ts';
+
+const mintUrl = 'https://mint.test';
+const normalizedUrl = 'https://mint.test';
+
+const fakeSession: AuthSession = {
+ mintUrl: normalizedUrl,
+ accessToken: 'cat-token-abc',
+ refreshToken: 'refresh-xyz',
+ expiresAt: Math.floor(Date.now() / 1000) + 3600,
+ scope: undefined,
+};
+
+const expiredSession: AuthSession = {
+ mintUrl: normalizedUrl,
+ accessToken: 'expired-cat',
+ expiresAt: Math.floor(Date.now() / 1000) - 100,
+};
+
+function makeMocks() {
+ const authSessionService = {
+ saveSession: mock(async () => fakeSession),
+ deleteSession: mock(async () => {}),
+ getValidSession: mock(async () => fakeSession),
+ hasSession: mock(async () => true),
+ } as unknown as AuthSessionService;
+
+ const mintAdapter = {
+ setAuthProvider: mock(() => {}),
+ clearAuthProvider: mock(() => {}),
+ checkBlindAuthState: mock(async () => ({ states: [] })),
+ spendBlindAuth: mock(async () => ({ state: { Y: 'y1', state: 'SPENT' } })),
+ } as unknown as MintAdapter;
+
+ return { authSessionService, mintAdapter };
+}
+
+describe('AuthApi', () => {
+ let api: AuthApi;
+ let authSessionService: AuthSessionService;
+ let mintAdapter: MintAdapter;
+
+ beforeEach(() => {
+ const mocks = makeMocks();
+ authSessionService = mocks.authSessionService;
+ mintAdapter = mocks.mintAdapter;
+ api = new AuthApi(authSessionService, mintAdapter);
+ });
+
+ describe('login', () => {
+ it('persists session and wires AuthProvider into MintAdapter', async () => {
+ const session = await api.login(mintUrl, {
+ access_token: 'cat-token-abc',
+ expires_in: 3600,
+ });
+
+ expect(session).toBe(fakeSession);
+ expect(authSessionService.saveSession).toHaveBeenCalledTimes(1);
+ expect(mintAdapter.setAuthProvider).toHaveBeenCalledTimes(1);
+
+ // AuthProvider should be cached
+ const provider = api.getAuthProvider(mintUrl);
+ expect(provider).toBeDefined();
+ expect(provider!.getCAT()).toBe('cat-token-abc');
+ });
+
+ it('sets CAT on AuthManager even without refresh_token', async () => {
+ await api.login(mintUrl, { access_token: 'no-refresh' });
+
+ const provider = api.getAuthProvider(mintUrl);
+ expect(provider).toBeDefined();
+ expect(provider!.getCAT()).toBe('no-refresh');
+ expect(mintAdapter.setAuthProvider).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls saveSession with batPool from exportPool', async () => {
+ await api.login(mintUrl, { access_token: 'cat-token-abc' });
+
+ // At login time the pool is empty, so batPool should be undefined
+ const calls = (authSessionService.saveSession as ReturnType).mock.calls;
+ expect(calls).toHaveLength(1);
+ // 3rd arg is batPool — empty pool yields undefined
+ expect(calls[0][2]).toBeUndefined();
+ });
+ });
+
+ describe('logout', () => {
+ it('deletes session and clears AuthProvider', async () => {
+ // First login
+ await api.login(mintUrl, { access_token: 'cat-token-abc' });
+ expect(api.getAuthProvider(mintUrl)).toBeDefined();
+
+ // Then logout
+ await api.logout(mintUrl);
+
+ expect(authSessionService.deleteSession).toHaveBeenCalledTimes(1);
+ expect(mintAdapter.clearAuthProvider).toHaveBeenCalledTimes(1);
+ expect(api.getAuthProvider(mintUrl)).toBeUndefined();
+ });
+ });
+
+ describe('getSession', () => {
+ it('delegates to AuthSessionService.getValidSession', async () => {
+ const session = await api.getSession(mintUrl);
+ expect(session).toBe(fakeSession);
+ expect(authSessionService.getValidSession).toHaveBeenCalledWith(mintUrl);
+ });
+ });
+
+ describe('hasSession', () => {
+ it('delegates to AuthSessionService.hasSession', async () => {
+ const result = await api.hasSession(mintUrl);
+ expect(result).toBe(true);
+ expect(authSessionService.hasSession).toHaveBeenCalledWith(mintUrl);
+ });
+ });
+
+ describe('restore', () => {
+ it('returns false when no valid session exists', async () => {
+ const mocks = makeMocks();
+ (mocks.authSessionService.getValidSession as ReturnType).mockImplementation(
+ async () => {
+ throw new Error('No session');
+ },
+ );
+ const testApi = new AuthApi(mocks.authSessionService, mocks.mintAdapter);
+
+ const result = await testApi.restore(mintUrl);
+ expect(result).toBe(false);
+ expect(mocks.mintAdapter.setAuthProvider).not.toHaveBeenCalled();
+ });
+
+ it('restores CAT and wires AuthProvider for valid session', async () => {
+ const result = await api.restore(mintUrl);
+
+ expect(result).toBe(true);
+ expect(mintAdapter.setAuthProvider).toHaveBeenCalledTimes(1);
+
+ const provider = api.getAuthProvider(mintUrl);
+ expect(provider).toBeDefined();
+ expect(provider!.getCAT()).toBe('cat-token-abc');
+ });
+
+ it('imports batPool into AuthManager when session has batPool', async () => {
+ const fakeBatPool = [
+ { id: 'key1', amount: 1, secret: 's1', C: 'c1' },
+ ] as any;
+ const sessionWithPool: AuthSession = {
+ ...fakeSession,
+ batPool: fakeBatPool,
+ };
+ const mocks = makeMocks();
+ (mocks.authSessionService.getValidSession as ReturnType).mockImplementation(
+ async () => sessionWithPool,
+ );
+ const testApi = new AuthApi(mocks.authSessionService, mocks.mintAdapter);
+
+ const result = await testApi.restore(mintUrl);
+ expect(result).toBe(true);
+
+ const provider = testApi.getAuthProvider(mintUrl);
+ expect(provider).toBeDefined();
+ expect(provider!.poolSize).toBe(1);
+ });
+
+ it('handles restore gracefully when session has no batPool', async () => {
+ const result = await api.restore(mintUrl);
+ expect(result).toBe(true);
+
+ const provider = api.getAuthProvider(mintUrl);
+ expect(provider).toBeDefined();
+ expect(provider!.poolSize).toBe(0);
+ });
+ });
+
+ describe('getAuthProvider', () => {
+ it('returns undefined for unknown mint', () => {
+ expect(api.getAuthProvider('https://unknown.test')).toBeUndefined();
+ });
+
+ it('returns AuthManager after login', async () => {
+ await api.login(mintUrl, { access_token: 'test' });
+ const provider = api.getAuthProvider(mintUrl);
+ expect(provider).toBeDefined();
+ expect(typeof provider!.getCAT).toBe('function');
+ expect(typeof provider!.getBlindAuthToken).toBe('function');
+ });
+ });
+
+ describe('checkBlindAuthState', () => {
+ it('converts Proof[] to AuthProof[] and delegates to mintAdapter', async () => {
+ const proofs = [
+ { id: 'k1', amount: 1, secret: 's1', C: 'C1', dleq: { e: 'e1', s: 's1', r: 'r1' } },
+ { id: 'k2', amount: 1, secret: 's2', C: 'C2' },
+ ] as any;
+
+ await api.checkBlindAuthState(mintUrl, proofs);
+
+ const calls = (mintAdapter.checkBlindAuthState as ReturnType).mock.calls;
+ expect(calls).toHaveLength(1);
+ expect(calls[0][0]).toBe(normalizedUrl);
+ const payload = calls[0][1];
+ // dleq should have e, s, r (amount/witness stripped)
+ expect(payload).toEqual({
+ auth_proofs: [
+ { id: 'k1', secret: 's1', C: 'C1', dleq: { e: 'e1', s: 's1', r: 'r1' } },
+ { id: 'k2', secret: 's2', C: 'C2' },
+ ],
+ });
+ });
+
+ it('normalizes mintUrl before calling mintAdapter', async () => {
+ await api.checkBlindAuthState('https://mint.test/', []);
+
+ const calls = (mintAdapter.checkBlindAuthState as ReturnType).mock.calls;
+ expect(calls[0][0]).toBe(normalizedUrl);
+ });
+ });
+
+ describe('spendBlindAuth', () => {
+ it('converts a single Proof to AuthProof and delegates to mintAdapter', async () => {
+ const proof = { id: 'k1', amount: 1, secret: 's1', C: 'C1' } as any;
+
+ const result = await api.spendBlindAuth(mintUrl, proof);
+
+ const calls = (mintAdapter.spendBlindAuth as ReturnType).mock.calls;
+ expect(calls).toHaveLength(1);
+ expect(calls[0][0]).toBe(normalizedUrl);
+ expect(calls[0][1]).toEqual({
+ auth_proof: { id: 'k1', secret: 's1', C: 'C1' },
+ });
+ expect(result).toEqual({ state: { Y: 'y1', state: 'SPENT' } });
+ });
+
+ it('propagates errors from mintAdapter', async () => {
+ const mocks = makeMocks();
+ (mocks.mintAdapter.spendBlindAuth as ReturnType).mockImplementation(
+ async () => { throw new Error('HttpResponseError: 400'); },
+ );
+ const testApi = new AuthApi(mocks.authSessionService, mocks.mintAdapter);
+
+ await expect(
+ testApi.spendBlindAuth(mintUrl, { id: 'k1', amount: 1, secret: 's1', C: 'C1' } as any),
+ ).rejects.toThrow('HttpResponseError: 400');
+ });
+ });
+});
diff --git a/packages/core/test/unit/AuthManager.topUp.test.ts b/packages/core/test/unit/AuthManager.topUp.test.ts
new file mode 100644
index 00000000..482dca0e
--- /dev/null
+++ b/packages/core/test/unit/AuthManager.topUp.test.ts
@@ -0,0 +1,283 @@
+import { describe, it, expect, beforeEach } from 'bun:test';
+import { AuthManager, type Proof } from '@cashu/cashu-ts';
+import { secp256k1 } from '@noble/curves/secp256k1.js';
+import { sha256 } from '@noble/hashes/sha2.js';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
+
+// ────────────────────────────────────────────────────────────────
+// Helpers
+// ────────────────────────────────────────────────────────────────
+
+/** secp256k1 scalar field (mod n) — used for DLEQ arithmetic */
+const Fn = (secp256k1.Point as any).Fn as {
+ ORDER: bigint;
+ fromBytes(b: Uint8Array): bigint;
+ toBytes(n: bigint): Uint8Array;
+ add(a: bigint, b: bigint): bigint;
+ mul(a: bigint, b: bigint): bigint;
+};
+
+/**
+ * hash_e per NUT-12 / cashu-ts implementation:
+ * uncompressed hex of each point → join → TextEncoder → sha256
+ */
+function hashE(points: InstanceType[]): Uint8Array {
+ const hex = points.map((p) => p.toHex(false)).join('');
+ return sha256(new TextEncoder().encode(hex));
+}
+
+/**
+ * Compute keyset id per NUT-02 v2:
+ * "00" + hex(sha256(sorted_compressed_pubkeys)[0:7])
+ */
+function computeKeysetId(keys: Record): string {
+ const sorted = Object.entries(keys).sort((a, b) => Number(a[0]) - Number(b[0]));
+ const totalLen = sorted.length * 33;
+ const concat = new Uint8Array(totalLen);
+ let offset = 0;
+ for (const [, pubKeyHex] of sorted) {
+ concat.set(hexToBytes(pubKeyHex), offset);
+ offset += 33;
+ }
+ const hash = sha256(concat);
+ return '00' + bytesToHex(hash.slice(0, 7));
+}
+
+// ────────────────────────────────────────────────────────────────
+// Mock mint with real secp256k1 blind signatures + DLEQ
+// ────────────────────────────────────────────────────────────────
+
+interface MockMint {
+ request: (args: any) => Promise;
+ calls: Array<{ endpoint: string; method: string; body?: any }>;
+ keysetId: string;
+ pubKeyHex: string;
+}
+
+function createMockMint(): MockMint {
+ const privKey = secp256k1.utils.randomSecretKey();
+ const kScalar = Fn.fromBytes(privKey);
+ const K = secp256k1.Point.BASE.multiply(kScalar);
+ const pubKeyHex = K.toHex(true);
+ const keysetId = computeKeysetId({ 1: pubKeyHex });
+
+ const calls: MockMint['calls'] = [];
+
+ const mintInfo = {
+ name: 'Test Mint',
+ version: 'test/0.1',
+ nuts: {
+ '1': { methods: [{ method: 'bolt11', unit: 'sat' }] },
+ '22': {
+ bat_max_mint: 10,
+ protected_endpoints: [
+ { method: 'POST', path: '/v1/mint/bolt11' },
+ { method: 'POST', path: '/v1/melt/bolt11' },
+ ],
+ },
+ },
+ };
+
+ /**
+ * Sign a blinded message B_ and produce a valid DLEQ proof.
+ *
+ * C_ = k * B_
+ * DLEQ: nonce p → R1=p*G, R2=p*B_
+ * e = hash_e(R1, R2, K, C_)
+ * s = p + e*k (mod n)
+ */
+ function signBlindedMessage(B_hex: string) {
+ const B_ = secp256k1.Point.fromHex(B_hex);
+ const C_ = B_.multiply(kScalar);
+
+ const nonceBytes = secp256k1.utils.randomSecretKey();
+ const p = Fn.fromBytes(nonceBytes);
+ const R1 = secp256k1.Point.BASE.multiply(p);
+ const R2 = B_.multiply(p);
+
+ const eBytes = hashE([R1, R2, K, C_]);
+ const eScalar = Fn.fromBytes(eBytes);
+ const sScalar = Fn.add(p, Fn.mul(eScalar, kScalar));
+
+ return {
+ id: keysetId,
+ amount: 1,
+ C_: C_.toHex(true),
+ dleq: {
+ e: bytesToHex(eBytes),
+ s: bytesToHex(Fn.toBytes(sScalar)),
+ },
+ };
+ }
+
+ const request = async (args: any) => {
+ calls.push({
+ endpoint: args.endpoint,
+ method: args.method,
+ body: args.requestBody,
+ });
+
+ const url = new URL(args.endpoint);
+ const path = url.pathname;
+
+ if (path === '/v1/info' && args.method === 'GET') {
+ return mintInfo;
+ }
+ if (path === '/v1/auth/blind/keysets' && args.method === 'GET') {
+ return {
+ keysets: [{ id: keysetId, unit: 'auth', active: true, input_fee_ppk: 0 }],
+ };
+ }
+ if (path === '/v1/auth/blind/keys' && args.method === 'GET') {
+ return {
+ keysets: [{ id: keysetId, unit: 'auth', keys: { '1': pubKeyHex } }],
+ };
+ }
+ if (path === '/v1/auth/blind/mint' && args.method === 'POST') {
+ const outputs = args.requestBody?.outputs;
+ if (!outputs) throw new Error('Mock mint: no outputs');
+ const signatures = outputs.map((o: any) => signBlindedMessage(o.B_));
+ return { signatures };
+ }
+
+ throw new Error(`Mock mint: unhandled ${args.method} ${path}`);
+ };
+
+ return { request, calls, keysetId, pubKeyHex };
+}
+
+// ────────────────────────────────────────────────────────────────
+// Tests
+// ────────────────────────────────────────────────────────────────
+
+describe('AuthManager automatic topUp', () => {
+ let mockMint: MockMint;
+ let auth: AuthManager;
+
+ beforeEach(() => {
+ mockMint = createMockMint();
+ auth = new AuthManager('https://mint.test', {
+ request: mockMint.request,
+ desiredPoolSize: 3,
+ });
+ });
+
+ it('triggers topUp when pool is empty', async () => {
+ expect(auth.poolSize).toBe(0);
+
+ // getBlindAuthToken → ensure(1) → init() + topUp(3)
+ const token = await auth.getBlindAuthToken({ method: 'POST', path: '/v1/mint/bolt11' });
+
+ expect(token).toBeDefined();
+ expect(token.startsWith('authA')).toBe(true);
+
+ // 3 minted, 1 consumed → pool = 2
+ expect(auth.poolSize).toBe(2);
+
+ // /v1/auth/blind/mint should have been called exactly once
+ const mintCalls = mockMint.calls.filter((c) => c.endpoint.includes('/v1/auth/blind/mint'));
+ expect(mintCalls).toHaveLength(1);
+ // Should have requested desiredPoolSize (3) tokens
+ expect(mintCalls[0].body.outputs).toHaveLength(3);
+ });
+
+ it('does NOT trigger topUp when pool already has tokens', async () => {
+ // First call: trigger topUp to populate pool
+ await auth.getBlindAuthToken({ method: 'POST', path: '/v1/mint/bolt11' });
+ expect(auth.poolSize).toBe(2);
+
+ // Clear call log
+ mockMint.calls.length = 0;
+
+ // Second call: pool has 2 tokens, no topUp needed
+ const token = await auth.getBlindAuthToken({ method: 'POST', path: '/v1/mint/bolt11' });
+ expect(token).toBeDefined();
+ expect(auth.poolSize).toBe(1);
+
+ // No new /v1/auth/blind/mint calls
+ const mintCalls = mockMint.calls.filter((c) => c.endpoint.includes('/v1/auth/blind/mint'));
+ expect(mintCalls).toHaveLength(0);
+ });
+
+ it('triggers topUp again after pool fully depletes', async () => {
+ // First topUp: mints 3 tokens
+ await auth.getBlindAuthToken({ method: 'POST', path: '/v1/mint/bolt11' });
+ expect(auth.poolSize).toBe(2);
+
+ // Consume remaining tokens
+ await auth.getBlindAuthToken({ method: 'POST', path: '/v1/mint/bolt11' });
+ expect(auth.poolSize).toBe(1);
+
+ await auth.getBlindAuthToken({ method: 'POST', path: '/v1/mint/bolt11' });
+ expect(auth.poolSize).toBe(0);
+
+ // Next call triggers second topUp
+ await auth.getBlindAuthToken({ method: 'POST', path: '/v1/mint/bolt11' });
+ expect(auth.poolSize).toBe(2); // 3 minted - 1 consumed
+
+ // /v1/auth/blind/mint called twice total
+ const mintCalls = mockMint.calls.filter((c) => c.endpoint.includes('/v1/auth/blind/mint'));
+ expect(mintCalls).toHaveLength(2);
+ });
+
+ it('topUp respects bat_max_mint limit', async () => {
+ // Create a mint with small bat_max_mint
+ const limitedMint = createMockMint();
+ const origRequest = limitedMint.request;
+ limitedMint.request = async (args: any) => {
+ const result = await origRequest(args);
+ if (new URL(args.endpoint).pathname === '/v1/info') {
+ result.nuts['22'].bat_max_mint = 2;
+ }
+ return result;
+ };
+
+ const limitedAuth = new AuthManager('https://mint.test', {
+ request: limitedMint.request,
+ desiredPoolSize: 5, // wants 5, but mint only allows 2 per request
+ });
+
+ await limitedAuth.getBlindAuthToken({ method: 'POST', path: '/v1/mint/bolt11' });
+
+ // Should have requested min(5, 2) = 2 tokens in first topUp
+ const mintCalls = limitedMint.calls.filter((c) =>
+ c.endpoint.includes('/v1/auth/blind/mint'),
+ );
+ expect(mintCalls).toHaveLength(1);
+ expect(mintCalls[0].body.outputs).toHaveLength(2);
+
+ // 2 minted - 1 consumed = 1 remaining
+ expect(limitedAuth.poolSize).toBe(1);
+ });
+
+ it('exported pool can be re-imported and avoids topUp', async () => {
+ // Trigger topUp to get real BATs
+ await auth.getBlindAuthToken({ method: 'POST', path: '/v1/mint/bolt11' });
+ expect(auth.poolSize).toBe(2);
+
+ // Export pool
+ const exported = auth.exportPool();
+ expect(exported).toHaveLength(2);
+
+ // Create a new AuthManager and import the pool
+ const auth2 = new AuthManager('https://mint.test', {
+ request: mockMint.request,
+ desiredPoolSize: 3,
+ });
+ auth2.importPool(exported, 'replace');
+ expect(auth2.poolSize).toBe(2);
+
+ // Clear call log
+ mockMint.calls.length = 0;
+
+ // getBlindAuthToken should work without new topUp
+ const token = await auth2.getBlindAuthToken({ method: 'POST', path: '/v1/mint/bolt11' });
+ expect(token).toBeDefined();
+ expect(token.startsWith('authA')).toBe(true);
+ expect(auth2.poolSize).toBe(1);
+
+ // init() is called (info + keysets + keys) but NOT topUp
+ const mintCalls = mockMint.calls.filter((c) => c.endpoint.includes('/v1/auth/blind/mint'));
+ expect(mintCalls).toHaveLength(0);
+ });
+});
diff --git a/packages/core/test/unit/AuthSessionService.test.ts b/packages/core/test/unit/AuthSessionService.test.ts
new file mode 100644
index 00000000..40bc6986
--- /dev/null
+++ b/packages/core/test/unit/AuthSessionService.test.ts
@@ -0,0 +1,122 @@
+import type { CoreEvents } from '@core/events';
+import { EventBus } from '@core/events';
+import { MemoryAuthSessionRepository } from '@core/repositories';
+import { AuthSessionService } from '@core/services';
+import { beforeEach, describe, expect, it } from 'bun:test';
+
+
+describe('AuthSessionService', () => {
+ const mintUrl = 'https://mint.test'
+ let repo: MemoryAuthSessionRepository;
+ let bus: EventBus
+ let service: AuthSessionService;
+
+ beforeEach(() => {
+ repo = new MemoryAuthSessionRepository();
+ bus = new EventBus;
+ service = new AuthSessionService(repo, bus);
+ });
+
+ it('saves and retrieves a valid session', async() =>{
+ await service.saveSession(mintUrl, {
+ access_token:'abc_123',
+ refresh_token:'def_456',
+ expires_in:3600,
+ });
+ const session = await service.getValidSession(mintUrl);
+
+ expect(session.accessToken).toBe('abc_123');
+ expect(session.refreshToken).toBe('def_456');
+ });
+
+ it('emits auth-session:updated on save', async() => {
+ const events: string[] = [];
+ bus.on('auth-session:updated', (payload) => {
+ events.push(payload.mintUrl);
+ });
+ await service.saveSession(mintUrl, {access_token: 'abc'});
+
+ expect(events).toEqual([mintUrl]);
+ })
+
+ it('throws AuthSessionExpiredError on expired session', async()=> {
+ //save expired session for test
+ await repo.saveSession({
+ mintUrl,
+ accessToken: 'old',
+ expiresAt: 0,
+ });
+ await expect(service.getValidSession(mintUrl)).rejects.toThrow('expired');
+ });
+
+ it('throws on non-existent session', async() => {
+ await expect(service.getValidSession(mintUrl)).rejects.toThrow('No auth session found');
+ })
+
+ it('normalizes mint URL', async() => {
+ //save URL with '/'
+ await service.saveSession('https://mint.test/', {access_token:'abc'});
+
+ //get Session without '/'
+ const session = await service.getValidSession('https://mint.test');
+
+ expect(session.accessToken).toBe('abc');
+ });
+
+ it('deletes session and emits event', async() =>{
+ const events: string[] = [];
+ bus.on('auth-session:deleted', (p) => {events.push(p.mintUrl)} );
+ await service.saveSession(mintUrl, {access_token: 'abc'});
+ await service.deleteSession(mintUrl);
+
+ await expect(service.getValidSession(mintUrl)).rejects.toThrow();
+ expect(events).toEqual([mintUrl]);
+ })
+
+ it('hasSession returns true for valid session', async () => {
+ await service.saveSession(mintUrl, {
+ access_token: 'abc',
+ expires_in: 3600,
+ });
+ expect(await service.hasSession(mintUrl)).toBe(true);
+ });
+
+ it('hasSession returns false for expired session', async () => {
+ await repo.saveSession({
+ mintUrl,
+ accessToken: 'old',
+ expiresAt: 0,
+ });
+ expect(await service.hasSession(mintUrl)).toBe(false);
+ });
+
+ it('hasSession returns false for non-existent session', async () => {
+ expect(await service.hasSession(mintUrl)).toBe(false);
+ });
+
+ it('saves and retrieves session with batPool', async () => {
+ const batPool = [
+ { id: 'key1', amount: 1, secret: 's1', C: 'c1' },
+ { id: 'key1', amount: 2, secret: 's2', C: 'c2' },
+ ] as any;
+
+ await service.saveSession(mintUrl, {
+ access_token: 'abc',
+ expires_in: 3600,
+ }, batPool);
+
+ const session = await service.getValidSession(mintUrl);
+ expect(session.batPool).toEqual(batPool);
+ expect(session.batPool).toHaveLength(2);
+ });
+
+ it('saves session without batPool (backward compat)', async () => {
+ await service.saveSession(mintUrl, {
+ access_token: 'abc',
+ expires_in: 3600,
+ });
+
+ const session = await service.getValidSession(mintUrl);
+ expect(session.batPool).toBeUndefined();
+ });
+});
\ No newline at end of file
diff --git a/packages/core/types.ts b/packages/core/types.ts
index a379a5f4..b7651c64 100644
--- a/packages/core/types.ts
+++ b/packages/core/types.ts
@@ -20,3 +20,43 @@ export interface CoreProof extends Proof {
*/
createdByOperationId?: string;
}
+
+// --- Blind Auth (non-standard cdk extension) ---
+
+/** Wire format for AuthProof — subset of Proof without amount/witness. */
+export interface AuthProof {
+ id: string;
+ secret: string;
+ C: string;
+ dleq?: { e: string; s: string; r: string };
+}
+
+export interface CheckBlindAuthStateRequest {
+ auth_proofs: AuthProof[];
+}
+
+/** NUT-07 ProofState wire format (reused by auth/blind endpoints). */
+export interface BlindAuthProofState {
+ Y: string;
+ state: 'SPENT' | 'UNSPENT' | 'PENDING';
+ witness?: string;
+}
+
+export interface CheckBlindAuthStateResponse {
+ states: BlindAuthProofState[];
+}
+
+export interface SpendBlindAuthRequest {
+ auth_proof: AuthProof;
+}
+
+export interface SpendBlindAuthResponse {
+ state: BlindAuthProofState;
+}
+
+/** Strip amount/witness from a BAT Proof to produce the cdk AuthProof wire format. */
+export function toAuthProof(proof: Proof): AuthProof {
+ const ap: AuthProof = { id: proof.id, secret: proof.secret, C: proof.C };
+ if (proof.dleq) ap.dleq = { e: proof.dleq.e, s: proof.dleq.s, r: proof.dleq.r };
+ return ap;
+}
diff --git a/packages/expo-sqlite/src/index.ts b/packages/expo-sqlite/src/index.ts
index 8e6be8a7..008f64fc 100644
--- a/packages/expo-sqlite/src/index.ts
+++ b/packages/expo-sqlite/src/index.ts
@@ -9,6 +9,7 @@ import type {
MeltQuoteRepository,
SendOperationRepository,
MeltOperationRepository,
+ AuthSessionRepository,
RepositoryTransactionScope,
} from 'coco-cashu-core';
import { ExpoSqliteDb, type ExpoSqliteDbOptions } from './db.ts';
@@ -23,6 +24,7 @@ import { ExpoMeltQuoteRepository } from './repositories/MeltQuoteRepository.ts';
import { ExpoHistoryRepository } from './repositories/HistoryRepository.ts';
import { ExpoSendOperationRepository } from './repositories/SendOperationRepository.ts';
import { ExpoMeltOperationRepository } from './repositories/MeltOperationRepository.ts';
+import { ExpoAuthSessionRepository } from './repositories/AuthSessionRepository.ts';
export interface ExpoSqliteRepositoriesOptions extends ExpoSqliteDbOptions {}
@@ -37,6 +39,7 @@ export class ExpoSqliteRepositories implements Repositories {
readonly historyRepository: ExpoHistoryRepository;
readonly sendOperationRepository: SendOperationRepository;
readonly meltOperationRepository: MeltOperationRepository;
+ readonly authSessionRepository: AuthSessionRepository;
readonly db: ExpoSqliteDb;
constructor(options: ExpoSqliteRepositoriesOptions) {
@@ -51,6 +54,7 @@ export class ExpoSqliteRepositories implements Repositories {
this.historyRepository = new ExpoHistoryRepository(this.db);
this.sendOperationRepository = new ExpoSendOperationRepository(this.db);
this.meltOperationRepository = new ExpoMeltOperationRepository(this.db);
+ this.authSessionRepository = new ExpoAuthSessionRepository(this.db);
}
async init(): Promise {
@@ -70,6 +74,7 @@ export class ExpoSqliteRepositories implements Repositories {
historyRepository: new ExpoHistoryRepository(txDb),
sendOperationRepository: new ExpoSendOperationRepository(txDb),
meltOperationRepository: new ExpoMeltOperationRepository(txDb),
+ authSessionRepository: new ExpoAuthSessionRepository(txDb),
};
return fn(scopedRepositories);
@@ -92,6 +97,7 @@ export {
ExpoHistoryRepository,
ExpoSendOperationRepository,
ExpoMeltOperationRepository,
+ ExpoAuthSessionRepository,
};
export type { Migration };
diff --git a/packages/expo-sqlite/src/repositories/AuthSessionRepository.ts b/packages/expo-sqlite/src/repositories/AuthSessionRepository.ts
new file mode 100644
index 00000000..14d83a4e
--- /dev/null
+++ b/packages/expo-sqlite/src/repositories/AuthSessionRepository.ts
@@ -0,0 +1,74 @@
+import type { AuthSessionRepository, AuthSession } from 'coco-cashu-core';
+import { ExpoSqliteDb } from '../db.ts';
+
+interface AuthSessionRow {
+ mintUrl: string;
+ accessToken: string;
+ refreshToken: string | null;
+ expiresAt: number;
+ scope: string | null;
+ batPoolJson: string | null;
+}
+
+export class ExpoAuthSessionRepository implements AuthSessionRepository {
+ private readonly db: ExpoSqliteDb;
+
+ constructor(db: ExpoSqliteDb) {
+ this.db = db;
+ }
+
+ async getSession(mintUrl: string): Promise {
+ const row = await this.db.get(
+ 'SELECT mintUrl, accessToken, refreshToken, expiresAt, scope, batPoolJson FROM coco_cashu_auth_sessions WHERE mintUrl = ? LIMIT 1',
+ [mintUrl],
+ );
+ if (!row) return null;
+ return {
+ mintUrl: row.mintUrl,
+ accessToken: row.accessToken,
+ refreshToken: row.refreshToken ?? undefined,
+ expiresAt: row.expiresAt,
+ scope: row.scope ?? undefined,
+ batPool: row.batPoolJson ? JSON.parse(row.batPoolJson) : undefined,
+ };
+ }
+
+ async saveSession(session: AuthSession): Promise {
+ await this.db.run(
+ `INSERT INTO coco_cashu_auth_sessions (mintUrl, accessToken, refreshToken, expiresAt, scope, batPoolJson)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(mintUrl) DO UPDATE SET
+ accessToken=excluded.accessToken,
+ refreshToken=excluded.refreshToken,
+ expiresAt=excluded.expiresAt,
+ scope=excluded.scope,
+ batPoolJson=excluded.batPoolJson`,
+ [
+ session.mintUrl,
+ session.accessToken,
+ session.refreshToken ?? null,
+ session.expiresAt,
+ session.scope ?? null,
+ session.batPool ? JSON.stringify(session.batPool) : null,
+ ],
+ );
+ }
+
+ async deleteSession(mintUrl: string): Promise {
+ await this.db.run('DELETE FROM coco_cashu_auth_sessions WHERE mintUrl = ?', [mintUrl]);
+ }
+
+ async getAllSessions(): Promise {
+ const rows = await this.db.all(
+ 'SELECT mintUrl, accessToken, refreshToken, expiresAt, scope, batPoolJson FROM coco_cashu_auth_sessions',
+ );
+ return rows.map((row) => ({
+ mintUrl: row.mintUrl,
+ accessToken: row.accessToken,
+ refreshToken: row.refreshToken ?? undefined,
+ expiresAt: row.expiresAt,
+ scope: row.scope ?? undefined,
+ batPool: row.batPoolJson ? JSON.parse(row.batPoolJson) : undefined,
+ }));
+ }
+}
diff --git a/packages/expo-sqlite/src/schema.ts b/packages/expo-sqlite/src/schema.ts
index 9b0681fc..506cad88 100644
--- a/packages/expo-sqlite/src/schema.ts
+++ b/packages/expo-sqlite/src/schema.ts
@@ -307,6 +307,19 @@ const MIGRATIONS: readonly Migration[] = [
WHERE quoteId IS NOT NULL;
`,
},
+ {
+ id: '012_auth_sessions',
+ sql: `
+ CREATE TABLE IF NOT EXISTS coco_cashu_auth_sessions (
+ mintUrl TEXT PRIMARY KEY NOT NULL,
+ accessToken TEXT NOT NULL,
+ refreshToken TEXT,
+ expiresAt INTEGER NOT NULL,
+ scope TEXT,
+ batPoolJson TEXT
+ );
+ `,
+ },
];
// Export for testing
diff --git a/packages/indexeddb/src/index.ts b/packages/indexeddb/src/index.ts
index e2c3a44b..36fd1c32 100644
--- a/packages/indexeddb/src/index.ts
+++ b/packages/indexeddb/src/index.ts
@@ -9,6 +9,7 @@ import type {
MeltQuoteRepository,
SendOperationRepository,
MeltOperationRepository,
+ AuthSessionRepository,
RepositoryTransactionScope,
} from 'coco-cashu-core';
import { IdbDb, type IdbDbOptions } from './lib/db.ts';
@@ -23,6 +24,7 @@ import { IdbMeltQuoteRepository } from './repositories/MeltQuoteRepository.ts';
import { IdbHistoryRepository } from './repositories/HistoryRepository.ts';
import { IdbSendOperationRepository } from './repositories/SendOperationRepository.ts';
import { IdbMeltOperationRepository } from './repositories/MeltOperationRepository.ts';
+import { IdbAuthSessionRepository } from './repositories/AuthSessionRepository.ts';
export interface IndexedDbRepositoriesOptions extends IdbDbOptions {}
@@ -37,6 +39,7 @@ export class IndexedDbRepositories implements Repositories {
readonly historyRepository: IdbHistoryRepository;
readonly sendOperationRepository: SendOperationRepository;
readonly meltOperationRepository: MeltOperationRepository;
+ readonly authSessionRepository: AuthSessionRepository;
readonly db: IdbDb;
private initialized = false;
@@ -52,6 +55,7 @@ export class IndexedDbRepositories implements Repositories {
this.historyRepository = new IdbHistoryRepository(this.db);
this.sendOperationRepository = new IdbSendOperationRepository(this.db);
this.meltOperationRepository = new IdbMeltOperationRepository(this.db);
+ this.authSessionRepository = new IdbAuthSessionRepository(this.db);
}
async init(): Promise {
@@ -79,6 +83,7 @@ export class IndexedDbRepositories implements Repositories {
historyRepository: new IdbHistoryRepository(scopedDb),
sendOperationRepository: new IdbSendOperationRepository(scopedDb),
meltOperationRepository: new IdbMeltOperationRepository(scopedDb),
+ authSessionRepository: new IdbAuthSessionRepository(scopedDb),
};
return fn(scopedRepositories);
});
@@ -98,4 +103,5 @@ export {
IdbHistoryRepository,
IdbSendOperationRepository,
IdbMeltOperationRepository,
+ IdbAuthSessionRepository,
};
diff --git a/packages/indexeddb/src/lib/db.ts b/packages/indexeddb/src/lib/db.ts
index 65a44cc8..438f7f8e 100644
--- a/packages/indexeddb/src/lib/db.ts
+++ b/packages/indexeddb/src/lib/db.ts
@@ -202,3 +202,12 @@ export interface MeltOperationRow {
changeOutputDataJson?: string | null;
swapOutputDataJson?: string | null;
}
+
+export interface AuthSessionRow {
+ mintUrl: string;
+ accessToken: string;
+ refreshToken: string | null;
+ expiresAt: number;
+ scope: string | null;
+ batPoolJson: string | null;
+}
diff --git a/packages/indexeddb/src/lib/schema.ts b/packages/indexeddb/src/lib/schema.ts
index c14a853b..2defd2af 100644
--- a/packages/indexeddb/src/lib/schema.ts
+++ b/packages/indexeddb/src/lib/schema.ts
@@ -247,4 +247,21 @@ export async function ensureSchema(db: IdbDb): Promise {
coco_cashu_send_operations: '&id, state, mintUrl',
coco_cashu_melt_operations: '&id, state, mintUrl, [mintUrl+quoteId]',
});
+
+ // Version 10: Add auth sessions store
+ db.version(10).stores({
+ coco_cashu_mints: '&mintUrl, name, updatedAt, trusted',
+ coco_cashu_keysets: '&[mintUrl+id], mintUrl, id, updatedAt, unit',
+ coco_cashu_counters: '&[mintUrl+keysetId]',
+ coco_cashu_proofs:
+ '&[mintUrl+secret], [mintUrl+state], [mintUrl+id+state], state, mintUrl, id, usedByOperationId, createdByOperationId',
+ coco_cashu_mint_quotes: '&[mintUrl+quote], state, mintUrl',
+ coco_cashu_melt_quotes: '&[mintUrl+quote], state, mintUrl',
+ coco_cashu_history:
+ '++id, mintUrl, type, createdAt, [mintUrl+quoteId+type], [mintUrl+operationId]',
+ coco_cashu_keypairs: '&publicKey, createdAt, derivationIndex',
+ coco_cashu_send_operations: '&id, state, mintUrl',
+ coco_cashu_melt_operations: '&id, state, mintUrl, [mintUrl+quoteId]',
+ coco_cashu_auth_sessions: '&mintUrl',
+ });
}
diff --git a/packages/indexeddb/src/repositories/AuthSessionRepository.ts b/packages/indexeddb/src/repositories/AuthSessionRepository.ts
new file mode 100644
index 00000000..b0696dfb
--- /dev/null
+++ b/packages/indexeddb/src/repositories/AuthSessionRepository.ts
@@ -0,0 +1,53 @@
+import type { AuthSessionRepository, AuthSession } from 'coco-cashu-core';
+import type { IdbDb, AuthSessionRow } from '../lib/db.ts';
+
+export class IdbAuthSessionRepository implements AuthSessionRepository {
+ private readonly db: IdbDb;
+
+ constructor(db: IdbDb) {
+ this.db = db;
+ }
+
+ async getSession(mintUrl: string): Promise {
+ const row = (await (this.db as any).table('coco_cashu_auth_sessions').get(mintUrl)) as
+ | AuthSessionRow
+ | undefined;
+ if (!row) return null;
+ return {
+ mintUrl: row.mintUrl,
+ accessToken: row.accessToken,
+ refreshToken: row.refreshToken ?? undefined,
+ expiresAt: row.expiresAt,
+ scope: row.scope ?? undefined,
+ batPool: row.batPoolJson ? JSON.parse(row.batPoolJson) : undefined,
+ };
+ }
+
+ async saveSession(session: AuthSession): Promise {
+ const row: AuthSessionRow = {
+ mintUrl: session.mintUrl,
+ accessToken: session.accessToken,
+ refreshToken: session.refreshToken ?? null,
+ expiresAt: session.expiresAt,
+ scope: session.scope ?? null,
+ batPoolJson: session.batPool ? JSON.stringify(session.batPool) : null,
+ };
+ await (this.db as any).table('coco_cashu_auth_sessions').put(row);
+ }
+
+ async deleteSession(mintUrl: string): Promise {
+ await (this.db as any).table('coco_cashu_auth_sessions').delete(mintUrl);
+ }
+
+ async getAllSessions(): Promise {
+ const rows = (await (this.db as any).table('coco_cashu_auth_sessions').toArray()) as AuthSessionRow[];
+ return rows.map((row) => ({
+ mintUrl: row.mintUrl,
+ accessToken: row.accessToken,
+ refreshToken: row.refreshToken ?? undefined,
+ expiresAt: row.expiresAt,
+ scope: row.scope ?? undefined,
+ batPool: row.batPoolJson ? JSON.parse(row.batPoolJson) : undefined,
+ }));
+ }
+}
diff --git a/packages/sqlite3/src/index.ts b/packages/sqlite3/src/index.ts
index f0be5e31..fe113e09 100644
--- a/packages/sqlite3/src/index.ts
+++ b/packages/sqlite3/src/index.ts
@@ -9,6 +9,7 @@ import type {
MeltQuoteRepository,
SendOperationRepository,
MeltOperationRepository,
+ AuthSessionRepository,
RepositoryTransactionScope,
} from 'coco-cashu-core';
import { SqliteDb, type SqliteDbOptions } from './db.ts';
@@ -23,6 +24,7 @@ import { SqliteMeltQuoteRepository } from './repositories/MeltQuoteRepository.ts
import { SqliteHistoryRepository } from './repositories/HistoryRepository.ts';
import { SqliteSendOperationRepository } from './repositories/SendOperationRepository.ts';
import { SqliteMeltOperationRepository } from './repositories/MeltOperationRepository.ts';
+import { SqliteAuthSessionRepository } from './repositories/AuthSessionRepository.ts';
export interface SqliteRepositoriesOptions extends SqliteDbOptions {}
@@ -37,6 +39,7 @@ export class SqliteRepositories implements Repositories {
readonly historyRepository: SqliteHistoryRepository;
readonly sendOperationRepository: SendOperationRepository;
readonly meltOperationRepository: MeltOperationRepository;
+ readonly authSessionRepository: AuthSessionRepository;
readonly db: SqliteDb;
constructor(options: SqliteRepositoriesOptions) {
@@ -51,6 +54,7 @@ export class SqliteRepositories implements Repositories {
this.historyRepository = new SqliteHistoryRepository(this.db);
this.sendOperationRepository = new SqliteSendOperationRepository(this.db);
this.meltOperationRepository = new SqliteMeltOperationRepository(this.db);
+ this.authSessionRepository = new SqliteAuthSessionRepository(this.db);
}
async init(): Promise {
@@ -70,6 +74,7 @@ export class SqliteRepositories implements Repositories {
historyRepository: new SqliteHistoryRepository(txDb),
sendOperationRepository: new SqliteSendOperationRepository(txDb),
meltOperationRepository: new SqliteMeltOperationRepository(txDb),
+ authSessionRepository: new SqliteAuthSessionRepository(txDb),
};
return fn(scopedRepositories);
@@ -92,6 +97,7 @@ export {
SqliteHistoryRepository,
SqliteSendOperationRepository,
SqliteMeltOperationRepository,
+ SqliteAuthSessionRepository,
};
export type { Migration };
diff --git a/packages/sqlite3/src/repositories/AuthSessionRepository.ts b/packages/sqlite3/src/repositories/AuthSessionRepository.ts
new file mode 100644
index 00000000..78bd3ce2
--- /dev/null
+++ b/packages/sqlite3/src/repositories/AuthSessionRepository.ts
@@ -0,0 +1,74 @@
+import type { AuthSessionRepository, AuthSession } from 'coco-cashu-core';
+import { SqliteDb } from '../db.ts';
+
+interface AuthSessionRow {
+ mintUrl: string;
+ accessToken: string;
+ refreshToken: string | null;
+ expiresAt: number;
+ scope: string | null;
+ batPoolJson: string | null;
+}
+
+export class SqliteAuthSessionRepository implements AuthSessionRepository {
+ private readonly db: SqliteDb;
+
+ constructor(db: SqliteDb) {
+ this.db = db;
+ }
+
+ async getSession(mintUrl: string): Promise {
+ const row = await this.db.get(
+ 'SELECT mintUrl, accessToken, refreshToken, expiresAt, scope, batPoolJson FROM coco_cashu_auth_sessions WHERE mintUrl = ? LIMIT 1',
+ [mintUrl],
+ );
+ if (!row) return null;
+ return {
+ mintUrl: row.mintUrl,
+ accessToken: row.accessToken,
+ refreshToken: row.refreshToken ?? undefined,
+ expiresAt: row.expiresAt,
+ scope: row.scope ?? undefined,
+ batPool: row.batPoolJson ? JSON.parse(row.batPoolJson) : undefined,
+ };
+ }
+
+ async saveSession(session: AuthSession): Promise {
+ await this.db.run(
+ `INSERT INTO coco_cashu_auth_sessions (mintUrl, accessToken, refreshToken, expiresAt, scope, batPoolJson)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(mintUrl) DO UPDATE SET
+ accessToken=excluded.accessToken,
+ refreshToken=excluded.refreshToken,
+ expiresAt=excluded.expiresAt,
+ scope=excluded.scope,
+ batPoolJson=excluded.batPoolJson`,
+ [
+ session.mintUrl,
+ session.accessToken,
+ session.refreshToken ?? null,
+ session.expiresAt,
+ session.scope ?? null,
+ session.batPool ? JSON.stringify(session.batPool) : null,
+ ],
+ );
+ }
+
+ async deleteSession(mintUrl: string): Promise {
+ await this.db.run('DELETE FROM coco_cashu_auth_sessions WHERE mintUrl = ?', [mintUrl]);
+ }
+
+ async getAllSessions(): Promise {
+ const rows = await this.db.all(
+ 'SELECT mintUrl, accessToken, refreshToken, expiresAt, scope, batPoolJson FROM coco_cashu_auth_sessions',
+ );
+ return rows.map((row) => ({
+ mintUrl: row.mintUrl,
+ accessToken: row.accessToken,
+ refreshToken: row.refreshToken ?? undefined,
+ expiresAt: row.expiresAt,
+ scope: row.scope ?? undefined,
+ batPool: row.batPoolJson ? JSON.parse(row.batPoolJson) : undefined,
+ }));
+ }
+}
diff --git a/packages/sqlite3/src/schema.ts b/packages/sqlite3/src/schema.ts
index a84c0a54..25e0d905 100644
--- a/packages/sqlite3/src/schema.ts
+++ b/packages/sqlite3/src/schema.ts
@@ -307,6 +307,19 @@ const MIGRATIONS: readonly Migration[] = [
WHERE quoteId IS NOT NULL;
`,
},
+ {
+ id: '012_auth_sessions',
+ sql: `
+ CREATE TABLE IF NOT EXISTS coco_cashu_auth_sessions (
+ mintUrl TEXT PRIMARY KEY NOT NULL,
+ accessToken TEXT NOT NULL,
+ refreshToken TEXT,
+ expiresAt INTEGER NOT NULL,
+ scope TEXT,
+ batPoolJson TEXT
+ );
+ `,
+ },
];
// Export for testing