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