V13-FE-006: consolidate approved UI and contract hardening
deploy / deploy (push) Successful in 1m52s
deploy / notify (push) Successful in 1s

This commit is contained in:
2026-08-13 02:41:00 +09:00
parent d79edae546
commit 3f293d8aa8
1278 changed files with 14384 additions and 1664 deletions
@@ -0,0 +1,103 @@
import type { InjectionKey, Ref } from 'vue'
import type { KbxScreenPreference } from '@kbx/contracts'
const PREFIX = 'kbx.screen.preference.v2'
const MAX_STORED_BYTES = 32_000
const MAX_SCOPE_LENGTH = 160
const MAX_ID_LENGTH = 128
const MAX_VERSION_LENGTH = 40
const MAX_SEARCH_KEYS = 24
const MAX_STRING_LENGTH = 256
export const KbxScreenPreferenceScopeKey: InjectionKey<Readonly<Ref<string>>> = Symbol('KbxScreenPreferenceScope')
function bounded(value: unknown, max: number) {
if (typeof value !== 'string') return ''
const clean = value.trim()
return clean.length <= max ? clean : ''
}
function normalizeScope(scope: unknown) { return bounded(scope, MAX_SCOPE_LENGTH) }
function normalizeId(value: unknown) { return bounded(value, MAX_ID_LENGTH) }
function normalizeVersion(value: unknown) { return bounded(value, MAX_VERSION_LENGTH) }
function storageKey(scope: string, screenId: string, screenVersion: string) {
return `${PREFIX}:${encodeURIComponent(scope)}:${encodeURIComponent(screenId)}:${encodeURIComponent(screenVersion)}`
}
function safeScalar(value: unknown): string | number | boolean | null | undefined {
if (value === null || typeof value === 'boolean') return value
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') return value.slice(0, MAX_STRING_LENGTH)
return undefined
}
function sanitizeJsonValue(value: unknown, depth=0): unknown {
if (depth > 4) return undefined
const scalar = safeScalar(value)
if (scalar !== undefined) return scalar
if (Array.isArray(value)) return value.slice(0, 100).map(item => sanitizeJsonValue(item, depth + 1)).filter(item => item !== undefined)
if (!value || typeof value !== 'object') return undefined
const result: Record<string, unknown> = {}
for (const [key, raw] of Object.entries(value).slice(0, 100)) {
const safeKey = bounded(key, 64)
const safeValue = sanitizeJsonValue(raw, depth + 1)
if (safeKey && safeValue !== undefined) result[safeKey] = safeValue
}
return result
}
function sanitizeSearchDefaults(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const result: Record<string, string | number | boolean | null> = {}
for (const [key, raw] of Object.entries(value).slice(0, MAX_SEARCH_KEYS)) {
const safeKey = bounded(key, 64)
const safeValue = safeScalar(raw)
if (safeKey && safeValue !== undefined) result[safeKey] = safeValue
}
return Object.keys(result).length ? result : undefined
}
function normalizePreference(value: unknown, screenId: string, screenVersion: string): KbxScreenPreference {
const raw = value && typeof value === 'object' ? value as Partial<KbxScreenPreference> : {}
const density = ['compact', 'comfortable', 'touch'].includes(String(raw.density)) ? raw.density : undefined
const pageSize = Number(raw.grid?.pageSize)
const columnState = sanitizeJsonValue(raw.grid?.columnState)
const searchDefaults = sanitizeSearchDefaults(raw.searchDefaults)
const grid = raw.grid && (Number.isFinite(pageSize) || columnState !== undefined) ? {
...(columnState !== undefined ? { columnState } : {}),
...(Number.isFinite(pageSize) ? { pageSize:Math.min(500, Math.max(10, pageSize)) } : {}),
} : undefined
return {
screenId,
screenVersion,
...(density ? { density } : {}),
...(grid ? { grid } : {}),
...(searchDefaults ? { searchDefaults } : {}),
rememberSearch: Boolean(raw.rememberSearch),
}
}
export function loadScreenPreference(scopeValue: string, screenIdValue: string, screenVersionValue: string): KbxScreenPreference | null {
const scope = normalizeScope(scopeValue)
const screenId = normalizeId(screenIdValue)
const screenVersion = normalizeVersion(screenVersionValue)
if (!scope || !screenId || !screenVersion || typeof localStorage === 'undefined') return null
let raw = ''
try { raw = localStorage.getItem(storageKey(scope, screenId, screenVersion)) ?? '' } catch { return null }
if (!raw || raw.length > MAX_STORED_BYTES) return null
try { return normalizePreference(JSON.parse(raw), screenId, screenVersion) } catch { return null }
}
export function saveScreenPreference(scopeValue: string, preference: KbxScreenPreference) {
const scope = normalizeScope(scopeValue)
const screenId = normalizeId(preference.screenId)
const screenVersion = normalizeVersion(preference.screenVersion)
if (!scope || !screenId || !screenVersion || typeof localStorage === 'undefined') return false
const normalized = normalizePreference(preference, screenId, screenVersion)
const payload = JSON.stringify(normalized)
if (payload.length > MAX_STORED_BYTES) return false
try { localStorage.setItem(storageKey(scope, screenId, screenVersion), payload); return true } catch { return false }
}
export function clearScreenPreference(scopeValue: string, screenIdValue: string, screenVersionValue: string) {
const scope = normalizeScope(scopeValue)
const screenId = normalizeId(screenIdValue)
const screenVersion = normalizeVersion(screenVersionValue)
if (!scope || !screenId || !screenVersion || typeof localStorage === 'undefined') return false
try { localStorage.removeItem(storageKey(scope, screenId, screenVersion)); return true } catch { return false }
}