You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(custom-domains): public API + dashboard + tenant routing policy
Brings custom domains from a service-layer-only feature to user-facing.
Owners can now register a domain (e.g. links.acme.com), publish DNS
records returned by the API, verify, and serve short links under their
own brand.
API
POST /api/v1/custom-domains register
POST /api/v1/custom-domains/{id}/verify trigger verification
GET /api/v1/custom-domains list owned (paginated)
DELETE /api/v1/custom-domains/{id} revoke (?cascade=true also
bulk-deletes URLs on the domain)
DELETE /api/v1/urls?domain=<fqdn> bulk URL delete on owned domain
POST /api/v1/shorten accepts an optional `domain` field; ownership +
ACTIVE-status enforced at the route layer. GET /api/v1/urls accepts a
`?domain=` filter and UrlListItem now exposes `domain`.
Tenant routing
Custom hostnames serve a strict allowlist: `/<alias>`, `/<alias>/password`,
`/favicon.ico`, `/robots.txt`. Everything else (operator surface like
/api, /dashboard, /auth, /oauth; brand pages like /about, /contact;
the stats suffix `/<alias>+`; /report; /health) returns 404 so
customer-branded hosts don't expose the canonical app surface.
Per-tenant `/robots.txt` is served inline as `Disallow: /`, and every
custom-tenant response is stamped with
`X-Robots-Tag: noindex, nofollow, noarchive`. The existing system-default
`/<alias>` redirect header gained `noarchive` alongside this.
Auth + scopes
New API key scopes `domains:manage` and `domains:read`. New
`require_scopes_verified` dependency combines scope auth with the
email-verified gate so both JWT and API key callers can create.
Response shape
CustomDomainResponse now carries structured `dns_records` (each with
type/name/value/purpose) plus `setup_notes` for human-readable warnings
(e.g. "Cloudflare DNS detected — set DNS-only / grey cloud"). Dropped
the old free-form `setup_instructions` string and the internal
`verification_token` field (no longer surfaced over the wire).
Service / repository
CustomDomainService gains `assert_owned`, `assert_owned_and_active`
helpers and `delete(*, cascade=False)` returning `(doc, urls_deleted)`.
Cascade order: REVOKE first, bulk delete URLs second, edge eviction
third, cache invalidate last. Partial failures swallow + log; a future
garbage-collection worker reaps orphan URLs.
UrlService.create accepts an opaque `domain=`; alias-uniqueness now
scopes through `(domain, alias)`. `delete_all_by_domain` refuses the
system-default namespace defensively.
Repository: list_aliases_by_owner_and_domain + delete_many_by_owner_and_domain.
Cache: invalidate_many for bulk cleanup.
Rate limits
Slowapi limits on every new route (5/hr CREATE, 10/min VERIFY/DELETE,
60/min READ, 5/min bulk delete). Service-side per-user Mongo + Redis
quotas still apply on top — layered defense.
Dashboard
New /dashboard/domains page with a sidebar nav entry. Markup matches
the existing keys/links pattern: external CSS+JS, `<template>` row
cloning, status badges via design tokens, server shell + client fetch
hydration. Delete modal fetches the current URL count and offers the
caller a choice between orphaning the URLs or cascade-deleting them.
Tests
74 new test cases across DTOs, service helpers, route happy/sad paths,
URL repository bulk methods, cache invalidate_many, and the tenant
middleware routing matrix (including robots.txt, favicon, operator
blocklist, stats-suffix block). Full suite: 1619 passing.
0 commit comments