diff --git a/frontend/src/components/PostList.vue b/frontend/src/components/PostList.vue
index 08c394c7f..ae38ffe6f 100644
--- a/frontend/src/components/PostList.vue
+++ b/frontend/src/components/PostList.vue
@@ -4,6 +4,7 @@ import { DateTime } from 'luxon'
import MemoryButton from './MemoryButton.vue'
import HashtagText from './HashtagText.vue'
import { useFollow } from '@/composables/useFollow'
+import AppIcon from '@/components/AppIcon.vue'
const postsStore = usePostsStore()
const { follow, isFollowing } = useFollow()
@@ -21,7 +22,7 @@ function onHashtagClick(hashtag: string): void {
:key="post.id"
>
-
+
{{ post.author.webId }} • {{ DateTime.fromISO(post.createdAt).toRelative() }}
diff --git a/frontend/src/components/UnifiedFeedList.vue b/frontend/src/components/UnifiedFeedList.vue
index c3f9a9732..fa5c8e173 100644
--- a/frontend/src/components/UnifiedFeedList.vue
+++ b/frontend/src/components/UnifiedFeedList.vue
@@ -12,6 +12,7 @@ import { useRouter } from 'vue-router'
import { useI18n } from '@/i18n'
import { useAtBridgeStore, type FeedSource, type TimelineMode, type UnifiedFeedItem as UnifiedFeedItemModel } from '@/stores/atBridgeStore'
import UnifiedFeedItem from './UnifiedFeedItem.vue'
+import AppIcon from '@/components/AppIcon.vue'
// Engagement scoring weights — adjust after observing real usage data.
const ENGAGEMENT_WEIGHTS = Object.freeze({
@@ -240,7 +241,7 @@ function toPopularLabel(item: PopularFeedItem): string {
v-if="store.isLoading && store.unifiedFeed.length === 0"
class="rounded-default bg-white shadow-sm flex flex-col items-center gap-3 py-14 text-center"
>
-
+
@@ -256,9 +257,7 @@ function toPopularLabel(item: PopularFeedItem): string {
>
diff --git a/frontend/src/composables/useFollow.ts b/frontend/src/composables/useFollow.ts
index 369e9f61d..54db9f8ab 100644
--- a/frontend/src/composables/useFollow.ts
+++ b/frontend/src/composables/useFollow.ts
@@ -4,7 +4,6 @@ import { t } from '@/i18n'
import ky, { HTTPError } from 'ky'
import { getLocalDb } from '@/db/localDb'
import { localFollows } from '@/db/localSchema'
-import { eq } from 'drizzle-orm'
const isAbsoluteHttpsUrl = (value: string): boolean => {
try {
diff --git a/frontend/src/composables/usePlatform.ts b/frontend/src/composables/usePlatform.ts
new file mode 100644
index 000000000..c0d2562ab
--- /dev/null
+++ b/frontend/src/composables/usePlatform.ts
@@ -0,0 +1,8 @@
+import { getPlatformCapabilities, type PlatformCapabilities } from '@/platform/capabilities'
+
+let _cached: PlatformCapabilities | null = null
+
+export function usePlatform(): PlatformCapabilities {
+ if (!_cached) _cached = getPlatformCapabilities()
+ return _cached
+}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index bcea40a57..25b622caf 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -1,12 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Vuesax from 'vuesax-alpha'
-import 'boxicons'
import App from './App.vue'
import router from './router'
import { initLocalDb } from './db/localDb'
import { initializeLocale } from './i18n'
+import { applyPlatformCapabilities } from './platform/capabilities'
import { logSessionPolicyConfig } from './utils/sessionPolicy'
// Styles
@@ -24,6 +24,7 @@ if ('serviceWorker' in navigator) {
initLocalDb().catch(err => console.error('[PGlite] init failed:', err))
initializeLocale()
+applyPlatformCapabilities()
// Log effective session policy once for environment-level verification.
logSessionPolicyConfig()
diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts
index d2ca0974e..caad45cce 100644
--- a/frontend/src/stores/authStore.ts
+++ b/frontend/src/stores/authStore.ts
@@ -2,15 +2,42 @@ import type { SignInBody, SignUpBody, SignInResponse } from '#api/types'
import { ApiClient } from '@/controller/api'
import { buildApiHeaders, getApiBaseUrl } from '@/controller/http'
import { beginOidcSignIn, finishOidcSignIn, DEFAULT_PROVIDER_ENDPOINT } from '@/controller/oidc'
-import { type ApiErrors, type App, type ProviderEndpoints, type User } from '@/types'
+import { type ApiErrors, type ProviderEndpoints, type User } from '@/types'
import { treaty } from '@elysiajs/eden'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { getSessionPolicyConfig } from '@/utils/sessionPolicy'
import { t } from '@/i18n'
+import { clearLocalData, clearAllAppStorage } from '@/db/localDb'
+
+/**
+ * Logout mode controls how aggressively local data is cleared on sign-out.
+ *
+ * standard — revoke session/tokens; keep local PGlite cache intact so the
+ * next sign-in on the same device loads instantly.
+ * private — revoke session/tokens + wipe all user-scoped PGlite tables
+ * (fail-closed: blocks re-auth until wipe succeeds).
+ * device-reset — sign out all state, drop the entire IndexedDB database, and
+ * clear all app localStorage keys (device handoff / incident).
+ */
+export type LogoutMode = 'standard' | 'private' | 'device-reset'
+type BlockedLogoutMode = Extract
const SESSION_STARTED_AT_KEY = 'memory.session.startedAt'
+const LOGOUT_BLOCKED_KEY = 'memory.logout.blocked'
+const LOGOUT_BLOCKED_MODE_KEY = 'memory.logout.blockedMode'
+const AUTH_USER_KEY = 'user'
+const AUTH_LOGGED_IN_KEY = 'loggedIn'
+const AUTH_TOKEN_KEY = 'token'
+const STORAGE_PREFIX = 'memory.'
+const OIDC_TRANSACTION_KEY = 'memory.oidc.transaction'
+
+const LOGOUT_MAX_ATTEMPTS = 4
+const LOGOUT_BACKOFF_BASE_MS = 180
+const LOGOUT_BACKOFF_JITTER_MS = 120
+
+const AUTH_STORAGE_KEYS = [AUTH_USER_KEY, AUTH_LOGGED_IN_KEY, AUTH_TOKEN_KEY, SESSION_STARTED_AT_KEY, LOGOUT_BLOCKED_KEY, LOGOUT_BLOCKED_MODE_KEY] as const
const sessionPolicyConfig = getSessionPolicyConfig()
export const useAuthStore = defineStore('auth', () => {
@@ -19,20 +46,127 @@ export const useAuthStore = defineStore('auth', () => {
const isLoggedIn = ref(false)
const token = ref('')
const authError = ref('')
+ const logoutBlocked = ref(false)
+ const logoutBlockedMode = ref(null)
// API
- const client = treaty(getApiBaseUrl(), {
+ // Eden treaty requires the Elysia App type parameter for full inference;
+ // without it the client cannot be statically typed on the frontend.
+
+ const client = treaty(getApiBaseUrl(), {
onRequest() {
return {
headers: buildApiHeaders({ authToken: token.value || undefined })
}
}
- })
+ }) as any // eslint-disable-line @typescript-eslint/no-explicit-any
const apiClient = new ApiClient()
// Vue Hooks
const router = useRouter()
+ function sleep(ms: number) {
+ return new Promise(resolve => setTimeout(resolve, ms))
+ }
+
+ function getBackoffDelayMs(attempt: number) {
+ const expo = LOGOUT_BACKOFF_BASE_MS * (2 ** attempt)
+ const jitter = Math.floor(Math.random() * LOGOUT_BACKOFF_JITTER_MS)
+ return expo + jitter
+ }
+
+ async function withExponentialBackoff(operation: () => Promise, label: string, maxAttempts = LOGOUT_MAX_ATTEMPTS): Promise {
+ let lastError: unknown = null
+
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
+ try {
+ return await operation()
+ } catch (err) {
+ lastError = err
+ if (attempt === maxAttempts - 1) break
+ await sleep(getBackoffDelayMs(attempt))
+ }
+ }
+
+ throw new Error(`[authStore] ${label} failed after ${maxAttempts} attempts`, { cause: lastError })
+ }
+
+ function removeStorageKey(storage: Storage, key: string) {
+ try {
+ storage.removeItem(key)
+ } catch {
+ // Ignore storage availability and quota exceptions.
+ }
+ }
+
+ function clearAuthStorageKeys() {
+ for (const key of AUTH_STORAGE_KEYS) {
+ removeStorageKey(localStorage, key)
+ }
+ removeStorageKey(sessionStorage, OIDC_TRANSACTION_KEY)
+ }
+
+ function clearMemoryScopedStorage() {
+ const localKeysToRemove: string[] = []
+ for (let i = 0; i < localStorage.length; i += 1) {
+ const key = localStorage.key(i)
+ if (!key) continue
+ if (key.startsWith(STORAGE_PREFIX) || AUTH_STORAGE_KEYS.includes(key as typeof AUTH_STORAGE_KEYS[number])) {
+ localKeysToRemove.push(key)
+ }
+ }
+ for (const key of localKeysToRemove) {
+ removeStorageKey(localStorage, key)
+ }
+
+ const sessionKeysToRemove: string[] = []
+ for (let i = 0; i < sessionStorage.length; i += 1) {
+ const key = sessionStorage.key(i)
+ if (!key) continue
+ if (key.startsWith(STORAGE_PREFIX)) {
+ sessionKeysToRemove.push(key)
+ }
+ }
+ for (const key of sessionKeysToRemove) {
+ removeStorageKey(sessionStorage, key)
+ }
+ }
+
+ async function clearBrowserCaches() {
+ if (typeof caches === 'undefined') return
+ try {
+ const cacheKeys = await caches.keys()
+ await Promise.all(cacheKeys.map(key => caches.delete(key)))
+ } catch {
+ // Best effort only; do not block logout if browser cache API is unavailable.
+ }
+ }
+
+ function applySignedOutState() {
+ logoutBlocked.value = false
+ logoutBlockedMode.value = null
+ isLoggedIn.value = false
+ token.value = ''
+ user.value = undefined
+ clearAuthStorageKeys()
+ }
+
+ async function routeToSignIn() {
+ if (router.currentRoute.value.name === 'signin') return
+ await router.push({ name: 'signin' })
+ }
+
function clearSessionStorage() {
- localStorage.removeItem(SESSION_STARTED_AT_KEY)
+ removeStorageKey(localStorage, SESSION_STARTED_AT_KEY)
+ }
+
+ function setLogoutBlocked(newValue: boolean, mode: BlockedLogoutMode | null = null) {
+ logoutBlocked.value = newValue
+ localStorage.setItem(LOGOUT_BLOCKED_KEY, newValue.toString())
+ logoutBlockedMode.value = newValue ? mode : null
+ if (newValue && mode) {
+ localStorage.setItem(LOGOUT_BLOCKED_MODE_KEY, mode)
+ } else {
+ removeStorageKey(localStorage, LOGOUT_BLOCKED_MODE_KEY)
+ }
}
function markSessionStartedNow() {
@@ -57,17 +191,29 @@ export const useAuthStore = defineStore('auth', () => {
// Setters
function setUser(newValue: User | undefined) {
user.value = newValue
- localStorage.setItem('user', JSON.stringify(newValue))
+ if (!newValue) {
+ removeStorageKey(localStorage, AUTH_USER_KEY)
+ return
+ }
+ localStorage.setItem(AUTH_USER_KEY, JSON.stringify(newValue))
}
function setLoggedIn(newValue: boolean) {
isLoggedIn.value = newValue
- localStorage.setItem('loggedIn', newValue.toString())
+ if (!newValue) {
+ removeStorageKey(localStorage, AUTH_LOGGED_IN_KEY)
+ return
+ }
+ localStorage.setItem(AUTH_LOGGED_IN_KEY, 'true')
}
function setToken(newValue: string) {
token.value = newValue
- localStorage.setItem('token', newValue)
+ if (!newValue) {
+ removeStorageKey(localStorage, AUTH_TOKEN_KEY)
+ return
+ }
+ localStorage.setItem(AUTH_TOKEN_KEY, newValue)
}
function setAuthError(newValue: string) {
@@ -79,28 +225,35 @@ export const useAuthStore = defineStore('auth', () => {
* Check if there is a user in the local storage
*/
function initStore() {
- const localUser = localStorage.getItem('user')
+ const localUser = localStorage.getItem(AUTH_USER_KEY)
if (localUser && localUser !== 'undefined') {
user.value = JSON.parse(localUser)
}
- const localLoggedIn = localStorage.getItem('loggedIn')
+ const localLoggedIn = localStorage.getItem(AUTH_LOGGED_IN_KEY)
if (localLoggedIn) {
isLoggedIn.value = JSON.parse(localLoggedIn)
}
- const localToken = localStorage.getItem('token')
+ const localToken = localStorage.getItem(AUTH_TOKEN_KEY)
if (localToken && localToken !== '') {
token.value = localToken
}
+ const localLogoutBlocked = localStorage.getItem(LOGOUT_BLOCKED_KEY)
+ if (localLogoutBlocked) {
+ logoutBlocked.value = JSON.parse(localLogoutBlocked)
+ }
+
+ const localBlockedMode = localStorage.getItem(LOGOUT_BLOCKED_MODE_KEY)
+ if (localBlockedMode === 'private' || localBlockedMode === 'device-reset') {
+ logoutBlockedMode.value = localBlockedMode
+ }
+
// Fail closed when session age is missing/stale so users are only kept signed
// in for a bounded period unless they re-authenticate.
if (isLoggedIn.value && !hasFreshBrowserSession()) {
- setLoggedIn(false)
- setToken('')
- setUser(undefined)
- clearSessionStorage()
+ applySignedOutState()
}
}
/**
@@ -110,6 +263,11 @@ export const useAuthStore = defineStore('auth', () => {
* @param providerEndpoint - endpoint
*/
async function signin(username: string, password: string, providerEndpoint: ProviderEndpoints) {
+ if (logoutBlocked.value) {
+ setAuthError(t('errors.secureLogoutRetryRequired'))
+ return
+ }
+
try {
setAuthError('')
const body: SignInBody = { username, password, providerEndpoint }
@@ -124,12 +282,17 @@ export const useAuthStore = defineStore('auth', () => {
router.push({ name: 'home' })
}
} catch (error) {
- console.log('error when trying to signIn: ', error)
+ console.error('error when trying to signIn: ', error)
setAuthError(t('errors.unableToSignIn'))
}
}
async function signinWithOidc(providerEndpoint: ProviderEndpoints = DEFAULT_PROVIDER_ENDPOINT as ProviderEndpoints) {
+ if (logoutBlocked.value) {
+ setAuthError(t('errors.secureLogoutRetryRequired'))
+ return
+ }
+
setAuthError('')
try {
await beginOidcSignIn(providerEndpoint)
@@ -141,6 +304,13 @@ export const useAuthStore = defineStore('auth', () => {
}
async function completeOidcSignin(search: string) {
+ if (logoutBlocked.value) {
+ const message = t('errors.secureLogoutRetryRequired')
+ setAuthError(message)
+ await router.replace({ name: 'signin' })
+ throw new Error(message)
+ }
+
setAuthError('')
try {
@@ -170,6 +340,11 @@ export const useAuthStore = defineStore('auth', () => {
password: string,
providerEndpoint: ProviderEndpoints
): Promise {
+ if (logoutBlocked.value) {
+ setAuthError(t('errors.secureLogoutRetryRequired'))
+ return
+ }
+
const body: SignUpBody = { username, password, email, providerEndpoint }
const { data: response, status } = await apiClient.signup(body)
if (status === 200) {
@@ -193,14 +368,56 @@ export const useAuthStore = defineStore('auth', () => {
/**
* Logout
+ *
+ * @param mode Controls how aggressively local data is cleared:
+ * - 'standard' Keep PGlite cache; only clear tokens/session.
+ * - 'private' Wipe PGlite tables (fail-closed before token clear).
+ * - 'device-reset' Drop IndexedDB entirely + clear all localStorage.
*/
- function logout() {
- setLoggedIn(false)
- setToken('')
- setUser(undefined)
+ async function logout(mode: LogoutMode = 'standard'): Promise {
setAuthError('')
+
+ if (mode === 'private') {
+ // Fail-closed: do not end the session until local data wipe succeeds.
+ try {
+ await withExponentialBackoff(() => clearLocalData(), 'clearLocalData')
+ } catch (err) {
+ console.warn('[authStore] clearLocalData failed on private logout:', err)
+ setLogoutBlocked(true, 'private')
+ setAuthError(t('errors.secureLogoutFailed'))
+ await routeToSignIn()
+ return false
+ }
+ }
+
+ if (mode === 'device-reset') {
+ // Fail-closed: block re-auth until full storage wipe succeeds.
+ try {
+ await withExponentialBackoff(() => clearAllAppStorage(), 'clearAllAppStorage')
+ } catch (err) {
+ console.warn('[authStore] clearAllAppStorage failed on device-reset:', err)
+ setLogoutBlocked(true, 'device-reset')
+ setAuthError(t('errors.deviceResetFailed'))
+ await routeToSignIn()
+ return false
+ }
+
+ clearMemoryScopedStorage()
+ await clearBrowserCaches()
+ applySignedOutState()
+ await routeToSignIn()
+ return true
+ }
+
+ // Standard and private (post-wipe) path.
+ applySignedOutState()
clearSessionStorage()
- router.push({ name: 'signin' })
+ await routeToSignIn()
+ return true
+ }
+
+ async function retrySecureLogout(): Promise {
+ return logout(logoutBlockedMode.value ?? 'private')
}
initStore()
@@ -209,12 +426,16 @@ export const useAuthStore = defineStore('auth', () => {
isLoggedIn,
token,
authError,
+ logoutBlocked,
+ logoutBlockedMode,
// functions
signin,
signinWithOidc,
completeOidcSignin,
signup,
logout,
+ retrySecureLogout,
+ deviceReset: () => logout('device-reset'),
authenticateUser,
hasFreshBrowserSession,
setAuthError
diff --git a/frontend/src/stores/postsStore.ts b/frontend/src/stores/postsStore.ts
index 523ed2343..c50a9424c 100644
--- a/frontend/src/stores/postsStore.ts
+++ b/frontend/src/stores/postsStore.ts
@@ -1,4 +1,4 @@
-import type { App, CreatePost, CreatePoll } from '@/types'
+import type { CreatePost, CreatePoll, MediaAttachmentInput, MediaUploadResponse, MediaUploadStatusResponse, PublicMediaAttachment } from '@/types'
import { buildApiHeaders, getApiBaseUrl } from '@/controller/http'
import { treaty } from '@elysiajs/eden'
import { defineStore } from 'pinia'
@@ -13,13 +13,16 @@ export const usePostsStore = defineStore('posts', () => {
// Stores
const authStore = useAuthStore()
// API
- const client = treaty(getApiBaseUrl(), {
+ // Eden treaty requires the Elysia App type parameter for full inference;
+ // without it the client cannot be statically typed on the frontend.
+
+ const client = treaty(getApiBaseUrl(), {
onRequest() {
return {
headers: buildApiHeaders({ authToken: authStore.token || undefined })
}
}
- })
+ }) as any // eslint-disable-line @typescript-eslint/no-explicit-any
// Util Functions
/**
@@ -34,7 +37,7 @@ export const usePostsStore = defineStore('posts', () => {
}
})) as unknown as { data: SelectPost[]; status: number }
if (status === 401) {
- authStore.logout()
+ await authStore.logout('private')
} else if (status === 200) {
const newPosts = postResponse as SelectPost[]
posts.value = newPosts
@@ -48,6 +51,68 @@ export const usePostsStore = defineStore('posts', () => {
postType?: 'note' | 'article'
name?: string | null
summary?: string | null
+ attachments?: MediaAttachmentInput[]
+ idempotencyKey?: string
+ }
+
+ async function uploadMedia(file: File): Promise {
+ const formData = new FormData()
+ formData.set('file', file)
+
+ let response: Response
+ try {
+ response = await fetch(`${getApiBaseUrl()}/media/uploads`, {
+ method: 'POST',
+ headers: buildApiHeaders({ authToken: authStore.token || undefined }),
+ body: formData,
+ })
+ } catch {
+ return null
+ }
+
+ if (response.status === 401) {
+ await authStore.logout('private')
+ return null
+ }
+ if (!response.ok) {
+ return null
+ }
+
+ try {
+ const payload = await response.json() as MediaUploadResponse
+ return payload.attachment
+ } catch {
+ return null
+ }
+ }
+
+ async function getMediaUpload(id: string): Promise {
+ try {
+ const response = await fetch(`${getApiBaseUrl()}/media/uploads/${encodeURIComponent(id)}`, {
+ headers: buildApiHeaders({ authToken: authStore.token || undefined }),
+ })
+ if (response.status === 401) {
+ await authStore.logout('private')
+ return null
+ }
+ if (!response.ok) return null
+ const payload = await response.json() as MediaUploadStatusResponse
+ return payload.media
+ } catch {
+ return null
+ }
+ }
+
+ async function deleteMediaUpload(id: string): Promise {
+ try {
+ const response = await fetch(`${getApiBaseUrl()}/media/uploads/${encodeURIComponent(id)}`, {
+ method: 'DELETE',
+ headers: buildApiHeaders({ authToken: authStore.token || undefined }),
+ })
+ if (response.status === 401) await authStore.logout('private')
+ } catch {
+ // Best-effort local cleanup; expired unattached uploads are also cleaned server-side.
+ }
}
/**
@@ -62,7 +127,13 @@ export const usePostsStore = defineStore('posts', () => {
postType = 'note',
name = null,
summary = null,
+ attachments = [],
+ idempotencyKey = crypto.randomUUID(),
}: CreatePostInput): Promise {
+ const durableAttachmentIds = attachments.map(attachment => attachment.id).filter((id): id is string => Boolean(id))
+ const legacyAttachments = attachments
+ .filter(attachment => !attachment.id)
+ .map(({ previewUrl: _previewUrl, state: _state, id: _id, ...attachment }) => attachment)
const requestBody: CreatePost = {
content,
...(hashtags.length > 0 ? { hashtags } : {}),
@@ -70,6 +141,9 @@ export const usePostsStore = defineStore('posts', () => {
postType,
...(name ? { name } : {}),
...(summary ? { summary } : {}),
+ ...(durableAttachmentIds.length > 0 ? { attachmentIds: durableAttachmentIds } : {}),
+ ...(legacyAttachments.length > 0 ? { attachments: legacyAttachments } : {}),
+ idempotencyKey,
...(poll ? { poll } : {}),
}
const postResponse = await client.posts.post(requestBody)
@@ -103,6 +177,9 @@ export const usePostsStore = defineStore('posts', () => {
posts,
hashtagFilter,
fetchPosts,
+ uploadMedia,
+ getMediaUpload,
+ deleteMediaUpload,
createPost,
setHashtagFilter,
clearHashtagFilter
diff --git a/frontend/src/views/ExploreView.vue b/frontend/src/views/ExploreView.vue
index e7a9a6753..aa6c1c4e3 100644
--- a/frontend/src/views/ExploreView.vue
+++ b/frontend/src/views/ExploreView.vue
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import UnifiedFeedList from '@/components/UnifiedFeedList.vue'
import PostEmbedCard from '@/components/PostEmbedCard.vue'
import { useFollow } from '@/composables/useFollow'
+import AppIcon from '@/components/AppIcon.vue'
const router = useRouter()
const { follow, isFollowing } = useFollow()
@@ -137,7 +138,7 @@ const demoPost = {
style="background: rgba(55,55,55,0.1); color: rgba(55,55,55,0.6);"
@click="router.back()"
>
-
+
explore.
@@ -204,9 +205,7 @@ const demoPost = {
@click="clearSearchHistory"
>
Clear Search History
-
+
@@ -222,7 +221,7 @@ const demoPost = {
class="w-7 h-7 flex items-center justify-center rounded-full text-dark-30 hover:bg-dark-10 transition-colors"
@click.stop="removeHistoryItem(i)"
>
-