fix: pre-launch review — security, money safety, privacy, legal, code quality
Security (P0): - IsJTIRevoked fails closed on DB error (previously accepted revoked tokens) - Remove dead consume parameter from SCA gate (prevented token replay) - Rate limiter map TTL-based eviction (prevented memory exhaustion) - 2FA attempt map already had LRU eviction (verified) Money Safety (P1): - Gift card transfer refuses expired destination cards - Gift card balance deduction has WHERE balance >= amount guard - Webhook clawback acquires till-sale advisory lock - Sweep/retry lock keys aligned Privacy/Cookies (P2): - Self-host Google Fonts (Playfair Display woff2) - Replace CARTO map tiles with OpenStreetMap raster tiles - Replace Wikimedia/icon-icons external images with local SVGs - Remove external image URLs from CSP Legal (P3): - Privacy policy: add 6 missing data categories (gift cards, 2FA, GDPR, notifications, technical, cookies) - Terms: add Tips section (optionality, non-refundable, same processing as bookings) Code Quality (P4): - twofa.Check accepts db.Querier for testability - depositPromotionMinPct uses literal 0.20 (not misleading alias) - HolidayHours.svelte uses proper type (not as any[]) - Remove stale TODO comments from main.go Testing (P5): - 94 new float64 money validity tests across 3 test files - Cover VAT, splits, refunds, gift cards, rounding, precision boundaries - All 27 backend test packages pass
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
httpOnly cookies is the durable fix (out of scope). -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' https: http://localhost:8080 ws:; font-src 'self' data: https://fonts.gstatic.com; frame-src 'self' https://*.squareup.com https://*.square.com"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://tile.openstreetmap.org; connect-src 'self' https: http://localhost:8080 ws:; font-src 'self' data:; frame-src 'self' https://*.squareup.com https://*.square.com"
|
||||
/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
@@ -230,8 +230,7 @@
|
||||
description: group.description,
|
||||
weekStarts: group.weekStarts || [],
|
||||
hours:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(group.hours as any[])?.map(
|
||||
(group.hours as HolidayHour[])?.map(
|
||||
(h: {
|
||||
id: number;
|
||||
weekday: number;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
email = 'chelsea@emailaddress.com',
|
||||
instagram = '@crussell',
|
||||
address = 'Business Centre, Office Street, Work',
|
||||
profileImage = 'https://images.icon-icons.com/5/PNG/256/MSN_messenger_user_156.png',
|
||||
profileImage = '',
|
||||
altText = 'Profile picture'
|
||||
}: {
|
||||
name?: string;
|
||||
@@ -64,9 +64,34 @@
|
||||
<div class="flex h-full flex-col rounded-lg border-2 border-gray-200 bg-white p-6">
|
||||
<!-- Profile Picture -->
|
||||
<div class="mb-4 flex shrink-0 justify-center">
|
||||
<div class="h-24 w-24 overflow-hidden rounded-full border-4 border-white shadow-lg">
|
||||
<img src={profileImage} alt={altText} class="h-full w-full object-cover" />
|
||||
</div>
|
||||
{#if profileImage}
|
||||
<img
|
||||
src={profileImage}
|
||||
alt={altText}
|
||||
class="h-24 w-24 rounded-full object-cover ring-4 ring-fuchsia-200"
|
||||
/>
|
||||
{:else if name}
|
||||
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-3xl font-bold text-gray-600 ring-4 ring-fuchsia-200">
|
||||
{name.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 ring-4 ring-fuchsia-200">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-10 w-10 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Name and Role -->
|
||||
<div class="mb-4 shrink-0 text-center">
|
||||
|
||||
@@ -1,38 +1,14 @@
|
||||
<script lang="ts">
|
||||
// Sample portfolio images - replace with your actual nail art images
|
||||
// Sample portfolio images — local SVG placeholders (replace with actual nail art photos)
|
||||
const portfolioImages = [
|
||||
{
|
||||
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Hand%2C_fingers_-_back.jpg/1024px-Hand%2C_fingers_-_back.jpg',
|
||||
alt: 'lorem ipsum'
|
||||
},
|
||||
{
|
||||
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9d/Palm%2C_fingers.jpg/1024px-Palm%2C_fingers.jpg',
|
||||
alt: 'lorem ipsum'
|
||||
},
|
||||
{
|
||||
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/52/Hand_parts_-_en.svg/1920px-Hand_parts_-_en.svg.png',
|
||||
alt: 'lorem ipsum'
|
||||
},
|
||||
{
|
||||
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Scheme_human_hand_bones-en.svg/1920px-Scheme_human_hand_bones-en.svg.png',
|
||||
alt: 'lorem ipsum'
|
||||
},
|
||||
{
|
||||
src: 'https://upload.wikimedia.org/wikipedia/commons/a/a1/3D_Medical_Animation_Human_Wrist.jpg',
|
||||
alt: 'lorem ipsum'
|
||||
},
|
||||
{
|
||||
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Wrist_and_hand_deeper_palmar_dissection-en.svg/1920px-Wrist_and_hand_deeper_palmar_dissection-en.svg.png',
|
||||
alt: 'lorem ipsum'
|
||||
},
|
||||
{
|
||||
src: 'https://upload.wikimedia.org/wikipedia/commons/3/38/Wrist_extensor_compartments_%28numbered%29.PNG',
|
||||
alt: 'lorem ipsum'
|
||||
},
|
||||
{
|
||||
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Gray812and814.svg/1920px-Gray812and814.svg.png',
|
||||
alt: 'lorem ipsum'
|
||||
}
|
||||
{ src: '/images/portfolio/placeholder-01.svg', alt: 'Nail Art — Classic' },
|
||||
{ src: '/images/portfolio/placeholder-02.svg', alt: 'Nail Art — French' },
|
||||
{ src: '/images/portfolio/placeholder-03.svg', alt: 'Nail Art — Warm' },
|
||||
{ src: '/images/portfolio/placeholder-04.svg', alt: 'Nail Art — Geometric' },
|
||||
{ src: '/images/portfolio/placeholder-05.svg', alt: 'Nail Art — Marble' },
|
||||
{ src: '/images/portfolio/placeholder-06.svg', alt: 'Nail Art — Floral' },
|
||||
{ src: '/images/portfolio/placeholder-07.svg', alt: 'Nail Art — Ombre' },
|
||||
{ src: '/images/portfolio/placeholder-08.svg', alt: 'Nail Art — Chrome' }
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -73,9 +73,22 @@
|
||||
onstyleloaded?: () => void;
|
||||
}
|
||||
|
||||
const osmRasterStyle: MapLibreGL.StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {
|
||||
osm: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}
|
||||
},
|
||||
layers: [{ id: 'osm', type: 'raster', source: 'osm' }]
|
||||
};
|
||||
|
||||
const defaultStyles = {
|
||||
dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
|
||||
light: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'
|
||||
dark: osmRasterStyle,
|
||||
light: osmRasterStyle
|
||||
};
|
||||
|
||||
let {
|
||||
|
||||
@@ -13,8 +13,21 @@ export const POLICY = {
|
||||
LOYALTY_DISCOUNT_RATE: 0.1,
|
||||
// Gratuity suggestion presets offered by the tip surfaces (admin payment
|
||||
// modal + customer tip page). Deliberately SEPARATE from the deposit policy
|
||||
// constants above — a tip suggestion is gratuity, not a deposit percentage,
|
||||
// constants below — a tip suggestion is gratuity, not a deposit percentage,
|
||||
// and coupling them would silently change the tip buttons if the deposit
|
||||
// rate ever changed.
|
||||
TIP_PRESET_PCTS: [10, 15, 20]
|
||||
} as const;
|
||||
|
||||
// Named re-exports so pages can import individual constants directly.
|
||||
// Keep in sync with the POLICY object above.
|
||||
export const {
|
||||
FULL_REFUND_THRESHOLD_HOURS,
|
||||
PARTIAL_REFUND_THRESHOLD_HOURS,
|
||||
NO_SHOW_THRESHOLD_HOURS,
|
||||
DEPOSIT_ADVANCE_HOURS,
|
||||
PROTECTED_DEPOSIT_MAX_PCT,
|
||||
REQUIRED_DEPOSIT_PCT,
|
||||
LOYALTY_DISCOUNT_RATE,
|
||||
TIP_PRESET_PCTS
|
||||
} = POLICY;
|
||||
|
||||
@@ -120,12 +120,7 @@
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/fonts/playfair-display.css" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen flex-col supports-[height:100dvh]:min-h-dvh">
|
||||
|
||||
@@ -94,12 +94,6 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<section class="py-20 text-center">
|
||||
|
||||
@@ -2169,7 +2169,7 @@ import { generateUUID } from '$lib/utils/uuid';
|
||||
<path
|
||||
d="M 50 25 L 56 43 L 75 43 L 60 53.5 L 66 71.5 L 50 62 L 34 71.5 L 40 53.5 L 25 43 L 44 43 Z"
|
||||
fill="black"
|
||||
/>
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<path
|
||||
@@ -2786,7 +2786,8 @@ import { generateUUID } from '$lib/utils/uuid';
|
||||
bind:selectedCardId={buySelectedCard}
|
||||
bind:saveCard={buySaveCard}
|
||||
onValidityChange={(v) => (buyCardSelectionValid = v)}
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if buyWaitingForSCA}
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
|
||||
|
||||
@@ -57,8 +57,7 @@
|
||||
email={contact.email}
|
||||
instagram="crussell"
|
||||
address={BUSINESS_ADDRESS}
|
||||
profileImage={contact.profilePicUrl ||
|
||||
'https://images.icon-icons.com/5/PNG/256/MSN_messenger_user_156.png'}
|
||||
profileImage={contact.profilePicUrl || ''}
|
||||
/>
|
||||
{:else}
|
||||
<ContactCard
|
||||
|
||||
@@ -158,13 +158,15 @@
|
||||
</p>
|
||||
<p class="mb-2 font-medium text-gray-800">Financial Data:</p>
|
||||
<ul class="mb-4 list-disc space-y-1 pl-5">
|
||||
<li>Gift card codes and balances</li>
|
||||
<li>Account balances</li>
|
||||
<li>Gift card codes (hashed), balances, and transaction history (purchases, redemptions, top-ups)</li>
|
||||
<li>Account balances (from redeemed gift cards or cash refunds)</li>
|
||||
<li>Payment transaction records (our ledger of record, retained for 7 years for HMRC)</li>
|
||||
<li>
|
||||
Saved-card references (tokenised, stored with our payment provider Square — see
|
||||
§2.2)
|
||||
</li>
|
||||
<li>Square customer IDs (created when you save a card for future payments)</li>
|
||||
<li>Card tokens (Square <code>ccof:</code> references for recurring payments)</li>
|
||||
<li>Dormant balance records (Account ID only, no PII)</li>
|
||||
</ul>
|
||||
|
||||
|
||||
@@ -271,7 +271,35 @@
|
||||
|
||||
<section>
|
||||
<h2 class="mb-3 text-base font-semibold text-gray-900">
|
||||
5. Gift Cards & Account Balances
|
||||
5. Tips
|
||||
</h2>
|
||||
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
<strong>Tips are entirely optional.</strong> You are under no obligation to leave a tip,
|
||||
and the quality of service you receive is not affected by whether you choose to do so.
|
||||
</li>
|
||||
<li>
|
||||
<strong>When you can tip:</strong> you may add a tip during the payment process or after
|
||||
the service has been completed, through your account.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Tips are non-refundable once processed.</strong> Because a tip is a voluntary
|
||||
gratuity paid after (or at the point of) service, it is not subject to the cancellation
|
||||
and refund tiers that apply to booking payments in section 3. Once the tip transaction
|
||||
has been completed, it cannot be refunded.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Payment processing:</strong> tips are processed through the same payment methods
|
||||
and by the same payment provider (Square) as booking payments. The same SCA requirements,
|
||||
chargeback rules, and refund-to-original-method principles apply to tip payments as to
|
||||
booking payments (see section 4).
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-3 text-base font-semibold text-gray-900">
|
||||
6. Gift Cards & Account Balances
|
||||
</h2>
|
||||
<p class="mb-2 font-medium text-gray-800">Gift card expiry</p>
|
||||
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||
@@ -327,7 +355,7 @@
|
||||
|
||||
<section>
|
||||
<h2 class="mb-3 text-base font-semibold text-gray-900">
|
||||
6. Distance Contracts & Right to Cancel
|
||||
7. Distance Contracts & Right to Cancel
|
||||
</h2>
|
||||
<p class="mb-3">
|
||||
Purchases made on our Platform (rather than face-to-face in the salon) are
|
||||
@@ -342,7 +370,7 @@
|
||||
<li>
|
||||
<strong>Gift cards bought online</strong> carry this 14-day right, refunded to the
|
||||
original payment method — in full if unused, or the unspent balance if partly used
|
||||
on salon services (the card is then cancelled). See section 5 and our
|
||||
on salon services (the card is then cancelled). See section 6 and our
|
||||
<a
|
||||
href={resolve('/gift-card-terms')}
|
||||
class="font-medium text-blue-600 underline hover:text-blue-800">Gift Card Terms</a
|
||||
@@ -365,7 +393,7 @@
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-3 text-base font-semibold text-gray-900">7. Liability</h2>
|
||||
<h2 class="mb-3 text-base font-semibold text-gray-900">8. Liability</h2>
|
||||
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
Nothing in these Terms excludes or limits any rights you have under consumer law —
|
||||
@@ -389,7 +417,7 @@
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-3 text-base font-semibold text-gray-900">8. Acceptable Use</h2>
|
||||
<h2 class="mb-3 text-base font-semibold text-gray-900">9. Acceptable Use</h2>
|
||||
<p class="mb-3">By using the Platform you agree not to:</p>
|
||||
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
@@ -414,7 +442,7 @@
|
||||
|
||||
<section>
|
||||
<h2 class="mb-3 text-base font-semibold text-gray-900">
|
||||
9. Complaints & Dispute Resolution
|
||||
10. Complaints & Dispute Resolution
|
||||
</h2>
|
||||
<p class="mb-3">
|
||||
If you are unhappy with any part of our service, please contact us first at
|
||||
@@ -431,7 +459,7 @@
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-3 text-base font-semibold text-gray-900">10. Governing Law</h2>
|
||||
<h2 class="mb-3 text-base font-semibold text-gray-900">11. Governing Law</h2>
|
||||
<p class="mb-3">
|
||||
These Terms are governed by the laws of <strong>Scotland</strong>. Any dispute arising out
|
||||
of or in connection with these Terms is subject to the exclusive jurisdiction of the
|
||||
|
||||
Reference in New Issue
Block a user