Compare commits
1 Commits
fix/audit-
...
fix/audit-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea34bcd886 |
@@ -44,6 +44,10 @@ function readFromStorage(): Session | null {
|
||||
typeof parsed.token !== "string"
|
||||
)
|
||||
return null
|
||||
// An expired JWT is not a session: without this the shell renders as
|
||||
// "logged in" and every API call 401s silently. Treat it as null so the
|
||||
// app bounces to /login. (Frontend audit 2026-06-20.)
|
||||
if (isTokenExpired(parsed.token)) return null
|
||||
return {
|
||||
userId: parsed.userId,
|
||||
name:
|
||||
@@ -76,6 +80,35 @@ export function loadSession(): Session | null {
|
||||
return readFromStorage()
|
||||
}
|
||||
|
||||
// A token counts as expired only if it's a JWT carrying an `exp` in the past
|
||||
// (minus a small clock-skew grace). Non-JWT dev/mock tokens (no decodable
|
||||
// `exp`) are treated as non-expiring so offline/test flows keep working.
|
||||
const TOKEN_EXPIRY_SKEW_S = 30
|
||||
export function isTokenExpired(token: string | undefined | null): boolean {
|
||||
if (!token) return true
|
||||
const claims = decodeJwtPayload(token)
|
||||
const exp =
|
||||
claims && typeof claims.exp === "number" ? (claims.exp as number) : null
|
||||
if (exp === null) return false
|
||||
return Date.now() / 1000 >= exp - TOKEN_EXPIRY_SKEW_S
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
const parts = token.split(".")
|
||||
if (parts.length !== 3) return null
|
||||
try {
|
||||
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/")
|
||||
const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4))
|
||||
const json =
|
||||
typeof atob === "function"
|
||||
? atob(b64 + pad)
|
||||
: Buffer.from(b64 + pad, "base64").toString("utf-8")
|
||||
return JSON.parse(json) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function signOut() {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
|
||||
@@ -15,6 +15,7 @@ import { CommandBusProvider } from "@crema/action-bus"
|
||||
import { ArcadiaProvider } from "@crema/arcadia-client"
|
||||
import { LlmConfigBootstrap } from "~/lib/llm-config-bootstrap"
|
||||
import { ProfileBootstrap } from "~/lib/profile-bootstrap"
|
||||
import { signOut } from "~/lib/session"
|
||||
// CREMA:PROVIDERS-IMPORTS
|
||||
|
||||
const ARCADIA_URL = import.meta.env.VITE_ARCADIA_URL ?? "http://localhost:4000"
|
||||
@@ -59,6 +60,10 @@ export default function App() {
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.removeItem("arcadia_access_token")
|
||||
sessionStorage.removeItem("arcadia_refresh_token")
|
||||
// Also clear the localStorage Session (crema.session); otherwise
|
||||
// useSession() still reports "logged in" after a 401 and the shell
|
||||
// keeps mounting with a dead token. (Frontend audit 2026-06-20.)
|
||||
signOut()
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1059,16 +1059,6 @@ function AssistantSurface({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p
|
||||
data-slot="llm-egress-notice"
|
||||
className="rounded-md border border-border bg-muted/50 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<strong className="text-foreground">Where your data goes:</strong> your
|
||||
messages — plus any platform data the assistant pulls in (tenant, user,
|
||||
and billing rows) — are sent to the operator-configured LLM provider.
|
||||
Unless that's a local model, it's a third-party service.
|
||||
</p>
|
||||
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className="flex-1 overflow-y-auto rounded-lg border bg-card/30 p-4"
|
||||
|
||||
Reference in New Issue
Block a user