diff --git a/internal/domain.go b/internal/domain.go index a2e0f9cff..2173de545 100644 --- a/internal/domain.go +++ b/internal/domain.go @@ -91,6 +91,33 @@ func RegisterDomainEndpoints(pb *pocketbase.PocketBase) error { return e.JSON(200, domainResponse(site, result)) }) + // Manually mark a site's attached custom domain as live. This is the + // confirmation step for the manual (self-hosted) provider: Primo can't + // verify an external domain remotely, so once the operator has pointed + // DNS at the box and fronted it with TLS, they click "Mark as connected" + // and we flip the status to live. Guarded to the manual provider — a + // Railway domain's liveness is driven by the real cert status, so faking + // it there would lie about whether the cert actually issued. + serveEvent.Router.POST("/api/primo/sites/{siteId}/domain/mark-live", func(e *core.RequestEvent) error { + site, err := authorizeSiteDomain(pb, e) + if err != nil { + return err + } + if getDomainProvider().Name() != "manual" { + return e.BadRequestError("This domain's status is managed automatically.", nil) + } + host := site.GetString("host") + if host == "" || host == site.Id { + return e.BadRequestError("No custom domain is assigned to this site.", nil) + } + // Live + no records: a manual domain has no Primo-generated DNS + // records to track, and the operator has confirmed reachability. + if err := applyDomainResult(pb, site, host, DomainResult{Status: DomainStatusLive}); err != nil { + return e.InternalServerError("Failed to update domain status", err) + } + return e.JSON(200, domainResponse(site, DomainResult{Status: DomainStatusLive})) + }) + // Re-check the status of a site's attached custom domain. serveEvent.Router.GET("/api/primo/sites/{siteId}/domain/status", func(e *core.RequestEvent) error { site, err := authorizeSiteDomain(pb, e) @@ -98,6 +125,10 @@ func RegisterDomainEndpoints(pb *pocketbase.PocketBase) error { return err } + if result, ok := domainStatusOverride(site); ok { + return e.JSON(200, domainResponse(site, result)) + } + result, err := getDomainProvider().DomainStatus(site.GetString("domain_provider_id"), site.GetString("host")) if err != nil { return e.BadRequestError("Failed to check domain status: "+err.Error(), err) @@ -142,6 +173,21 @@ func authorizeSiteDomain(pb *pocketbase.PocketBase, e *core.RequestEvent) (*core return site, nil } +// domainStatusOverride returns a result the status endpoint should serve +// without polling the provider (ok=true), or ok=false when a real poll should +// run and be persisted. The manual provider can't verify an external domain +// remotely — its poll always reports "pending" — so a stored "live" there is an +// operator confirmation (via mark-live) that the poll cannot contradict. +// Persisting the poll result would silently revert that confirmation and put +// the host back on the pending-domain path. Railway liveness is driven by the +// real cert status, so no override applies there. +func domainStatusOverride(site *core.Record) (DomainResult, bool) { + if getDomainProvider().Name() == "manual" && site.GetString("domain_status") == DomainStatusLive { + return DomainResult{Status: DomainStatusLive}, true + } + return DomainResult{}, false +} + // domainErrorMax mirrors the domain_error TextField Max in the migration. // applyDomainResult truncates to it so a verbose provider error can never fail // the save (which would turn a transient error into a permanent status-refresh diff --git a/internal/domain_provider.go b/internal/domain_provider.go index ec37cb109..5d5c87276 100644 --- a/internal/domain_provider.go +++ b/internal/domain_provider.go @@ -68,42 +68,28 @@ type manualProvider struct{} func (manualProvider) Name() string { return "manual" } -// manualRoutingRecord is the single CNAME guidance a manual (self-hosted) -// operator must create. Both AttachDomain and DomainStatus return it so the -// records never disappear from the UI while the domain is reported live — the -// operator still needs to see what to point at their reverse proxy. -func manualRoutingRecord(host string) DNSRecord { - return DNSRecord{ - Type: "CNAME", - Host: host, - Value: "(point this at your Primo server)", - Status: "pending", - Purpose: "routing", - } -} - func (manualProvider) AttachDomain(host string) (DomainResult, error) { // A base-domain subdomain is already covered by the wildcard cert/routing — // nothing for the user to do. if isSubdomainOfBase(host) { return DomainResult{Status: DomainStatusLive}, nil } - return DomainResult{ - Status: DomainStatusVerifying, - Records: []DNSRecord{manualRoutingRecord(host)}, - }, nil + // An external domain on a self-hosted box: Primo can't talk to a platform + // API to issue a cert or generate correct routing records (an apex can't + // even use a CNAME), so we can't honestly hand over DNS records. Report + // "pending" and let the operator point DNS + TLS out of band, then mark the + // domain connected via the mark-live endpoint. No records — the UI shows a + // plain "point this domain at your server" instruction instead. + return DomainResult{Status: DomainStatusPending}, nil } func (manualProvider) DomainStatus(_ string, host string) (DomainResult, error) { if isSubdomainOfBase(host) { return DomainResult{Status: DomainStatusLive}, nil } - // Manual providers can't verify remotely; treat as live once assigned so the - // UI doesn't spin forever (the operator confirms reachability out of band). - // Keep returning the routing record so the CNAME guidance isn't erased — - // applyDomainResult would otherwise overwrite the stored records with []. - return DomainResult{ - Status: DomainStatusLive, - Records: []DNSRecord{manualRoutingRecord(host)}, - }, nil + // Manual providers can't verify remotely. Report "pending" and leave the + // domain there until the operator explicitly marks it connected (mark-live). + // Previously this optimistically returned "live" after one poll, which made + // the UI claim a domain was serving before any DNS was pointed at the box. + return DomainResult{Status: DomainStatusPending}, nil } diff --git a/internal/domain_test.go b/internal/domain_test.go index 5797e0325..d327a50fa 100644 --- a/internal/domain_test.go +++ b/internal/domain_test.go @@ -55,10 +55,12 @@ func TestApplyDomainResultTruncatesError(t *testing.T) { } } -// TestManualProviderStatusKeepsRecords guards that a manual-provider status -// check keeps the routing record (so the CNAME guidance isn't erased) even -// though it reports the domain live. -func TestManualProviderStatusKeepsRecords(t *testing.T) { +// TestManualProviderCustomDomainStaysPending guards that a manual-provider +// external domain stays "pending" (not auto-flipped to live) and returns no +// records — the operator points DNS + TLS out of band and confirms via the +// mark-live endpoint. Previously this optimistically reported the domain live +// after one status poll, claiming a site was serving before any DNS existed. +func TestManualProviderCustomDomainStaysPending(t *testing.T) { t.Setenv("PRIMO_BASE_DOMAIN", "acme.primo.page") p := manualProvider{} @@ -66,11 +68,52 @@ func TestManualProviderStatusKeepsRecords(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Status != DomainStatusLive { - t.Errorf("status = %q, want live", res.Status) + if res.Status != DomainStatusPending { + t.Errorf("status = %q, want pending", res.Status) } - if len(res.Records) != 1 || res.Records[0].Type != "CNAME" { - t.Errorf("expected the routing CNAME to survive, got %+v", res.Records) + if len(res.Records) != 0 { + t.Errorf("expected no records for a manual custom domain, got %+v", res.Records) + } +} + +// TestManualMarkLiveSurvivesStatusCheck guards the mark-live → status-check +// sequence: the manual provider's poll always reports "pending" for an +// external host, so persisting that poll would silently revert the operator's +// explicit confirmation. The status endpoint must serve the stored "live" +// instead of polling. +func TestManualMarkLiveSurvivesStatusCheck(t *testing.T) { + t.Setenv("PRIMO_DOMAIN_PROVIDER", "") + t.Setenv("PRIMO_BASE_DOMAIN", "acme.primo.page") + app := newImportTestApp(t) + defer app.ResetBootstrapState() + site := createImportTestSite(t, app) + + // Attach an external domain: pending, and status checks still poll. + if err := applyDomainResult(app, site, "theirbrand.com", DomainResult{Status: DomainStatusPending}); err != nil { + t.Fatalf("attach: %v", err) + } + if _, ok := domainStatusOverride(site); ok { + t.Fatal("pending manual domain should still poll the provider") + } + + // Operator confirms reachability (mark-live persists "live"). + if err := applyDomainResult(app, site, "theirbrand.com", DomainResult{Status: DomainStatusLive}); err != nil { + t.Fatalf("mark-live: %v", err) + } + + // A later status check must serve the stored confirmation, not the poll. + result, ok := domainStatusOverride(site) + if !ok { + t.Fatal("manual live domain should serve the stored status, not poll") + } + if result.Status != DomainStatusLive { + t.Errorf("status = %q, want live", result.Status) + } + + // Railway liveness is driven by the real cert status — never overridden. + t.Setenv("PRIMO_DOMAIN_PROVIDER", "railway") + if _, ok := domainStatusOverride(site); ok { + t.Error("railway provider must poll real status even when stored live") } } @@ -294,8 +337,10 @@ func TestManualProviderSubdomainShortCircuit(t *testing.T) { if err != nil { t.Fatal(err) } - if custom.Status != DomainStatusVerifying || len(custom.Records) != 1 { - t.Errorf("custom domain should return one record, got %+v", custom) + // A manual external domain can't be verified or cert-issued by Primo, so it + // stays pending with no records until the operator marks it connected. + if custom.Status != DomainStatusPending || len(custom.Records) != 0 { + t.Errorf("custom domain should be pending with no records, got %+v", custom) } } @@ -303,9 +348,9 @@ func TestHostPattern(t *testing.T) { // validHost mirrors the handler's check: pattern AND length limits. validHost := func(h string) bool { return hostPattern.MatchString(h) && validHostLength(h) } - longLabel := strings.Repeat("a", 64) + ".com" // one label > 63 - longHost := strings.Repeat("a.", 130) + "com" // total > 253 - okLongLabel := strings.Repeat("a", 63) + ".com" // label exactly 63 + longLabel := strings.Repeat("a", 64) + ".com" // one label > 63 + longHost := strings.Repeat("a.", 130) + "com" // total > 253 + okLongLabel := strings.Repeat("a", 63) + ".com" // label exactly 63 valid := []string{"example.com", "sub.example.com", "a.b.c.example.com", "my-site.example.io", okLongLabel} invalid := []string{ diff --git a/src/lib/components/ConnectDomain.svelte b/src/lib/components/ConnectDomain.svelte index f83ae21e4..3d7dd7a45 100644 --- a/src/lib/components/ConnectDomain.svelte +++ b/src/lib/components/ConnectDomain.svelte @@ -3,18 +3,28 @@ import { Input } from '$lib/components/ui/input' import { Button } from '$lib/components/ui/button' import { Copy, Check, Loader, ChevronRight, ExternalLink, TriangleAlert } from 'lucide-svelte' - import { onDestroy } from 'svelte' + import { onDestroy, untrack } from 'svelte' import { self } from '$lib/pocketbase/managers' - import { is_host_assigned } from '$lib/site_host' + import { is_host_assigned, is_base_subdomain } from '$lib/site_host' + import { instance } from '$lib/instance' import type { Site } from '$lib/common/models/Site' - // Reusable connect-a-domain flow. The server-side domain provider (Railway - // in hosted mode, manual otherwise) attaches the host and returns the DNS - // records the user must create; we poll until the cert is live. Uniqueness + - // validation are enforced server-side. Used from the dashboard and the - // editor's publish dialog. + // Reusable connect-a-domain flow. The server-side domain provider attaches + // the host and reports status. Railway returns real DNS records and issues a + // cert (we poll until live); the manual (self-hosted) provider can't verify + // remotely, so an external domain there is a "point DNS at your box, then + // mark it connected" flow with no records to poll. Uniqueness + validation + // are enforced server-side. Used from the dashboard and the editor's publish + // dialog. type DnsRecord = { type: string; host: string; value: string; status: string; purpose: string } + // Manual provider on an external (non-base) domain: Primo can't generate + // correct routing records or issue a cert, so we show plain "connect it + // yourself, then confirm" guidance + a Mark-as-connected button instead of + // the DNS-records/poll UI. A base-domain subdomain is always live instantly + // (wildcard-covered), so it never needs this path. + const is_manual_provider = instance.domain_provider === 'manual' + let { site, open = $bindable(false), @@ -61,17 +71,31 @@ } if (!site) return const assigned = is_host_assigned(site) ? site.host : '' - if (!dirty) { + // `dirty` is a guard, not a trigger: read it untracked. Both call sites + // pass a snapshot captured when the dialog opened, so if changing `dirty` + // re-ran this effect (e.g. handle_connect clearing it after a successful + // attach), the stale snapshot would overwrite the just-connected state + // and reset the dialog to its initial prompt. + if (!untrack(() => dirty)) { new_site_host = assigned } + // Decide polling from the prop's status, not the local `domain_status` + // state: reading the local state here would make it a dependency, so + // every apply_status() (poll tick, refresh, mark-live) would re-run this + // effect and reseed from the possibly-stale snapshot — overwriting the + // fresher status the poll just delivered. + const status = site.domain_status || '' attached_host = assigned - domain_status = site.domain_status || '' + domain_status = status domain_error = site.domain_error || '' domain_records = parse_dns_records(site.domain_dns_records) // A domain that's attached but not yet live needs the poll running to // advance to live on its own — otherwise reopening shows a spinner that - // never resolves without a manual refresh. - if (assigned && domain_status && domain_status !== 'live' && domain_status !== 'error') { + // never resolves without a manual refresh. Skip it for a manual external + // domain: its status only changes when the operator marks it connected, + // so polling would just spin (and re-save) with nothing to advance. + const manual_ext = is_manual_provider && !!assigned && !is_base_subdomain(assigned) + if (assigned && status && status !== 'live' && status !== 'error' && !manual_ext) { start_poll(site.id) } }) @@ -93,6 +117,14 @@ // Once the user edits the input to switch domains, the old records are stale. const show_records = $derived(domain_records.length > 0 && on_attached_host) + // Manual provider + external domain that's attached but not yet marked live: + // show the "point DNS at your server, then mark connected" guidance instead + // of the automated records/poll UI. Base-domain subdomains go live instantly + // server-side, so they never land here. + const manual_external = $derived( + is_manual_provider && !!attached_host && !is_base_subdomain(attached_host) && on_attached_host && !live + ) + // In the live state the domain shows as read-only text; the editable input is // revealed only when the user opts to change it. let changing = $state(false) @@ -164,10 +196,14 @@ // track the record again. dirty = false apply_status(result) - // Live immediately (e.g. base-domain subdomain or manual) — close. + // Live immediately (e.g. base-domain subdomain) — close. A manual + // external domain stays pending with no records to poll; the operator + // points DNS and then clicks Mark-as-connected, so don't start a poll + // that would never advance. Everything else (Railway) polls to live. + const manual_ext = is_manual_provider && !is_base_subdomain(host) if (domain_status === 'live') { open = false - } else if (domain_status !== 'error') { + } else if (domain_status !== 'error' && !manual_ext) { start_poll(site.id) } } catch (err) { @@ -209,6 +245,32 @@ } } + // Manual provider only: confirm the operator has pointed DNS at the box and + // fronted it with TLS, flipping the domain to live. The server guards this to + // the manual provider, so it's a no-op affordance elsewhere. + async function mark_live() { + if (!site) return + error = '' + connecting = true + try { + const response = await fetch(endpoint(site.id, '/mark-live'), { + method: 'POST', + headers: auth_headers() + }) + if (response.ok) { + apply_status(await response.json()) + open = false + } else { + const data = await response.json().catch(() => ({})) + error = data.message || `Failed to mark domain connected (${response.status})` + } + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to mark domain connected' + } finally { + connecting = false + } + } + function start_poll(site_id: string) { // Idempotent: the seeding effect may re-run on reactive site changes, and // we don't want to stack timers or reset the countdown each time. Track a @@ -289,6 +351,8 @@

{#if live && !changing} This site is live at your domain. + {:else if manual_external} + Point this domain at your server, then mark it connected. {:else} Enter the domain you want this site served at. We'll show you the DNS records to add at your registrar. {/if} @@ -333,13 +397,22 @@

{domain_error}

{/if}

Check the DNS records below, then connect again.

+ {:else if manual_external} + +

+ Add a DNS record at your registrar pointing {attached_host} at this server + (an A/ALIAS record for a root domain, or a CNAME for a subdomain), and make sure it's served over HTTPS. Then click + Mark as connected. +

{:else if awaiting || show_records}
Waiting for DNS & certificate…
{/if} - {#if show_records} + {#if show_records && !manual_external} {#if live} {:else} {/if} - {#if awaiting} + {#if manual_external} + + {:else if awaiting} diff --git a/src/lib/instance.ts b/src/lib/instance.ts index 731505a24..6f513370c 100644 --- a/src/lib/instance.ts +++ b/src/lib/instance.ts @@ -12,6 +12,12 @@ export type InstanceInfo = { site_count: number library_block_count: number editor_cap?: number + // Domain provider ("railway" runs attach+poll, "manual" shows generic DNS + // guidance) and the configured base domain (if set, new sites get a live + // "." subdomain). Both drive the connect-domain flow and let the + // dashboard tell a reachable host from a not-yet-connected one. + domain_provider: string + base_domain?: string } export const instance: InstanceInfo = await fetch(new URL('/api/primo/info', self.baseURL)).then((res) => res.json()) diff --git a/src/lib/site_host.ts b/src/lib/site_host.ts index 9b86b1ab6..54cb01410 100644 --- a/src/lib/site_host.ts +++ b/src/lib/site_host.ts @@ -1,4 +1,5 @@ import type { Site } from '$lib/common/models/Site' +import { instance } from '$lib/instance' // A pushed/auto-created site is seeded with `host = id` as a placeholder (see // import.go: the sites collection has a UNIQUE, required `host`, so "no host" @@ -8,12 +9,33 @@ import type { Site } from '$lib/common/models/Site' // that assigns a real host up front (the deploy URL). export const is_host_assigned = (site: Pick) => !!site.host && site.host !== site.id +// Whether `host` sits under the configured base domain — a "." +// subdomain that the wildcard cert/routing already covers, so it's reachable +// the moment it's stored (no per-domain DNS/cert work). Mirrors +// isSubdomainOfBase in internal/domains.go. +export const is_base_subdomain = (host: string) => { + const base = instance.base_domain + if (!base) return false + return host === base || host.endsWith(`.${base}`) +} + +// Whether the site's assigned host will actually resolve/serve right now. +// Assigning a domain stores it optimistically (status "verifying"/"pending" for +// a custom domain, or empty for an auto-assigned base subdomain), but a custom +// domain isn't reachable until its DNS + cert land ("live"). Base-domain +// subdomains are reachable immediately. Used to keep the dashboard from linking +// a card at a domain that would dead-end on a connection error. +export const is_host_reachable = (site: Pick) => + is_host_assigned(site) && (is_base_subdomain(site.host) || site.domain_status === 'live') + // Where to open a site in the editor. // -// Assigned sites live at their own vhost (`//host/admin/site`) — the host-based -// editor route resolves the site from the request Host. Unassigned sites have -// no reachable vhost, so they're edited by id via the same-origin id-based -// route (`/admin/sites/{id}`), which resolves the site directly and never -// touches `host`. -export const site_editor_url = (site: Pick) => - is_host_assigned(site) ? `//${site.host}/admin/site` : `/admin/sites/${site.id}` +// A reachable assigned host lives at its own vhost (`//host/admin/site`) — the +// host-based editor route resolves the site from the request Host. Unassigned +// sites (and assigned-but-not-yet-reachable ones, e.g. a custom domain still +// waiting on DNS) have no reachable vhost, so they're edited by id via the +// same-origin id-based route (`/admin/sites/{id}`), which resolves the site +// directly and never touches `host` — this avoids dead-ending on a domain that +// isn't connected yet. +export const site_editor_url = (site: Pick) => + is_host_reachable(site) ? `//${site.host}/admin/site` : `/admin/sites/${site.id}` diff --git a/src/routes/site/+layout.svelte b/src/routes/site/+layout.svelte index 32dbec1b8..e2ff6d30b 100644 --- a/src/routes/site/+layout.svelte +++ b/src/routes/site/+layout.svelte @@ -8,7 +8,7 @@ import { page } from '$app/state' import { Sites } from '$lib/pocketbase/collections' import CreateSite from '$lib/components/CreateSite.svelte' - import { is_host_assigned, site_editor_url } from '$lib/site_host' + import { is_host_reachable, site_editor_url } from '$lib/site_host' import { current_user, set_current_user } from '$lib/pocketbase/user' import { Loader } from 'lucide-svelte' @@ -94,11 +94,12 @@ {#if creating_site && $current_user} { - // Hard-navigate to the new site's admin. An assigned site lives at - // its own vhost (redirect there); an unassigned site (host === id) - // has no vhost, so open it by id. Hard nav (not goto) sidesteps the - // stale Sites.list() cache that would otherwise re-trigger the gate. - if (created && is_host_assigned(created)) { + // Hard-navigate to the new site's admin. A reachable assigned site + // lives at its own vhost (redirect there); an unassigned site + // (host === id) or one whose custom domain isn't live yet has no + // reachable vhost, so open it by id. Hard nav (not goto) sidesteps + // the stale Sites.list() cache that would otherwise re-trigger the gate. + if (created && is_host_reachable(created)) { const protocol = page.url.protocol || 'http:' window.location.href = `${protocol}//${created.host}/admin/site` } else if (created) {