# Domainsansar

A domain-name marketplace: search, buy, sell, negotiate and manage domain names,
with verified ownership, secure payment and transfers that are independently
confirmed before anyone is paid.

```
app/          Laravel Backend (Models, Controllers, Policies, Services)
resources/js/ React 19 + TypeScript SPA
scripts/      Database bootstrap and local setup
```

---

## Getting started

The schema and the full test suite are verified against MySQL 8.0.46 — run
`./scripts/test-mysql.sh` to reproduce that on any machine with a `mysqld`
binary, no root required.

```bash
# 1. Create the database and application user (needs a MySQL admin account)
sudo mysql < scripts/setup-database.sql

# 2. Put the password you chose into .env as DB_PASSWORD

# 3. Install, migrate, seed
./scripts/setup.sh
```

Then run:

| What | Command |
|---|---|
| Web App & API | `php artisan serve` |
| Vite Dev Server (HMR) | `npm run dev` |
| Queue worker | `php artisan queue:work` |
| Scheduler | `php artisan schedule:work` |

The single Laravel application serves both the React SPA and the `/api/v1` REST API on `:8000`, so the session cookie stays first-party with zero CORS overhead.

### Demo inventory (development only)

`./scripts/setup.sh` seeds reference data only — TLDs, categories, plans and
fees — so a fresh marketplace has no listings in it. To browse something:

```bash
php artisan db:seed --class=DemoListingSeeder
```

That creates two demo sellers and fourteen listings, including deliberate
negative cases (a sold name, a draft, a withdrawn one) that must *not* appear in
search. It is not wired into `DatabaseSeeder` and refuses to run in production:
fabricated inventory is a development convenience, and a live marketplace
showing invented prices against names nobody owns is worse than an empty one.
Sign in as either demo seller with the password the seeder prints.

### Checks

```bash
composer test          # 328 backend tests on SQLite (fast)
./scripts/test-mysql.sh  # the same 328 against a throwaway MySQL 8
composer lint          # PHP code style (Pint)
composer fees:check    # fee schedule sanity (see Fees, below)
composer schema:check  # render the schema as MySQL DDL, offline
npm test               # 66 frontend React tests (Vitest)
npm run typecheck      # TypeScript check
npm run build          # production Vite build
```

`test-mysql.sh` stands up its own MySQL 8 in a temp directory, runs the suite
against it and tears it down. It needs no root and does not touch any MySQL
already on the machine. SQLite is the default because it is fast, but it is
permissive where MySQL is not — it ignores column signedness, treats `ENUM` as
free text and has no InnoDB index key limit — so a green SQLite run is not on its
own evidence that the schema works on the deployment target.

---

## Deploying

The two halves deploy separately, and only one of them can go on Vercel.

### The SPA on Vercel

`web/vercel.json` is committed. Set **Root Directory** to `web` in the Vercel
project — this is a monorepo, and there is no `package.json` at the root for
Vercel to find.

The config does one thing: rewrite unmatched paths to `index.html` so React
Router's client-side routes survive a page reload. `/api/` and `/sanctum/` are
excluded from that rewrite deliberately. Catching them would answer an API call
with the index page and a `200`, so a frontend pointed at a missing API would
render empty states instead of failing — a misconfiguration that looks like an
empty marketplace. Excluded, those paths `404` and the failure is visible.

Set `VITE_API_BASE` to the deployed API's origin. It is read at **build** time
and inlined into the bundle, so changing it needs a redeploy rather than a
restart. Left unset, the SPA calls its own origin and finds no API.

Note `VITE_API_URL` is a different variable and development-only: it is the
target of the Vite dev-server proxy, which is why `VITE_API_BASE` is empty in
development.

### The API is not deployable on Vercel

It is a Laravel application with a queue worker and a scheduler, both of which
have to run as long-lived processes. Use Railway, Render, Fly, or a VPS. On the
API host: generate `APP_KEY`, point `DB_*` at a real MySQL 8, run the migrations
and the reference seeders, and keep `php artisan queue:work` and
`php artisan schedule:work` running.

### Sessions are cookies, so the two origins matter

Authentication is Sanctum's cookie session guard, not bearer tokens, so the
browser has to be willing to send the session cookie to the API. A SPA on
`something.vercel.app` calling an API on an unrelated domain is cross-site, and
a `Lax` cookie is not sent cross-site — login appears to succeed and every
authenticated request then fails.

The arrangement that avoids the problem is one apex domain: the SPA on
`www.example.com`, the API on `api.example.com`, and on the API:

```
APP_URL=https://api.example.com
FRONTEND_URL=https://www.example.com
SANCTUM_STATEFUL_DOMAINS=www.example.com
SESSION_DOMAIN=.example.com
SESSION_SECURE_COOKIE=true
```

`config/cors.php` already derives its allowed origin from `FRONTEND_URL` and
sets `supports_credentials`, so it needs no separate change.

### Deploying the SPA alone

Useful for reviewing the interface, and honest about its limits. The pages that
read no data — the landing page, its FAQ, the auth forms — render fully. Search,
categories and pricing show their error states, because there is no API behind
them. Nothing can be signed into.

### cPanel, or any Apache host

Unlike Vercel, cPanel can host **both halves**, which also removes the
cross-origin cookie problem entirely. It is the better fit for this stack.

`web/public/.htaccess` is committed and Vite copies it into `dist/` on every
build, so the built folder is self-contained. It does the same job as
`vercel.json`: filesystem first, `/api/` and `/sanctum/` excluded, everything
else to `index.html`. It also sets immutable caching on the content-hashed
assets and `no-cache` on `index.html`, so a returning visitor is not served a
stale bundle.

The recommended layout uses one apex domain and two document roots:

| What | Where | Document root |
|---|---|---|
| SPA | `www.example.com` | `public_html/` ← upload `web/dist/*` |
| API | `api.example.com` | `laravel/backend/public` |

Put the Laravel application *outside* `public_html` and point the subdomain's
document root at its `public/` directory. Never serve the application root
itself: `.env` sits there.

Four things need checking before this works:

- **PHP 8.3 or newer**, set for the API subdomain in MultiPHP Manager. The
  `pdo_mysql`, `mbstring` and `intl` extensions are required.
- **No Redis.** Shared hosting rarely has it, and `.env.example` defaults to it
  for sessions, cache and queue. Switch all three to `database`: the `sessions`,
  `cache`, `cache_locks`, `jobs`, `job_batches` and `failed_jobs` tables already
  exist in the migrations, so this is an env change with nothing to create. The
  suite passes on those drivers.
- **The queue needs a cron.** Nothing on shared hosting keeps `queue:work`
  alive, and queued work is not optional here: login risk assessment, the
  suspicious-login email and the three retention sweeps all run on the queue, so
  without a worker they accumulate in the `jobs` table and never happen. Add a
  cron that drains and exits rather than a daemon:

  ```
  * * * * * cd /home/USER/laravel/backend && php artisan queue:work --stop-when-empty --max-time=55 >> /dev/null 2>&1
  * * * * * cd /home/USER/laravel/backend && php artisan schedule:run >> /dev/null 2>&1
  ```

  `--stop-when-empty` with `--max-time` keeps each run inside its minute instead
  of leaving overlapping processes behind.
- **Composer.** If the host provides no SSH, run `composer install
  --no-dev --optimize-autoloader` locally and upload `vendor/` with the
  application. It is gitignored, so it will not arrive with a `git pull`.

Then set the same session variables as above (`SESSION_DOMAIN=.example.com`,
`SESSION_SECURE_COOKIE=true`, `FRONTEND_URL`, `SANCTUM_STATEFUL_DOMAINS`), and
build the SPA with `VITE_API_BASE=https://api.example.com` before uploading it.

---

## Architecture

### Both sides of the marketplace, one account

There is no separate buyer or seller account. Every user can buy and sell; the
seller side is a `seller_profiles` record created the first time someone acts as
a seller. Nothing in the schema or the API models a "seller account".

### Money

Every monetary column is a `BIGINT` of minor units, and all arithmetic goes
through `App\Support\Money\Money` — an immutable value object that refuses to
mix currencies, applies rates in basis points, and rounds in exactly one place.
No float touches a price, a fee or a payout.

Columns are **signed** wherever a reversal is meaningful, because every fee has a
refund and every credit has a clawback. Storing amounts unsigned makes the
reversal unrepresentable and pushes direction into some other column, which is
how ledgers stop reconciling. The exceptions are listed and justified in
`tests/Feature/Schema/MoneyColumnTest.php`, which fails the build if a new
`*_minor` column is added unsigned without a stated reason — and also fails if
any migration introduces a floating-point column.

### Fees are data, never code — and scopes compose

Every charge resolves through a row in the `fees` table. Resolution is two-stage,
and the split is the important part:

- A **base** rule sets the rate. Exactly one applies — the most specific match,
  with `priority` breaking ties.
- Every matching **adjustment** then shifts that figure. Adjustments compose;
  they do not compete.

Scope dimensions have to combine, because a marketplace wants volume-discount
bands *and* per-TLD tweaks *and* plan benefits at once. A single-winner model
cannot express that: the narrowest scope wins outright and silently cancels the
rest. In practice that let an administrator add a per-TLD rule they read as a
small discount and, without warning, remove a volume discount worth thousands on
a large sale — a $250,000 sale jumping from $15,000 commission to $22,500, with
nothing logged because a rule *had* matched.

Two properties are worth preserving in any change here. Adjustments are computed
against the original amount, never the running total, so composition is
commutative and rule creation order cannot change the answer. And the base rule's
floor and ceiling apply *after* adjustments, so a cap bounds what is actually
charged.

Because a schedule of a dozen rules is still not readable by inspection,
`composer fees:check` reports two failure modes and exits non-zero on either:

- **unreachable** — a base rule that never wins anywhere; usually a scope typo,
  or a rule that should have been an adjustment.
- **flattened bands** — a base rule with no price band outranking rules that
  have them, so the volume structure silently stops applying inside its scope.

It runs nightly from the scheduler. `php artisan fees:inspect` prints the full
effective-rate matrix, and `FeeScheduleInspector::previewChange()` shows the
before-and-after of a proposed rule without saving it — which is what an admin
surface must display before a rate change is committed.

A resolved fee is snapshotted onto the order that used it, including every rule
in its derivation, so a historical charge stays explainable: when a seller asks
why they paid 7% rather than 10%, the answer names the base band and each
adjustment that moved it.

If no base rule matches, a configured fallback rate applies **and the miss is
logged as a warning** — a misconfigured environment should not silently give away
the platform's revenue.

### Trust is proved, never claimed

- Domain ownership is verified by DNS TXT record across a quorum of independent
  resolvers, or by registrar API. Only verified domains can be listed.
- A seller asserting "I transferred it" never releases funds. Transfer
  completion is confirmed independently, then held for a configurable dispute
  window before payout eligibility.
- Payment state is only ever set from a verified provider webhook.

### Escrow, stated honestly

Funds are held by a licensed payment provider. Domainsansar coordinates and
verifies transfers; it is **not** a bank or a regulated escrow institution, and
nothing in the code or the copy claims otherwise.

### Audit trail

`audit_logs` and `auth_events` are append-only — the models refuse updates and
deletes outright. Entries are mirrored to a dedicated log channel, so tampering
with the database alone does not erase the record. Secrets (passwords, 2FA
seeds, EPP codes, tax IDs) are redacted before anything is written.

Each request carries a correlation id (`X-Request-Id`), shared by every job it
spawns, so one user action can be reassembled during a dispute.

### Authorization

Two layers, because they answer different questions.

**Staff abilities** are named after actions, not roles (`suspend-users`,
`manage-fees`), and each maps to a minimum role. Money-shaped configuration —
fee rules, payouts, refunds — sits a level above routine administration.

**Ownership policies** decide whether a record is yours. Owner-scoped resources
implement `App\Contracts\OwnedByUser` and get a policy extending
`OwnershipPolicy`, which encodes the rules they all share: an account in bad
standing can do nothing whatever it owns; support may read anything, because
dispute resolution is impossible otherwise; and staff do **not** inherit
ownership — an administrator editing a seller's listing goes through an audited
admin endpoint, not the member route, so the trail does not read as though the
seller made the change themselves. Denial for someone else's record is
deliberately indistinguishable from the record not existing.

Every gate and policy re-checks account standing, so suspending a compromised
staff account is enough to stop it acting. Suspension also destroys the
credential rather than merely refusing it: the next request is unauthenticated,
not forbidden.

Domains and listings plug into this layer rather than reinventing an ownership
check each; they add only what is genuinely theirs, which is the state rules — a
sold listing cannot be edited, a domain with a live listing cannot be discarded,
and an ownership proof is deliberately something no staff role can perform on a
seller's behalf. Offers and orders follow the same pattern.

### Asynchronous work

Anything that does not need to happen before the response happens after it.
Login risk assessment is the clearest case: it reads login history, may write a
fraud flag and may send mail, none of which the person signing in should wait
for — and none of which should be able to fail their sign-in. It runs as a queued
listener on a `LoginSucceeded` event, with the request signature computed at the
edge because a worker has no `Request` to derive it from.

The scheduler carries three retention sweeps and the nightly fee-schedule check.
`php artisan schedule:list` is the authoritative list. Phase 2 onward adds the
marketplace's time-driven rules here — expiring offers, re-checking DNS
ownership, polling registrar transfers, releasing payouts once their hold period
elapses.

### Device fingerprinting, and why it ignores versions

Unfamiliar-login detection hashes a *normalised* user agent, with version
numbers stripped. Hashing the raw string meant every browser update changed the
signature, so an account holder received a "new sign-in detected" email roughly
monthly. Recurring false alarms train people to ignore the one message that
matters, which makes the feature worse than not having it.

### Two client shapes, one API

The web SPA authenticates statefully with a Sanctum cookie session, so no token
is ever exposed to JavaScript. Mobile and integration clients name their device
and receive a bearer token. Both hit the same routes; the request shape selects
the mechanism.

---

## Design system

Tokens live in `web/src/styles/theme.css` and are the single source of truth for
colour, elevation, radius and type. Three rules are encoded there:

1. **No gradients.** There is no gradient token in any form. Depth comes from
   layered opaque surfaces, hairline borders and restrained shadow.
2. **Light mode is true white.** The page canvas is `#ffffff`, not off-white.
   Separation between layers comes from borders and shadow, never from tinting
   the page.
3. **Glass is a highlight, not a texture.** One blur strength exists, spent on
   the sticky header and modal scrims only.

The accent is a deep marine teal, chosen to read as considered and financial
rather than energetic, and deliberately distinct from the indigo and violet that
dominate developer-tool branding.

Theme is three-state (light / dark / system). `system` keeps following the OS
rather than freezing at the moment it was picked, and the resolved theme is
applied before first paint so there is no flash of the wrong theme.

Breakpoints match the widths the brief names rather than Tailwind's defaults. Two
matter in particular: `xs` at 430px separates the large-phone band from the small
one, which a single 640px step could not distinguish, and `2xl` is 1440px.

**Borders carry three roles, and the distinction is an accessibility
requirement.** `border-subtle` is decoration — card edges, dividers, table rules
— and is free to stay a hairline because it conveys nothing a user must perceive
to operate the interface. `border-control` is the resting edge of an input,
checkbox or select: WCAG 1.4.11 requires 3:1 against the adjacent surface for
anything that identifies a control's boundary, and a hairline measures 1.25:1, so
a low-vision user could not see where a field began. Every colour pair in the
token set is checked against WCAG AA and all fifteen pass in both themes.

---

## Status

### Phase 1 — complete

| Area | State |
|---|---|
| Project architecture, monorepo, tooling | Complete |
| MySQL foundation schema (18 tables, FKs, indexes, soft deletes) | Complete |
| Registration, login, logout, email verification, password reset | Complete |
| Two-factor authentication (TOTP + hashed single-use recovery codes) | Complete |
| Role-based access control (21 staff abilities) | Complete |
| Profiles, notification preferences, seller onboarding | Complete |
| Device/token management, security activity log | Complete |
| Money value object and fee resolution engine | Complete |
| Audit trail and auth event trail (append-only) | Complete |
| Login throttling, enumeration resistance, unfamiliar-login detection | Complete |
| Design tokens, light/dark themes, UI primitives, app shell | Complete |
| TLD, category, plan and fee reference data | Seeded |
| Ownership policy layer | Complete |
| Queue, jobs, events and scheduler | Complete |
| Fee schedule inspection and change preview | Complete |
| Frontend test suite | Complete |

### Phase 2 — in progress

| Area | State |
|---|---|
| `domains` and `listings` schema, with exclusive verified claims | Complete |
| Domain name parsing and TLD splitting (`co.uk`, punycode, pasted URLs) | Complete |
| Listing lifecycle, one open listing per domain | Complete |
| Domain and listing ownership policies | Complete |
| Public search: keyword, TLD, category, price, length, character filters | Complete |
| Browse grid and the indexable domain detail page | Complete |
| Denormalised browse counters, recounted from what is visible | Complete |
| DNS ownership verification (resolver quorum against live DNS) | Not started |
| Seller inventory and listing management endpoints | Not started |
| Full-text search index | Not started |

The pieces that are not started are the reason a seller cannot yet add a domain
through the UI: inventory exists in the schema and is browsable, but the only
way to create it today is the demo seeder or tinker. Ownership verification is
modelled end to end — token, TTL, attempt count, exclusive verified claim — and
what is missing is the service that actually queries the resolvers.

Where a surface is still to come, the routes render an honest "being built"
state, and the domain detail page shows its buy and offer actions as disabled
with the reason. No route shows invented prices against names nobody owns: on a
marketplace, mocked inventory is not a harmless placeholder. Demo data is opt-in
and development-only — see *Demo inventory* above.

### Phases 3–7

Offers and negotiation · checkout, payments, orders, transfers · payouts,
reviews, disputes, admin · Flutter mobile app · registrar integrations, AI
valuation, advanced risk systems.

---

## Known gaps

- **Coverage is unmeasured.** Neither PCOV nor Xdebug is installed and neither
  can be added without root, so every figure quoted is a test count, not a
  coverage percentage. `composer test:coverage` works the moment a driver is
  present and says so plainly when it is not.
- **Email changes** are not yet self-service. The endpoint deliberately ignores
  an `email` field, because changing it re-opens verification and is a common
  account-takeover vector; it needs its own audited flow.
- **Identity and payment verification** are modelled in the schema and surfaced
  in the UI, but the provider integrations arrive with payouts in Phase 5.
- **The `redis` PHP extension is absent** in this environment, so `predis` is
  configured. Switching to phpredis is a later performance change, not a
  correctness one.
- **One currency only.** `seller_profiles.payout_currency` exists because
  per-seller payout currencies are the eventual design, but there is no
  exchange-rate layer, so anything other than the base currency is refused
  outright rather than stored and mis-summed later.
- **No image uploads yet**, so `avatar_url` and `logo_url` are always null. The
  media endpoint serves only prefixes something actually writes to — an
  allow-list should not run ahead of the uploader that fills it.
