# Custom domains

Let each tenant serve their panel from a domain they own — `app.acme-corp.com` instead of `acme.myapp.test` — with DNS ownership verification, full-host routing, and single-origin authentication handled by the plugin. Subdomain identification mode only.

## Enable the feature

```php
TenancyPlugin::make()->customDomains()
```

or in `config/packstub-tenancy.php`:

```php
'custom_domains' => [
    'enabled' => true,
],
```

Enabling it turns on three things:

- a **Domains page** in the tenant panel (visible to tenant owners) to add, verify, and remove domains, plus the `tenants:domains` Artisan command for operators;
- **routing**: verified custom domains resolve the tenant exactly like subdomains do;
- the **auth handoff endpoints** that sign users in on custom domains (see below).

## How a tenant attaches a domain

1. **Add** the domain on the Domains page (or `php artisan tenants:domains acme --add=app.acme-corp.com`). It starts **unverified** — unverified domains never identify a tenant, so someone pointing a domain at your platform gains nothing until they prove ownership.
2. **Point DNS at your deployment** — typically a CNAME from `app.acme-corp.com` to the tenant's default subdomain (`acme.myapp.test`), or an A/ALIAS record per your host's instructions.
3. **Publish the verification TXT record** shown on the page:

   ```
   _packstub-verify.app.acme-corp.com   TXT   packstub-verify=<token>
   ```

   A CNAME pointing the domain at the tenant's default subdomain (or the central domain) also counts as proof — it demonstrates control and routing at once.
4. **Verify.** The plugin checks DNS and stamps `verified_at`. From that moment the domain resolves the tenant.

Operators can bypass DNS with `php artisan tenants:domains acme --force-verify=app.acme-corp.com` (useful in local/dev environments where no real DNS exists — add a hosts-file or Herd alias instead).

## How sign-in works (the handoff)

Browsers no longer share cookies across registrable domains, so a session on `myapp.test` is invisible on `app.acme-corp.com`. The plugin uses the post-third-party-cookie standard pattern (Clerk satellite domains, Shopify, Auth0): **login happens on exactly one origin — your central domain — and identity crosses to custom domains via a single-use, short-lived code.**

1. An unauthenticated visit to `app.acme-corp.com/admin` redirects to the central authorize endpoint, carrying only the target **host** (validated against verified domains — never a free-form URL) and a path.
2. If a central session exists, nothing is shown (silent). Otherwise the central login form appears — and the flow resumes after login.
3. The server mints a 256-bit single-use code (only its hash is stored, TTL 60 seconds) and redirects to `https://app.acme-corp.com/auth/handoff?code=…`.
4. That landing page — no referrer leakage, non-cacheable, zero third-party assets — immediately POSTs the code same-origin. The exchange atomically consumes the code (replays fail), re-validates the tenant↔host mapping at redemption time, regenerates the session, and signs the user in behind a **host-only** cookie scoped to the custom domain.

The default is fully silent. To show a "Continue as …" confirmation on the custom domain instead:

```php
TenancyPlugin::make()->customDomains()->handoffInterstitial()
```

Failures (expired code, replay, tampering) are deliberately generic — the visitor is returned to the central login; details are logged server-side under a correlation id, and rate limiting is keyed per host and per code (never per client IP, which CDN edges don't reliably preserve).

## Logout propagates everywhere

Every custom-domain session references a central **session grant**. Logging out on any host — central, subdomain, or custom domain — revokes all grants minted from that central session **and** destroys the central session itself. Custom domains notice on their next request (each request re-validates the grant — a cheap central query); subdomains notice immediately because their shared session is gone. Expect propagation within one request per host.

## Things to know

- **HTTPS/TLS is deployment-level.** The plugin verifies ownership and routes requests; issuing certificates for tenant domains is your platform's job — e.g. Laravel Cloud/Vapor custom domains, Caddy on-demand TLS, Cloudflare for SaaS, or a wildcard + per-domain ACME setup on your own proxy. Serve custom domains over HTTPS only; the session cookie is issued `Secure` when the request is.
- **Passkeys/WebAuthn stay on the central domain.** The credentials are origin-bound; a passkey registered on `myapp.test` can never sign in directly on `app.acme-corp.com` — which is fine, because login only ever happens centrally and the handoff carries the session across.
- **Remember-me is per-host.** The central session's lifetime is what makes re-handoffs silent — protect it (sensible session lifetime, rotation).
- **Slug renames don't touch custom domains.** Renaming a tenant's slug moves its default subdomain; explicitly added domains are never modified.
- **Sanctum guards are unsupported for the handoff** (it requires a stateful guard); the guard in use is captured when the code is minted and re-checked at redemption.
- **URL generation follows the host.** While a user browses `app.acme-corp.com`, every generated panel link stays on it; links generated anywhere else (central panel, email, console) use the tenant's default subdomain.

## Configuration reference

```php
'custom_domains' => [
    'enabled' => false,
    // Show "Continue as …" instead of signing in silently.
    'interstitial' => false,
    // Seconds a handoff code stays redeemable. Hard-capped at 120.
    'handoff_ttl' => 60,
    // Path of the landing/exchange endpoint on tenant hosts.
    'handoff_path' => 'auth/handoff',
    // DNS TXT record name prefix used for ownership verification.
    'verification_prefix' => '_packstub-verify',
],
```

Fluent equivalents: `->customDomains(bool)` and `->handoffInterstitial(bool)`.

## The `tenants:domains` command

```bash
php artisan tenants:domains                                  # list every domain
php artisan tenants:domains acme                             # one tenant, with pending TXT records
php artisan tenants:domains acme --add=app.acme-corp.com     # attach (unverified)
php artisan tenants:domains acme --verify=app.acme-corp.com  # run DNS verification
php artisan tenants:domains acme --force-verify=app.acme-corp.com  # mark verified, no DNS check
php artisan tenants:domains acme --remove=app.acme-corp.com  # detach
```

The default `{slug}.central-domain` subdomain can never be removed, and domains inside the central namespace can never be added manually — they're managed by the slug.
