diff --git a/backend/tools/verify_supabase_cutover_schema.py b/backend/tools/verify_supabase_cutover_schema.py index b89666be..6ec1c80c 100644 --- a/backend/tools/verify_supabase_cutover_schema.py +++ b/backend/tools/verify_supabase_cutover_schema.py @@ -16,6 +16,7 @@ async def main() -> int: engine = None + stage = "supabase_cutover_schema" try: engine = create_async_engine( connection_url(os.environ.get("DIRECT_URL", "")), @@ -29,7 +30,7 @@ async def main() -> int: "set transaction isolation level repeatable read, read only" )) await connection.execute(text("set local statement_timeout = '10s'")) - ready = await connection.scalar(text(""" + schema_ready = await connection.scalar(text(""" select to_regclass('public.edge_worker_jobs') is not null and to_regclass('public.edge_worker_runs') is not null and to_regprocedure('public.edge_api_public_profile(text)') is not null @@ -39,10 +40,23 @@ async def main() -> int: and position('delivery_unknown' in coalesce(pg_get_functiondef( to_regprocedure('public.edge_worker_claim_email(integer)')), '')) > 0 """)) - print("supabase_cutover_schema=" + ("ready" if ready else "missing")) - return 0 if ready else 1 + stage = "profile_avatar_bucket" + bucket_ready = await connection.scalar(text(""" + select exists ( + select 1 from storage.buckets + where id = 'profile-avatars' + and public = false + and file_size_limit = 262144 + and allowed_mime_types @> array[ + 'image/jpeg', 'image/png', 'image/webp' + ]::text[] + ) + """)) + print("supabase_cutover_schema=" + ("ready" if schema_ready else "missing")) + print("profile_avatar_bucket=" + ("ready" if bucket_ready else "missing")) + return 0 if schema_ready and bucket_ready else 1 except Exception as exc: - print("supabase_cutover_schema=error:" + type(exc).__name__) + print(stage + "=error:" + type(exc).__name__) return 1 finally: if engine is not None: diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md index 44bc8418..7e4930a4 100644 --- a/docs/CODEBASE_MAP.md +++ b/docs/CODEBASE_MAP.md @@ -256,7 +256,7 @@ inside a root stack, with a concept-detail modal above them. | `WeeklyQuizScreen.tsx` | Optional server-backed weekly quiz: eligibility progress, seven reviewed questions, icon-marked accessible answer selection, result feedback and reattempts. | | `SubtopicQuizzesScreen.tsx` / `SubtopicQuizScreen.tsx` | Profile-linked optional quizzes for completed subtopics, frozen reviewed questions, icon-marked accessible answer selection, retries, and prior-score history. | | `ProfileScreen.tsx` | Task-grouped account hub: profile/privacy, learning, topics, preferences, library and support. It keeps clear reminder prerequisites and immediate per-control queued reminder writes. | -| `EditProfileScreen.tsx` / `services/profilePhotoPicker.ts` / `services/profilePhotoPreparation.ts` | Name and bio editing with temporary Android keyboard scroll room, camera/library photo preview and explicit save, account-bound picker recovery, bounded JPEG bytes from the picker or converter with a signature check, and confirmed avatar writes. | +| `EditProfileScreen.tsx` / `services/profilePhotoPicker.ts` / `services/profilePhotoPreparation.ts` | Name and bio editing with temporary Android keyboard scroll room, camera/library photo preview and explicit save, account-bound picker recovery, bounded JPEG bytes from the picker or converter with a signature check, and confirmed avatar writes. `remoteProgressRepository.ts` gives the photo upload a 60-second request deadline and keeps photo-only 503 cooldowns off unrelated lesson requests; the screen labels a timeout separately from a connection failure. | | `ProfileSharingScreen.tsx` / `ProfilePublishReviewSheet.tsx` / `PublicProfileLink.tsx` | Opt-in, independently selected sharing fields, a private review-before-publish step, explicit unpublish confirmation, native share/local QR, and uncached incoming public-profile view. | | `PersonalizationScreen.tsx` | Server topic catalog and follow controls through `useTopics`. | | `SavedScreen.tsx` | Recent/cached saved concepts, older metadata pagination, search/category filters, and detail navigation. | @@ -376,6 +376,8 @@ The production Supabase API from #416 lives in `supabase/functions/api/` and All 73 JSON paths have candidate handlers, including private avatar upload, short-lived Storage signing, and editorial read/command routes. The avatar image module bundles a pinned ImageMagick WASM file for 512px JPEG conversion. +Photo normalization and Storage upload failures produce distinct, data-free +incident log categories while the client receives a generic 503. Its `public_pages.ts` serves the browser confirmation, recovery, and learner privacy pages at the same API path. Production clients use the Edge route. The historical parity checklist is in diff --git a/docs/PROFILE_AVATARS.md b/docs/PROFILE_AVATARS.md index ae19fbae..17171998 100644 --- a/docs/PROFILE_AVATARS.md +++ b/docs/PROFILE_AVATARS.md @@ -8,6 +8,8 @@ Learners may choose a built-in avatar, select a library photo, or take a camera The Supabase Edge candidate for #416 retains the 25 MP JPEG limit and applies an 8 MP limit to PNG/WebP because those formats must be decoded at full size within the hosted memory limit. A larger PNG/WebP returns 422; the mobile client can ask for a smaller photo or JPEG during the #418 cutover. +On 2026-11-04, the production bucket was found missing even though the API and app were deployed. Creating the private bucket with the settings below restored gallery and camera photo saves on the owner's production Android phone. The production deploy guard now verifies these settings before publishing future Edge code. + The photo picker stores only the initiating account ID until it returns. Sign-out clears that marker; a result recovered after Android activity recreation appears as a preview only for the initiating account. The selected photo stays in memory until Save or Cancel, and an upload failure preserves the last confirmed avatar for retry. Public-profile sharing remains private by default. The dedicated **Profile avatar** choice must be reviewed and published before a visitor receives a short-lived delivery URL. Turning that choice off removes it from future public responses immediately. @@ -17,7 +19,8 @@ Public-profile sharing remains private by default. The dedicated **Profile avata Before merging and deploying the feature: 1. Apply migration `0040_profile_avatar_bio.sql` through the normal migration process. Do not add it to `backend/migrations/applied.txt` until production confirms it. -2. In Supabase Storage, create `profile-avatars` as a **private** bucket with a 256 KiB file limit and JPEG/PNG/WebP allowlist. Storage buckets are managed by Supabase Storage, not the application PostgreSQL migration runner. +2. In Supabase Storage, create `profile-avatars` as a **private** bucket with a 256 KiB file limit and JPEG/PNG/WebP allowlist. Use the [Storage dashboard or API](https://supabase.com/docs/guides/storage/buckets/creating-buckets); the application PostgreSQL migration runner does not create buckets. + The protected production deployment check now reads the bucket metadata and reports `profile_avatar_bucket=missing` before deploying API code if this bucket is absent or misconfigured. 3. Confirm the production Supabase Edge API has its server-only Storage credential; it must never be copied to Expo variables. 4. Build and install a new Android/iOS binary. `expo-image-picker` and its camera/photo permission messages are native configuration, so an OTA alone cannot enable photo selection on existing installs. 5. On a staging account, test each preset, denied library/camera permission, picker cancellation, preview Save/Cancel, an oversized/invalid image, photo replacement/removal, sign-out during picker or upload, and public-avatar sharing on/off from another browser. Check the Android activity-recreation path on a physical device; web tests do not prove native picker behavior. diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index e28f6a6c..e6915983 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -7,6 +7,14 @@ claims as completed work. ## Current status +### #457 production photo upload follow-up — 2026-11-04 + +- The owner confirmed v1.10.18 still cannot save a profile photo on Android. Two November 2 screenshots show a completed photo preview followed by either “Could not reach the photo service” (no HTTP response) or “Photo saving is unavailable right now” (HTTP 5xx). These no longer show the earlier local photo-preparation error. Issue #457 was reopened; do not close it until a real production device save succeeds. +- This `develop` follow-up uses separate changes for a 60-second photo-only request deadline and safe Edge incident logs that distinguish image normalization from Storage upload failures. The default 15-second deadline for other requests remains unchanged. No SQL migration, production deploy, or Cron change is involved. +- A production read-only query returned **no row** for `storage.buckets.id='profile-avatars'`. This was the confirmed configuration blocker: a normalized JPEG had nowhere to be stored. The owner created the bucket, then confirmed **both gallery and camera saves work** on the installed production phone and an uploaded image appears in Supabase Storage. A second read-only query confirmed the bucket is private, has a 262144-byte limit, and allows exactly JPEG, PNG, and WebP. The supplied earlier function-log excerpt showed normal profile GET/PATCH traffic but no photo PUT; the separate no-response symptom did not recur in the successful retest. The protected schema guard now detects a missing or misconfigured bucket before future Edge deployments. +- Focused commits: `39bca53` extends only the photo upload deadline; `dee5839` adds data-free normalization/Storage incident categories and 503 regressions; `b5227ae` adds the read-only production bucket gate; `1b33baa` distinguishes timeout from offline in the mobile error message; `881cd86` prevents an avatar Storage 503 from cooling down unrelated lesson requests. Validation passed: mobile TypeScript, focused mobile client/timeout tests, Edge TypeScript and avatar handler tests, Python syntax, whitespace, and the owner's production gallery/camera checks after bucket creation. [PR #482](https://github.com/Coding-Moves/one-concept/pull/482) is open for review; the new code is not deployed. +- Re-review found that the first timeout implementation also changed default 15-second Auth and profile-sharing failures. `e4ce9df` keeps the original AbortError for those callers and classifies timeouts only for the explicitly extended photo request; focused timeout and API-client tests and mobile TypeScript passed. `e8feb3a` reports `profile_avatar_bucket=missing` separately from schema failures in the protected read-only deployment check; Python syntax and whitespace passed. All four hosted checks passed on this code in [run 41871285348](https://github.com/Coding-Moves/one-concept/actions/runs/41871285348). + ### v1.10.18 photo release preparation — 2026-11-02 - The owner has one phone and asked to test the remaining photo fix on the production app. PR #477 merged into `develop` as `2924c81`, its preview OTA passed, and all four PR quality checks passed; the owner has not yet verified the native photo path on a device. [Preparation PR #478](https://github.com/Coding-Moves/one-concept/pull/478) targets `develop` for a JS-only v1.10.18 release with production phone verification after rollout. diff --git a/mobile/src/api/client.ts b/mobile/src/api/client.ts index 745d4b61..e39caabd 100644 --- a/mobile/src/api/client.ts +++ b/mobile/src/api/client.ts @@ -7,7 +7,7 @@ * module never imports the auth stack (and never stores a token itself). */ -import { fetchWithTimeout } from './fetchWithTimeout'; +import { fetchWithTimeout, RequestTimeoutError } from './fetchWithTimeout'; import { ApiConfigurationError, ApiError } from './errors'; export { ApiConfigurationError, ApiError } from './errors'; @@ -79,8 +79,10 @@ interface RequestOptions { /** Raw body for a narrow authenticated upload; never queued or persisted. */ rawBody?: BodyInit; contentType?: string; + /** Give bounded uploads time for the first Edge image worker to start. */ + timeoutMs?: number; signal?: AbortSignal; - /** A domain-specific invitation cooldown must not pause daily learning. */ + /** A request-local failure must not pause daily learning. */ rateLimitScope?: 'request'; } @@ -111,9 +113,13 @@ export async function apiRequest(path: string, options: RequestOptions = {}): headers, body: options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body)), signal: options.signal, - }); + }, options.timeoutMs); } catch (cause) { if (epoch !== accountEpoch) throw new ApiError(401, 'Account changed'); + if (cause instanceof RequestTimeoutError) { + // A stalled request does not prove that the device lost connectivity. + throw new ApiError(0, 'Request timed out', 'timeout'); + } // Offline or unreachable host: callers fall back to cached state. setConnectivity(false); throw new ApiError(0, 'Network request failed', cause); @@ -135,7 +141,7 @@ export async function apiRequest(path: string, options: RequestOptions = {}): ? (payload as { detail: unknown }).detail : null; const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After')); - if ((response.status === 429 && options.rateLimitScope !== 'request') || response.status === 503) { + if ((response.status === 429 || response.status === 503) && options.rateLimitScope !== 'request') { retryUntil = Math.max(retryUntil, Date.now() + retryAfterMs); } throw new ApiError(response.status, `Request failed: ${response.status}`, detail, retryAfterMs); diff --git a/mobile/src/api/fetchWithTimeout.ts b/mobile/src/api/fetchWithTimeout.ts index 2c0bc490..c392b851 100644 --- a/mobile/src/api/fetchWithTimeout.ts +++ b/mobile/src/api/fetchWithTimeout.ts @@ -1,13 +1,30 @@ /** Bound stalled connections, so screens can offer retry instead of spinning forever. */ -export const fetchWithTimeout: typeof fetch = async (input, init) => { +export class RequestTimeoutError extends Error { + constructor() { + super('Request timed out'); + this.name = 'RequestTimeoutError'; + } +} + +export const fetchWithTimeout = async ( + input: RequestInfo | URL, + init?: Parameters[1], + timeoutMs?: number, +): Promise => { const controller = new AbortController(); + let timedOut = false; const signal = init?.signal; const abort = () => controller.abort(); if (signal?.aborted) abort(); signal?.addEventListener('abort', abort); - const timeout = setTimeout(abort, 15000); + const timeout = setTimeout(() => { timedOut = true; abort(); }, timeoutMs ?? 15000); try { return await fetch(input, { ...init, signal: controller.signal }); + } catch (error) { + // Only explicitly extended requests opt into the new timeout error. + // Supabase Auth and other existing callers keep their prior AbortError. + if (timedOut && timeoutMs !== undefined) throw new RequestTimeoutError(); + throw error; } finally { clearTimeout(timeout); signal?.removeEventListener('abort', abort); diff --git a/mobile/src/screens/EditProfileScreen.tsx b/mobile/src/screens/EditProfileScreen.tsx index a6547d5f..fa2ac06c 100644 --- a/mobile/src/screens/EditProfileScreen.tsx +++ b/mobile/src/screens/EditProfileScreen.tsx @@ -19,6 +19,7 @@ function photoSaveError(error: unknown): string { if (error instanceof ProfilePhotoPreparationError) return error.message; if (error instanceof ApiConfigurationError) return 'Install the latest app update before saving a photo.'; if (error instanceof ApiError) { + if (error.status === 0 && error.detail === 'timeout') return 'Photo saving took too long. Please try again.'; if (error.status === 0) return 'Could not reach the photo service. Check your connection and try again.'; if (error.status === 400 || error.status === 422) return 'This photo could not be processed. Choose another image.'; if (error.status === 401 || error.status === 403) return 'Your session needs attention. Sign in again, then retry your photo.'; diff --git a/mobile/src/services/remoteProgressRepository.ts b/mobile/src/services/remoteProgressRepository.ts index daaa84ca..7afd68b6 100644 --- a/mobile/src/services/remoteProgressRepository.ts +++ b/mobile/src/services/remoteProgressRepository.ts @@ -197,7 +197,7 @@ export class RemoteProgressRepository implements ProgressRepository { async uploadAvatar(jpeg: Uint8Array, userId: string): Promise { const epoch = this.epoch; - const payload = await this.request(epoch, '/v1/me/avatar?compact=true', { method: 'PUT', rawBody: jpeg, contentType: 'image/jpeg', expectedUserId: userId }); + const payload = await this.request(epoch, '/v1/me/avatar?compact=true', { method: 'PUT', rawBody: jpeg, contentType: 'image/jpeg', expectedUserId: userId, timeoutMs: 60_000, rateLimitScope: 'request' }); return this.fromState(payload, epoch); } diff --git a/mobile/tests/apiClient.test.mjs b/mobile/tests/apiClient.test.mjs index e11f339b..fcce3728 100644 --- a/mobile/tests/apiClient.test.mjs +++ b/mobile/tests/apiClient.test.mjs @@ -30,6 +30,42 @@ test('late old-account responses cannot alter new-account connectivity or return assert.equal(getConnectivity(), false); }); +test('a photo request timeout stays distinct from an offline transport failure', async t => { + invalidateAccountRequests(); setConnectivity(true); setTokenProvider(async () => 'token'); + t.mock.timers.enable({ apis: ['setTimeout'] }); + t.mock.method(globalThis, 'fetch', (_, { signal }) => new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); + })); + const pending = apiRequest('/v1/me/avatar', { + method: 'PUT', rawBody: new Uint8Array([0xff, 0xd8, 0xff]), + contentType: 'image/jpeg', timeoutMs: 60_000, + }); + const rejected = assert.rejects(pending, error => + error instanceof ApiError && error.status === 0 && error.detail === 'timeout'); + await tick(); + t.mock.timers.tick(60_000); + await rejected; + assert.equal(getConnectivity(), true); +}); + +test('a photo Storage failure does not pause unrelated lesson requests', async t => { + invalidateAccountRequests(); setTokenProvider(async () => 'token'); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + return calls === 1 + ? new Response('{"detail":"unavailable"}', { status: 503, headers: { 'Retry-After': '30' } }) + : new Response('{"daily":true}', { status: 200 }); + }); + await assert.rejects(apiRequest('/v1/me/avatar', { + method: 'PUT', rawBody: new Uint8Array([0xff, 0xd8, 0xff]), + contentType: 'image/jpeg', rateLimitScope: 'request', + }), error => error instanceof ApiError && error.status === 503); + assert.equal(apiRetryDelay(), 0); + assert.deepEqual(await apiRequest('/v1/daily'), { daily: true }); + assert.equal(calls, 2); +}); + test('429 Retry-After prevents subsequent requests until the server deadline', async t => { invalidateAccountRequests(); setTokenProvider(async () => 'token'); let now = 100000; diff --git a/mobile/tests/fetchWithTimeout.test.mjs b/mobile/tests/fetchWithTimeout.test.mjs index 752a7912..50f45fab 100644 --- a/mobile/tests/fetchWithTimeout.test.mjs +++ b/mobile/tests/fetchWithTimeout.test.mjs @@ -13,6 +13,21 @@ test('stalled fetch aborts after 15 seconds', async (t) => { await rejected; }); +test('photo-sized requests may use a longer deadline without changing the default', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let signal; + t.mock.method(globalThis, 'fetch', (_, init) => new Promise((_, reject) => { + signal = init.signal; + signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); + })); + const pending = fetchWithTimeout('https://example.invalid/photo', { method: 'PUT' }, 60_000); + const rejected = assert.rejects(pending, { name: 'RequestTimeoutError' }); + t.mock.timers.tick(15_000); + assert.equal(signal.aborted, false); + t.mock.timers.tick(45_000); + await rejected; +}); + test('forwards caller cancellation and successful responses', async (t) => { const response = new Response('{}'); const fetch = t.mock.method(globalThis, 'fetch', async (_, { signal }) => { @@ -23,5 +38,6 @@ test('forwards caller cancellation and successful responses', async (t) => { const controller = new AbortController(); controller.abort(); await assert.rejects(fetchWithTimeout('https://example.invalid', { signal: controller.signal }), { name: 'AbortError' }); - assert.equal(fetch.mock.callCount(), 2); + await assert.rejects(fetchWithTimeout('https://example.invalid', { signal: controller.signal }, 60_000), { name: 'AbortError' }); + assert.equal(fetch.mock.callCount(), 3); }); diff --git a/supabase/functions/api/avatar.test.mjs b/supabase/functions/api/avatar.test.mjs index ec2e2844..a4599fcc 100644 --- a/supabase/functions/api/avatar.test.mjs +++ b/supabase/functions/api/avatar.test.mjs @@ -54,6 +54,47 @@ test('avatar upload and removal save private keys and clean old objects', async method: 'GET', headers: auth }))).status, 405); }); +test('avatar storage failures return an incident without changing the profile', async (t) => { + const logs = []; + t.mock.method(console, 'error', line => logs.push(line)); + let avatarWrites = 0; + const handler = createHandler(config, async (url) => { + if (url.endsWith('/auth/v1/user')) return Response.json({ id: userId }); + if (url.includes('/storage/v1/object/profile-avatars/')) return Response.json({}, { status: 404 }); + if (url.endsWith('edge_api_set_avatar')) avatarWrites++; + return Response.json({}); + }, async () => Uint8Array.from([0xff, 0xd8, 0xff, 0xd9])); + const result = await handler(request('/v1/me/avatar', { + method: 'PUT', headers: { authorization: 'Bearer user-token', 'content-type': 'image/jpeg' }, + body: Uint8Array.from([0xff, 0xd8, 0xff, 0xd9]), + })); + assert.equal(result.status, 503); + assert.equal(avatarWrites, 0); + assert.match(result.headers.get('x-incident-id'), /^[a-f0-9]{32}$/); + assert.equal((await result.json()).error.code, 'service_unavailable'); + assert.match(logs[0], /storage:404:avatar-upload:status/); + assert.doesNotMatch(logs[0], new RegExp(userId)); +}); + +test('avatar normalization failures are distinguishable from Storage failures', async (t) => { + const logs = []; + t.mock.method(console, 'error', line => logs.push(line)); + let storageCalls = 0; + const handler = createHandler(config, async (url) => { + if (url.endsWith('/auth/v1/user')) return Response.json({ id: userId }); + if (url.includes('/storage/v1/')) storageCalls++; + return Response.json({}); + }, async () => { throw new Error('WASM unavailable'); }); + const result = await handler(request('/v1/me/avatar', { + method: 'PUT', headers: { authorization: 'Bearer user-token', 'content-type': 'image/jpeg' }, + body: Uint8Array.from([0xff, 0xd8, 0xff, 0xd9]), + })); + assert.equal(result.status, 503); + assert.equal(storageCalls, 0); + assert.match(logs[0], /image:0:avatar-normalize:status/); + assert.doesNotMatch(logs[0], /WASM unavailable/); +}); + test('avatar rejects invalid and oversized uploads before storage', async () => { const calls = []; const handler = createHandler(config, async (url) => { diff --git a/supabase/functions/api/handler.ts b/supabase/functions/api/handler.ts index 5bc5bf9a..704f7f92 100644 --- a/supabase/functions/api/handler.ts +++ b/supabase/functions/api/handler.ts @@ -55,11 +55,11 @@ class ClientBodyError extends Error { } class UpstreamError extends Error { - readonly component: 'auth' | 'database'; + readonly component: 'auth' | 'database' | 'storage' | 'image'; readonly status: number; readonly operation?: string; readonly reason?: 'timeout' | 'network'; - constructor(component: 'auth' | 'database', status: number, + constructor(component: 'auth' | 'database' | 'storage' | 'image', status: number, operation?: string, reason?: 'timeout' | 'network') { super('Upstream unavailable'); this.component = component; @@ -929,9 +929,10 @@ async function readAvatar(request: Request): Promise { } async function uploadAvatar(key: string, bytes: Uint8Array, - config: ApiConfig, fetcher: Fetcher): Promise { + config: ApiConfig, fetcher: Fetcher): Promise { + let result: Response; try { - const result = await fetcher(config.supabaseUrl + '/storage/v1/object/profile-avatars/' + key, { + result = await fetcher(config.supabaseUrl + '/storage/v1/object/profile-avatars/' + key, { method: 'POST', headers: { apikey: config.serviceRoleKey, @@ -943,8 +944,11 @@ async function uploadAvatar(key: string, bytes: Uint8Array, signal: AbortSignal.timeout(15000), redirect: 'error', }); - return result.ok; - } catch { return false; } + } catch (error) { + throw new UpstreamError('storage', 0, 'avatar-upload', + error instanceof DOMException && error.name === 'TimeoutError' ? 'timeout' : 'network'); + } + if (!result.ok) throw new UpstreamError('storage', result.status, 'avatar-upload'); } function historyCursor(query: URLSearchParams): string | null { @@ -2337,11 +2341,15 @@ export function createHandler(config: ApiConfig, fetcher: Fetcher = fetch, throw new AvatarInputError(415, 'Choose a JPEG, PNG, or WebP photo'); } const bytes = await readAvatar(request); - const normalized = await avatarNormalizer(bytes, contentType); - key = 'avatars/' + userId + '/' + crypto.randomUUID().replaceAll('-', '') + '.jpg'; - if (!await uploadAvatar(key, normalized, config, fetcher)) { - return httpError(503, 'We could not save your photo. Please try again.', cors); + let normalized: Uint8Array; + try { + normalized = await avatarNormalizer(bytes, contentType); + } catch (error) { + if (error instanceof AvatarInputError) throw error; + throw new UpstreamError('image', 0, 'avatar-normalize'); } + key = 'avatars/' + userId + '/' + crypto.randomUUID().replaceAll('-', '') + '.jpg'; + await uploadAvatar(key, normalized, config, fetcher); } const data = await rpc('edge_api_set_avatar', { p_user_id: userId, p_avatar_key: key, p_compact: compact, p_today: null,