Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ See `IMPLEMENTATION-PLAN.md` for the full 36-step plan.
- Step 21 (Login Crypto Flow) — complete
- Step 22 (Login UI + Vault Unlock) — complete
- Step 23 (Non-Extractable Key Vault + Zustand Store Refactor) — complete
- Step 24 (Supabase API Adapter) — complete

### Implementation Notes

Expand All @@ -196,5 +197,6 @@ Non-obvious decisions not visible from code alone:
- **`deriveRegistrationKeys` is a pure crypto function**: in `features/encryption/model/registration.ts`, it has no side effects (no auth, no DB, no store writes). The orchestration (signup + upload + store population) lives in `auth-flow.ts` `signUpUser`. Do not add side effects to this function.
- **`signUpUser` error cleanup**: on any error after `deriveRegistrationKeys` succeeds, attempts `authAdapter.logout()` as best-effort cleanup (harmless if no session exists, since Supabase signOut with no session is a no-op).
- **Login salt fetch is pre-auth**: `get_login_salts(p_username)` is a SECURITY DEFINER RPC callable by anonymous users, rate-limited (5 req/2 min/IP). Salts must be fetched before auth to derive `authHash` for Supabase Auth, but the `keys` table is RLS-protected. After auth succeeds, `fetchMasterKeyEnvelope` and `fetchFieldKeys` fetch wrapped key material through standard RLS-protected queries.
- **Auth error codes fold username format into invalid credentials**: `AuthErrorCode.INVALID_USERNAME_FORMAT` doesn't exist — `supabase-keys.ts` throws `INVALID_CREDENTIALS` for invalid username format. This is deliberate: showing a different error for "wrong format" vs "wrong password" would leak whether a username exists.
- **Network errors can bypass the adapter boundary**: `getAuthErrorMessage` in `auth-error-messages.ts` has an `isNetworkError` fallback because raw `TypeError('Failed to fetch')` from the browser can reach the UI without being wrapped by the adapter. The adapter wraps what it can, but the fallback catches the rest.
- **Auth error codes fold username format into invalid credentials**: `AuthErrorCode.INVALID_USERNAME_FORMAT` doesn't exist — `supabase-keys.ts` throws `INVALID_CREDENTIALS` for invalid username format. This is deliberate: showing a different error for "wrong format" vs "wrong password" would leak whether a username exists. Missing key data is `ApiError(NOT_FOUND)`, not an auth error — `KEYS_NOT_FOUND` was removed from `AuthErrorCode` because "data not found" is a data-layer concern, not an auth concern.
- **`AuthError` vs `ApiError` domain boundary**: `AuthError` (in `shared/auth/auth-errors.ts`) covers authentication errors (`INVALID_CREDENTIALS`, `USERNAME_TAKEN`, `NETWORK_ERROR`, `UNEXPECTED`). `ApiError` (in `shared/api/api-errors.ts`) covers data-layer errors (`NETWORK_ERROR`, `NOT_FOUND`, `UNEXPECTED`). `fetchLoginSalts` throws `AuthError` because it's a pre-auth RPC that's part of the login flow; all other data queries throw `ApiError`. Each domain has its own `wrapXxxError` that classifies raw errors using the shared `isNetworkError` helper.
- **Network errors can bypass the adapter boundary**: `isNetworkError` (in `shared/lib/network-errors.ts`) is shared by both `wrapAuthError` and `wrapApiError`. Raw `TypeError('Failed to fetch')` from the browser can reach the UI without being wrapped by any adapter, so `getAuthErrorMessage` in `auth-error-messages.ts` also calls `isNetworkError` as a final fallback.
33 changes: 16 additions & 17 deletions docs/implementation-plan/06-phase-6-data.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,30 @@
# Phase 6: Encrypted Data Layer

## Step 24 — Supabase API Adapter
## Step 24 — Supabase API Adapter

**Goal:** Full CRUD implementation for all database operations, split into focused modules.

**Code:**
- `src/shared/api/supabase-client.ts` — Supabase client initialization and export only (no business logic)
- `src/shared/api/api-errors.ts` — `ApiError` class + `ApiErrorCode` (`NETWORK_ERROR`, `NOT_FOUND`, `UNEXPECTED`), `isApiError()` type guard, `wrapApiError()` (reuses `isNetworkError` from auth-errors)
- `src/shared/api/supabase-keys.ts` — Keys CRUD:
- `getMasterKeyEnvelope(userId: string): Promise<ServerKeys>` — fetch auth_salt, key_salt, wrapped_master_key, master_key_iv
- `getFieldKeys(userId: string): Promise<ServerFieldKey[]>` — fetch all wrapped field keys with versions
- `saveWrappedKey(userId: string, data: WrappedKeyData): Promise<void>` — save/update wrapped key data
- `fetchMasterKeyEnvelope(userId)` — now throws `ApiError(NOT_FOUND)` instead of `AuthError(KEYS_NOT_FOUND)`
- `fetchFieldKeys(userId)` — same error refactor
- `saveWrappedKey(userId, data: SaveWrappedKeyData)` — upsert on `field_keys` with `onConflict: 'user_id,field_name,version'`
- `fetchLoginSalts` stays with `AuthError` (pre-auth, not an IApiAdapter method); local error helper renamed to `wrapAuthError`
- `src/shared/api/supabase-fields.ts` — Fields CRUD:
- `getField(userId: string, fieldName: string): Promise<ServerEncryptedField | null>` — fetch encrypted field data
- `saveField(userId: string, fieldName: string, blob: Uint8Array, iv: Uint8Array): Promise<void>` — upsert encrypted field
- `fetchField(userId, fieldName)` — `.maybeSingle()` on `encrypted_fields`, returns `null` if missing, maps snake_case → camelCase
- `saveField(userId, fieldName, data: SaveFieldData)` — `.upsert()` with `onConflict: 'user_id,field_name'`
- `src/shared/api/supabase-recovery.ts` — Recovery data CRUD:
- `saveRecoveryData(userId: string, data: RecoveryData): Promise<void>` — save recovery data
- `getRecoveryData(userId: string): Promise<ServerRecoveryData | null>` — fetch recovery data
- All queries use Supabase client with RLS (user can only access own data)
- `ServerKeys`, `ServerFieldKey`, `ServerEncryptedField`, `ServerRecoveryData` types in api.types.ts
- `fetchRecoveryData(userId)` — `.maybeSingle()` on `recovery`, returns `null` if missing
- `saveRecoveryData(userId, data: SaveRecoveryData)` — `.upsert()` with `onConflict: 'user_id'`
- All data flows as hex strings in the API layer — no Uint8Array conversion at this boundary
- Remove `KEYS_NOT_FOUND` from `AuthErrorCode`; update `auth-error-messages.ts` to handle `ApiError` codes
- Update `IApiAdapter` interface: rename `getField` → `fetchField`, `getRecoveryData` → `fetchRecoveryData`

**Tests:**
- Integration tests against local Supabase:
- Create user → save keys → fetch keys → verify match
- Create user → save field → fetch field → verify match
- Update field → fetch → verify updated
- RLS: user A cannot read user B's data
- RLS: unauthenticated user cannot read any data
- Unit tests with mocked Supabase client for all CRUD functions
- `api-errors.test.ts` — construction, type guard, `wrapApiError` mapping
- Error assertions: `ApiError(NOT_FOUND)` for missing data, `ApiError(UNEXPECTED)` for query failures

---

Expand Down
36 changes: 26 additions & 10 deletions src/app/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,37 @@
import { useTranslation } from 'react-i18next'
import type { ErrorComponentProps } from '@tanstack/react-router'
import { Button } from '@/shared/ui/button'
import { DecryptionError, CorruptedDataError } from '@/shared/crypto/errors'
import { Argon2Error, CorruptedDataError, DecryptionError, MnemonicError } from '@/shared/crypto/errors'
import { AuthError, AuthErrorCode } from '@/shared/auth/auth-errors'
import { ApiError, ApiErrorCode } from '@/shared/api/api-errors'

/** Crypto errors with fixed descriptions — add new ones here instead of extending the if-chain. */
const CRYPTO_ERRORS: readonly (readonly [new () => Error, string])[] = [
[DecryptionError, 'crypto:errors.decryptFailed'],
[CorruptedDataError, 'crypto:errors.corruptedData'],
[Argon2Error, 'crypto:errors.argon2Failed'],
[MnemonicError, 'crypto:errors.mnemonicFailed'],
]

function getErrorMessage(error: Error): { title: string; description: string } {
if (error instanceof DecryptionError) {
return {
title: 'common:status.error',
description: 'crypto:errors.decryptFailed',
for (const [ErrorClass, description] of CRYPTO_ERRORS) {
if (error instanceof ErrorClass) {
return { title: 'common:status.error', description }
}
}
if (error instanceof CorruptedDataError) {
return {
title: 'common:status.error',
description: 'crypto:errors.corruptedData',
}

if (error instanceof AuthError) {
return error.code === AuthErrorCode.NETWORK_ERROR
? { title: 'common:status.error', description: 'common:errors.networkError' }
: { title: 'common:status.error', description: 'common:errors.unexpectedError' }
}

if (error instanceof ApiError) {
return error.code === ApiErrorCode.NETWORK_ERROR
? { title: 'common:status.error', description: 'common:errors.networkError' }
: { title: 'common:status.error', description: 'common:errors.unexpectedError' }
}

return {
title: 'common:status.error',
description: error.message || 'common:status.error',
Expand Down
19 changes: 16 additions & 3 deletions src/features/auth/model/auth-error-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { TFunction } from 'i18next'
import { describe, it, expect, vi } from 'vitest'
import { getAuthErrorMessage } from '@/features/auth/model/auth-error-messages'
import { AuthError, AuthErrorCode } from '@/shared/auth/auth-errors'
import { ApiError, ApiErrorCode } from '@/shared/api/api-errors'

const t = vi.fn((key: string) => key) as unknown as TFunction<'auth'>

Expand All @@ -26,13 +27,25 @@ describe('getAuthErrorMessage', () => {
expect(t).toHaveBeenCalledWith('errors.networkError')
})

it('maps KEYS_NOT_FOUND to unexpectedError', () => {
getAuthErrorMessage(new AuthError(AuthErrorCode.KEYS_NOT_FOUND), t)
it('maps UNEXPECTED to unexpectedError', () => {
getAuthErrorMessage(new AuthError(AuthErrorCode.UNEXPECTED), t)
expect(t).toHaveBeenCalledWith('errors.unexpectedError')
})
})

describe('ApiError code mapping', () => {
it('maps NETWORK_ERROR to networkError', () => {
getAuthErrorMessage(new ApiError(ApiErrorCode.NETWORK_ERROR), t)
expect(t).toHaveBeenCalledWith('errors.networkError')
})

it('maps NOT_FOUND to unexpectedError', () => {
getAuthErrorMessage(new ApiError(ApiErrorCode.NOT_FOUND), t)
expect(t).toHaveBeenCalledWith('errors.unexpectedError')
})

it('maps UNEXPECTED to unexpectedError', () => {
getAuthErrorMessage(new AuthError(AuthErrorCode.UNEXPECTED), t)
getAuthErrorMessage(new ApiError(ApiErrorCode.UNEXPECTED), t)
expect(t).toHaveBeenCalledWith('errors.unexpectedError')
})
})
Expand Down
15 changes: 13 additions & 2 deletions src/features/auth/model/auth-error-messages.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { TFunction } from 'i18next'
import { AuthErrorCode, isAuthError, isNetworkError } from '@/shared/auth/auth-errors'
import { AuthErrorCode, isAuthError } from '@/shared/auth/auth-errors'
import { isNetworkError } from '@/shared/lib/network-errors'
import { ApiErrorCode, isApiError } from '@/shared/api/api-errors'

export function getAuthErrorMessage(error: unknown, t: TFunction<'auth'>): string {
if (isAuthError(error)) {
Expand All @@ -10,12 +12,21 @@ export function getAuthErrorMessage(error: unknown, t: TFunction<'auth'>): strin
return t('errors.usernameTaken')
case AuthErrorCode.NETWORK_ERROR:
return t('errors.networkError')
case AuthErrorCode.KEYS_NOT_FOUND:
case AuthErrorCode.UNEXPECTED:
return t('errors.unexpectedError')
}
}

if (isApiError(error)) {
switch (error.code) {
case ApiErrorCode.NETWORK_ERROR:
return t('errors.networkError')
case ApiErrorCode.NOT_FOUND:
case ApiErrorCode.UNEXPECTED:
return t('errors.unexpectedError')
}
}

if (isNetworkError(error)) return t('errors.networkError')

return t('errors.unexpectedError')
Expand Down
2 changes: 1 addition & 1 deletion src/features/encryption/model/crypto-error-messages.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { TFunction } from 'i18next'

import { DecryptionError, CorruptedDataError, Argon2Error } from '@/shared/crypto/errors'
import { isNetworkError } from '@/shared/auth/auth-errors'
import { isNetworkError } from '@/shared/lib/network-errors'

export function getCryptoErrorMessage(error: unknown, t: TFunction<'crypto'>): string {
if (error instanceof DecryptionError) return t('errors.wrongPassword')
Expand Down
66 changes: 66 additions & 0 deletions src/shared/api/api-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest'
import { ApiError, ApiErrorCode, isApiError, wrapApiError } from '@/shared/api/api-errors'

describe('ApiError', () => {
it('constructs with code and default message', () => {
const error = new ApiError(ApiErrorCode.NOT_FOUND)
expect(error).toBeInstanceOf(Error)
expect(error).toBeInstanceOf(ApiError)
expect(error.name).toBe('ApiError')
expect(error.message).toBe('ApiError: NOT_FOUND')
expect(error.code).toBe('NOT_FOUND')
})

it('preserves cause via ErrorOptions', () => {
const cause = new Error('db down')
const error = new ApiError(ApiErrorCode.UNEXPECTED, { cause })
expect(error.cause).toBe(cause)
})
})

describe('isApiError', () => {
it('returns true for ApiError instances', () => {
expect(isApiError(new ApiError(ApiErrorCode.NETWORK_ERROR))).toBe(true)
})

it('returns false for plain Error', () => {
expect(isApiError(new Error('oops'))).toBe(false)
})

it('returns false for null', () => {
expect(isApiError(null)).toBe(false)
})

it('returns false for string', () => {
expect(isApiError('ApiError: NOT_FOUND')).toBe(false)
})
})

describe('wrapApiError', () => {
it('maps TypeError "Failed to fetch" to NETWORK_ERROR', () => {
const error = wrapApiError(new TypeError('Failed to fetch'))
expect(error).toBeInstanceOf(ApiError)
expect(error.code).toBe(ApiErrorCode.NETWORK_ERROR)
})

it('maps Error with "network" in message to NETWORK_ERROR', () => {
const error = wrapApiError(new Error('A Network failure occurred'))
expect(error).toBeInstanceOf(ApiError)
expect(error.code).toBe(ApiErrorCode.NETWORK_ERROR)
})

it('maps non-network errors to UNEXPECTED', () => {
const original = new Error('something else')
const error = wrapApiError(original)
expect(error).toBeInstanceOf(ApiError)
expect(error.code).toBe(ApiErrorCode.UNEXPECTED)
expect(error.cause).toBe(original)
})

it('maps non-Error values to UNEXPECTED', () => {
const error = wrapApiError('string error')
expect(error).toBeInstanceOf(ApiError)
expect(error.code).toBe(ApiErrorCode.UNEXPECTED)
expect(error.cause).toBeUndefined()
})
})
31 changes: 31 additions & 0 deletions src/shared/api/api-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { isNetworkError } from '@/shared/lib/network-errors'

export const ApiErrorCode = {
NETWORK_ERROR: 'NETWORK_ERROR',
NOT_FOUND: 'NOT_FOUND',
UNEXPECTED: 'UNEXPECTED',
} as const

export type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode]

export class ApiError extends Error {
readonly code: ApiErrorCode

constructor(code: ApiErrorCode, options?: ErrorOptions) {
super(`ApiError: ${code}`, options)
this.name = 'ApiError'
this.code = code
}
}

export function isApiError(error: unknown): error is ApiError {
return error instanceof ApiError
}

export function wrapApiError(error: unknown): ApiError {
const cause = error instanceof Error ? error : undefined
if (isNetworkError(error)) {
return new ApiError(ApiErrorCode.NETWORK_ERROR, { cause })
}
return new ApiError(ApiErrorCode.UNEXPECTED, { cause })
}
4 changes: 2 additions & 2 deletions src/shared/api/api.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ export interface IApiAdapter {
fetchFieldKeys(userId: string): Promise<ServerFieldKey[]>
saveWrappedKey(userId: string, data: SaveWrappedKeyData): Promise<void>

getField(userId: string, fieldName: string): Promise<ServerEncryptedField | null>
fetchField(userId: string, fieldName: string): Promise<ServerEncryptedField | null>
saveField(userId: string, fieldName: string, data: SaveFieldData): Promise<void>

saveRecoveryData(userId: string, data: SaveRecoveryData): Promise<void>
getRecoveryData(userId: string): Promise<ServerRecoveryData | null>
fetchRecoveryData(userId: string): Promise<ServerRecoveryData | null>
}
Loading
Loading