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:
2026-08-22 00:34:51 +01:00
parent 9a12a2d886
commit 4146f8e09a
31 changed files with 4351 additions and 81 deletions
+1 -1
View File
@@ -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>
+15 -2
View File
@@ -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 {
+14 -1
View File
@@ -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;
+1 -6
View File
@@ -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">
-6
View File
@@ -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">
+3 -2
View File
@@ -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">
+1 -2
View File
@@ -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 &mdash; see
&sect;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>
+35 -7
View File
@@ -271,7 +271,35 @@
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">
5. Gift Cards &amp; 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 &amp; 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 &amp; Right to Cancel
7. Distance Contracts &amp; 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 &mdash; 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 &mdash;
@@ -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 &amp; Dispute Resolution
10. Complaints &amp; 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