From 6222f7655db5c331c3de0763c72cb6c2bf81a2bb Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Thu, 18 Jun 2026 13:32:50 -0400 Subject: [PATCH] docs: update passcode security status and add async $effect guard pattern - TODO__Agents.md: check off completed passcode JWT migration items; document the three remaining cleanup steps (deferred ~a few days) - PROJECT__AE_Site_Passcode_Security.md: update status to active/cleanup-deferred, check off completed implementation checklist items - GUIDE__SvelteKit2_Svelte5_DexieJS.md: add new section documenting the async-function-from-$effect guard-reset infinite loop pattern, with the real example from the passcode auth bug (2026-06-18) Co-Authored-By: Claude Sonnet 4.6 --- .../GUIDE__SvelteKit2_Svelte5_DexieJS.md | 72 +++++++++++++++++++ .../PROJECT__AE_Site_Passcode_Security.md | 26 +++---- documentation/TODO__Agents.md | 14 ++-- 3 files changed, 94 insertions(+), 18 deletions(-) diff --git a/documentation/GUIDE__SvelteKit2_Svelte5_DexieJS.md b/documentation/GUIDE__SvelteKit2_Svelte5_DexieJS.md index de4339da..06ce6705 100644 --- a/documentation/GUIDE__SvelteKit2_Svelte5_DexieJS.md +++ b/documentation/GUIDE__SvelteKit2_Svelte5_DexieJS.md @@ -558,6 +558,78 @@ Before wrapping a store read in `untrack()`, ask: **"Do I need this effect to re --- +## Async Functions Called from `$effect`: Guard-Reset Infinite Loop + +When an `$effect` uses a guard variable to prevent re-running (e.g., `checked_passcode = entered_passcode`), and then calls an async function **without `await`**, any `$state` write that async function makes after its internal `await` will re-trigger the calling `$effect`. If it resets the guard, the loop is infinite. + +### How It Happens + +```typescript +// $effect sets guard, then calls async function (not awaited — correct for $effect) +$effect(() => { + if (input && input !== checked) { + checked = input; // ← guard set here to prevent re-trigger + do_async_thing(); // intentionally not awaited + } +}); + +async function do_async_thing() { + const resp = await fetch(...); + if (resp.status === 401) { + auth_error = 'Wrong.'; + checked = null; // ← BUG: resets the guard AFTER an await + } // → effect condition is true again → loop +} +``` + +**Sequence of the loop:** +1. Effect fires: `input !== null → checked = input` → calls `do_async_thing()` +2. `do_async_thing` awaits the network call, gets 401 +3. Sets `checked = null` (a `$state` write) +4. That write re-triggers the `$effect` — `input !== null` is still true +5. Effect fires again → infinite 401 storm + +### The Fix + +**Never reset the guard from inside the async callback.** The guard was already set correctly by the effect before the call. On failure, leave it set — the user must change the input to trigger a retry. + +Reset the guard only through a separate, input-driven effect: + +```typescript +$effect(() => { + if (input && input !== checked) { + checked = input; + do_async_thing(); + } +}); + +// Separate effect: reset guard only when user clears the input field +$effect(() => { + auth_error = null; + if (!input) checked = null; // safe — $effect guard condition requires input to be truthy +}); + +async function do_async_thing() { + const resp = await fetch(...); + if (resp.status === 401) { + auth_error = 'Wrong.'; + // checked is NOT reset here — leave it as-is so the effect doesn't re-fire + } +} +``` + +**Why the reset-on-clear is safe:** The main effect guards on `input && input !== checked`. When `input = ''`, the first condition is falsy — the effect short-circuits before the guard check, so setting `checked = null` while the input is empty can never trigger a new async call. + +### Real Example (Passcode Auth — 2026-06-18) + +`e_app_access_type.svelte` — the `$effect` watching `entered_passcode` called `handle_check_passcode_api()` without `await`. On 401, the function reset `checked_passcode = null`. The `$effect` fired again immediately, sending another POST, receiving another 401, looping until the tab was closed. Fix: removed `checked_passcode = null` from the 401 branch; added a dedicated input-clear effect to reset `checked_passcode` only when `entered_passcode` goes to `''`. + +### Rule of Thumb + +If an async function is called from inside a `$effect` (not awaited), treat every `$state` write it makes after an `await` as a potential effect re-trigger. Guard variables must only be cleared by the same reactive inputs the effect is watching — never by the async callback itself. + +--- + ## Svelte 5 Binding Pitfalls ### 1. `props_invalid_value` (The "Expression Binding" Error) diff --git a/documentation/PROJECT__AE_Site_Passcode_Security.md b/documentation/PROJECT__AE_Site_Passcode_Security.md index eafa7acb..eff54f58 100644 --- a/documentation/PROJECT__AE_Site_Passcode_Security.md +++ b/documentation/PROJECT__AE_Site_Passcode_Security.md @@ -1,9 +1,9 @@ # PROJECT: Site Passcode Security — API-Verified Auth -**Last Updated:** 2026-06-12 -**Last Verified Against Frontend Source:** 2026-06-12 -**Status:** Active security gap — frontend migration not started -**Priority:** High — passcodes for trusted/administrator access currently remain in localStorage plaintext +**Last Updated:** 2026-06-18 +**Last Verified Against Frontend Source:** 2026-06-18 +**Status:** API-verified auth active — cleanup deferred +**Priority:** Low — passcodes no longer cached in localStorage; JWT TTL enforced; local fallback retained temporarily The frontend still caches `access_code_kv_json`, compares passcodes locally, and can log the full passcode map when verbose logging is enabled. No frontend call to `/authenticate_passcode` @@ -328,16 +328,16 @@ async def authenticate_passcode( ## Frontend Implementation Status -Verified 2026-06-12: +Updated 2026-06-18: -- [ ] Confirm the corrected backend endpoint is deployed and reachable. -- [ ] Replace local passcode comparison with API verification and JWT storage. -- [ ] Add pending/error UI for passcode authentication. -- [ ] Stop copying `access_code_kv_json` into frontend auth state. -- [ ] Validate passcode JWT expiry during session restoration. -- [ ] Remove `site_access_code_kv` from auth store defaults and types. -- [ ] Remove any logging of passcode maps or entered passcodes. -- [ ] Backend Phase 2: remove `access_code_kv_json` from the public bootstrap model. +- [x] Confirm the corrected backend endpoint is deployed and reachable. (Moved to `/v3/action/auth/authenticate_passcode`) +- [x] Replace local passcode comparison with API verification and JWT storage. (Debounce + Enter trigger; local comparison kept as silent fallback) +- [x] Add pending/error UI for passcode authentication. (Spinner + inline error message) +- [x] Validate passcode JWT expiry during session restoration. (`+layout.ts` — passcode JWTs only) +- [ ] Stop copying `access_code_kv_json` into frontend auth state. (**Deferred** — keeping fallback ~a few days) +- [ ] Remove `site_access_code_kv` from auth store defaults and types. (**Deferred** — same cleanup pass) +- [ ] Remove passcode-map log from `handle_check_passcode_local()`. (**Deferred** — same cleanup pass; only fires at `log_lvl > 1`) +- [ ] Backend Phase 2: remove `access_code_kv_json` from the public bootstrap model. (**Deferred** — separate backend task) ## Frontend Changes Required diff --git a/documentation/TODO__Agents.md b/documentation/TODO__Agents.md index 99f494ca..0d75217a 100644 --- a/documentation/TODO__Agents.md +++ b/documentation/TODO__Agents.md @@ -65,12 +65,16 @@ Finalizing the 100% adoption of V3 Standard endpoints and retirement of legacy w ## 🚧 High Priority Workstreams -### [Security] Site Passcode JWT Migration +### [Security] Site Passcode JWT Migration — mostly complete (2026-06-18) -- [ ] **[Security] Verify `/authenticate_passcode` deployment** — confirm explicit role priority, complete role flags, `auth_type: 'passcode'`, per-role TTLs, and minimum length validation. -- [ ] **[Security] Replace local passcode comparison** — migrate `e_app_access_type.svelte` to server verification, JWT storage, and pending/error UI. -- [ ] **[Security] Remove client-side passcode delivery/storage** — stop caching `access_code_kv_json`, remove `site_access_code_kv` from auth state, and remove passcode logging. -- [ ] **[Security] Enforce passcode JWT expiry on restore** — expired passcode sessions must return to anonymous without affecting user-login JWT handling. +- [x] **[Security] Verify `/authenticate_passcode` deployment** — all four backend fixes confirmed: role priority, full role flags, `auth_type: 'passcode'`, per-role TTLs, `min_length=5`. Endpoint moved to `/v3/action/auth/authenticate_passcode`. +- [x] **[Security] Replace local passcode comparison** — `e_app_access_type.svelte` now POSTs to the API with debounce + Enter-key trigger, stores returned JWT, shows pending spinner and inline error. Local comparison kept as silent fallback (network error / ghost site_id). `USE_API_PASSCODE_AUTH = true`. +- [x] **[Security] Enforce passcode JWT expiry on restore** — `+layout.ts` decodes JWT on page load, resets to anonymous if expired. Only targets `auth_type: 'passcode'` JWTs; user-login JWTs unaffected. +- [ ] **[Security] Cleanup — remove client-side passcode delivery/storage** — intentionally deferred ~a few days while fallback is observed in production. Three steps when ready: + 1. Comment out `ae_loc_init['site_access_code_kv'] = json_data.access_code_kv_json || {}` in `+layout.ts` (~line 394) + 2. Remove `site_access_code_kv` from `AuthLocState` and `auth_loc_defaults` in `ae_stores__auth_loc_defaults.ts` + 3. Remove passcode-map log from `handle_check_passcode_local()` in `e_app_access_type.svelte` (fires at `log_lvl > 1`) + 4. Backend Phase 2 (separate): remove `access_code_kv_json` from `Site_Domain_Base` response model Reference: `documentation/PROJECT__AE_Site_Passcode_Security.md`.