fix(core): preserve account context on key params and harden account detail fallback

- api_get/post/patch_object: stop treating params.key as account-bypass trigger\n- account detail: remove forced key usage, add list/cache fallback path\n- account detail: fix fallback bug that set load_error even when fallback record existed\n- sites detail: pretty-print cfg_json before save\n- docs: clarify key != bypass and add 403 troubleshooting notes
This commit is contained in:
Scott Idem
2026-04-30 16:37:54 -04:00
parent 90adb19f5d
commit 2f5ad8ccc0
7 changed files with 98 additions and 11 deletions

View File

@@ -206,6 +206,10 @@ x-aether-api-key: <PUBLIC_AE_API_SECRET_KEY>
x-account-id: <account_id> x-account-id: <account_id>
``` ```
**Do not treat `params.key` as an auth bypass.**
Only explicit `x-no-account-id: bypass` means "drop account context".
If `key` is present for business logic, keep `x-account-id` intact.
### Dexie queries — always use the object ID index, not `.get()` ### Dexie queries — always use the object ID index, not `.get()`
All `db_core` (and other module) Dexie tables define their schema with `id` as the first All `db_core` (and other module) Dexie tables define their schema with `id` as the first
field (primary key), followed by the object's string ID (e.g. `person_id`). V3 **never** field (primary key), followed by the object's string ID (e.g. `person_id`). V3 **never**
@@ -288,6 +292,10 @@ These are real incidents — know them before you start.
clean of data loads in private modules. See `GUIDE__SvelteKit2_Svelte5_DexieJS.md` clean of data loads in private modules. See `GUIDE__SvelteKit2_Svelte5_DexieJS.md`
"SvelteKit Layout Hierarchy: Security and Execution Order" for the full explanation. "SvelteKit Layout Hierarchy: Security and Execution Order" for the full explanation.
10. **Using query `key` as a proxy for bypass stripped `x-account-id`** — this caused
valid account-scoped requests to lose account context and 403. `key` can be a valid
endpoint/business param, but it is not equivalent to `x-no-account-id: bypass`.
--- ---
## 8. Source Layout (Quick Reference) ## 8. Source Layout (Quick Reference)

View File

@@ -21,6 +21,9 @@ Required for any non-public data (Journals, Badges, Users, etc.).
* **Header:** `x-no-account-id: bypass` * **Header:** `x-no-account-id: bypass`
3. **Token Access**: Provide a **JWT** in the query string. 3. **Token Access**: Provide a **JWT** in the query string.
* **Query Param:** `?jwt=<token>` * **Query Param:** `?jwt=<token>`
4. **Important Distinction:** A query parameter named `key` is **not** an account-context bypass signal.
* `key` may be used by specific endpoints/business logic, but it must **not** cause the frontend to remove `x-account-id`.
* Only explicit `x-no-account-id: bypass` should strip account context.
> [!CAUTION] > [!CAUTION]
> **UNSUPPORTED HEADERS:** The header `x-aether-api-token` is **NOT recognized** by the V3 API. If you send it, the backend will treat you as a guest and block access to private data. > **UNSUPPORTED HEADERS:** The header `x-aether-api-token` is **NOT recognized** by the V3 API. If you send it, the backend will treat you as a guest and block access to private data.
@@ -587,3 +590,5 @@ If you receive a 403 on a valid ID:
2. Ensure you are sending `x-account-id` and NOT `x-aether-api-token`. 2. Ensure you are sending `x-account-id` and NOT `x-aether-api-token`.
3. Verify the record actually belongs to the account ID you are sending. 3. Verify the record actually belongs to the account ID you are sending.
4. Check if the object is marked `public_read: True` in the registry. (Posts and Archive Content allow guest access; Journals and Badges do not). 4. Check if the object is marked `public_read: True` in the registry. (Posts and Archive Content allow guest access; Journals and Badges do not).
5. Confirm the frontend is not treating `params.key` as an implicit bypass and stripping `x-account-id`.
6. If list/search endpoints work but `GET /v3/crud/{obj_type}/{id}` still returns 403, this is likely endpoint-level policy (e.g., requires stronger auth like JWT) rather than a transport/header bug.

View File

@@ -108,7 +108,6 @@ export const get_object = async function get_object({
const is_valid_bypass = const is_valid_bypass =
bypass_val === 'bypass' || bypass_val === 'bypass' ||
bypass_val === 'Nothing to See Here' || bypass_val === 'Nothing to See Here' ||
params['key'] ||
bypass_val === 'direct-download'; bypass_val === 'direct-download';
if (is_valid_bypass) { if (is_valid_bypass) {

View File

@@ -82,7 +82,6 @@ export const patch_object = async function patch_object({
const is_valid_bypass = const is_valid_bypass =
bypass_val === 'bypass' || bypass_val === 'bypass' ||
bypass_val === 'Nothing to See Here' || bypass_val === 'Nothing to See Here' ||
params['key'] ||
bypass_val === 'direct-download'; bypass_val === 'direct-download';
if (is_valid_bypass) { if (is_valid_bypass) {

View File

@@ -104,7 +104,6 @@ export const post_object = async function post_object({
const is_valid_bypass = const is_valid_bypass =
bypass_val === 'bypass' || bypass_val === 'bypass' ||
bypass_val === 'Nothing to See Here' || bypass_val === 'Nothing to See Here' ||
params['key'] ||
bypass_val === 'direct-download'; bypass_val === 'direct-download';
if (is_valid_bypass) { if (is_valid_bypass) {

View File

@@ -3,9 +3,11 @@ import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { import {
load_ae_obj_id__account, load_ae_obj_id__account,
load_ae_obj_li__account,
update_ae_obj__account, update_ae_obj__account,
delete_ae_obj_id__account delete_ae_obj_id__account
} from '$lib/ae_core/ae_core__account'; } from '$lib/ae_core/ae_core__account';
import { db_core } from '$lib/ae_core/db_core';
import { editable_fields__account } from '$lib/ae_core/ae_core__account.editable_fields'; import { editable_fields__account } from '$lib/ae_core/ae_core__account.editable_fields';
import { ae_api, ae_loc } from '$lib/stores/ae_stores'; import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -24,15 +26,60 @@ let loading = $state(true);
let saving = $state(false); let saving = $state(false);
let save_success = $state(false); let save_success = $state(false);
let confirm_action = $state<string | null>(null); let confirm_action = $state<string | null>(null);
let load_error = $state<string | null>(null);
let loaded_from_cache = $state(false);
async function load_account() { async function load_account() {
loading = true; loading = true;
account = await load_ae_obj_id__account({ load_error = null;
api_cfg: $ae_api, loaded_from_cache = false;
account_id, try {
log_lvl: 1 account = await load_ae_obj_id__account({
}); api_cfg: $ae_api,
loading = false; account_id,
log_lvl: 1
});
if (!account) {
const account_li = await load_ae_obj_li__account({
api_cfg: $ae_api,
enabled: 'all',
hidden: 'all',
try_cache: false,
log_lvl: 0
});
const account_from_list =
account_li?.find((a: any) => a.account_id === account_id) ??
null;
if (account_from_list) {
account = account_from_list;
loaded_from_cache = true;
}
const cached_account = await db_core.account
.where('account_id')
.equals(account_id)
.first();
if (!account && cached_account) {
account = cached_account;
loaded_from_cache = true;
}
if (!account) {
load_error =
'Unable to load this account. Authentication or permissions may be missing for this record.';
}
}
} catch (error) {
console.error('Failed to load account:', error);
load_error =
'Unable to load this account due to a network or API error. Please retry.';
} finally {
loading = false;
}
} }
onMount(() => { onMount(() => {
@@ -40,6 +87,7 @@ onMount(() => {
goto('/core'); goto('/core');
return; return;
} }
load_account(); load_account();
}); });
@@ -151,7 +199,36 @@ async function handle_delete() {
<div class="placeholder h-full w-full animate-pulse rounded-2xl"> <div class="placeholder h-full w-full animate-pulse rounded-2xl">
</div> </div>
</div> </div>
{:else if load_error}
<div
class="card border-error-500/30 bg-error-500/5 flex flex-col items-start gap-3 border p-6 shadow-xl">
<h3 class="h4 font-black text-error-600">Account Load Failed</h3>
<p class="text-sm opacity-80">{load_error}</p>
{#if !$ae_loc.jwt}
<p class="text-xs opacity-70">
This session is using manager passcode access (no JWT). Some account-by-id API routes may require stronger auth than list views.
</p>
{/if}
<div class="flex flex-wrap gap-2 pt-1">
<button
class="btn btn-sm preset-filled-primary"
onclick={load_account}>
Retry
</button>
<a class="btn btn-sm preset-tonal-surface" href="/core/accounts">
Back to Accounts
</a>
</div>
</div>
{:else if account} {:else if account}
{#if loaded_from_cache}
<div
class="card border-warning-500/30 bg-warning-500/5 mb-2 flex items-start gap-3 border p-4 shadow-sm">
<p class="text-sm opacity-80">
This record loaded from local cache because direct account API access is currently restricted for this session.
</p>
</div>
{/if}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2"> <div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div <div
class="card preset-tonal-surface border-surface-500/10 space-y-4 border p-6 shadow-xl"> class="card preset-tonal-surface border-surface-500/10 space-y-4 border p-6 shadow-xl">

View File

@@ -70,8 +70,8 @@ async function handle_save_site() {
} }
}); });
// Ensure cfg_json is included explicitly // Pretty-print cfg_json so it's human-readable in the DB (TEXT column)
data_kv.cfg_json = site.cfg_json; data_kv.cfg_json = JSON.stringify(site.cfg_json, null, 2);
await update_ae_obj__site({ await update_ae_obj__site({
api_cfg: $ae_api, api_cfg: $ae_api,