Files
TREK/client/src/types.ts
T
Julien G. e7b419d397 security: login timing enumeration fix + dep CVE patches (v3.0.18) (#984)
* fix(security): equalise login response timing to prevent user enumeration (CWE-208)

Always run bcrypt.compareSync regardless of whether the email exists, using a
module-scope DUMMY_PASSWORD_HASH for unknown/OIDC-only accounts. Also wraps the
login handler in a 350ms minimum-latency pad (matching /forgot-password) as
defence-in-depth against CPU jitter and future code-path drift.

Fixes: CWE-203, CWE-208 — Observable Timing Discrepancy (CVSS 5.3 Medium)

* chore(deps): patch hono/picomatch/ip-address/brace-expansion CVEs, bump to node:24-alpine

Extends server/package.json overrides to pin hono >=4.12.16, picomatch >=4.0.4,
brace-expansion >=2.0.3, ip-address >=10.1.1. Adds matching overrides to client/.
Lockfiles regenerated to resolve: hono 4.12.18, ip-address 10.2.0, picomatch 4.0.4.

Also bumps base image node:22-alpine -> node:24-alpine (reduces base image CVEs)
and adds .github/workflows/security.yml to gate PRs on critical/high CVEs via
Docker Scout.

Addresses: CVE-2026-44456, CVE-2026-44455 (hono), CVE-2026-42338 (ip-address),
           CVE-2026-33671, CVE-2026-33672 (picomatch), CVE-2026-33750 (brace-expansion)

* chore: update emails in security.md

* ci(security): use docker/login-action for Scout auth instead of env vars

* chore: regenerate lock files

* chore: correct secret names

* chore: pr perms write

* fix(docker): remove package-lock.json from production image after npm ci

Docker Scout reads package-lock.json as an SBOM source and reports all
lockfile entries including devDependencies (e.g. picomatch via vitest/vite)
even when they are not physically installed. The lockfile has no runtime
purpose after npm ci completes, so delete it to ensure Scout only reports
packages actually present in node_modules.

* fix(docker): remove npm CLI from production image to eliminate bundled CVEs

picomatch@4.0.3, brace-expansion@5.0.4, and ip-address@10.1.0 were all
coming from /usr/local/lib/node_modules/npm — npm's own bundled packages
shipped with node:24-alpine. The production container only needs the node
binary to run the server; npm is unused at runtime.

Removing npm + npx after npm ci drops the package count from 500 to 365
and eliminates all npm-ecosystem CVEs (0H 0M remaining from npm packages).
Only busybox CVE-2025-60876 remains, which has no fix in Alpine 3.23.

* fix(deps): remove client overrides and brace-expansion server override; audit fix

brace-expansion ^2.0.3 in the client forced all installations to v2, breaking
minimatch in CI (test:coverage path via @vitest/coverage-v8 -> test-exclude)
which expects the named-export API of brace-expansion v5. The CVE it targeted
(>=4.0.0,<5.0.5) was only in npm's own bundled packages, already eliminated
by removing npm from the Docker image.

Also removes picomatch and ip-address client overrides for the same reason:
all three CVEs sourced from /usr/local/lib/node_modules/npm/, not app deps.
Drops brace-expansion from server overrides (server uses v2.1.0, outside the
affected range >=4.0.0).

* fix(#981): align public share itinerary order with daily planner (#985)

The public share page rendered daily items in a different order than the
authenticated planner because it used a simplified, divergent merge
algorithm. Five specific bugs:

1. shareService never loaded reservation_day_positions, so per-day
   transport positions were lost on the share page (fell back to
   day_plan_position ?? 999, pushing transports to the bottom).
2. Multi-day transports (overnight trains/flights) only appeared on their
   start day due to date-string filtering instead of day_id span logic.
3. Assignment-linked transports appeared twice (once as place, once as
   transport card) because the assignment_id exclusion was missing.
4. Time-based transport insertion was absent; missing positions used 999
   instead of a computed fractional position from the place timeline.
5. created_at tiebreaker was missing for assignments and notes with equal
   order_index/sort_order, making order non-deterministic on the share page.

Fix: extract the authoritative merge logic (parseTimeToMinutes,
getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems)
from DayPlanSidebar into client/src/utils/dayMerge.ts and use it in both
the planner and SharedTripPage. Enrich the shareService payload with
day_positions from reservation_day_positions and add created_at tiebreakers
to the assignment and day_notes ORDER BY clauses.

* fix(#983): shift owner vacay entries when update_trip moves trip window

updateTrip() now calls shiftOwnerEntriesForTripWindow() which looks up
the owner's own vacay plan (not the active plan) and shifts all entries
in the old date window by the same offset as the trip start date.
2026-05-10 16:03:15 +02:00

440 lines
9.0 KiB
TypeScript

// Shared types for the TREK travel planner
export interface User {
id: number
username: string
email: string
role: 'admin' | 'user'
avatar_url: string | null
maps_api_key: string | null
created_at: string
/** Present after load; true when TOTP MFA is enabled for password login */
mfa_enabled?: boolean
/** True when a password change is required before the user can continue */
must_change_password?: boolean
}
export interface Trip {
id: number
name: string
description: string | null
start_date: string
end_date: string
cover_url: string | null
is_archived: boolean
reminder_days: number
owner_id: number
created_at: string
updated_at: string
}
export interface Day {
id: number
trip_id: number
day_number?: number
date: string
title: string | null
notes: string | null
assignments: Assignment[]
notes_items: DayNote[]
}
export interface Place {
id: number
trip_id: number
name: string
description: string | null
notes: string | null
lat: number | null
lng: number | null
address: string | null
category_id: number | null
icon: string | null
price: string | null
currency: string | null
image_url: string | null
google_place_id: string | null
osm_id: string | null
route_geometry: string | null
place_time: string | null
end_time: string | null
duration_minutes: number | null
transport_mode: string | null
website: string | null
phone: string | null
created_at: string
}
export interface Assignment {
id: number
day_id: number
place_id?: number
order_index: number
notes: string | null
place: Place
}
export interface DayNote {
id: number
day_id: number
text: string
time: string | null
icon: string | null
sort_order?: number
created_at: string
}
export interface PackingItem {
id: number
trip_id: number
name: string
category: string | null
checked: number
quantity: number
}
export interface TodoItem {
id: number
trip_id: number
name: string
category: string | null
checked: number
sort_order: number
due_date: string | null
description: string | null
assigned_user_id: number | null
priority: number
}
export interface Tag {
id: number
name: string
color: string | null
user_id: number
}
export interface Category {
id: number
name: string
icon: string | null
user_id: number
}
export interface BudgetItem {
id: number
trip_id: number
name: string
amount: number
currency: string
category: string | null
paid_by: number | null
persons: number
members: BudgetMember[]
expense_date: string | null
}
export interface BudgetMember {
user_id: number
paid: boolean
}
export interface ReservationEndpoint {
id?: number
reservation_id?: number
role: 'from' | 'to' | 'stop'
sequence: number
name: string
code: string | null
lat: number
lng: number
timezone: string | null
local_time: string | null
local_date: string | null
}
export interface Reservation {
id: number
trip_id: number
name: string
title?: string
type: string
status: 'pending' | 'confirmed'
date: string | null
time: string | null
reservation_time?: string | null
reservation_end_time?: string | null
location?: string | null
confirmation_number: string | null
notes: string | null
url: string | null
day_id?: number | null
end_day_id?: number | null
place_id?: number | null
assignment_id?: number | null
accommodation_id?: number | null
accommodation_start_day_id?: number | null
accommodation_end_day_id?: number | null
day_plan_position?: number | null
day_positions?: Record<number, number> | null
metadata?: Record<string, string> | string | null
needs_review?: number
endpoints?: ReservationEndpoint[]
created_at: string
}
export interface TripFile {
id: number
trip_id: number
place_id?: number | null
reservation_id?: number | null
note_id?: number | null
uploaded_by?: number | null
uploaded_by_name?: string | null
uploaded_by_avatar?: string | null
filename: string
original_name: string
file_size?: number | null
mime_type: string
description?: string | null
starred?: number
deleted_at?: string | null
created_at: string
reservation_title?: string
linked_reservation_ids?: number[]
url?: string
}
export interface Settings {
map_tile_url: string
default_lat: number
default_lng: number
default_zoom: number
dark_mode: boolean | string
default_currency: string
language: string
temperature_unit: string
time_format: string
show_place_description: boolean
route_calculation?: boolean
blur_booking_codes?: boolean
map_booking_labels?: boolean
map_provider?: 'leaflet' | 'mapbox-gl'
mapbox_access_token?: string
mapbox_style?: string
mapbox_3d_enabled?: boolean
mapbox_quality_mode?: boolean
}
export interface AssignmentsMap {
[dayId: string]: Assignment[]
}
export interface DayNotesMap {
[dayId: string]: DayNote[]
}
export interface RouteSegment {
mid: [number, number]
from: [number, number]
to: [number, number]
walkingText: string
drivingText: string
}
export interface RouteResult {
coordinates: [number, number][]
distance: number
duration: number
distanceText: string
durationText: string
walkingText: string
drivingText: string
}
export interface Waypoint {
lat: number
lng: number
}
// User with optional OIDC fields
export interface UserWithOidc extends User {
oidc_issuer?: string | null
}
// Accommodation type
export interface Accommodation {
id: number
trip_id: number
name: string
address: string | null
check_in: string | null
check_in_end: string | null
check_out: string | null
confirmation_number: string | null
notes: string | null
url: string | null
created_at: string
}
// Trip member (owner or collaborator)
export interface TripMember {
id: number
username: string
email?: string
avatar_url?: string | null
role?: string
}
// Photo type
export interface Photo {
id: number
trip_id: number
filename: string
original_name: string
mime_type: string
size: number
caption: string | null
place_id: number | null
day_id: number | null
created_at: string
}
// Atlas place detail
export interface AtlasPlace {
id: number
name: string
lat: number | null
lng: number | null
}
// GeoJSON types (simplified for atlas map)
export interface GeoJsonFeature {
type: 'Feature'
properties: Record<string, string | number | null | undefined>
geometry: {
type: string
coordinates: unknown
}
id?: string
}
export interface GeoJsonFeatureCollection {
type: 'FeatureCollection'
features: GeoJsonFeature[]
}
// App config from /auth/app-config
export interface AppConfig {
has_users: boolean
allow_registration: boolean
demo_mode: boolean
oidc_configured: boolean
oidc_display_name?: string
oidc_only_mode?: boolean
has_maps_key?: boolean
allowed_file_types?: string
timezone?: string
/** When true, users without MFA cannot use the app until they enable it */
require_mfa?: boolean
// Granular auth toggles
password_login?: boolean
password_registration?: boolean
oidc_login?: boolean
oidc_registration?: boolean
env_override_oidc_only?: boolean
}
// Translation function type
export type TranslationFn = (key: string, params?: Record<string, string | number | null>) => string
// WebSocket event type
export interface WebSocketEvent {
type: string
[key: string]: unknown
}
// Vacay types
export interface VacayHolidayCalendar {
id: number
plan_id: number
region: string
label: string | null
color: string
sort_order: number
}
export interface VacayPlan {
id: number
holidays_enabled: boolean
holidays_region: string | null
holiday_calendars: VacayHolidayCalendar[]
block_weekends: boolean
carry_over_enabled: boolean
company_holidays_enabled: boolean
week_start?: number
name?: string
year?: number
owner_id?: number
created_at?: string
updated_at?: string
}
export interface VacayUser {
id: number
username: string
color: string | null
}
export interface VacayEntry {
date: string
user_id: number
plan_id?: number
person_color?: string
person_name?: string
}
export interface VacayStat {
user_id: number
vacation_days: number
used: number
}
export interface HolidayInfo {
name: string
localName: string
color: string
label: string | null
}
export interface HolidaysMap {
[date: string]: HolidayInfo
}
// API error shape from axios
export interface ApiError {
response?: {
data?: {
error?: string
}
status?: number
}
message: string
}
/** Safely extract an error message from an unknown catch value */
export function getApiErrorMessage(err: unknown, fallback: string): string {
if (typeof err === 'object' && err !== null && 'response' in err) {
const apiErr = err as ApiError
if (apiErr.response?.data?.error) return apiErr.response.data.error
}
if (err instanceof Error) return err.message
return fallback
}
// MergedItem used in day notes hook
export interface MergedItem {
type: 'assignment' | 'note' | 'place' | 'transport'
sortKey: number
data: Assignment | DayNote | Reservation
}