2026-06-17 17:06:30 -06:00
|
|
|
/**
|
|
|
|
|
* Typed API client with transparent 401 → token-refresh → retry.
|
|
|
|
|
*
|
2026-06-17 22:07:18 -06:00
|
|
|
* Ported from static/js/core/fetchWrapper.js. Unlike that wrapper, this does
|
|
|
|
|
* NOT monkeypatch `window.fetch`; every endpoint module calls `apiFetch`
|
2026-06-17 17:06:30 -06:00
|
|
|
* explicitly. The behavioural invariants are preserved exactly:
|
|
|
|
|
*
|
|
|
|
|
* - A captured raw `fetch` is used for the real network calls so the refresh
|
|
|
|
|
* request and the retry never re-enter the interceptor (no recursion).
|
|
|
|
|
* - Concurrent 401s collapse into a single in-flight `/api/auth/refresh`.
|
|
|
|
|
* - Cross-origin responses are passed through untouched.
|
|
|
|
|
* - Auth primitives (login/logout/refresh/register/setup/oidc/device) and
|
|
|
|
|
* public-share endpoints (/api/s/) bypass the refresh-and-retry path:
|
|
|
|
|
* a 401 there is genuine ("bad credentials" / "password required"), not an
|
|
|
|
|
* expired access token.
|
|
|
|
|
* - When refresh fails, the session-expired handler fires (clear + redirect)
|
|
|
|
|
* and the call rejects.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { getCsrfHeaders } from './csrf';
|
2026-08-01 18:01:10 +02:00
|
|
|
import { updateFromHeader } from '$lib/stores/serverStatus.svelte';
|
2026-08-08 14:33:25 +02:00
|
|
|
import {
|
|
|
|
|
buildDpopProof,
|
|
|
|
|
isDpopNonceChallenge,
|
|
|
|
|
updateNonceFromResponse
|
|
|
|
|
} from '$lib/auth/dpop-proof';
|
2026-08-01 18:01:10 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Name of the response header the server stamps while a
|
|
|
|
|
* maintenance event is live. Case-insensitive on the wire — the
|
|
|
|
|
* Fetch API's `Headers.get` matches irrespective of case, so this
|
|
|
|
|
* constant matches whatever axum emits.
|
|
|
|
|
*/
|
|
|
|
|
const SERVER_STATUS_HEADER = 'x-server-status';
|
2026-06-17 17:06:30 -06:00
|
|
|
|
|
|
|
|
const REFRESH_ENDPOINT = '/api/auth/refresh';
|
|
|
|
|
|
2026-08-09 14:04:42 +02:00
|
|
|
/** Auth primitives — a 401 here is genuine, never an expired access token.
|
|
|
|
|
* Also used by the session-teardown gate to exempt endpoints that must
|
|
|
|
|
* still fire during / immediately after a logout (the logout POST itself,
|
|
|
|
|
* and every login path a user might retry on the /login landing). */
|
2026-06-17 17:06:30 -06:00
|
|
|
const AUTH_PRIMITIVES = [
|
|
|
|
|
'/api/auth/login',
|
|
|
|
|
'/api/auth/logout',
|
|
|
|
|
'/api/auth/refresh',
|
|
|
|
|
'/api/auth/register',
|
|
|
|
|
'/api/auth/setup',
|
|
|
|
|
'/api/auth/oidc/',
|
2026-08-09 14:04:42 +02:00
|
|
|
'/api/auth/device/',
|
|
|
|
|
'/api/auth/opaque/',
|
|
|
|
|
'/api/auth/magic-link/',
|
|
|
|
|
'/api/auth/status',
|
|
|
|
|
'/api/auth/dpop/'
|
2026-06-17 17:06:30 -06:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
export type FetchFn = typeof fetch;
|
|
|
|
|
|
|
|
|
|
export interface ApiClientDeps {
|
|
|
|
|
/** Underlying fetch used for the real network call (bypasses the interceptor). */
|
|
|
|
|
rawFetch: FetchFn;
|
|
|
|
|
/** Invoked once when a refresh definitively fails (clear session + redirect). */
|
|
|
|
|
onSessionExpired: () => void;
|
2026-08-04 21:05:08 +02:00
|
|
|
/**
|
|
|
|
|
* Invoked when the server returns `403 { error_type: "PasswordChangeRequired" }`.
|
|
|
|
|
* Typically routes the SPA to `/profile?forcePasswordChange=1` — the same
|
|
|
|
|
* destination the root layout's nav-guard uses for a fresh navigation. Default
|
|
|
|
|
* is a no-op; the app wires the real handler at startup.
|
|
|
|
|
*/
|
|
|
|
|
onPasswordChangeRequired?: () => void;
|
2026-06-17 17:06:30 -06:00
|
|
|
/** Test seam for `window.location.origin`. */
|
|
|
|
|
origin?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function urlString(input: RequestInfo | URL): string {
|
|
|
|
|
if (typeof input === 'string') return input;
|
|
|
|
|
if (input instanceof URL) return input.href;
|
|
|
|
|
return input.url ?? '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isCrossOrigin(urlStr: string, origin: string): boolean {
|
|
|
|
|
try {
|
|
|
|
|
return new URL(urlStr, origin).origin !== origin;
|
|
|
|
|
} catch {
|
|
|
|
|
// Unparseable URL — treat as cross-origin so we pass it through untouched.
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bypassesRetry(urlStr: string): boolean {
|
|
|
|
|
return AUTH_PRIMITIVES.some((p) => urlStr.includes(p)) || urlStr.includes('/api/s/');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build an isolated apiFetch with its own refresh-dedup state. Used directly in
|
|
|
|
|
* tests; the app uses the default singleton below.
|
|
|
|
|
*/
|
|
|
|
|
export function createApiFetch(deps: ApiClientDeps): FetchFn {
|
|
|
|
|
const { rawFetch, onSessionExpired } = deps;
|
2026-08-04 21:05:08 +02:00
|
|
|
// Default no-op keeps existing test callers that don't wire this
|
|
|
|
|
// dep from crashing on a 403 PasswordChangeRequired — they'd just
|
|
|
|
|
// see the raw 403 flow through, which is what they already assert.
|
|
|
|
|
const onPasswordChangeRequired = deps.onPasswordChangeRequired ?? (() => {});
|
2026-06-17 17:06:30 -06:00
|
|
|
let refreshInFlight: Promise<boolean> | null = null;
|
|
|
|
|
|
|
|
|
|
async function refresh(): Promise<boolean> {
|
|
|
|
|
if (refreshInFlight) return refreshInFlight;
|
|
|
|
|
refreshInFlight = (async () => {
|
|
|
|
|
try {
|
2026-08-08 14:33:25 +02:00
|
|
|
const r = await dpopFetch(REFRESH_ENDPOINT, {
|
2026-06-17 17:06:30 -06:00
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
|
|
|
|
body: '{}'
|
|
|
|
|
});
|
|
|
|
|
return r.ok;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
} finally {
|
|
|
|
|
refreshInFlight = null;
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
return refreshInFlight;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 14:33:25 +02:00
|
|
|
/**
|
|
|
|
|
* Wrap the raw fetch with DPoP proof injection + nonce challenge/retry.
|
|
|
|
|
*
|
|
|
|
|
* 1. Build proof for the request's method + canonical URL (no query).
|
|
|
|
|
* 2. Attach as `DPoP` header. Fail-open if the keypair is unavailable
|
|
|
|
|
* (browser without SubtleCrypto / IndexedDB) — we simply skip the
|
|
|
|
|
* header and let the request go through unbound; server-side
|
|
|
|
|
* middleware exempts unbound sessions.
|
|
|
|
|
* 3. After response, harvest a fresh `DPoP-Nonce` if the server sent
|
|
|
|
|
* one, so the NEXT request has the current nonce.
|
|
|
|
|
* 4. If the response is a nonce challenge (`401 use_dpop_nonce`),
|
|
|
|
|
* REBUILD the proof with the just-received nonce and retry ONCE.
|
|
|
|
|
* A second challenge on the retry is a bug — surface it as a real
|
|
|
|
|
* 401 rather than looping.
|
|
|
|
|
*
|
|
|
|
|
* Cross-origin requests skip DPoP entirely (privacy — don't leak the
|
|
|
|
|
* user's public key to third parties). Request bodies are consumed at
|
|
|
|
|
* most once during retry: `init.body` is passed by reference, and the
|
|
|
|
|
* only mutating step is `Headers`; a caller-supplied `ReadableStream`
|
|
|
|
|
* body would need `duplex: 'half'`, which they'd already have to opt
|
|
|
|
|
* into for cross-origin CORS anyway.
|
|
|
|
|
*/
|
|
|
|
|
async function dpopFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
|
|
|
const origin = deps.origin ?? globalThis.location?.origin ?? 'http://localhost';
|
|
|
|
|
const urlStr = urlString(input as RequestInfo | URL);
|
|
|
|
|
if (isCrossOrigin(urlStr, origin)) return rawFetch(input, init);
|
|
|
|
|
|
|
|
|
|
const method = init?.method ?? 'GET';
|
|
|
|
|
const withProof = async (): Promise<Response> => {
|
|
|
|
|
const proof = await buildDpopProof(method, urlStr);
|
|
|
|
|
const initWithProof: RequestInit = proof
|
|
|
|
|
? { ...init, headers: mergeHeader(init?.headers, 'DPoP', proof) }
|
|
|
|
|
: (init ?? {});
|
|
|
|
|
const res = await rawFetch(input, initWithProof);
|
|
|
|
|
updateNonceFromResponse(res);
|
|
|
|
|
return res;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const first = await withProof();
|
|
|
|
|
if (!isDpopNonceChallenge(first)) return first;
|
|
|
|
|
// `updateNonceFromResponse` already stored the fresh nonce
|
|
|
|
|
// carried on this 401; the next `buildDpopProof` will pick it
|
|
|
|
|
// up. If the RETRY also produces `use_dpop_nonce`, surface it
|
|
|
|
|
// — infinite retry would mask a server-side nonce bug.
|
|
|
|
|
return withProof();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
const apiFetch: FetchFn = async (input, init) => {
|
|
|
|
|
const origin = deps.origin ?? globalThis.location?.origin ?? 'http://localhost';
|
2026-08-09 13:47:47 +02:00
|
|
|
// Session-teardown short-circuit. While a logout is in flight (or
|
|
|
|
|
// the caller has already navigated to /login post-logout without
|
|
|
|
|
// re-authenticating), the session is dead — any subscriber-fired
|
|
|
|
|
// refresh (`session.load()` in the layout, a store `$effect` re-
|
|
|
|
|
// fetching its slice, an idle poll) would hit /me → 401 → refresh
|
|
|
|
|
// → 401 → sessionExpiredHandler and clobber the friendly
|
|
|
|
|
// "logged out" landing with `?source=session_expired`. Fail these
|
|
|
|
|
// fast with an AbortError so callers unwrap cleanly via their
|
|
|
|
|
// existing `.catch` blocks and no server hop occurs. The auth
|
|
|
|
|
// primitives themselves (notably `/api/auth/logout`) are exempt so
|
|
|
|
|
// the logout POST that FLIPPED the gate can still complete.
|
|
|
|
|
const urlStrEarly = urlString(input as RequestInfo | URL);
|
|
|
|
|
if (logoutInProgress && !bypassesRetry(urlStrEarly)) {
|
|
|
|
|
throw new DOMException('Session terminated', 'AbortError');
|
|
|
|
|
}
|
2026-08-08 14:33:25 +02:00
|
|
|
const response = await dpopFetch(input, init);
|
2026-08-01 18:01:10 +02:00
|
|
|
// Server-status header piggyback — the server stamps
|
|
|
|
|
// `x-server-status` on every response while a maintenance
|
|
|
|
|
// event is in progress (see middleware::server_status). Read
|
|
|
|
|
// it and update the reactive store; the AppShell banner
|
|
|
|
|
// subscribes and shows/hides itself. Absent header = nothing
|
|
|
|
|
// happening; the update fn resets the store to default in
|
|
|
|
|
// that case so a lingering banner disappears.
|
|
|
|
|
//
|
|
|
|
|
// Runs on EVERY response including a 401 (below) so a session
|
|
|
|
|
// refresh doesn't accidentally clear a live banner.
|
|
|
|
|
updateFromHeader(response.headers.get(SERVER_STATUS_HEADER));
|
|
|
|
|
|
2026-08-04 21:05:08 +02:00
|
|
|
// Backend `require_no_password_change_pending_layer` returns 403
|
|
|
|
|
// `PasswordChangeRequired` on every non-allowlisted endpoint
|
|
|
|
|
// while the caller's `force_password_change_at_next_login` flag
|
|
|
|
|
// is set (admin picked a temporary password). Intercepting here
|
|
|
|
|
// short-circuits any stale-tab request that outran the SPA's
|
|
|
|
|
// nav-guard — the user is bounced to `/profile` in mandatory
|
|
|
|
|
// mode, matching what the guard would do on a fresh navigation.
|
|
|
|
|
//
|
|
|
|
|
// Clones the body so downstream callers can still consume the
|
|
|
|
|
// response after we've peeked at the error_type. Skipped for
|
|
|
|
|
// non-JSON responses (WebDAV, etc.) — the check silently
|
|
|
|
|
// falls through and returns the original 403 to the caller,
|
|
|
|
|
// which will surface its own error the usual way.
|
|
|
|
|
if (response.status === 403) {
|
|
|
|
|
const clone = response.clone();
|
|
|
|
|
try {
|
|
|
|
|
const body = (await clone.json()) as { error_type?: unknown };
|
|
|
|
|
if (body?.error_type === 'PasswordChangeRequired') {
|
|
|
|
|
onPasswordChangeRequired();
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
/* not JSON or parse failed — pass through as normal 403 */
|
|
|
|
|
}
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
if (response.status !== 401) return response;
|
|
|
|
|
|
|
|
|
|
const urlStr = urlString(input as RequestInfo | URL);
|
|
|
|
|
if (isCrossOrigin(urlStr, origin)) return response;
|
|
|
|
|
if (bypassesRetry(urlStr)) return response;
|
|
|
|
|
|
|
|
|
|
const refreshed = await refresh();
|
|
|
|
|
if (!refreshed) {
|
2026-08-08 21:19:21 +02:00
|
|
|
// Suppress the session-expired divert while a logout POST
|
|
|
|
|
// is in flight — see `logoutInProgress` above. Without this
|
|
|
|
|
// gate the ambient 401 race cancels the pending logout and
|
|
|
|
|
// swallows its `post_logout_url` response body.
|
|
|
|
|
if (!logoutInProgress) {
|
|
|
|
|
onSessionExpired();
|
|
|
|
|
}
|
2026-06-17 17:06:30 -06:00
|
|
|
throw new Error('Session expired');
|
|
|
|
|
}
|
2026-08-08 14:33:25 +02:00
|
|
|
// Retry through dpopFetch (not rawFetch directly) so the
|
|
|
|
|
// post-refresh request also carries a valid DPoP proof —
|
|
|
|
|
// otherwise a session bound to a keypair would 401 again on
|
|
|
|
|
// the retry with `dpop_missing`.
|
|
|
|
|
const retryResponse = await dpopFetch(input, init);
|
2026-08-01 18:01:10 +02:00
|
|
|
updateFromHeader(retryResponse.headers.get(SERVER_STATUS_HEADER));
|
|
|
|
|
return retryResponse;
|
2026-06-17 17:06:30 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return apiFetch;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 14:33:25 +02:00
|
|
|
/**
|
|
|
|
|
* Merge a single header into an existing `HeadersInit` (`Headers`, plain
|
|
|
|
|
* object, or array-of-pairs), returning a fresh `Headers` so the caller's
|
|
|
|
|
* init isn't mutated. Preserves case-insensitivity via the `Headers` API.
|
|
|
|
|
*/
|
|
|
|
|
function mergeHeader(base: HeadersInit | undefined, name: string, value: string): Headers {
|
|
|
|
|
const merged = new Headers(base ?? {});
|
|
|
|
|
merged.set(name, value);
|
|
|
|
|
return merged;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
// ── Default singleton ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
let sessionExpiredHandler: () => void = () => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
window.location.href = '/login?source=session_expired';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** Wire the real session-expired behaviour (clear store + redirect) at startup. */
|
|
|
|
|
export function setSessionExpiredHandler(fn: () => void): void {
|
|
|
|
|
sessionExpiredHandler = fn;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 13:47:47 +02:00
|
|
|
// Session-teardown gate. Flipped ON by `AppShell::onLogout` immediately
|
|
|
|
|
// BEFORE it calls `logout()` and left ON across the redirect to /login
|
|
|
|
|
// (module state persists over SvelteKit soft nav — a hard reload wipes
|
|
|
|
|
// it back to `false`, which is the correct default for a fresh session).
|
|
|
|
|
// While set:
|
|
|
|
|
// 1. `apiFetch` short-circuits every non-auth-primitive request with
|
|
|
|
|
// an `AbortError` — no server hop, no 401, no audit noise. Callers
|
|
|
|
|
// unwrap through their existing `.catch` blocks.
|
|
|
|
|
// 2. On a 401 the `sessionExpiredHandler` divert is suppressed so it
|
|
|
|
|
// cannot clobber the friendly `/login?source=logged_out` landing
|
|
|
|
|
// with `?source=session_expired`.
|
|
|
|
|
// Rule (1) alone would defeat the logout POST itself, so the auth
|
|
|
|
|
// primitives (`/api/auth/logout`, `/api/auth/refresh`, …) are exempted
|
|
|
|
|
// via `bypassesRetry`. Rule (2) additionally covers the tail-end race
|
|
|
|
|
// where the logout response's `post_logout_url` matters for OIDC — an
|
|
|
|
|
// ambient 401 mid-flight cannot cancel the pending POST and swallow
|
|
|
|
|
// its body, which would leave the IdP session live.
|
2026-08-08 21:19:21 +02:00
|
|
|
let logoutInProgress = false;
|
|
|
|
|
|
|
|
|
|
export function setLogoutInProgress(value: boolean): void {
|
|
|
|
|
logoutInProgress = value;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 14:40:10 +02:00
|
|
|
/** Read-only view of the gate — used by cross-tab handlers to distinguish
|
|
|
|
|
* OUR logout (already handled by AppShell.onLogout with source=logged_out)
|
|
|
|
|
* from ANOTHER tab's logout (which needs a bare redirect). */
|
|
|
|
|
export function isLogoutInProgress(): boolean {
|
|
|
|
|
return logoutInProgress;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 21:05:08 +02:00
|
|
|
// Same shape as `sessionExpiredHandler` — mutable so the app can install
|
|
|
|
|
// the real behaviour post-mount, and a fallback for the (rare) case
|
|
|
|
|
// where no handler is wired yet (bootstrap, tests). The fallback does
|
|
|
|
|
// a hard `window.location` navigation so a stale tab that outran the
|
|
|
|
|
// SPA's nav-guard still lands the user on the mandatory form.
|
|
|
|
|
let passwordChangeRequiredHandler: () => void = () => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const here = encodeURIComponent(window.location.pathname + window.location.search);
|
|
|
|
|
window.location.href = `/profile?forcePasswordChange=1&next=${here}`;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Wire the SPA's mandatory-mode handler. Called once from the root
|
|
|
|
|
* layout: uses `goto()` for a soft nav so `next=` preserves the
|
|
|
|
|
* intended destination without triggering a full page reload.
|
|
|
|
|
*/
|
|
|
|
|
export function setPasswordChangeRequiredHandler(fn: () => void): void {
|
|
|
|
|
passwordChangeRequiredHandler = fn;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
const rawFetch: FetchFn =
|
|
|
|
|
typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : (undefined as never);
|
|
|
|
|
|
|
|
|
|
/** App-wide fetch — route every API call through this. */
|
|
|
|
|
export const apiFetch: FetchFn = createApiFetch({
|
|
|
|
|
rawFetch,
|
2026-08-04 21:05:08 +02:00
|
|
|
onSessionExpired: () => sessionExpiredHandler(),
|
|
|
|
|
onPasswordChangeRequired: () => passwordChangeRequiredHandler()
|
2026-06-17 17:06:30 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/** Convenience: fetch JSON, throwing on non-2xx. */
|
|
|
|
|
export async function apiJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
|
|
|
|
const res = await apiFetch(input, init);
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
throw new ApiError(res.status, res.statusText, input);
|
|
|
|
|
}
|
|
|
|
|
return (await res.json()) as T;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class ApiError extends Error {
|
2026-07-14 01:46:33 +02:00
|
|
|
/**
|
|
|
|
|
* `error_type` field from the backend's `ErrorResponse` body, when
|
|
|
|
|
* present. Callers switch on this to render specific UX for
|
|
|
|
|
* distinguished failures (e.g. `EmailNotVerified` → "resend
|
|
|
|
|
* verification link" prompt). Falls back to `undefined` when the
|
|
|
|
|
* response body isn't parseable or the endpoint doesn't emit one.
|
|
|
|
|
*/
|
|
|
|
|
readonly errorType?: string;
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
constructor(
|
|
|
|
|
readonly status: number,
|
|
|
|
|
readonly statusText: string,
|
2026-07-14 01:46:33 +02:00
|
|
|
readonly resource: RequestInfo | URL,
|
|
|
|
|
errorType?: string,
|
|
|
|
|
serverMessage?: string
|
2026-06-17 17:06:30 -06:00
|
|
|
) {
|
2026-07-14 01:46:33 +02:00
|
|
|
super(
|
|
|
|
|
serverMessage ?? `API ${status} ${statusText} for ${urlString(resource as RequestInfo | URL)}`
|
|
|
|
|
);
|
2026-06-17 17:06:30 -06:00
|
|
|
this.name = 'ApiError';
|
2026-07-14 01:46:33 +02:00
|
|
|
this.errorType = errorType;
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
}
|