From 39bca53ebf7146946ada2e20244123e10633df33 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Wed, 4 Nov 2026 21:25:56 +0500 Subject: [PATCH 01/12] Allow longer timeout for profile photo uploads --- mobile/src/api/client.ts | 4 +++- mobile/src/api/fetchWithTimeout.ts | 8 ++++++-- mobile/src/services/remoteProgressRepository.ts | 2 +- mobile/tests/fetchWithTimeout.test.mjs | 15 +++++++++++++++ 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/mobile/src/api/client.ts b/mobile/src/api/client.ts index 745d4b61..70e805e7 100644 --- a/mobile/src/api/client.ts +++ b/mobile/src/api/client.ts @@ -79,6 +79,8 @@ 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. */ rateLimitScope?: 'request'; @@ -111,7 +113,7 @@ 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'); // Offline or unreachable host: callers fall back to cached state. diff --git a/mobile/src/api/fetchWithTimeout.ts b/mobile/src/api/fetchWithTimeout.ts index 2c0bc490..92388854 100644 --- a/mobile/src/api/fetchWithTimeout.ts +++ b/mobile/src/api/fetchWithTimeout.ts @@ -1,11 +1,15 @@ /** Bound stalled connections, so screens can offer retry instead of spinning forever. */ -export const fetchWithTimeout: typeof fetch = async (input, init) => { +export const fetchWithTimeout = async ( + input: RequestInfo | URL, + init?: Parameters[1], + timeoutMs = 15000, +): Promise => { const controller = new AbortController(); const signal = init?.signal; const abort = () => controller.abort(); if (signal?.aborted) abort(); signal?.addEventListener('abort', abort); - const timeout = setTimeout(abort, 15000); + const timeout = setTimeout(abort, timeoutMs); try { return await fetch(input, { ...init, signal: controller.signal }); } finally { diff --git a/mobile/src/services/remoteProgressRepository.ts b/mobile/src/services/remoteProgressRepository.ts index daaa84ca..82b6efa9 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 }); return this.fromState(payload, epoch); } diff --git a/mobile/tests/fetchWithTimeout.test.mjs b/mobile/tests/fetchWithTimeout.test.mjs index 752a7912..078fd172 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: 'AbortError' }); + 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 }) => { From dee58391e589e976196bbc228c8520b832411ccf Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Wed, 4 Nov 2026 21:26:10 +0500 Subject: [PATCH 02/12] Record safe incident categories for avatar upload failures --- supabase/functions/api/avatar.test.mjs | 41 ++++++++++++++++++++++++++ supabase/functions/api/handler.ts | 28 +++++++++++------- 2 files changed, 59 insertions(+), 10 deletions(-) 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, From b5227ae5c555960041803ae06474c3166d2f7e5a Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Wed, 4 Nov 2026 21:29:22 +0500 Subject: [PATCH 03/12] Gate production deploy on private avatar bucket --- backend/tools/verify_supabase_cutover_schema.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/tools/verify_supabase_cutover_schema.py b/backend/tools/verify_supabase_cutover_schema.py index b89666be..931e2e92 100644 --- a/backend/tools/verify_supabase_cutover_schema.py +++ b/backend/tools/verify_supabase_cutover_schema.py @@ -38,6 +38,15 @@ async def main() -> int: and to_regprocedure('public.edge_worker_claim_email(integer)') is not null and position('delivery_unknown' in coalesce(pg_get_functiondef( to_regprocedure('public.edge_worker_claim_email(integer)')), '')) > 0 + and 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 ready else "missing")) return 0 if ready else 1 From b2c602e0bb38e9c57e7063a15c453ca0304e700a Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Wed, 4 Nov 2026 21:31:09 +0500 Subject: [PATCH 04/12] Document production avatar bucket diagnosis and guard --- docs/CODEBASE_MAP.md | 4 +++- docs/PROFILE_AVATARS.md | 3 ++- docs/WORK_LOG.md | 7 +++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md index 44bc8418..32a8b836 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. | | `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..c58d3286 100644 --- a/docs/PROFILE_AVATARS.md +++ b/docs/PROFILE_AVATARS.md @@ -17,7 +17,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 fails 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..a24e1a55 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -7,6 +7,13 @@ 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 is a confirmed configuration blocker: a normalized JPEG cannot be stored. The supplied function-log excerpt showed normal profile GET/PATCH traffic but no photo PUT, so the independent no-response symptom is still unverified. The owner reports creating the private 256 KiB production bucket; a real device retry remains pending. 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. Validation passed: mobile TypeScript, focused mobile client/timeout tests, Edge TypeScript and avatar handler tests, Python syntax, and whitespace. No physical device success or live Storage upload has been reported yet. + ### 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. From 1b33baac6ba8a8114bed93d8ef9ed2eb46bf8c16 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Wed, 4 Nov 2026 21:35:39 +0500 Subject: [PATCH 05/12] Distinguish photo timeout from lost connectivity --- mobile/src/api/client.ts | 6 +++++- mobile/src/api/fetchWithTimeout.ts | 13 ++++++++++++- mobile/src/screens/EditProfileScreen.tsx | 1 + mobile/tests/apiClient.test.mjs | 18 ++++++++++++++++++ mobile/tests/fetchWithTimeout.test.mjs | 4 ++-- 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/mobile/src/api/client.ts b/mobile/src/api/client.ts index 70e805e7..bab28507 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'; @@ -116,6 +116,10 @@ export async function apiRequest(path: string, options: RequestOptions = {}): }, 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); diff --git a/mobile/src/api/fetchWithTimeout.ts b/mobile/src/api/fetchWithTimeout.ts index 92388854..203dca34 100644 --- a/mobile/src/api/fetchWithTimeout.ts +++ b/mobile/src/api/fetchWithTimeout.ts @@ -1,17 +1,28 @@ /** Bound stalled connections, so screens can offer retry instead of spinning forever. */ +export class RequestTimeoutError extends Error { + constructor() { + super('Request timed out'); + this.name = 'RequestTimeoutError'; + } +} + export const fetchWithTimeout = async ( input: RequestInfo | URL, init?: Parameters[1], timeoutMs = 15000, ): 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, timeoutMs); + const timeout = setTimeout(() => { timedOut = true; abort(); }, timeoutMs); try { return await fetch(input, { ...init, signal: controller.signal }); + } catch (error) { + if (timedOut) 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/tests/apiClient.test.mjs b/mobile/tests/apiClient.test.mjs index e11f339b..e5608274 100644 --- a/mobile/tests/apiClient.test.mjs +++ b/mobile/tests/apiClient.test.mjs @@ -30,6 +30,24 @@ 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('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 078fd172..31751552 100644 --- a/mobile/tests/fetchWithTimeout.test.mjs +++ b/mobile/tests/fetchWithTimeout.test.mjs @@ -8,7 +8,7 @@ test('stalled fetch aborts after 15 seconds', async (t) => { signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); })); const pending = fetchWithTimeout('https://example.invalid'); - const rejected = assert.rejects(pending, { name: 'AbortError' }); + const rejected = assert.rejects(pending, { name: 'RequestTimeoutError' }); t.mock.timers.tick(15000); await rejected; }); @@ -21,7 +21,7 @@ test('photo-sized requests may use a longer deadline without changing the defaul 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: 'AbortError' }); + const rejected = assert.rejects(pending, { name: 'RequestTimeoutError' }); t.mock.timers.tick(15_000); assert.equal(signal.aborted, false); t.mock.timers.tick(45_000); From 23f2d0906979d75c26e2f68a470df9906070c282 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Wed, 4 Nov 2026 21:36:14 +0500 Subject: [PATCH 06/12] Record verified production photo recovery --- docs/CODEBASE_MAP.md | 2 +- docs/PROFILE_AVATARS.md | 2 ++ docs/WORK_LOG.md | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md index 32a8b836..2be68096 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. `remoteProgressRepository.ts` gives the photo upload a 60-second request deadline. | +| `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; the screen labels that 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. | diff --git a/docs/PROFILE_AVATARS.md b/docs/PROFILE_AVATARS.md index c58d3286..ac4a55d7 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. diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index a24e1a55..ddfc0c00 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -11,8 +11,8 @@ claims as completed work. - 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 is a confirmed configuration blocker: a normalized JPEG cannot be stored. The supplied function-log excerpt showed normal profile GET/PATCH traffic but no photo PUT, so the independent no-response symptom is still unverified. The owner reports creating the private 256 KiB production bucket; a real device retry remains pending. 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. Validation passed: mobile TypeScript, focused mobile client/timeout tests, Edge TypeScript and avatar handler tests, Python syntax, and whitespace. No physical device success or live Storage upload has been reported yet. +- 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. 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. ### v1.10.18 photo release preparation — 2026-11-02 From 881cd862752e6fcc936100628159f7071bf7aac1 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Wed, 4 Nov 2026 21:38:57 +0500 Subject: [PATCH 07/12] Keep avatar Storage failures from pausing lessons --- mobile/src/api/client.ts | 4 ++-- .../src/services/remoteProgressRepository.ts | 2 +- mobile/tests/apiClient.test.mjs | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/mobile/src/api/client.ts b/mobile/src/api/client.ts index bab28507..e39caabd 100644 --- a/mobile/src/api/client.ts +++ b/mobile/src/api/client.ts @@ -82,7 +82,7 @@ interface RequestOptions { /** 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'; } @@ -141,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/services/remoteProgressRepository.ts b/mobile/src/services/remoteProgressRepository.ts index 82b6efa9..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, timeoutMs: 60_000 }); + 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 e5608274..fcce3728 100644 --- a/mobile/tests/apiClient.test.mjs +++ b/mobile/tests/apiClient.test.mjs @@ -48,6 +48,24 @@ test('a photo request timeout stays distinct from an offline transport failure', 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; From ac3756932d5661057167985cbee5ba937786751d Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Wed, 4 Nov 2026 21:39:28 +0500 Subject: [PATCH 08/12] Record isolated avatar failure handling --- docs/CODEBASE_MAP.md | 2 +- docs/WORK_LOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md index 2be68096..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. `remoteProgressRepository.ts` gives the photo upload a 60-second request deadline; the screen labels that timeout separately from a connection failure. | +| `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. | diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index ddfc0c00..4b6c6d56 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -12,7 +12,7 @@ claims as completed work. - 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. 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. +- 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. ### v1.10.18 photo release preparation — 2026-11-02 From e4ce9df0f60c2d797685a5d5a0c8d7e5e5e24c8f Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Wed, 4 Nov 2026 21:48:04 +0500 Subject: [PATCH 09/12] Scope timeout classification to photo uploads --- mobile/src/api/fetchWithTimeout.ts | 8 +++++--- mobile/tests/fetchWithTimeout.test.mjs | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/mobile/src/api/fetchWithTimeout.ts b/mobile/src/api/fetchWithTimeout.ts index 203dca34..c392b851 100644 --- a/mobile/src/api/fetchWithTimeout.ts +++ b/mobile/src/api/fetchWithTimeout.ts @@ -9,7 +9,7 @@ export class RequestTimeoutError extends Error { export const fetchWithTimeout = async ( input: RequestInfo | URL, init?: Parameters[1], - timeoutMs = 15000, + timeoutMs?: number, ): Promise => { const controller = new AbortController(); let timedOut = false; @@ -17,11 +17,13 @@ export const fetchWithTimeout = async ( const abort = () => controller.abort(); if (signal?.aborted) abort(); signal?.addEventListener('abort', abort); - const timeout = setTimeout(() => { timedOut = true; abort(); }, timeoutMs); + const timeout = setTimeout(() => { timedOut = true; abort(); }, timeoutMs ?? 15000); try { return await fetch(input, { ...init, signal: controller.signal }); } catch (error) { - if (timedOut) throw new RequestTimeoutError(); + // 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); diff --git a/mobile/tests/fetchWithTimeout.test.mjs b/mobile/tests/fetchWithTimeout.test.mjs index 31751552..50f45fab 100644 --- a/mobile/tests/fetchWithTimeout.test.mjs +++ b/mobile/tests/fetchWithTimeout.test.mjs @@ -8,7 +8,7 @@ test('stalled fetch aborts after 15 seconds', async (t) => { signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); })); const pending = fetchWithTimeout('https://example.invalid'); - const rejected = assert.rejects(pending, { name: 'RequestTimeoutError' }); + const rejected = assert.rejects(pending, { name: 'AbortError' }); t.mock.timers.tick(15000); await rejected; }); @@ -38,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); }); From e8feb3af2b3fc0f4675e9b29d09660edc333f86a Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Wed, 4 Nov 2026 21:48:08 +0500 Subject: [PATCH 10/12] Name missing avatar bucket in deploy checks --- .../tools/verify_supabase_cutover_schema.py | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/backend/tools/verify_supabase_cutover_schema.py b/backend/tools/verify_supabase_cutover_schema.py index 931e2e92..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 @@ -38,20 +39,24 @@ async def main() -> int: and to_regprocedure('public.edge_worker_claim_email(integer)') is not null and position('delivery_unknown' in coalesce(pg_get_functiondef( to_regprocedure('public.edge_worker_claim_email(integer)')), '')) > 0 - and 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 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: From f7ec080763ed5786c86f0d5985b5866fe18a8b47 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Wed, 4 Nov 2026 21:48:25 +0500 Subject: [PATCH 11/12] Record follow-up review findings --- docs/PROFILE_AVATARS.md | 2 +- docs/WORK_LOG.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/PROFILE_AVATARS.md b/docs/PROFILE_AVATARS.md index ac4a55d7..17171998 100644 --- a/docs/PROFILE_AVATARS.md +++ b/docs/PROFILE_AVATARS.md @@ -20,7 +20,7 @@ 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. 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 fails before deploying API code if this bucket is absent or misconfigured. + 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 4b6c6d56..74037b75 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -13,6 +13,7 @@ claims as completed work. - 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. The updated PR head still needs hosted checks. ### v1.10.18 photo release preparation — 2026-11-02 From 1bb0766f08921a5ea96055bab17386af165c4060 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Wed, 4 Nov 2026 21:52:58 +0500 Subject: [PATCH 12/12] Record passing PR review checks --- docs/WORK_LOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 74037b75..e6915983 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -13,7 +13,7 @@ claims as completed work. - 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. The updated PR head still needs hosted checks. +- 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