Skip to content

Refactor: break up monolithic +page.svelte into 14 files#38

Merged
TheOneWhoBurns merged 2 commits intorental-systemfrom
refactor/break-up-monolithic-page
Feb 6, 2026
Merged

Refactor: break up monolithic +page.svelte into 14 files#38
TheOneWhoBurns merged 2 commits intorental-systemfrom
refactor/break-up-monolithic-page

Conversation

@TheOneWhoBurns
Copy link
Owner

@TheOneWhoBurns TheOneWhoBurns commented Feb 6, 2026

Summary

  • Replace the 4076-line monolithic +page.svelte with a layered architecture of 14 small, single-responsibility files (~1200 total lines)
  • Add service layer (src/lib/services/) with typed fetch wrappers for each API domain (rentals, reservations, tours, store sales, shifts, guides)
  • Add tab sections (src/lib/sections/) as smart containers that own their domain modals and call services directly
  • Reduce +page.svelte to a ~260-line orchestrator handling tabs, shared DeleteModal, and cross-tab wiring (e.g. start rental from reservation)
  • Add shared toast store for consistent notification state across all tabs

Architecture

src/lib/services/     # 7 files - API fetch wrappers
src/lib/stores/       # toast.ts - notification store
src/lib/sections/     # 5 files - tab content components
src/routes/+page.svelte  # thin orchestrator

No changes to: server-side logic, API routes, +page.server.ts, or existing modal/card components.

Test plan

  • Login and verify shift starts correctly
  • Create rental, edit, close with payment split
  • Create reservation, start rental from reservation (cross-tab flow)
  • Create tour booking, edit, close with cost
  • Create store sale, delete with passcode
  • Delete operations across all entity types (shared DeleteModal)
  • Reservation conflict override flow (409 handling)
  • End shift with Excel/Google Sheets export
  • npm run build passes

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added modal dialogs for editing rentals, reservations, and tour bookings with streamlined forms
    • Added passcode-protected deletion flows for rentals, reservations, and bookings
    • Introduced new Store Sales section for tracking sales activities
    • Added Previous Rentals tab for historical rental viewing
    • Enhanced card-based UI with edit-on-click interactions
  • Security Improvements

    • Implemented rate limiting on authentication endpoints to prevent brute-force attacks
    • Enhanced session validation with signed cookies

TheOneWhoBurns and others added 2 commits February 5, 2026 18:39
Rate limiting was in-memory (Map), which breaks under multi-instance
scaling since each instance tracks independently. Now uses a rate_limits
table with upsert logic and piggyback cleanup.

Operator cookie was an unsigned plain integer, forgeable by anyone who
knows an ID. Now HMAC-signed with COOKIE_SECRET env var and verified
via getVerifiedOperatorId() across all endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the 4076-line monolith with a layered architecture:
- Service layer (7 files): typed fetch wrappers for each API domain
- Toast store: shared notification state
- Tab sections (5 files): smart containers owning domain modals
- Page orchestrator (~260 lines): tabs, shared DeleteModal, cross-tab wiring

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 6, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a comprehensive modal-driven UI layer for managing rentals, reservations, and tours, including new delete/edit modals with passcode verification, a service layer for API communication, database-backed rate limiting, cookie security enhancements, and toast notifications.

Changes

Cohort / File(s) Summary
Modal Components
src/lib/components/DeleteModal.svelte, src/lib/components/EditReservationModal.svelte, src/lib/components/EditTourModal.svelte
New modals for confirming deletion with 4-digit passcode and editing reservations/tours with form validation, error handling, and loading states.
Tab/Section Components
src/lib/sections/ActiveRentalsTab.svelte, src/lib/sections/PreviousRentalsTab.svelte, src/lib/sections/ReservationsTab.svelte, src/lib/sections/StoreSalesTab.svelte, src/lib/sections/ToursTab.svelte
New tab components managing grids of cards with integrated create/edit/close modals, service calls, toast notifications, and empty states.
Updated Card Components
src/lib/components/RentalCard.svelte, src/lib/components/ReservationCard.svelte, src/lib/components/TourBookingCard.svelte
Enhanced cards with new onEdit/onDelete callbacks, click-to-edit interactions, stopPropagation on actions, updated layout and styling.
Service Layer
src/lib/services/api.ts, src/lib/services/guide.service.ts, src/lib/services/rental.service.ts, src/lib/services/reservation.service.ts, src/lib/services/shift.service.ts, src/lib/services/store-sale.service.ts, src/lib/services/tour.service.ts
New HTTP client wrapper (apiPost, apiPatch, apiDelete, apiGet) with standardized error handling (ApiError); service modules wrapping domain-specific API endpoints with JSON parsing.
Auth & Database
src/lib/server/auth.ts, src/lib/server/db/schema.ts
Migrated rate limiting from in-memory to database-backed rateLimits table; added cookie signing/verification utilities (signCookieValue, verifyCookieValue, getVerifiedOperatorId); enhanced passcode verification to support scrypt and bcrypt hashes.
Store
src/lib/stores/toast.ts
New Svelte writable store for toast notifications with success/error/warning variants and dismiss action.
Route Updates
src/routes/+layout.server.ts, src/routes/+page.server.ts, src/routes/api/admin/login/+server.ts, src/routes/api/guides/verify/+server.ts, src/routes/api/operators/verify/+server.ts, src/routes/api/shifts/close/+server.ts, src/routes/api/shifts/end/+server.ts, src/routes/api/shifts/start/+server.ts, src/routes/api/store-sales/+server.ts
Replaced manual cookie parsing with getVerifiedOperatorId helper; converted synchronous rate-limit calls to async/await; integrated signCookieValue for secure cookie setting.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 Modal dances and services sing,
Cards now clickable, edits in spring,
Passcodes confirm, toasts gently chime,
Rate limits safe in the database time,
A symphony of CRUD, perfectly aligned!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Refactor: break up monolithic +page.svelte into 14 files' accurately and clearly summarizes the primary change—decomposing a large single file into 14 smaller files across a layered architecture.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/break-up-monolithic-page

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Fix all issues with AI agents
In `@src/lib/components/DeleteModal.svelte`:
- Around line 59-71: Replace the native <input> in DeleteModal.svelte with the
Material Web text field component (e.g., the MW3 textfield used in the project)
so the control is consistent and accessible; keep the same attributes
(type="password", maxlength="4", inputmode="numeric", placeholder) but bind the
component's .value explicitly to the passcode variable via an on:input handler
that sets passcode (do not use Svelte bind:value), and preserve the existing
Enter key behavior by handling on:keydown to call submit(). Ensure the label is
properly associated with the text field (use the component's label prop or a
for/id pairing) and remove the standalone span label so accessibility semantics
come from the material component.

In `@src/lib/components/RentalCard.svelte`:
- Around line 46-98: The card uses a <button> wrapper but nests interactive
controls (md-icon-button, md-filled-tonal-button), which is invalid; change the
outer element in RentalCard.svelte from a <button class="rental-card"> to a
non-interactive <div class="rental-card" role="button" tabindex="0">, keep the
existing onclick/onEdit invocation by wiring keyboard activation (handle
Enter/Space to call onEdit(rental.id)) and preserve disabled state via
aria-disabled or a data-disabled attribute so inner buttons remain operable;
update CSS selectors that relied on :disabled (e.g., .rental-card:disabled) to
target the new attribute/class instead and ensure onDelete and onClose handlers
(onDelete, onClose) still call e.stopPropagation() to prevent outer activation.

In `@src/lib/components/ReservationCard.svelte`:
- Around line 28-33: The reservation card button ('button' element with class
"reservation-card" that calls onEdit(reservation) and is disabled by {loading})
lacks an accessible name for screen readers; add an accessible name by providing
an appropriate aria-label (or aria-labelledby to reference visible text inside
the card) that describes the action and reservation (e.g., "Open reservation for
[guest/name]" or similar), ensuring the label updates if reservation info
changes and that it is present even when the button is disabled.

In `@src/lib/components/TourBookingCard.svelte`:
- Around line 20-24: The button with class "tour-card" used to trigger
onEdit(booking) lacks an accessible name; add an aria-label to the button (in
TourBookingCard.svelte) that clearly describes the action such as "Edit booking"
plus a unique booking identifier or title (e.g., include booking.id or
booking.name when available) so screen readers know what will be edited; keep
the aria-label dynamic alongside the existing onclick/onEdit,
disabled={loading}, and booking usage.

In `@src/lib/sections/PreviousRentalsTab.svelte`:
- Around line 4-8: Guard against a missing rentals prop by giving it a default
empty array when destructuring from $props; change the current let { rentals }:
{ rentals: any[] } = $props() to ensure rentals is initialized (e.g., set
rentals = [] if $props().rentals is undefined) so usages like rentals.length
won't throw in PreviousRentalsTab.svelte.
- Around line 11-15: The decorative icon in PreviousRentalsTab.svelte is missing
ARIA handling; update the <span class="material-symbols-rounded
empty-icon">history</span> element to include aria-hidden="true" so screen
readers ignore it, matching the pattern used in EditRentalModal.svelte and
CreateRentalModal.svelte.

In `@src/lib/sections/StoreSalesTab.svelte`:
- Around line 60-62: Replace the plain <button class="delete-btn"> element with
a Material Web 3 md-icon-button while preserving the onclick handler and
aria-label: use the md-icon-button component (the element that matches your
project’s MD3 import) and wire its onclick to the existing
onPromptDelete('storeSale', sale.id, sale.productName || 'Sale') call, keep the
delete icon inside the md-icon-button (using the same material icon name
"delete") and retain aria-label="Delete sale" so accessibility and event
handling via the onPromptDelete function remain unchanged.

In `@src/lib/server/auth.ts`:
- Around line 96-104: In verifyCookieValue, guard against malformed hex
signatures before calling timingSafeEqual: validate that the sig part contains
only hex characters (e.g., match against /^[0-9a-fA-F]+$/) and create a Buffer
from the expected signature (Buffer.from(expected, 'hex')) and compare its
length to Buffer.from(sig, 'hex'); if the sig has non-hex chars or the buffers
differ in length, return null instead of calling timingSafeEqual. This ensures
createHmac/expected and Buffer.from(sig, 'hex') are safe to compare in
timingSafeEqual.

In `@src/lib/services/shift.service.ts`:
- Around line 14-37: endShift currently skips HTTP error checking and opens
report links without noopener; update endShift to check res.ok after fetch and
handle non-OK responses by parsing JSON or text (similar to api.ts) and
throwing/returning a structured error, ensuring callers can handle failures, and
when opening the returned URL use a safe opener (e.g., window.open(data.url,
'_blank', 'noopener,noreferrer') or create an anchor with rel="noopener
noreferrer" and target="_blank") to prevent tab-nabbing; also preserve existing
blob download flow (URL.createObjectURL, a.download, cleanup) and ensure errors
during blob parsing are caught and surfaced.
🧹 Nitpick comments (16)
src/lib/server/db/schema.ts (1)

206-210: Add an index on reset_at for cleanup queries.
The cleanup path filters by reset_at < now; an index keeps this fast as the table grows.

♻️ Suggested index
-export const rateLimits = pgTable('rate_limits', {
-	key: text('key').primaryKey(),
-	count: integer('count').notNull().default(1),
-	resetAt: timestamp('reset_at', { withTimezone: true }).notNull()
-});
+export const rateLimits = pgTable(
+	'rate_limits',
+	{
+		key: text('key').primaryKey(),
+		count: integer('count').notNull().default(1),
+		resetAt: timestamp('reset_at', { withTimezone: true }).notNull()
+	},
+	(table) => [index('idx_rate_limits_reset_at').on(table.resetAt)]
+);

As per coding guidelines, "src/**/*.ts: Review TypeScript files for: Database query efficiency".

src/lib/services/api.ts (1)

1-21: Throw an Error instance so stacks/instanceof checks work.
Right now a plain object is thrown; wrapping it preserves stack traces and common error handling.

🛠️ Suggested adjustment
-export interface ApiError {
-	message: string;
-	status: number;
-	data?: any;
-}
+export interface ApiError extends Error {
+	status: number;
+	data?: unknown;
+}
@@
-	const err: ApiError = {
-		message: body.error || body.message || res.statusText,
-		status: res.status,
-		data: body
-	};
-	throw err;
+	const err = Object.assign(
+		new Error(body.error || body.message || res.statusText),
+		{ status: res.status, data: body }
+	) as ApiError;
+	throw err;

As per coding guidelines, "src/**/*.ts: Review TypeScript files for: Type safety and proper typing; API error handling".

src/lib/services/shift.service.ts (1)

3-12: Guard against non-array payloads (and type checklist items).
If the endpoint returns a non-array payload, .filter will throw; a small guard keeps it safe and improves typing. Consider typing the shift summary response similarly.

✅ Safer parsing
 import { apiGet, apiPost } from './api';
+
+type ClosingChecklistItem = { isActive?: boolean };
@@
 	const res = await apiGet('/api/closing-checklist');
 	const data = await res.json();
-	return data.filter((item: any) => item.isActive !== false);
+	const items: ClosingChecklistItem[] = Array.isArray(data) ? data : [];
+	return items.filter((item) => item.isActive !== false);

As per coding guidelines, "src/**/*.ts: Review TypeScript files for: Type safety and proper typing".

src/lib/sections/StoreSalesTab.svelte (1)

6-18: Type storeSales / storeProducts props instead of any[].
Stronger typing will make template usage and service responses safer.

As per coding guidelines, "**/*.{ts,tsx}: Use TypeScript for type-safe development in both backend and frontend code".

src/lib/services/rental.service.ts (1)

3-56: Introduce request/response types instead of any.
These services are a central API surface; typing payloads and responses will make callers safer and clarify conflict shapes.

As per coding guidelines, "/*.{ts,tsx}: Use TypeScript for type-safe development in both backend and frontend code" and "src//*.ts: Review TypeScript files for: Type safety and proper typing".

src/routes/api/guides/verify/+server.ts (1)

18-18: Consider wrapping request.json() in try-catch for consistency.

Other routes in this PR (e.g., src/routes/api/admin/login/+server.ts Lines 25-29, src/routes/api/operators/verify/+server.ts Lines 19-23) wrap request.json() in try-catch to return a 400 error for malformed JSON. This route lacks that handling, which could result in an unhandled exception on invalid JSON input.

Suggested fix
-	const { guideId, passcode } = await request.json();
+	let body;
+	try {
+		body = await request.json();
+	} catch {
+		return json({ error: 'Invalid request body' }, { status: 400 });
+	}
+
+	const { guideId, passcode } = body;
src/lib/services/guide.service.ts (1)

3-9: Rate-limit errors are silently swallowed.

When the server returns a 429 status with Retry-After header, this function returns false without informing the caller about the rate-limiting. Users won't see feedback about why verification failed or when to retry.

Consider returning additional context or throwing a specific error for rate-limiting scenarios so the UI can display an appropriate message.

Suggested approach
+export interface VerifyResult {
+	success: boolean;
+	rateLimited?: boolean;
+	retryAfter?: number;
+}
+
-export async function verifyGuidePin(guideId: number, passcode: string): Promise<boolean> {
+export async function verifyGuidePin(guideId: number, passcode: string): Promise<VerifyResult> {
 	try {
 		await apiPost('/api/guides/verify', { guideId, passcode });
-		return true;
-	} catch {
-		return false;
+		return { success: true };
+	} catch (e: any) {
+		if (e?.status === 429) {
+			return { success: false, rateLimited: true, retryAfter: e.retryAfter };
+		}
+		return { success: false };
 	}
 }
src/routes/api/shifts/start/+server.ts (1)

19-19: Missing try-catch for request.json().

Similar to the guides/verify route, this endpoint doesn't wrap request.json() in try-catch, while admin/login and operators/verify do. Malformed JSON will cause an unhandled exception.

Suggested fix
-	const { operatorId, passcode } = await request.json();
+	let body;
+	try {
+		body = await request.json();
+	} catch {
+		return json({ error: 'Invalid request body' }, { status: 400 });
+	}
+
+	const { operatorId, passcode } = body;
src/routes/+layout.server.ts (1)

14-32: Consider parallelizing operator and shift queries.

The operator and shift queries are independent and could be fetched in parallel to reduce latency, similar to the pattern used in src/routes/api/shifts/close/+server.ts (Lines 61-64).

Suggested optimization
-	const [operator] = await db
-		.select()
-		.from(operators)
-		.where(and(eq(operators.id, operatorId), eq(operators.isActive, true)));
-
-	if (!operator) {
-		cookies.delete('operatorId', { path: '/' });
-		return { operator: null, shift: null };
-	}
-
-	const [activeShift] = await db
-		.select()
-		.from(shifts)
-		.where(and(eq(shifts.operatorId, operatorId), isNull(shifts.endedAt)));
+	const [operatorResult, shiftResult] = await Promise.all([
+		db.select().from(operators).where(and(eq(operators.id, operatorId), eq(operators.isActive, true))),
+		db.select().from(shifts).where(and(eq(shifts.operatorId, operatorId), isNull(shifts.endedAt)))
+	]);
+
+	const [operator] = operatorResult;
+	const [activeShift] = shiftResult;
+
+	if (!operator) {
+		cookies.delete('operatorId', { path: '/' });
+		return { operator: null, shift: null };
+	}
src/lib/stores/toast.ts (1)

3-21: Add an info helper (or drop the info variant).
Right now the union includes 'info' but the store API never emits it.

♻️ Suggested fix
 function createToastStore() {
 	const { subscribe, set } = writable<ToastState>({
 		message: '',
 		variant: 'success',
 		visible: false
 	});

 	return {
 		subscribe,
 		success: (message: string) => set({ message, variant: 'success', visible: true }),
 		error: (message: string) => set({ message, variant: 'error', visible: true }),
 		warning: (message: string) => set({ message, variant: 'warning', visible: true }),
+		info: (message: string) => set({ message, variant: 'info', visible: true }),
 		dismiss: () => set({ message: '', variant: 'success', visible: false })
 	};
 }
src/lib/sections/ActiveRentalsTab.svelte (1)

16-53: Replace any with concrete types for props/state.
This file is in TS mode; typed interfaces will prevent downstream runtime errors and align with the repo’s type‑safety guideline.

♻️ Suggested approach
 <script lang="ts">
+	type Rental = { id: number; /* extend with fields used by modals/cards */ };
+	type Product = { id: number; };
+	type TrackedItem = { id: number; };
+	type Category = { id: number; };
+	type Guide = { id: number; };
+	type Conflict = { id: number; };
+	type RentalPayload = Record<string, unknown>;

 	let {
 		rentals,
 		products,
 		trackedItems,
 		categories,
 		guides,
 		shiftId,
 		operatorId,
 		prefillFromReservation = null,
 		fromReservationId = null,
 		onDataChanged,
 		onPromptDelete,
 		onClearPrefill
 	}: {
-		rentals: any[];
-		products: any[];
-		trackedItems: any[];
-		categories: any[];
-		guides: any[];
+		rentals: Rental[];
+		products: Product[];
+		trackedItems: TrackedItem[];
+		categories: Category[];
+		guides: Guide[];
 		shiftId: number;
 		operatorId: number;
-		prefillFromReservation?: any;
+		prefillFromReservation?: RentalPayload | null;
 		fromReservationId?: number | null;
 		onDataChanged: () => void;
 		onPromptDelete: (type: string, id: number, label: string) => void;
 		onClearPrefill: () => void;
 	} = $props();

-	let selectedRental = $state<any>(null);
-	let conflictData = $state<any[]>([]);
-	let pendingPayload = $state<any>(null);
+	let selectedRental = $state<Rental | null>(null);
+	let conflictData = $state<Conflict[]>([]);
+	let pendingPayload = $state<RentalPayload | null>(null);

As per coding guidelines: “Use TypeScript for type-safe development in both backend and frontend code.”

Also applies to: 62-127

src/lib/sections/ReservationsTab.svelte (1)

9-71: Avoid any in props/state—prefer explicit types.
This keeps the reservation flow type‑safe and consistent with TS usage across the repo.

♻️ Suggested approach
 <script lang="ts">
+	type Reservation = { id: number; /* extend as needed */ };
+	type Product = { id: number; };
+	type TrackedItem = { id: number; };
+	type Category = { id: number; };
+	type Guide = { id: number; };
+	type ReservationPayload = Record<string, unknown>;

 	let {
 		reservations,
 		products,
 		trackedItems,
 		categories,
 		guides,
 		onDataChanged,
 		onPromptDelete,
 		onStartRentalFromReservation
 	}: {
-		reservations: any[];
-		products: any[];
-		trackedItems: any[];
-		categories: any[];
-		guides: any[];
+		reservations: Reservation[];
+		products: Product[];
+		trackedItems: TrackedItem[];
+		categories: Category[];
+		guides: Guide[];
 		onDataChanged: () => void;
 		onPromptDelete: (type: string, id: number, label: string) => void;
-		onStartRentalFromReservation: (reservationId: number, prefill: any) => void;
+		onStartRentalFromReservation: (reservationId: number, prefill: ReservationPayload) => void;
 	} = $props();
@@
-	let selectedReservation = $state<any>(null);
+	let selectedReservation = $state<Reservation | null>(null);

As per coding guidelines: “Use TypeScript for type-safe development in both backend and frontend code.”

src/lib/sections/ToursTab.svelte (1)

17-24: Consider adding TypeScript interfaces for props instead of any[].

The component uses any for all array props (tourBookings, tourProducts, guides) and the selectedBooking state. This reduces type safety and IDE support. Consider defining or importing interfaces that match the API response shapes.

♻️ Suggested type definitions
+interface TourBooking {
+	id: number;
+	productName: string;
+	pax: number;
+	totalPrice: number;
+	unitPrice: number;
+	activityDate: string;
+	tourProductId: number;
+	guideId?: number | null;
+	status: string;
+}
+
+interface TourProduct {
+	id: number;
+	name: string;
+	price: number;
+}
+
+interface Guide {
+	id: number;
+	name: string;
+}
+
 let {
 	tourBookings,
 	tourProducts,
 	guides,
 	shiftId,
 	onDataChanged,
 	onPromptDelete
 }: {
-	tourBookings: any[];
-	tourProducts: any[];
-	guides: any[];
+	tourBookings: TourBooking[];
+	tourProducts: TourProduct[];
+	guides: Guide[];
 	shiftId: number;
 	onDataChanged: () => void;
 	onPromptDelete: (type: string, id: number, label: string) => void;
 } = $props();

As per coding guidelines: "Use TypeScript for type-safe development in both backend and frontend code"

src/lib/components/EditReservationModal.svelte (1)

45-63: Potential date format mismatch between display and stored value.

The flatpickr dateFormat is set to 'Y-m-d H:i' for display, but onChange stores the full ISO string via date.toISOString(). This works correctly for submission, but when the modal reopens, the defaultDate receives an ISO string which flatpickr can parse. However, if users see 2024-01-15 10:30 in the input but the value is 2024-01-15T10:30:00.000Z, this could cause timezone-related confusion.

Consider using a consistent format or explicitly handling timezone conversion if the business requires local time display.

src/lib/components/EditTourModal.svelte (1)

86-91: Handle empty tourProducts array edge case.

If tourProducts is empty, the select will have no options, and productId could remain as the initial booking value or become invalid. Consider adding validation or a placeholder option.

♻️ Suggested improvement
 <select class="form-select" bind:value={productId} disabled={loading || saving}>
+	{`#if` tourProducts.length === 0}
+		<option value={null} disabled>No products available</option>
+	{/if}
 	{`#each` tourProducts as product}
 		<option value={product.id}>{product.name} - ${(product.price / 100).toFixed(2)}/person</option>
 	{/each}
 </select>
src/lib/components/RentalCard.svelte (1)

88-96: Good event handling with stopPropagation, but redundant calls.

The stopPropagation on the parent card-actions div (line 88) already prevents clicks from bubbling to the card. The additional e.stopPropagation() calls inside each button handler (lines 89, 92) are redundant but harmless.

The aria-label="Delete rental" on the delete button is good for accessibility. Consider adding an explicit aria-label to the close button as well for consistency, though the visible "Close" text provides sufficient context.

Comment on lines +59 to +71
<label class="form-label">
<span class="material-symbols-rounded">lock</span>
<span class="md-title-small">Enter Passcode</span>
</label>
<input
type="password"
maxlength="4"
inputmode="numeric"
placeholder="4-digit passcode"
bind:value={passcode}
class="passcode-input"
onkeydown={(e) => { if (e.key === 'Enter') submit(); }}
/>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Material Web 3 text field component API Svelte binding

💡 Result:

@material/web Material 3 text fields (<md-filled-text-field>, <md-outlined-text-field>) expose a value property and fire native input / change events (bubbling; input is also composed), so in Svelte you typically bind by wiring value + on:input. [1]

Svelte (recommended): “controlled” binding

<script>
  import '@material/web/textfield/filled-text-field.js';
  // or: import '@material/web/textfield/outlined-text-field.js';

  let value = '';
</script>

<md-filled-text-field
  label="Name"
  {value}
  on:input={(e) => (value = e.currentTarget.value)}
/>

This uses Svelte’s normal event handling and updates your state from the web component’s value. [1]

Why not bind:value directly?

Svelte’s built-in bind:value behavior is specifically documented for native form elements like <input> (where Svelte knows exactly which event to listen to). For web components, the reliable cross-version approach is the explicit value + on:input pattern above. [2]

Sources: Material Web text field API/events [1], Svelte binding docs [2].

[1] (material-web.dev)
[2] (svelte.dev)

Citations:


🌐 Web query:

Material Design 3 md-filled-text-field maxlength password type attributes

💡 Result:

In Material Design 3 / Material Web, <md-filled-text-field> forwards common <input> constraints and supports:

  • Password field: set the type attribute to password

    <md-filled-text-field label="Password" type="password"></md-filled-text-field>

    (type is the underlying HTML input type.) [1]

  • Max length: set maxlength (attribute) / maxLength (JS property). When maxlength is present, the component shows a character counter automatically.

    <md-filled-text-field label="PIN" type="password" maxlength="12"></md-filled-text-field>

    Material Web docs: maxLengthmaxlength, where -1 means “no limit”. [1]

Notes:

  • maxlength is an HTML constraint attribute (integer ≥ 0; measured in UTF‑16 code units). [2]
  • For <input type="password">, maxlength is supported and participates in constraint validation. [3]

Sources: Material Web text-field docs [1], MDN maxlength [2], MDN password input attributes [3].

[1] (material-web.dev)
[2] (developer.mozilla.org)
[3] (developer.mozilla.org)

Citations:


🏁 Script executed:

# Check if the file exists and examine the current implementation
cat -n src/lib/components/DeleteModal.svelte | sed -n '59,71p'

Repository: TheOneWhoBurns/cpoint

Length of output: 559


Use a Material Web text field component instead of native <input> for UI consistency and accessibility.
The current native <input> doesn't follow the "Material Web 3 components" guideline, and the label isn't properly associated with the control. Additionally, bind:value is unreliable with web components—use explicit value property binding and on:input instead.

♻️ Suggested fix (align with Material Web 3 + accessibility)
 <script lang="ts">
 	import '@material/web/button/filled-button.js';
 	import '@material/web/button/outlined-button.js';
 	import '@material/web/iconbutton/icon-button.js';
+	import '@material/web/textfield/filled-text-field.js';
@@
-					<label class="form-label">
+					<label class="form-label" for="delete-passcode">
 						<span class="material-symbols-rounded">lock</span>
 						<span class="md-title-small">Enter Passcode</span>
 					</label>
-					<input
-						type="password"
-						maxlength="4"
-						inputmode="numeric"
-						placeholder="4-digit passcode"
-						bind:value={passcode}
-						class="passcode-input"
-						onkeydown={(e) => { if (e.key === 'Enter') submit(); }}
-					/>
+					<md-filled-text-field
+						id="delete-passcode"
+						type="password"
+						label="Enter Passcode"
+						maxlength="4"
+						inputmode="numeric"
+						placeholder="4-digit passcode"
+						value={passcode}
+						on:input={(e) => (passcode = e.currentTarget.value)}
+						class="passcode-input"
+						onkeydown={(e) => { if (e.key === 'Enter') submit(); }}
+					/>

Per coding guidelines: "Use Material Web 3 components for UI consistency across the tablet-optimized interface" and "Review Svelte components for Accessibility (ARIA labels, keyboard navigation)."

🤖 Prompt for AI Agents
In `@src/lib/components/DeleteModal.svelte` around lines 59 - 71, Replace the
native <input> in DeleteModal.svelte with the Material Web text field component
(e.g., the MW3 textfield used in the project) so the control is consistent and
accessible; keep the same attributes (type="password", maxlength="4",
inputmode="numeric", placeholder) but bind the component's .value explicitly to
the passcode variable via an on:input handler that sets passcode (do not use
Svelte bind:value), and preserve the existing Enter key behavior by handling
on:keydown to call submit(). Ensure the label is properly associated with the
text field (use the component's label prop or a for/id pairing) and remove the
standalone span label so accessibility semantics come from the material
component.

Comment on lines +46 to +98
<button
class="rental-card"
class:hourly={pricing?.type === 'hourly'}
class:fullday={pricing?.type !== 'hourly'}
onclick={() => onEdit(rental.id)}
disabled={loading}
>
<div class="card-header">
<div class="customer-info">
<span class="material-symbols-rounded customer-icon">person</span>
<div class="avatar">
<span class="material-symbols-rounded">person</span>
</div>
<div class="customer-details">
<span class="md-title-medium">{customer?.name || 'Unknown'}</span>
<span class="name">{customer?.name || 'Unknown'}</span>
{#if customer?.hotel}
<span class="md-body-small hotel-text">
<span class="meta">
<span class="material-symbols-rounded icon-xs">hotel</span>
{customer.hotel}
</span>
{/if}
{#if guideName}
<span class="md-body-small guide-text">
<span class="material-symbols-rounded icon-xs">hiking</span>
{guideName}
</span>
{/if}
</div>
</div>
<div class="rental-type-badge" class:hourly={pricing?.type === 'hourly'} class:fullday={pricing?.type !== 'hourly'}>
<span class="type-badge" class:hourly={pricing?.type === 'hourly'}>
{pricing?.type === 'hourly' ? 'Hourly' : 'Full Day'}
</div>
</span>
</div>

<div class="rental-items-list">
<div class="items-list">
{#each items as item}
<div class="item-chip">
<span class="material-symbols-rounded icon-sm">
{item.code ? 'qr_code_2' : 'inventory_2'}
</span>
<span class="md-body-small">
{item.name}{item.code ? ` (${item.code})` : ''}{item.quantity ? ` x${item.quantity}` : ''}
</span>
<span class="material-symbols-rounded icon-sm">{item.code ? 'qr_code_2' : 'inventory_2'}</span>
<span>{item.name}{item.code ? ` (${item.code})` : ''}{item.quantity ? ` x${item.quantity}` : ''}</span>
</div>
{/each}
</div>

<div class="rental-footer">
<div class="card-footer">
<div class="time-info">
<span class="material-symbols-rounded icon-sm">schedule</span>
<span class="md-body-small">Started {new Date(rental.startedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
<span>{new Date(rental.startedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
<span class="elapsed-badge">{elapsedTime}</span>
</div>
<div class="rental-actions">
<md-icon-button onclick={() => onEdit(rental.id)} disabled={loading} aria-label="Edit rental">
<span class="material-symbols-rounded">edit</span>
<div class="card-actions" onclick={(e) => e.stopPropagation()}>
<md-icon-button onclick={(e: Event) => { e.stopPropagation(); onDelete(rental.id, customer?.name || 'Rental'); }} disabled={loading} aria-label="Delete rental">
<span class="material-symbols-rounded delete-icon">delete</span>
</md-icon-button>
<md-filled-tonal-button onclick={() => onClose(rental.id)} disabled={loading}>
<md-filled-tonal-button onclick={(e: Event) => { e.stopPropagation(); onClose(rental.id); }} disabled={loading}>
<span class="material-symbols-rounded" slot="icon">check_circle</span>
Close Rental
Close
</md-filled-tonal-button>
</div>
</div>
</div>
</button>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Invalid HTML: interactive elements nested inside a <button>.

The card is rendered as a <button> (line 46) but contains other interactive elements (<md-icon-button> on line 89, <md-filled-tonal-button> on line 92). Nesting buttons inside buttons is invalid HTML and causes accessibility issues—screen readers may not correctly announce or navigate to the inner controls.

Consider changing the card to a <div> with proper keyboard accessibility attributes.

♿ Proposed fix using a div with role and tabindex
-<button
+<div
+	role="button"
+	tabindex="0"
 	class="rental-card"
 	class:hourly={pricing?.type === 'hourly'}
 	class:fullday={pricing?.type !== 'hourly'}
 	onclick={() => onEdit(rental.id)}
+	onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onEdit(rental.id); } }}
-	disabled={loading}
->
+	aria-disabled={loading}
+>
 	<!-- ... card content ... -->
-</button>
+</div>

Then update the disabled styling selector:

-.rental-card:disabled {
+.rental-card[aria-disabled="true"] {
 	opacity: 0.6;
 	cursor: not-allowed;
+	pointer-events: none;
 }

As per coding guidelines: "Review Svelte components for Accessibility (ARIA labels, keyboard navigation)".

🤖 Prompt for AI Agents
In `@src/lib/components/RentalCard.svelte` around lines 46 - 98, The card uses a
<button> wrapper but nests interactive controls (md-icon-button,
md-filled-tonal-button), which is invalid; change the outer element in
RentalCard.svelte from a <button class="rental-card"> to a non-interactive <div
class="rental-card" role="button" tabindex="0">, keep the existing
onclick/onEdit invocation by wiring keyboard activation (handle Enter/Space to
call onEdit(rental.id)) and preserve disabled state via aria-disabled or a
data-disabled attribute so inner buttons remain operable; update CSS selectors
that relied on :disabled (e.g., .rental-card:disabled) to target the new
attribute/class instead and ensure onDelete and onClose handlers (onDelete,
onClose) still call e.stopPropagation() to prevent outer activation.

Comment on lines +28 to +33
<button
class="reservation-card"
class:expired
onclick={() => onEdit(reservation)}
disabled={loading}
>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add accessible name to the card button.

Same issue as TourBookingCard - the clickable card button needs an accessible name for screen reader users.

🔧 Suggested fix
 <button
 	class="reservation-card"
 	class:expired
 	onclick={() => onEdit(reservation)}
 	disabled={loading}
+	aria-label="Edit reservation for {customer?.name || 'unknown customer'}"
 >

As per coding guidelines: "Accessibility (ARIA labels, keyboard navigation)"

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
class="reservation-card"
class:expired
onclick={() => onEdit(reservation)}
disabled={loading}
>
<button
class="reservation-card"
class:expired
onclick={() => onEdit(reservation)}
disabled={loading}
aria-label="Edit reservation for {customer?.name || 'unknown customer'}"
>
🤖 Prompt for AI Agents
In `@src/lib/components/ReservationCard.svelte` around lines 28 - 33, The
reservation card button ('button' element with class "reservation-card" that
calls onEdit(reservation) and is disabled by {loading}) lacks an accessible name
for screen readers; add an accessible name by providing an appropriate
aria-label (or aria-labelledby to reference visible text inside the card) that
describes the action and reservation (e.g., "Open reservation for [guest/name]"
or similar), ensuring the label updates if reservation info changes and that it
is present even when the button is disabled.

Comment on lines +20 to +24
<button
class="tour-card"
onclick={() => onEdit(booking)}
disabled={loading}
>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add accessible name to the card button.

The card is now a <button> for click-to-edit, but it lacks an accessible name for screen reader users. Consider adding aria-label to describe the action.

🔧 Suggested fix
 <button
 	class="tour-card"
 	onclick={() => onEdit(booking)}
 	disabled={loading}
+	aria-label="Edit {booking.productName || 'booking'}"
 >

As per coding guidelines: "Accessibility (ARIA labels, keyboard navigation)"

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
class="tour-card"
onclick={() => onEdit(booking)}
disabled={loading}
>
<button
class="tour-card"
onclick={() => onEdit(booking)}
disabled={loading}
aria-label="Edit {booking.productName || 'booking'}"
>
🤖 Prompt for AI Agents
In `@src/lib/components/TourBookingCard.svelte` around lines 20 - 24, The button
with class "tour-card" used to trigger onEdit(booking) lacks an accessible name;
add an aria-label to the button (in TourBookingCard.svelte) that clearly
describes the action such as "Edit booking" plus a unique booking identifier or
title (e.g., include booking.id or booking.name when available) so screen
readers know what will be edited; keep the aria-label dynamic alongside the
existing onclick/onEdit, disabled={loading}, and booking usage.

Comment on lines +4 to +8
let {
rentals
}: {
rentals: any[];
} = $props();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against missing rentals prop to avoid runtime errors.

If a parent forgets to pass rentals, rentals.length will throw. Default to an empty array.

Proposed fix
-let {
-	rentals
-}: {
-	rentals: any[];
-} = $props();
+let {
+	rentals = []
+}: {
+	rentals?: any[];
+} = $props();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let {
rentals
}: {
rentals: any[];
} = $props();
let {
rentals = []
}: {
rentals?: any[];
} = $props();
🤖 Prompt for AI Agents
In `@src/lib/sections/PreviousRentalsTab.svelte` around lines 4 - 8, Guard against
a missing rentals prop by giving it a default empty array when destructuring
from $props; change the current let { rentals }: { rentals: any[] } = $props()
to ensure rentals is initialized (e.g., set rentals = [] if $props().rentals is
undefined) so usages like rentals.length won't throw in
PreviousRentalsTab.svelte.

Comment on lines +11 to +15
{#if rentals.length === 0}
<div class="empty-state">
<span class="material-symbols-rounded empty-icon">history</span>
<p class="md-body-large">No previous rentals</p>
</div>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find existing Material Web component usage to establish patterns
rg -n "<md-" -g "*.svelte" --max-count=20

Repository: TheOneWhoBurns/cpoint

Length of output: 22089


🏁 Script executed:

# Find material-symbols-rounded usage to see how icons are currently handled
rg -n "material-symbols-rounded" -g "*.svelte" -B2 -A2

Repository: TheOneWhoBurns/cpoint

Length of output: 50378


🏁 Script executed:

# Check if there are Material Web 3 components for empty states or cards
rg -n "empty" -g "*.svelte" -i --max-count=20

Repository: TheOneWhoBurns/cpoint

Length of output: 6205


🏁 Script executed:

cat -n src/lib/sections/PreviousRentalsTab.svelte | head -50

Repository: TheOneWhoBurns/cpoint

Length of output: 1278


🏁 Script executed:

# Check for Material Web 3 empty state or card components
rg -n "md-card|md-list|md-container" -g "*.svelte" --max-count=5

Repository: TheOneWhoBurns/cpoint

Length of output: 47


🏁 Script executed:

# Check if Material Web 3 empty-state pattern uses containers/cards in other similar UI
rg -B2 -A2 "empty-state" src/lib/sections/ | head -40

Repository: TheOneWhoBurns/cpoint

Length of output: 2583


🏁 Script executed:

# Verify icon accessibility patterns in Material Web components
rg "aria-hidden|role=\"presentation\"" -g "*.svelte" | head -10

Repository: TheOneWhoBurns/cpoint

Length of output: 418


Mark the icon as decorative with aria-hidden="true".

The icon lacks ARIA handling. Add aria-hidden="true" to the <span class="material-symbols-rounded empty-icon">history</span> to prevent screen readers from announcing the decorative icon. This aligns with the project's accessibility standards (see similar patterns in EditRentalModal.svelte and CreateRentalModal.svelte).

🤖 Prompt for AI Agents
In `@src/lib/sections/PreviousRentalsTab.svelte` around lines 11 - 15, The
decorative icon in PreviousRentalsTab.svelte is missing ARIA handling; update
the <span class="material-symbols-rounded empty-icon">history</span> element to
include aria-hidden="true" so screen readers ignore it, matching the pattern
used in EditRentalModal.svelte and CreateRentalModal.svelte.

Comment on lines +60 to +62
<button class="delete-btn" onclick={() => onPromptDelete('storeSale', sale.id, sale.productName || 'Sale')} aria-label="Delete sale">
<span class="material-symbols-rounded delete-icon">delete</span>
</button>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n src/lib/sections/StoreSalesTab.svelte | head -80 | tail -30

Repository: TheOneWhoBurns/cpoint

Length of output: 1256


🏁 Script executed:

rg "md-icon-button" --type svelte -A 3 -B 1

Repository: TheOneWhoBurns/cpoint

Length of output: 95


🏁 Script executed:

rg "onclick" --type svelte | head -20

Repository: TheOneWhoBurns/cpoint

Length of output: 95


🏁 Script executed:

rg "on:click" --type svelte | head -20

Repository: TheOneWhoBurns/cpoint

Length of output: 95


🏁 Script executed:

fd "*.svelte" -x grep -l "md-icon\|md-" {} \; 2>/dev/null | head -10

Repository: TheOneWhoBurns/cpoint

Length of output: 47


🏁 Script executed:

rg "md-icon-button|md-icon" -A 2 | head -40

Repository: TheOneWhoBurns/cpoint

Length of output: 2758


🏁 Script executed:

rg "on:click" -A 1 | head -40

Repository: TheOneWhoBurns/cpoint

Length of output: 47


🏁 Script executed:

rg "onclick" -A 1 | head -40

Repository: TheOneWhoBurns/cpoint

Length of output: 3189


🏁 Script executed:

find src -name "*.svelte" -exec grep -l "material\|md-" {} \; | head -10

Repository: TheOneWhoBurns/cpoint

Length of output: 1762


🏁 Script executed:

cat src/lib/sections/StoreSalesTab.svelte | head -30

Repository: TheOneWhoBurns/cpoint

Length of output: 876


Use a Material Web 3 md-icon-button with onclick handler for the delete action.
This keeps UI consistent with MD3 components used throughout the codebase and follows the established event-handling pattern.

🧩 Example adjustment
-						<button class="delete-btn" onclick={() => onPromptDelete('storeSale', sale.id, sale.productName || 'Sale')} aria-label="Delete sale">
-							<span class="material-symbols-rounded delete-icon">delete</span>
-						</button>
+						<md-icon-button
+							class="delete-btn"
+							aria-label="Delete sale"
+							onclick={() => onPromptDelete('storeSale', sale.id, sale.productName || 'Sale')}
+						>
+							<span class="material-symbols-rounded">delete</span>
+						</md-icon-button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button class="delete-btn" onclick={() => onPromptDelete('storeSale', sale.id, sale.productName || 'Sale')} aria-label="Delete sale">
<span class="material-symbols-rounded delete-icon">delete</span>
</button>
<md-icon-button
class="delete-btn"
aria-label="Delete sale"
onclick={() => onPromptDelete('storeSale', sale.id, sale.productName || 'Sale')}
>
<span class="material-symbols-rounded">delete</span>
</md-icon-button>
🤖 Prompt for AI Agents
In `@src/lib/sections/StoreSalesTab.svelte` around lines 60 - 62, Replace the
plain <button class="delete-btn"> element with a Material Web 3 md-icon-button
while preserving the onclick handler and aria-label: use the md-icon-button
component (the element that matches your project’s MD3 import) and wire its
onclick to the existing onPromptDelete('storeSale', sale.id, sale.productName ||
'Sale') call, keep the delete icon inside the md-icon-button (using the same
material icon name "delete") and retain aria-label="Delete sale" so
accessibility and event handling via the onPromptDelete function remain
unchanged.

Comment on lines +96 to +104
export function verifyCookieValue(signed: string): string | null {
const dot = signed.lastIndexOf('.');
if (dot === -1) return null;
const value = signed.slice(0, dot);
const sig = signed.slice(dot + 1);
const expected = createHmac('sha256', getCookieSecret()).update(value).digest('hex');
if (sig.length !== expected.length) return null;
if (!timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))) return null;
return value;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and read the specific lines
cat -n src/lib/server/auth.ts | sed -n '90,110p'

Repository: TheOneWhoBurns/cpoint

Length of output: 1013


🌐 Web query:

Node.js Buffer.from hex invalid input behavior throws error

💡 Result:

Buffer.from(str, 'hex') does not throw on invalid hex input; it decodes what it can and then truncates.

  • It truncates when it hits the first non-hex character (e.g. Buffer.from('1ag123','hex') produces <Buffer 1a>). It also truncates if the string ends with a single trailing hex digit (odd length), e.g. Buffer.from('1a7','hex') produces <Buffer 1a>. No exception is thrown for these cases. [1]

Related (often confused) behavior:

  • buf.fill(value, 'hex') also truncates invalid characters, but throws if nothing valid remains (e.g. buf.fill('zz', 'hex') throws). [2]

Sources:
[1] Node.js Buffer docs (hex decoding truncation examples) https://nodejs.org/api/buffer.html
[2] Node.js Buffer docs (buf.fill() invalid hex throws when no valid data remains) https://nodejs.org/api/buffer.html


🌐 Web query:

Node.js timingSafeEqual throws on different buffer lengths

💡 Result:

crypto.timingSafeEqual(a, b) requires a and b to have the same byte length; if they differ, Node.js throws an error (rather than returning false) to preserve the guarantees of its constant‑time comparison algorithm. [1]

Fix/pattern (recommended): ensure equal-length inputs before calling it.

import { timingSafeEqual } from "node:crypto";

function safeEqual(a, b) {
  if (a.length !== b.length) return false;   // lengths must match
  return timingSafeEqual(a, b);
}

If you’re comparing values that may be different lengths (e.g., user input vs stored secret), a common approach is to compare fixed-length digests (e.g., both SHA-256 / HMAC outputs) so lengths are always equal. [1]

Source: Node.js crypto.timingSafeEqual docs (states same byte length required; error thrown otherwise). [1]

References:
[1] Node.js Crypto docs (crypto.timingSafeEqual), https://nodejs.org/api/crypto.html


Guard against malformed signature hex before timingSafeEqual.

Malformed cookies can cause timingSafeEqual to throw a 500 error. If the signature contains invalid hex characters, Buffer.from(sig, 'hex') silently truncates the input, producing a buffer shorter than expected. While the string length check passes, the resulting buffer lengths differ, causing timingSafeEqual to throw.

Validate hex and buffer lengths before comparing:

🛡️ Safer verification
 	const value = signed.slice(0, dot);
 	const sig = signed.slice(dot + 1);
 	const expected = createHmac('sha256', getCookieSecret()).update(value).digest('hex');
 	if (sig.length !== expected.length) return null;
+	if (!/^[0-9a-f]+$/i.test(sig)) return null;
-	if (!timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))) return null;
+	const sigBuf = Buffer.from(sig, 'hex');
+	const expBuf = Buffer.from(expected, 'hex');
+	if (sigBuf.length !== expBuf.length) return null;
+	if (!timingSafeEqual(sigBuf, expBuf)) return null;
 	return value;
🤖 Prompt for AI Agents
In `@src/lib/server/auth.ts` around lines 96 - 104, In verifyCookieValue, guard
against malformed hex signatures before calling timingSafeEqual: validate that
the sig part contains only hex characters (e.g., match against /^[0-9a-fA-F]+$/)
and create a Buffer from the expected signature (Buffer.from(expected, 'hex'))
and compare its length to Buffer.from(sig, 'hex'); if the sig has non-hex chars
or the buffers differ in length, return null instead of calling timingSafeEqual.
This ensures createHmac/expected and Buffer.from(sig, 'hex') are safe to compare
in timingSafeEqual.

Comment on lines +14 to +37
export async function endShift() {
const res = await fetch('/api/shifts/close', { method: 'POST' });
const contentType = res.headers.get('content-type') || '';

if (contentType.includes('application/json')) {
const data = await res.json();
if (data.url) {
window.open(data.url, '_blank');
}
return data;
}

const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const disposition = res.headers.get('content-disposition') || '';
const match = disposition.match(/filename="?([^"]+)"?/);
a.download = match?.[1] || 'shift-report.xlsx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
return { success: true };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "shift.service.ts" -type f

Repository: TheOneWhoBurns/cpoint

Length of output: 100


🏁 Script executed:

cat src/lib/services/shift.service.ts

Repository: TheOneWhoBurns/cpoint

Length of output: 1214


🏁 Script executed:

find . -name "api.ts" -o -name "api.js" | head -5

Repository: TheOneWhoBurns/cpoint

Length of output: 90


🏁 Script executed:

cat src/lib/services/api.ts

Repository: TheOneWhoBurns/cpoint

Length of output: 1375


Add error handling to endShift() and use noopener for report links to prevent tab-nabbing attacks.

The endShift() function uses bare fetch() without checking res.ok, bypassing the standard error handling pattern established in api.ts. It also calls window.open() without the noopener flag, exposing a tab-nabbing vulnerability where the opened page can access window.opener and redirect the original tab.

🛡️ Suggested hardening
 export async function endShift() {
 	const res = await fetch('/api/shifts/close', { method: 'POST' });
+	if (!res.ok) {
+		let message = res.statusText;
+		try {
+			const body = await res.json();
+			message = body.error || body.message || message;
+		} catch {}
+		throw Object.assign(new Error(message), { status: res.status });
+	}
 	const contentType = res.headers.get('content-type') || '';
@@
 	if (contentType.includes('application/json')) {
 		const data = await res.json();
 		if (data.url) {
-			window.open(data.url, '_blank');
+			window.open(data.url, '_blank', 'noopener');
 		}
 		return data;
 	}
🤖 Prompt for AI Agents
In `@src/lib/services/shift.service.ts` around lines 14 - 37, endShift currently
skips HTTP error checking and opens report links without noopener; update
endShift to check res.ok after fetch and handle non-OK responses by parsing JSON
or text (similar to api.ts) and throwing/returning a structured error, ensuring
callers can handle failures, and when opening the returned URL use a safe opener
(e.g., window.open(data.url, '_blank', 'noopener,noreferrer') or create an
anchor with rel="noopener noreferrer" and target="_blank") to prevent
tab-nabbing; also preserve existing blob download flow (URL.createObjectURL,
a.download, cleanup) and ensure errors during blob parsing are caught and
surfaced.

@TheOneWhoBurns TheOneWhoBurns merged commit cf3f012 into rental-system Feb 6, 2026
1 of 2 checks passed
@TheOneWhoBurns TheOneWhoBurns deleted the refactor/break-up-monolithic-page branch February 6, 2026 04:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant