diff --git a/.devcontainer/universal/devcontainer.json b/.devcontainer/universal/devcontainer.json new file mode 100644 index 0000000000..c6ad2b434d --- /dev/null +++ b/.devcontainer/universal/devcontainer.json @@ -0,0 +1,9 @@ +{ + "name": "universal", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers/features/node:1": {}, + "ghcr.io/devcontainers/features/rust:1": {}, + "ghcr.io/devcontainers/features/git:1": {} + } +} diff --git a/.github/workflows/database-tests.yml b/.github/workflows/database-tests.yml index 16d7b7a55a..882c0b856c 100644 --- a/.github/workflows/database-tests.yml +++ b/.github/workflows/database-tests.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@v4 - uses: supabase/setup-cli@v1 with: - version: latest + version: 2.72.7 - run: printf '[]' > supabase/signing_keys.json && supabase gen signing-key --append --yes - run: supabase db start - run: supabase test db diff --git a/.vscode/settings.json b/.vscode/settings.json index 6833a2a17a..fc9815f2cd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -48,4 +48,7 @@ ], "editor.formatOnSave": true, "editor.defaultFormatter": "oxc.oxc-vscode", + "[markdown]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, } diff --git a/database/database-generated.types.ts b/database/database-generated.types.ts index 3d1225e950..1d5cbf8ac6 100644 --- a/database/database-generated.types.ts +++ b/database/database-generated.types.ts @@ -3540,7 +3540,7 @@ export type Database = { analyze: { Args: { p_campaign_id: string - p_interval?: unknown + p_interval?: string p_names?: string[] p_time_from?: string p_time_to?: string @@ -3654,6 +3654,69 @@ export type Database = { } grida_www: { Tables: { + domain: { + Row: { + canonical: boolean + created_at: string + hostname: string + id: string + kind: string | null + last_checked_at: string | null + last_error: string | null + last_error_code: string | null + last_verified_at: string | null + status: string + updated_at: string + vercel: Json | null + www_id: string + } + Insert: { + canonical?: boolean + created_at?: string + hostname: string + id?: string + kind?: string | null + last_checked_at?: string | null + last_error?: string | null + last_error_code?: string | null + last_verified_at?: string | null + status?: string + updated_at?: string + vercel?: Json | null + www_id: string + } + Update: { + canonical?: boolean + created_at?: string + hostname?: string + id?: string + kind?: string | null + last_checked_at?: string | null + last_error?: string | null + last_error_code?: string | null + last_verified_at?: string | null + status?: string + updated_at?: string + vercel?: Json | null + www_id?: string + } + Relationships: [ + { + foreignKeyName: "domain_www_id_fkey" + columns: ["www_id"] + isOneToOne: false + referencedRelation: "www" + referencedColumns: ["id"] + }, + { + foreignKeyName: "domain_www_id_fkey" + columns: ["www_id"] + isOneToOne: false + referencedRelation: "www_public" + referencedColumns: ["id"] + }, + ] + } layout: { Row: { base_path: string | null @@ -4562,6 +4625,20 @@ export type Database = { } Returns: undefined } + www_get_canonical_hostname: { + Args: { p_www_name: string } + Returns: { + canonical_hostname: string + }[] + } + www_resolve_hostname: { + Args: { p_hostname: string } + Returns: { + canonical_hostname: string + www_id: string + www_name: string + }[] + } } Enums: { doctype: diff --git a/database/database.types.ts b/database/database.types.ts index 2eb50e2129..73a020873b 100644 --- a/database/database.types.ts +++ b/database/database.types.ts @@ -1,6 +1,30 @@ +/** + * @fileoverview + * Manual type overrides for the Supabase database schema. + * + * ## Role + * Supabase CLI generates `database-generated.types.ts`, but those types are not always strong enough + * or may not reflect application-level guarantees. Common cases: + * - **Views**: view columns are often typed as optional/nullable too broadly by the generator. + * - **`jsonb` with enforced schema**: when the DB enforces a JSON shape (via constraints/triggers), + * we may want a stronger, explicit TypeScript type. + * + * This file provides a **manually managed override layer** (via `MergeDeep`) to make development + * safer and more ergonomic with trusted, strong types. + * + * ## Modification policy (important) + * - **Prefer not to edit this file**. If the generated types are correct, keep them as-is. + * - **Only add/adjust overrides when you are 100% certain** the runtime data matches the override. + * These overrides are **blindly trusted** by TypeScript and can hide real runtime/DB mismatches. + * - **Best for known generator limitations** (especially **Views**) or well-defined, enforced JSON + * shapes. When possible, prefer improving the DB/schema and re-generating types instead. + */ // https://supabase.com/docs/reference/javascript/typescript-support#helper-types-for-tables-and-joins import { MergeDeep } from "type-fest"; -import { Database as DatabaseGenerated } from "./database-generated.types"; +import { + Database as DatabaseGenerated, + type Json, +} from "./database-generated.types"; export { type Json } from "./database-generated.types"; type SystemSchema_Favicon = { diff --git a/docs/wg/platform/index.md b/docs/wg/platform/index.md new file mode 100644 index 0000000000..c197bb1585 --- /dev/null +++ b/docs/wg/platform/index.md @@ -0,0 +1,11 @@ +--- +title: Platform (WG) +--- + +# Platform (WG) + +Working group documents for Grida platform and infrastructure topics. + +## Documents + +- [Multi-tenant Custom Domains on Vercel](./multi-tenant-custom-domain-vercel) diff --git a/docs/wg/platform/multi-tenant-custom-domain-vercel.md b/docs/wg/platform/multi-tenant-custom-domain-vercel.md new file mode 100644 index 0000000000..26efc56b01 --- /dev/null +++ b/docs/wg/platform/multi-tenant-custom-domain-vercel.md @@ -0,0 +1,416 @@ +--- +title: Multi-tenant Custom Domains on Vercel +--- + +# Multi-tenant Custom Domains on Vercel + +> Long-lived invariants and architectural decisions for Grida custom domain support when Vercel is the edge provider. + +## Audience + +- Core platform engineers +- Infra maintainers +- Future agents working on routing and domain mapping + +## Purpose + +Capture long‑lived architectural truths and invariants for custom domain support. This document intentionally avoids task-level instructions and short-term implementation details. + +--- + +## Problem space + +Grida is a multi‑tenant web platform where tenant content must be addressable via: + +- Platform subdomains (e.g. `tenant.grida.site`) +- User‑owned domains (e.g. `example.com`, `app.example.com`) + +The system must support true apex domains (root domains without subpaths or proxies) while preserving: + +- Tenant isolation +- HTTPS by default +- Predictable routing semantics +- Operational simplicity + +--- + +## Non‑negotiable platform decisions + +The following decisions are foundational and must not be revisited lightly. + +### Hosting & edge provider + +- Vercel is the authoritative edge and routing provider +- All HTTP(S) traffic terminates at Vercel +- TLS certificates are provisioned and renewed by Vercel + +No custom reverse proxies, certificate authorities, or DNS‑level routing layers are introduced. + +### Domain ownership model + +- A domain is an identity key, not a content container +- Each hostname resolves to exactly one tenant +- A tenant may own multiple domains +- A domain may not be shared across tenants + +### DNS responsibility boundary + +- Grida never modifies user DNS records +- Users configure DNS at their registrar +- Grida provides deterministic instructions only + +This keeps legal ownership, trust, and failure modes explicit. + +--- + +## Canonical domain types + +### Apex domains + +**Definition** + +- Root domains without subdomain labels +- Examples: `example.com`, `mybrand.co` + +**Routing constraint** + +- Apex domains cannot use CNAME records +- Must resolve via IPv4 A record + +**Vercel requirement** + +- A record must point to Vercel’s anycast IP: `76.76.21.21` + +This value is considered stable infrastructure knowledge (as long as Vercel remains the edge provider). + +### Subdomains + +**Definition** + +- Domains with a subdomain label +- Examples: `app.example.com`, `links.example.com` + +**Routing constraint** + +- Subdomains resolve via CNAME + +**Vercel model** + +- Each Vercel project exposes a canonical DNS target +- Subdomains must CNAME to that target + +The exact alias may change over time; the abstraction is what matters. + +--- + +## Domain verification semantics + +### Default verification path + +- DNS resolution to the correct Vercel endpoint implies control +- No explicit challenge is required in most cases + +### Explicit verification (edge case) + +Explicit verification is required only when: + +- A domain is already claimed in another Vercel account +- Ownership is ambiguous at the platform level + +Mechanism: + +- Temporary TXT record at the apex +- Token is issued by Vercel + +This is a fallback, not the primary path. + +--- + +## Routing & resolution model + +### Host-based resolution + +Tenant resolution is driven by the HTTP `Host` header, not URL paths. + +Conceptually: + +```text +Host -> Domain Registry -> Tenant Identity +``` + +### Platform domains + +- Known suffix (e.g. `.grida.site`) +- Tenant identity is derived from the subdomain + +### Custom domains + +- Any host not under the platform suffix +- Tenant identity is resolved via a domain mapping registry + +The routing layer must not assume origin or intent — only hostname. + +--- + +## Canonicalization & redirect policy + +### Canonical host + +- If a tenant has a custom domain, it becomes the canonical host +- Platform subdomains are secondary + +### Duplicate hostnames + +Common duplicates: + +- `www.example.com` vs `example.com` +- `tenant.grida.site` vs a custom domain + +Policy: + +- Exactly one canonical hostname per tenant +- All other hostnames must `301` redirect + +This is primarily an SEO and user‑trust concern. + +--- + +## Security invariants + +The following must always hold: + +- No domain may resolve to multiple tenants +- No tenant may claim an unverified domain +- HTTPS is mandatory +- Domain verification is DNS‑based only + +No application‑level trust is placed on user input. + +--- + +## Edge cases & security risks (operationally important) + +This section documents edge cases that commonly confuse operators and create real security risk if handled casually. +These are not “rare” at scale; they are normal internet behavior. + +### Dangling DNS + reclaim hazard after removal + +**Problem** + +If a hostname is removed from Grida/Vercel but the customer **does not** remove the DNS record (A/CNAME) pointing to Vercel, +then the hostname may become **re-attachable** and **re-verifiable** (quickly) by whoever can add it to the Vercel project. + +**Why this matters** + +- DNS resolution is treated as the default proof of control (see “Domain verification semantics”). +- If the DNS still points at Vercel, “control” may appear satisfied. + +**Invariant** + +- A domain must never resolve to the wrong tenant. + +**Mitigation** + +- Treat domain removal as a two-step operation: + - Detach from Vercel (transport identity) + - Remove or change DNS at the registrar (ownership intent) +- Grida should be explicit in UX/runbook: if DNS is left pointing to Vercel, the domain can be re-attached and may route elsewhere. + +This is not a platform bug; it is a property of DNS-based ownership. + +### Domain claimed elsewhere (TXT verification required) + +When Vercel reports that a domain is already claimed in another Vercel account or ownership is ambiguous (see “Domain verification semantics”), +Grida must: + +- Keep the domain in a safe `pending` state +- Surface the TXT record challenge issued by Vercel +- Provide clear instructions and failure transparency (no silent failure) + +### Hostname collision and “www” duplicates + +Common duplicates include: + +- `example.com` vs `www.example.com` +- `tenant.grida.site` vs `example.com` + +Policy (see “Canonicalization & redirect policy”): + +- Exactly one canonical hostname per tenant +- All non-canonical hosts must `301` redirect to the canonical host + +### Reserved and blacklisted hostnames + +To preserve tenant isolation and prevent hijacking: + +- Platform-owned hostnames (app/service) must never be claimable as user custom domains. +- Provider/keyword blacklists may be necessary (e.g. blocking hostnames containing `vercel`) because some provider-owned hostnames + can be immediately verified/linked, creating a hijack surface. + +These lists must be code-owned and reviewed as security primitives. + +### Preview and local development hosts + +Preview URLs and local development hostnames are not stable identities and must not be claimable. +Routing logic must treat them as special cases and avoid polluting the domain registry. + +--- + +## Operational expectations + +### Propagation reality + +- DNS changes are not instant +- Platform must tolerate delayed resolution +- “Pending” is a first‑class state + +### Failure transparency + +Failures must be attributable to one of: + +- DNS misconfiguration +- Ownership verification missing +- External provider error (Vercel) + +Silent failure is unacceptable. + +--- + +## Operator runbook (non-binding but recommended) + +### Adding a domain (apex vs subdomain) + +- **Apex** (`example.com`): instruct user to set `A @ -> 76.76.21.21` (see “Apex domains”). +- **Subdomain** (`app.example.com`): instruct user to set `CNAME app -> ` (see “Subdomains”). + +### Verifying a domain + +- Expect delays: DNS propagation is real (see “Operational expectations”). +- If verification remains pending: + - Check DNS resolution (does it point to Vercel?) + - If Vercel requires TXT challenge, ensure the TXT record is present (see “Domain verification semantics”) + - Re-run verification (Vercel verify endpoint) after DNS changes + +### Removing a domain safely + +Recommended order: + +1. **Remove DNS record** (or change it away from Vercel) at the customer’s registrar +2. Detach the domain from the Vercel project +3. Remove the mapping from Grida + +If step (1) is skipped, the domain may remain effectively “verifiable” while pointed at Vercel, which increases the risk of +unintended reassociation. + +--- + +## Explicit non-goals + +This system intentionally does not support: + +- DNS automation +- Registrar integrations +- Wildcard domains +- Nameserver delegation +- Custom TLS management + +These are architectural escape hatches, not core needs. + +--- + +## Design philosophy + +Custom domains are not a feature — they are infrastructure identity. + +The platform must: + +- Treat domains as stable identifiers +- Avoid cleverness +- Align with how the web actually works + +Anything that violates those principles is a bug, not innovation. + +--- + +## References & conceptual recipes + +This section intentionally contains stable external references and high-level recipes. These are meant for orientation and recall, not copy-paste implementation. + +### Canonical references (Vercel) + +These links are considered authoritative as long as Vercel remains the edge provider: + +- [Vercel — Multi-tenant domain management](https://vercel.com/docs/multi-tenant/domain-management) +- [Vercel — Domains](https://vercel.com/docs/projects/domains) +- [Vercel — Platforms Starter Kit](https://vercel.com/blog/platforms-starter-kit) + +### Reference implementation (community) + +The following project is a **non-authoritative but high-quality reference** that implements the same domain model at scale: + +- Dub.co (open source) + https://github.com/dubinc/dub + +Dub follows the same core principles documented here: + +- Vercel-managed domains and TLS +- DNS-based ownership verification +- Host-based tenant resolution + +This reference is included for practical orientation only. Platform behavior must remain aligned with Vercel’s guarantees, not any third-party implementation. + +### Conceptual recipes (non-binding) + +These are mental checklists, not implementation guides. + +#### Adding a custom domain (conceptual) + +1. Tenant declares intent to use a domain +2. Platform associates domain with the Vercel project +3. Platform instructs user to configure DNS +4. DNS resolution proves control +5. Vercel provisions TLS +6. Platform maps `host -> tenant` + +If any step is skipped, the system must remain in a safe, non-active state. + +#### Resolving a request + +On every request: + +1. Read `Host` header +2. Decide: platform domain vs custom domain +3. Resolve to tenant identity +4. Enforce canonical host (redirect if needed) +5. Serve tenant content + +No request should bypass this sequence. + +#### Diagnosing a broken domain + +If a custom domain is not working, the root cause is almost always one of: + +- DNS record incorrect or missing +- DNS propagation delay +- Domain claimed elsewhere (verification required) +- External provider outage + +Application logic is rarely the culprit. + +--- + +## Longevity statement + +This document is expected to remain valid across: + +- UI rewrites +- Routing refactors +- Backend framework changes +- Agent turnover + +As long as: + +- Vercel remains the edge provider +- DNS remains the ownership primitive + +If either assumption changes, this document must be revisited. diff --git a/editor/.env.example b/editor/.env.example index 59e27efd99..04b54d83df 100644 --- a/editor/.env.example +++ b/editor/.env.example @@ -1,10 +1,19 @@ # [required] # supabase # run `supabase start` or `supabase status -o env` to get the local keys -NEXT_PUBLIC_SUPABASE_URL="http://127.0.0.1:54321" +SUPABASE_URL="http://127.0.0.1:54321" # same as NEXT_PUBLIC_SUPABASE_URL +NEXT_PUBLIC_SUPABASE_URL="http://127.0.0.1:54321" # same as SUPABASE_URL NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY="sb_publishable_..." SUPABASE_SECRET_KEY="sb_secret_..." +# [required] +# public app url (host only, no scheme) +# used for canonical host logic and tenant parsing in hosted environments +# examples: +# NEXT_PUBLIC_URL="grida.co" +# NEXT_PUBLIC_URL="canary.grida.co" +NEXT_PUBLIC_URL="grida.co" + # supabase (additional) (only required when using grida_ciam) (see //supabase/README.md) SUPABASE_SIGNING_KEY_JSON='{"kty":"EC","kid":"...","use":"sig","key_ops":["sign","verify"],"alg":"ES256","ext": true,"d": "...","crv":"P-256","x":"...","y":"..."}' @@ -35,6 +44,11 @@ NEXT_PUBLIC_OPENAI_BEST_MODEL_ID="gpt-4o-mini" # resend RESEND_API_KEY='re_123' +# upstash redis (used for rate limiting) +# @see https://upstash.com/docs/redis/overall/getstarted +UPSTASH_REDIS_REST_URL="" +UPSTASH_REDIS_REST_TOKEN="" + # vercel VERCEL_AUTH_BEARER_TOKEN='' # (get it from https://vercel.com/account/tokens) # Vercel Team ID that can be found here: https://vercel.com/teams//settings @@ -43,6 +57,10 @@ VERCEL_TEAM_ID='' # Vercel Project ID that can be found here: https://vercel.com///settings VERCEL_PROJECT_ID='' +# internal (proxy -> internal api auth) +# used to authenticate requests from `proxy.ts` to `/internal/resolve-host` +GRIDA_INTERNAL_PROXY_TOKEN="" + # telemetry (sentry) NEXT_PUBLIC_GRIDA_USE_TELEMETRY="0" # set to 1 to enable diff --git a/editor/app/(api)/internal/resolve-host/route.ts b/editor/app/(api)/internal/resolve-host/route.ts new file mode 100644 index 0000000000..631f5e995f --- /dev/null +++ b/editor/app/(api)/internal/resolve-host/route.ts @@ -0,0 +1,130 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { unstable_cache } from "next/cache"; +import { serviceRolePublicClient } from "@/lib/supabase/service-role-cookie-free-clients"; +import { isReservedAppHostname } from "@/lib/domains"; + +import { + DEFAULT_PLATFORM_APEX_DOMAIN, + isPlatformSiteHostname, + platformSiteHostnameForTenant, + platformSiteTenantFromHostname, +} from "@/lib/domains"; + +function normalizeHost(input: string) { + const host = input.trim().toLowerCase(); + // strip port if present + return host.split(":")[0] ?? host; +} + +function isPlatformHost(hostname: string) { + return isPlatformSiteHostname(hostname); +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function firstCanonicalHostname(data: unknown): string | null { + if (!Array.isArray(data) || data.length === 0) return null; + const first = data[0]; + if (!isPlainObject(first)) return null; + const v = first.canonical_hostname; + return typeof v === "string" || v === null ? v : null; +} + +type Resolution = { + www_name: string; + canonical_host: string; + source: "custom" | "platform"; +} | null; + +const resolveHostCached = unstable_cache( + async (hostname: string): Promise => { + const pub = serviceRolePublicClient(); + + // App hosts are never tenant identities. + if (isReservedAppHostname(hostname)) return null; + + // Custom domain: resolve by hostname mapping. + if (!isPlatformHost(hostname)) { + const { data, error } = await pub.rpc("www_resolve_hostname", { + p_hostname: hostname, + }); + + if (error || !Array.isArray(data) || data.length === 0) return null; + + const row = data[0] as unknown as { + www_name: string; + canonical_hostname: string | null; + }; + + if (!row?.www_name) return null; + + return { + www_name: row.www_name, + canonical_host: + row.canonical_hostname ?? + platformSiteHostnameForTenant( + row.www_name, + DEFAULT_PLATFORM_APEX_DOMAIN + ), + source: "custom", + }; + } + + // Platform host: derive tenant name from hostname, then fetch canonical. + const parsed = platformSiteTenantFromHostname(hostname); + const tenant = parsed?.tenant ?? null; + if (!tenant) return null; + + const { data, error } = await pub.rpc("www_get_canonical_hostname", { + p_www_name: tenant, + }); + + const canonical = error ? null : firstCanonicalHostname(data); + + return { + www_name: tenant, + canonical_host: + canonical ?? + platformSiteHostnameForTenant(tenant, DEFAULT_PLATFORM_APEX_DOMAIN), + source: "platform", + }; + }, + ["grida:resolve-host"], + { revalidate: 60, tags: ["grida:domain-registry"] } +); + +export async function GET(req: NextRequest) { + const token = req.headers.get("x-grida-internal-token"); + const expected = process.env.GRIDA_INTERNAL_PROXY_TOKEN; + + if (!expected || token !== expected) { + return NextResponse.json( + { error: "unauthorized" }, + { status: 401, headers: { "cache-control": "no-store" } } + ); + } + + const host = req.nextUrl.searchParams.get("host"); + if (!host) { + return NextResponse.json( + { error: "missing host" }, + { status: 400, headers: { "cache-control": "no-store" } } + ); + } + + const hostname = normalizeHost(host); + if (!hostname) { + return NextResponse.json( + { error: "invalid host" }, + { status: 400, headers: { "cache-control": "no-store" } } + ); + } + + const data = await resolveHostCached(hostname); + return NextResponse.json( + { data }, + { status: 200, headers: { "cache-control": "no-store" } } + ); +} diff --git a/editor/app/(api)/private/domains/[domain]/verify/route.ts b/editor/app/(api)/private/domains/[domain]/verify/route.ts deleted file mode 100644 index 95abbba799..0000000000 --- a/editor/app/(api)/private/domains/[domain]/verify/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { projectsVerifyProjectDomain } from "@/clients/vercel"; -import { NextRequest, NextResponse } from "next/server"; - -export async function GET(req: NextRequest) { - // const reqjson = await req.json() - const r = await projectsVerifyProjectDomain("domain-name.com"); - console.log(r); - - return NextResponse.json(r, { - status: 200, - }); -} diff --git a/editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/_refresh.ts b/editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/_refresh.ts new file mode 100644 index 0000000000..01a1c57148 --- /dev/null +++ b/editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/_refresh.ts @@ -0,0 +1,231 @@ +import { + projectsGetProjectDomain, + projectsVerifyProjectDomain, + vercelGetDomainConfig, +} from "@/clients/vercel"; +import { normalizeHostname } from "@/lib/domains"; +import { createClient, createWWWClient } from "@/lib/supabase/server"; +import { NextResponse, type NextRequest } from "next/server"; +import { notFound } from "next/navigation"; +import { revalidateTag } from "next/cache"; + +const IS_VERCEL_HOSTED = process.env.VERCEL === "1"; +const IS_VERCEL_PROD = process.env.VERCEL_ENV === "production"; + +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function errorMessage(e: unknown): string | null { + if (e instanceof Error) return e.message; + if (isPlainObject(e) && typeof e.message === "string") return e.message; + return null; +} + +function errorBody(e: unknown): unknown | null { + if (isPlainObject(e) && "body" in e) return (e as { body?: unknown }).body ?? null; + return null; +} + +type VercelDomainConfig = { + misconfigured: boolean; +} & Record; + +function isVercelDomainConfig(v: unknown): v is VercelDomainConfig { + return isPlainObject(v) && typeof v.misconfigured === "boolean"; +} + +// Local JSON type compatible with Supabase `jsonb` columns. +type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[]; + +function toJson(value: unknown): Json | null { + try { + return JSON.parse(JSON.stringify(value)) as Json; + } catch { + return null; + } +} + +type Params = { + org: string; + proj: string; + hostname: string; +}; + +export async function refreshDomain( + req: NextRequest, + { params }: { params: Promise } +) { + const { org, proj, hostname: rawHostname } = await params; + + if (IS_VERCEL_HOSTED && !IS_VERCEL_PROD) { + return NextResponse.json( + { + error: { + code: "DOMAIN_MANAGEMENT_DISABLED_IN_THIS_ENV", + message: "Custom domain management is enabled only in production.", + }, + }, + { status: 403 } + ); + } + + const hostname = normalizeHostname(rawHostname); + if (!hostname) { + return NextResponse.json( + { error: { code: "INVALID_HOSTNAME", message: "Invalid hostname." } }, + { status: 400 } + ); + } + + const client = await createClient(); + const wwwClient = await createWWWClient(); + + const { data: project, error: project_err } = await client + .rpc("find_project", { p_org_ref: org, p_proj_ref: proj }, { get: true }) + .single(); + if (project_err) return notFound(); + + const { data: www, error: www_err } = await wwwClient + .from("www") + .select("id, name") + .eq("project_id", project.id) + .single(); + if (www_err) return notFound(); + + const { data: domain_row, error: domain_err } = await wwwClient + .from("domain") + .select("id, canonical, vercel") + .eq("www_id", www.id) + .ilike("hostname", hostname) + .single(); + + if (domain_err || !domain_row) return notFound(); + + let vercel_verify: unknown = null; + let vercel_domain: Awaited> | null = + null; + let vercel_config: unknown = null; + try { + vercel_verify = await projectsVerifyProjectDomain(hostname); + vercel_domain = await projectsGetProjectDomain(hostname); + vercel_config = await vercelGetDomainConfig(hostname); + } catch (e: unknown) { + const prevObj = isPlainObject(domain_row.vercel) ? domain_row.vercel : {}; + await wwwClient + .from("domain") + .update({ + status: "error", + last_checked_at: new Date().toISOString(), + last_error: errorMessage(e) ?? "Vercel domain refresh failed.", + last_error_code: "VERCEL_API_ERROR", + vercel: toJson({ + ...prevObj, + verify: vercel_verify, + domain: null, + config: null, + }), + }) + .eq("id", domain_row.id); + + return NextResponse.json( + { + error: { + code: "VERCEL_ERROR", + message: errorMessage(e) ?? "Vercel domain refresh failed.", + provider: { name: "vercel", detail: errorBody(e) }, + }, + }, + { status: 502 } + ); + } + + // Vercel has two relevant concepts: + // - `domain.verified`: ownership verified for use on the project + // - `config.misconfigured`: whether DNS is correctly configured and Vercel can issue TLS + // + // For Grida routing, "active" should mean the domain is actually ready to serve HTTPS traffic, + // so we require both ownership verification and a non-misconfigured config. + const ownershipVerified = vercel_domain?.verified === true; + const cfg = isVercelDomainConfig(vercel_config) ? vercel_config : null; + const properlyConfigured = cfg?.misconfigured === false; + const active = ownershipVerified && properlyConfigured; + + const verification = vercel_domain?.verification; + const verificationRequired = + ownershipVerified === false && + Array.isArray(verification) && + verification.length > 0; + + const last_error_code = active + ? null + : verificationRequired + ? "VERIFICATION_REQUIRED" + : ownershipVerified + ? properlyConfigured + ? null + : "DNS_MISCONFIGURED" + : null; + + const { data: updated, error: update_err } = await wwwClient + .from("domain") + .update({ + status: active ? "active" : "pending", + last_checked_at: new Date().toISOString(), + last_verified_at: active ? new Date().toISOString() : null, + last_error: null, + last_error_code, + vercel: (() => { + const prevObj = isPlainObject(domain_row.vercel) ? domain_row.vercel : {}; + return toJson({ + ...prevObj, + verify: vercel_verify, + domain: vercel_domain, + config: vercel_config, + }); + })(), + }) + .eq("id", domain_row.id) + .select() + .single(); + + if (update_err) { + return NextResponse.json( + { error: { code: "DB_ERROR", message: update_err.message } }, + { status: 500 } + ); + } + + // If this domain just became active AND is flagged canonical ("make primary once active"), + // enforce single-canonical by clearing other canonicals. + if (active && domain_row.canonical === true) { + await wwwClient + .from("domain") + .update({ canonical: false }) + .eq("www_id", www.id) + .neq("id", domain_row.id); + } + + // TODO(optimization): sync Redis routing index here (write-through cache). + // Reliability-first routing should resolve from DB even if Redis is empty. + revalidateTag("grida:domain-registry", "max"); + + return NextResponse.json({ + data: { + www: { id: www.id, name: www.name }, + domain: updated, + provider: { + name: "vercel", + verify: vercel_verify, + domain: vercel_domain, + config: vercel_config, + }, + }, + }); +} diff --git a/editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/canonical/route.ts b/editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/canonical/route.ts new file mode 100644 index 0000000000..d9674c16f8 --- /dev/null +++ b/editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/canonical/route.ts @@ -0,0 +1,101 @@ +import { normalizeHostname } from "@/lib/domains"; +import { createClient, createWWWClient } from "@/lib/supabase/server"; +import { NextResponse, type NextRequest } from "next/server"; +import { notFound } from "next/navigation"; +import { revalidateTag } from "next/cache"; + +type Params = { + org: string; + proj: string; + hostname: string; +}; + +export async function POST( + req: NextRequest, + { params }: { params: Promise } +) { + const { org, proj, hostname: rawHostname } = await params; + + const hostname = normalizeHostname(rawHostname); + if (!hostname) { + return NextResponse.json( + { error: { code: "INVALID_HOSTNAME", message: "Invalid hostname." } }, + { status: 400 } + ); + } + + const client = await createClient(); + const wwwClient = await createWWWClient(); + + const { data: project, error: project_err } = await client + .rpc( + "find_project", + { p_org_ref: org, p_proj_ref: proj }, + { get: true } + ) + .single(); + if (project_err) return notFound(); + + const { data: www, error: www_err } = await wwwClient + .from("www") + .select("id, name") + .eq("project_id", project.id) + .single(); + if (www_err) return notFound(); + + const { data: target, error: target_err } = await wwwClient + .from("domain") + .select("id, hostname, status") + .eq("www_id", www.id) + .ilike("hostname", hostname) + .single(); + + if (target_err || !target) return notFound(); + + // Only allow canonicalizing active domains. + if (target.status !== "active") { + return NextResponse.json( + { + error: { + code: "DOMAIN_NOT_ACTIVE", + message: "Only active domains can be set as canonical.", + }, + }, + { status: 409 } + ); + } + + // Set requested domain canonical, clear others. + const { error: clear_err } = await wwwClient + .from("domain") + .update({ canonical: false }) + .eq("www_id", www.id); + if (clear_err) { + return NextResponse.json( + { error: { code: "DB_ERROR", message: clear_err.message } }, + { status: 500 } + ); + } + + const { data: updated, error: set_err } = await wwwClient + .from("domain") + .update({ canonical: true }) + .eq("id", target.id) + .select() + .single(); + if (set_err) { + return NextResponse.json( + { error: { code: "DB_ERROR", message: set_err.message } }, + { status: 500 } + ); + } + + // TODO(optimization): sync Redis routing index here (write-through cache). + // Reliability-first routing should resolve from DB even if Redis is empty. + revalidateTag("grida:domain-registry", "max"); + + return NextResponse.json({ + data: { www: { id: www.id, name: www.name }, domain: updated }, + }); +} + diff --git a/editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/refresh/route.ts b/editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/refresh/route.ts new file mode 100644 index 0000000000..504506a097 --- /dev/null +++ b/editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/refresh/route.ts @@ -0,0 +1,9 @@ +import type { NextRequest } from "next/server"; +import { refreshDomain } from "../_refresh"; + +export async function POST( + req: NextRequest, + ctx: { params: Promise<{ org: string; proj: string; hostname: string }> } +) { + return refreshDomain(req, ctx); +} diff --git a/editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/route.ts b/editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/route.ts new file mode 100644 index 0000000000..91e70b9aab --- /dev/null +++ b/editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/route.ts @@ -0,0 +1,118 @@ +import { projectsRemoveProjectDomain } from "@/clients/vercel"; +import { normalizeHostname } from "@/lib/domains"; +import { createClient, createWWWClient } from "@/lib/supabase/server"; +import { NextResponse, type NextRequest } from "next/server"; +import { notFound } from "next/navigation"; +import { revalidateTag } from "next/cache"; + +const IS_VERCEL_HOSTED = process.env.VERCEL === "1"; +const IS_VERCEL_PROD = process.env.VERCEL_ENV === "production"; + +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function errorMessage(e: unknown): string | null { + if (e instanceof Error) return e.message; + if (isPlainObject(e) && typeof e.message === "string") return e.message; + return null; +} + +function errorBody(e: unknown): unknown | null { + if (isPlainObject(e) && "body" in e) return (e as { body?: unknown }).body ?? null; + return null; +} + +type Params = { + org: string; + proj: string; + hostname: string; +}; + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise } +) { + const { org, proj, hostname: rawHostname } = await params; + + if (IS_VERCEL_HOSTED && !IS_VERCEL_PROD) { + return NextResponse.json( + { + error: { + code: "DOMAIN_MANAGEMENT_DISABLED_IN_THIS_ENV", + message: "Custom domain management is enabled only in production.", + }, + }, + { status: 403 } + ); + } + + const hostname = normalizeHostname(rawHostname); + if (!hostname) { + return NextResponse.json( + { error: { code: "INVALID_HOSTNAME", message: "Invalid hostname." } }, + { status: 400 } + ); + } + + const client = await createClient(); + const wwwClient = await createWWWClient(); + + const { data: project, error: project_err } = await client + .rpc("find_project", { p_org_ref: org, p_proj_ref: proj }, { get: true }) + .single(); + if (project_err) return notFound(); + + const { data: www, error: www_err } = await wwwClient + .from("www") + .select("id, name") + .eq("project_id", project.id) + .single(); + if (www_err) return notFound(); + + const { data: domain_row, error: domain_err } = await wwwClient + .from("domain") + .select("id, canonical") + .eq("www_id", www.id) + .ilike("hostname", hostname) + .single(); + if (domain_err || !domain_row) return notFound(); + + // Remove from Vercel project + try { + await projectsRemoveProjectDomain(hostname); + } catch (e: unknown) { + return NextResponse.json( + { + error: { + code: "VERCEL_ERROR", + message: errorMessage(e) ?? "Vercel domain removal failed.", + provider: { name: "vercel", detail: errorBody(e) }, + }, + }, + { status: 502 } + ); + } + + const { error: del_err } = await wwwClient + .from("domain") + .delete() + .eq("id", domain_row.id); + if (del_err) { + return NextResponse.json( + { error: { code: "DB_ERROR", message: del_err.message } }, + { status: 500 } + ); + } + + revalidateTag("grida:domain-registry", "max"); + + // If canonical was removed, fall back to platform domain (no further action needed). + return NextResponse.json({ + data: { + ok: true, + removed: hostname, + canonical_was_removed: domain_row.canonical, + }, + }); +} diff --git a/editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/verify/route.ts b/editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/verify/route.ts new file mode 100644 index 0000000000..ab9915e4d0 --- /dev/null +++ b/editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/verify/route.ts @@ -0,0 +1,10 @@ +import type { NextRequest } from "next/server"; +import { refreshDomain } from "../_refresh"; + +export async function POST( + req: NextRequest, + ctx: { params: Promise<{ org: string; proj: string; hostname: string }> } +) { + // Backwards-compatible alias for "refresh". + return refreshDomain(req, ctx); +} diff --git a/editor/app/(api)/private/~/[org]/[proj]/www/domains/platform/canonical/route.ts b/editor/app/(api)/private/~/[org]/[proj]/www/domains/platform/canonical/route.ts new file mode 100644 index 0000000000..5650405078 --- /dev/null +++ b/editor/app/(api)/private/~/[org]/[proj]/www/domains/platform/canonical/route.ts @@ -0,0 +1,53 @@ +import { createClient, createWWWClient } from "@/lib/supabase/server"; +import { NextResponse, type NextRequest } from "next/server"; +import { notFound } from "next/navigation"; +import { revalidateTag } from "next/cache"; + +type Params = { + org: string; + proj: string; +}; + +/** + * Make the platform domain the primary domain by clearing + * any custom-domain canonical flags for this tenant. + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise } +) { + const { org, proj } = await params; + + const client = await createClient(); + const wwwClient = await createWWWClient(); + + const { data: project, error: project_err } = await client + .rpc("find_project", { p_org_ref: org, p_proj_ref: proj }, { get: true }) + .single(); + if (project_err) return notFound(); + + const { data: www, error: www_err } = await wwwClient + .from("www") + .select("id, name") + .eq("project_id", project.id) + .single(); + if (www_err) return notFound(); + + const { error: clear_err } = await wwwClient + .from("domain") + .update({ canonical: false }) + .eq("www_id", www.id); + + if (clear_err) { + return NextResponse.json( + { error: { code: "DB_ERROR", message: clear_err.message } }, + { status: 500 } + ); + } + + // TODO(optimization): sync Redis routing index here (write-through cache). + // Reliability-first routing should resolve from DB even if Redis is empty. + revalidateTag("grida:domain-registry", "max"); + + return NextResponse.json({ data: { ok: true, primary: "platform" } }); +} diff --git a/editor/app/(api)/private/~/[org]/[proj]/www/domains/route.ts b/editor/app/(api)/private/~/[org]/[proj]/www/domains/route.ts new file mode 100644 index 0000000000..50a8437f17 --- /dev/null +++ b/editor/app/(api)/private/~/[org]/[proj]/www/domains/route.ts @@ -0,0 +1,341 @@ +import { + projectsAddProjectDomain, + projectsGetProjectDomain, +} from "@/clients/vercel"; +import { vercelGetDomainConfig } from "@/clients/vercel"; +import { + isBlacklistedHostname, + isPlatformSiteHostname, + isReservedAppHostname, + normalizeHostname, +} from "@/lib/domains"; +import { createClient, createWWWClient } from "@/lib/supabase/server"; +import { NextResponse, type NextRequest } from "next/server"; +import { notFound } from "next/navigation"; +import { revalidateTag } from "next/cache"; + +const IS_VERCEL_HOSTED = process.env.VERCEL === "1"; +const IS_VERCEL_PROD = process.env.VERCEL_ENV === "production"; + +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function errorMessage(e: unknown): string | null { + if (e instanceof Error) return e.message; + if (isPlainObject(e) && typeof e.message === "string") return e.message; + return null; +} + +function errorBody(e: unknown): unknown | null { + if (isPlainObject(e) && "body" in e) return (e as { body?: unknown }).body ?? null; + return null; +} + +type VercelDomainConfig = { + misconfigured: boolean; +} & Record; + +function isVercelDomainConfig(v: unknown): v is VercelDomainConfig { + return isPlainObject(v) && typeof v.misconfigured === "boolean"; +} + +// Local JSON type compatible with Supabase `jsonb` columns. +type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[]; + +function toJson(value: unknown): Json | null { + try { + return JSON.parse(JSON.stringify(value)) as Json; + } catch { + return null; + } +} + +type Params = { + org: string; + proj: string; +}; + +type DomainRow = { + id: string; + www_id: string; + hostname: string; + status: "pending" | "active" | "error"; + canonical: boolean; + kind: "apex" | "subdomain"; + vercel: unknown | null; + last_verified_at: string | null; + last_error: string | null; + created_at: string; + updated_at: string; +}; + +function log_domains_api_error( + message: string, + context: Record, + error?: unknown +) { + // Keep logs high-signal for local debugging and production ops. + // Avoid logging secrets; hostname/org/proj are safe and actionable. + if (error) { + console.error(`[www/domains] ${message}`, context, error); + } else { + console.error(`[www/domains] ${message}`, context); + } +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise } +) { + const { org, proj } = await params; + + const context = { method: "GET", org, proj }; + + try { + const client = await createClient(); + const wwwClient = await createWWWClient(); + + const { data: project, error: project_err } = await client + .rpc("find_project", { p_org_ref: org, p_proj_ref: proj }, { get: true }) + .single(); + if (project_err) return notFound(); + + const { data: www, error: www_err } = await wwwClient + .from("www") + .select("id, name") + .eq("project_id", project.id) + .single(); + if (www_err) return notFound(); + + const { data: domains, error } = await wwwClient + .from("domain") + .select("*") + .eq("www_id", www.id) + .order("canonical", { ascending: false }) + .order("created_at", { ascending: true }); + + if (error) { + log_domains_api_error( + "db error listing domains", + { ...context, www_id: www.id }, + error + ); + return NextResponse.json( + { error: { code: "DB_ERROR", message: error.message } }, + { status: 500 } + ); + } + + return NextResponse.json({ + data: { + www: { id: www.id, name: www.name }, + domains: (domains ?? []) as DomainRow[], + }, + }); + } catch (e) { + log_domains_api_error("unexpected error", context, e); + return NextResponse.json( + { error: { code: "INTERNAL_ERROR", message: "Internal server error." } }, + { status: 500 } + ); + } +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise } +) { + const { org, proj } = await params; + const contextBase = { method: "POST", org, proj }; + + if (IS_VERCEL_HOSTED && !IS_VERCEL_PROD) { + return NextResponse.json( + { + error: { + code: "DOMAIN_MANAGEMENT_DISABLED_IN_THIS_ENV", + message: "Custom domain management is enabled only in production.", + }, + }, + { status: 403 } + ); + } + + const body = (await req.json().catch(() => null)) as { + hostname?: string; + canonical?: boolean; + } | null; + + const hostname = normalizeHostname(body?.hostname ?? ""); + if (!hostname) { + return NextResponse.json( + { error: { code: "INVALID_HOSTNAME", message: "Invalid hostname." } }, + { status: 400 } + ); + } + + // Disallow platform domains as “custom domains”. + if (isPlatformSiteHostname(hostname)) { + return NextResponse.json( + { + error: { + code: "HOSTNAME_NOT_ALLOWED", + message: "Platform domains cannot be attached as custom domains.", + }, + }, + { status: 400 } + ); + } + + // Disallow Grida-owned app hostnames (and subdomains) to prevent hijacking. + if (isReservedAppHostname(hostname)) { + return NextResponse.json( + { + error: { + message: + "This hostname is reserved by Grida and cannot be attached as a custom domain.", + }, + }, + { status: 400 } + ); + } + + // Disallow blacklisted hostnames (provider-owned / keyword blocked). + if (isBlacklistedHostname(hostname)) { + return NextResponse.json( + { + error: { + message: + "This hostname is not allowed. Please use a different domain name.", + }, + }, + { status: 400 } + ); + } + + const context = { + ...contextBase, + hostname, + canonical: body?.canonical === true, + }; + + try { + const client = await createClient(); + const wwwClient = await createWWWClient(); + + const { data: project, error: project_err } = await client + .rpc("find_project", { p_org_ref: org, p_proj_ref: proj }, { get: true }) + .single(); + if (project_err) return notFound(); + + const { data: www, error: www_err } = await wwwClient + .from("www") + .select("id, name") + .eq("project_id", project.id) + .single(); + if (www_err) return notFound(); + + // 1) Attach to Vercel project + let vercel_add: unknown = null; + let vercel_domain: Awaited> | null = + null; + let vercel_config: unknown = null; + try { + vercel_add = await projectsAddProjectDomain(hostname); + vercel_domain = await projectsGetProjectDomain(hostname); + vercel_config = await vercelGetDomainConfig(hostname); + } catch (e: unknown) { + log_domains_api_error("vercel domain attach failed", context, e); + return NextResponse.json( + { + error: { + code: "VERCEL_ERROR", + message: errorMessage(e) ?? "Vercel domain operation failed.", + provider: { name: "vercel", detail: errorBody(e) }, + }, + }, + { status: 502 } + ); + } + + // 2) Persist in DB (pending by default; UI can trigger verify/refresh) + const canonical = body?.canonical === true; + const ownershipVerified = vercel_domain?.verified === true; + const cfg = isVercelDomainConfig(vercel_config) ? vercel_config : null; + const properlyConfigured = cfg?.misconfigured === false; + const verification = vercel_domain?.verification; + const verificationRequired = + ownershipVerified === false && + Array.isArray(verification) && + verification.length > 0; + + const last_error_code = + ownershipVerified && properlyConfigured + ? null + : verificationRequired + ? "VERIFICATION_REQUIRED" + : properlyConfigured + ? null + : "DNS_MISCONFIGURED"; + + const { data: inserted, error: insert_err } = await wwwClient + .from("domain") + .insert({ + www_id: www.id, + hostname, + // "canonical" here means "make this primary once active". + // We only enforce "single canonical" among *active* domains at the DB level. + canonical, + status: "pending", + last_checked_at: new Date().toISOString(), + last_error_code, + vercel: { + add: toJson(vercel_add), + domain: toJson(vercel_domain), + config: toJson(vercel_config), + }, + }) + .select() + .single(); + + if (insert_err) { + log_domains_api_error( + "db error inserting domain", + { ...context, www_id: www.id, www_name: www.name }, + insert_err + ); + return NextResponse.json( + { error: { code: "DB_ERROR", message: insert_err.message } }, + { status: 500 } + ); + } + + // NOTE: + // We intentionally do NOT clear other canonical domains here. + // Primary routing uses the *active* canonical domain only; pending canonicals do not affect routing. + // Canonical conflicts are resolved when a domain becomes active (verify) or when explicitly set canonical. + + // Invalidate internal resolver cache (best-effort). + revalidateTag("grida:domain-registry", "max"); + + return NextResponse.json({ + data: { + www: { id: www.id, name: www.name }, + domain: inserted as DomainRow, + provider: { name: "vercel", add: vercel_add, domain: vercel_domain }, + }, + }); + } catch (e) { + log_domains_api_error("unexpected error", context, e); + return NextResponse.json( + { error: { code: "INTERNAL_ERROR", message: "Internal server error." } }, + { status: 500 } + ); + } +} diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(new)/new/referral/welcome-dialog.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(new)/new/referral/welcome-dialog.tsx index 42526b9a19..0431351295 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(new)/new/referral/welcome-dialog.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(new)/new/referral/welcome-dialog.tsx @@ -4,6 +4,8 @@ import { AlertDialogContent, AlertDialogFooter, AlertDialogCancel, + AlertDialogTitle, + AlertDialogDescription, } from "@/components/ui/alert-dialog"; import Image from "next/image"; @@ -13,6 +15,11 @@ export default function WelcomeDialog( return ( + WelcomeDialog + + You're about to launch a referral campaign powered by{" "} + Grida WEST — the easiest way to grow through sharing. +
createBrowserWWWClient(), []); + + const key = "site-domains"; + + const { data, isLoading, error } = useSWR( + key, + async () => { + const { data } = await client + .from("www") + .select("id, name, project_id") + .eq("project_id", project.id) + .single() + .throwOnError(); + + return data satisfies ProjectWWWMinimal; + } + ); + + const checkDomainName = useCallback( + async (name: string) => { + const { data: available, error } = await client.rpc( + "check_www_name_available", + { + p_name: name, + } + ); + + if (error) return false; + return available; + }, + [client] + ); + + const changeDomainName = useCallback( + async (name: string) => { + if (!data) return false; + const { error } = await client.rpc("change_www_name", { + p_www_id: data.id, + p_name: name, + }); + + if (error) return false; + + // Keep both pages in sync if they're open in tabs. + mutate(key); + mutate("site"); + return true; + }, + [client, data] + ); + + return { + project, + data, + isLoading, + error, + checkDomainName, + changeDomainName, + }; +} + +export default function DomainsPage() { + const { project, data, isLoading, checkDomainName, changeDomainName } = + useDomainsPageData(); + + if (isLoading || !data) { + return ( +
+ + + +
+ ); + } + + return ( +
+ { + const available = await checkDomainName(name); + if (!available) return false; + return await changeDomainName(name); + }} + /> +
+ ); +} diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/domains/section-domains.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/domains/section-domains.tsx new file mode 100644 index 0000000000..f17f41707a --- /dev/null +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/domains/section-domains.tsx @@ -0,0 +1,712 @@ +"use client"; + +import React, { useMemo, useState } from "react"; +import useSWR, { mutate } from "swr"; +import { toast } from "sonner"; +import { CopyToClipboardInput } from "@/components/copy-to-clipboard-input"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; +import { Spinner } from "@/components/ui/spinner"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/components/lib/utils/index"; +import { + DotsHorizontalIcon, + Pencil2Icon, + PlusIcon, +} from "@radix-ui/react-icons"; +import { CheckCircle2, XCircle } from "lucide-react"; + +type DomainStatus = "pending" | "active" | "error"; +type DomainKind = "apex" | "subdomain"; + +type DomainRow = { + id: string; + hostname: string; + status: DomainStatus; + canonical: boolean; + kind: DomainKind; + last_error: string | null; + vercel: unknown | null; +}; + +type ApiListResponse = { + data?: { + www: { id: string; name: string }; + domains: DomainRow[]; + }; + error?: { code: string; message: string }; +}; + +const fetcher = (url: string) => fetch(url).then((r) => r.json()); + +function toHostPathSegment(hostname: string) { + // Next.js route segment for [hostname] is a single segment, so encode dots. + return encodeURIComponent(hostname); +} + +type DnsInstruction = { + type: string; + name: string; + value: string; +}; + +// Dub-style deterministic “base” DNS instructions. +// +// - Apex domains route to Vercel via A record. +// - Subdomains route via a CNAME. We use a branded alias (`cname.grida.co`) that +// you configure in Grida’s own DNS (Cloudflare) as a CNAME to Vercel's target. +// +// This keeps UI stable and avoids coupling UX to provider response shapes. +const VERCEL_APEX_A_RECORD_VALUE = "76.76.21.21"; +const GRIDA_VERCEL_CNAME_ALIAS = "cname.grida.co"; + +function dnsInstructions(domain: DomainRow): DnsInstruction[] { + // Use provider payload when available, but always include deterministic base records: + // - Apex: A @ -> 76.76.21.21 + // - Subdomain: CNAME -> cname.grida.co + // + // Important: Vercel's `verification[]` is about ownership challenges (commonly TXT), + // and does NOT replace the base A/CNAME routing record. So we append verification + // records to the base instructions when present. + const vercel = domain.vercel as unknown; + const vercelDomain = (vercel && + typeof vercel === "object" && + !Array.isArray(vercel) && + "domain" in vercel) + ? ((vercel as { domain?: unknown }).domain as + | { name?: string; apexName?: string; verification?: unknown[] } + | undefined) + : undefined; + + const fqdn = String(vercelDomain?.name ?? domain.hostname); + const apexName = String( + vercelDomain?.apexName ?? fqdn.split(".").slice(-2).join(".") + ); + + const nameRelativeToApex = + fqdn === apexName + ? "@" + : fqdn.endsWith(`.${apexName}`) + ? fqdn.slice(0, -1 * `.${apexName}`.length) + : (fqdn.split(".")[0] ?? "@"); + + const base: DnsInstruction[] = + domain.kind === "apex" + ? [{ type: "A", name: "@", value: VERCEL_APEX_A_RECORD_VALUE }] + : [ + { + type: "CNAME", + name: nameRelativeToApex, + value: GRIDA_VERCEL_CNAME_ALIAS, + }, + ]; + + const verification = vercelDomain?.verification; + const provider: DnsInstruction[] = Array.isArray(verification) + ? verification + .map((v: unknown) => { + if (!v || typeof v !== "object" || Array.isArray(v)) return null; + const o = v as Record; + const type = o.type ?? o.recordType ?? o.kind ?? "DNS"; + const name = o.domain ?? o.name ?? o.recordName ?? "@"; + const value = o.value ?? o.target ?? o.expectedValue ?? ""; + return { + type: String(type).toUpperCase(), + name: String(name), + value: String(value), + } satisfies DnsInstruction; + }) + .filter((x): x is DnsInstruction => Boolean(x?.type && x?.name && x?.value)) + : []; + + // Dedupe. + const out: DnsInstruction[] = []; + const seen = new Set(); + for (const r of [...base, ...provider]) { + const key = `${r.type}|${r.name}|${r.value}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(r); + } + + return out; +} + +function StatusIcon({ status }: { status: DomainStatus }) { + switch (status) { + case "active": + return ; + case "pending": + // Not verified yet. + return ; + case "error": + return ; + } +} + +export function CustomDomainsSection({ + org, + proj, + platformName, + platformDomain, + onPlatformNameChange, +}: { + org: string; + proj: string; + platformName: string; // e.g. "tenant" + platformDomain: string; // e.g. "tenant.grida.site" + onPlatformNameChange: (name: string) => Promise; +}) { + const apiBase = useMemo( + () => `/private/~/${org}/${proj}/www/domains`, + [org, proj] + ); + + const { data, isLoading } = useSWR(apiBase, fetcher); + + const domains = data?.data?.domains ?? []; + const activeCanonical = domains.find( + (d) => d.canonical === true && d.status === "active" + ); + const primaryHostname = activeCanonical?.hostname ?? platformDomain; + + const [hostname, setHostname] = useState(""); + const [setAsCanonical, setSetAsCanonical] = useState(true); + const [busy, setBusy] = useState(false); + const [refreshing, setRefreshing] = useState>({}); + const [addOpen, setAddOpen] = useState(false); + const [renameOpen, setRenameOpen] = useState(false); + const [setupOpen, setSetupOpen] = useState>({}); + + const refresh = () => mutate(apiBase); + + const addDomain = async () => { + setBusy(true); + try { + const res = await fetch(apiBase, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + hostname, + canonical: setAsCanonical, + }), + }); + const json = (await res.json()) as ApiListResponse; + if (!res.ok) { + toast.error(json.error?.message ?? "Failed to add domain"); + return; + } + toast.success("Domain added. Configure DNS, then refresh."); + setHostname(""); + setAddOpen(false); + refresh(); + } finally { + setBusy(false); + } + }; + + const refreshDomain = async (h: string) => { + setRefreshing((prev) => ({ ...prev, [h]: true })); + try { + const res = await fetch(`${apiBase}/${toHostPathSegment(h)}/refresh`, { + method: "POST", + }); + const json = await res.json(); + if (!res.ok) { + toast.error(json.error?.message ?? "Refresh failed"); + return; + } + toast.success("Domain refreshed"); + refresh(); + } finally { + setRefreshing((prev) => ({ ...prev, [h]: false })); + } + }; + + const setCanonical = async (h: string) => { + setBusy(true); + try { + const res = await fetch(`${apiBase}/${toHostPathSegment(h)}/canonical`, { + method: "POST", + }); + const json = await res.json(); + if (!res.ok) { + toast.error(json.error?.message ?? "Failed to set canonical domain"); + return; + } + toast.success("Canonical domain updated"); + refresh(); + } finally { + setBusy(false); + } + }; + + const setPlatformPrimary = async () => { + setBusy(true); + try { + const res = await fetch(`${apiBase}/platform/canonical`, { + method: "POST", + }); + const json = await res.json(); + if (!res.ok) { + toast.error(json.error?.message ?? "Failed to set platform as primary"); + return; + } + toast.success("Primary domain updated"); + refresh(); + } finally { + setBusy(false); + } + }; + + const toggleSetup = (h: string) => { + setSetupOpen((prev) => ({ ...prev, [h]: !prev[h] })); + }; + + const removeDomain = async (h: string) => { + setBusy(true); + try { + const res = await fetch(`${apiBase}/${toHostPathSegment(h)}`, { + method: "DELETE", + }); + const json = await res.json(); + if (!res.ok) { + toast.error(json.error?.message ?? "Failed to remove domain"); + return; + } + toast.success("Domain removed"); + refresh(); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+
Domains
+
+ Add custom domains and choose the primary one. +
+
+
+ + + + + + + Add domain + + Add an apex domain (example.com) or subdomain + (app.example.com). Configure DNS, then refresh until it + becomes active. + + + +
+ + Hostname + setHostname(e.target.value)} + /> + + Do not include protocol (https://) or paths. + + + +
+
+ +
+ When verified, this domain becomes primary. +
+
+ setSetAsCanonical(Boolean(v))} + /> +
+
+ + + + + + + +
+
+
+
+ +
+ {isLoading ? ( +
+
+ Loading… +
+
+ ) : ( +
+ + + + {platformDomain} + + {primaryHostname === platformDomain ? ( + primary + ) : ( + platform + )} +
+ } + actions={ + + + + + + Actions + setRenameOpen(true)}> + + Edit + + + setPlatformPrimary()} + disabled={primaryHostname === platformDomain} + > + Make primary + + + + } + /> + + {domains.length === 0 ? ( +
+ No custom domains configured. +
+ ) : null} + + {domains.map((d) => { + const records = dnsInstructions(d); + const isPrimary = primaryHostname === d.hostname; + const pending = d.status !== "active"; + const showSetup = pending && setupOpen[d.hostname] === true; + const isRefreshing = refreshing[d.hostname] === true; + + return ( + + + + {d.hostname} + + {isPrimary ? primary : null} + {!isPrimary && d.canonical ? ( + primary (once active) + ) : null} + {d.kind} +
+ } + actions={ +
+ + + + + + + Actions + setCanonical(d.hostname)} + disabled={d.status !== "active" || isPrimary} + > + Make primary + + + removeDomain(d.hostname)} + > + Remove + + + +
+ } + > + {pending ? ( + + ) : null} + + {pending && showSetup ? ( + + ) : null} + + ); + })} +
+ )} + + + + + ); +} + +function DomainListRow({ + leading, + actions, + children, + className, +}: { + leading: React.ReactNode; + actions: React.ReactNode; + children?: React.ReactNode; + className?: string; +}) { + return ( +
+
+
{leading}
+
{actions}
+
+ {children ?
{children}
: null} +
+ ); +} + +function DnsRecordsCard({ + records, + lastError, +}: { + records: Array<{ type: string; name: string; value: string }>; + lastError: string | null; +}) { + return ( +
+
+
DNS Records
+
+ +
+
+ The DNS records at your provider must match the following records to + verify and connect your domain. +
+ +
+
+
Type
+
Name
+
Value
+
Proxy
+
+
+ {records.map((r, idx) => ( +
+
{r.type}
+
+ {r.name} +
+
+ +
+
+ + Disabled +
+
+ ))} +
+
+ +
+ It might take some time for the DNS records to apply. +
+ + {lastError ? ( +
+ {lastError} +
+ ) : null} +
+
+ ); +} + +function RenamePlatformDomainDialog({ + open, + onOpenChange, + defaultName, + onSubmit, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultName: string; + onSubmit: (name: string) => Promise; +}) { + const [name, setName] = useState(defaultName); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const dirty = useMemo(() => name !== defaultName, [name, defaultName]); + + const onSubmitHandler = async () => { + setBusy(true); + const ok = await onSubmit(name); + setBusy(false); + if (ok) { + toast.success("Domain updated"); + onOpenChange(false); + } else { + setError( + "This domain is either already taken or not allowed. Please try a different name using only letters, numbers, or dashes." + ); + } + }; + + return ( + + + + Edit platform domain + + This changes your {defaultName}.grida.site hostname. + + + + Domain name +
+ { + setName(e.target.value); + setError(null); + }} + /> + + .grida.site + +
+ + {error + ? error + : "lowercase letters, numbers, and dashes are allowed"} + +
+ + + + + + +
+
+ ); +} diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/layout.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/layout.tsx index d014bf777d..d2df989e1a 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/layout.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/layout.tsx @@ -106,6 +106,14 @@ export default async function Layout({ + + + + + Domains + + + diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/www/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/www/page.tsx index 9cfb9ccf49..82daee3f16 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/www/page.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/www/page.tsx @@ -18,9 +18,11 @@ import { Spinner } from "@/components/ui/spinner"; import { useForm, useWatch } from "react-hook-form"; import { Skeleton } from "@/components/ui/skeleton"; import { FaviconEditor } from "@/scaffolds/www-theme-config/components/favicon"; -import { SiteDomainsSection } from "./section-domain"; import type { PostgrestError } from "@supabase/supabase-js"; -import type { Database } from "@app/database"; +import { + DEFAULT_PLATFORM_APEX_DOMAIN, + platformSiteHostnameForTenant, +} from "@/lib/domains"; type ProjectWWW = { id: string; @@ -181,8 +183,6 @@ export default function ProjectWWWSettingsPage() { update, updateFavicon, updateOgImage, - checkDomainName, - changeDomainName, getPublicUrl, } = useSiteSettings(); @@ -208,7 +208,10 @@ export default function ProjectWWWSettingsPage() { return (
- - Site Images @@ -322,33 +317,3 @@ function FormSiteGeneral({ ); } - -function FormSiteDomain({ - defaultValues, - changeDomainName, - checkDomainName, -}: { - checkDomainName: (name: string) => Promise; - changeDomainName: (name: string) => Promise; - defaultValues: { - name: string; - }; -}) { - return ( - - - Domains - - - { - const available = await checkDomainName(name); - if (!available) return false; - return await changeDomainName(name); - }} - name={defaultValues.name} - /> - - - ); -} diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/www/section-domain.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/www/section-domain.tsx deleted file mode 100644 index a11d917752..0000000000 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/www/section-domain.tsx +++ /dev/null @@ -1,142 +0,0 @@ -"use client"; - -import React, { useMemo } from "react"; -import { useDialogState } from "@/components/hooks/use-dialog-state"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; -import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; -import { Pencil2Icon } from "@radix-ui/react-icons"; -import { toast } from "sonner"; - -export function SiteDomainsSection({ - name, - onDomainNameChange, -}: { - name: string; - onDomainNameChange: (name: string) => Promise; -}) { - const updateNameDialog = useDialogState("update-domain-name", { - refreshkey: true, - }); - return ( -
-
-
{/* */}
-
-
- - {name} - .grida.site - - -
- - -
- ); -} - -function UpdateNameDialog({ - onSubmit, - defaultValues, - ...props -}: React.ComponentProps & { - onSubmit: (name: string) => Promise; - defaultValues: { - name: string; - }; -}) { - const [name, setName] = React.useState(defaultValues.name); - const [busy, setBusy] = React.useState(false); - const [error, setError] = React.useState(null); - - const onSubmitHandler = async () => { - setBusy(true); - const ok = await onSubmit(name); - setBusy(false); - if (ok) { - toast.success("Domain name updated successfully"); - props.onOpenChange?.(false); - } else { - setError( - "This domain is either already taken or not allowed. Please try a different name using only letters, numbers, or dashes." - ); - } - }; - - const dirty = useMemo(() => { - return name !== defaultValues.name; - }, [name, defaultValues.name]); - - return ( - - - - Update existing domain - - This update will affect all live sites currently using this domain - - - - Domain name -
- { - setName(e.target.value); - setError(null); - }} - /> - - .grida.site - -
- - {error - ? error - : "lowercase letters, numbers, and dashes are allowed"} - -
- - - - - - -
-
- ); -} diff --git a/editor/clients/vercel/index.ts b/editor/clients/vercel/index.ts index 47251cb2dd..9e7183f86c 100644 --- a/editor/clients/vercel/index.ts +++ b/editor/clients/vercel/index.ts @@ -1,24 +1,117 @@ import { VercelCore } from "@vercel/sdk/core"; import { domainsDeleteDomain as _domainsDeleteDomain } from "@vercel/sdk/funcs/domainsDeleteDomain"; +import { projectsAddProjectDomain as _projectsAddProjectDomain } from "@vercel/sdk/funcs/projectsAddProjectDomain"; +import { projectsGetProjectDomain as _projectsGetProjectDomain } from "@vercel/sdk/funcs/projectsGetProjectDomain"; +import { projectsGetProjectDomains as _projectsGetProjectDomains } from "@vercel/sdk/funcs/projectsGetProjectDomains"; +import { projectsRemoveProjectDomain as _projectsRemoveProjectDomain } from "@vercel/sdk/funcs/projectsRemoveProjectDomain"; import { projectsVerifyProjectDomain as _projectsVerifyProjectDomain } from "@vercel/sdk/funcs/projectsVerifyProjectDomain"; +import { unwrapAsync } from "@vercel/sdk/types/fp"; -const VERCEL_AUTH_BEARER_TOKEN = process.env.VERCEL_AUTH_BEARER_TOKEN || ""; -const VERCEL_TEAM_ID = process.env.VERCEL_TEAM_ID || ""; -const VERCEL_PROJECT_ID = process.env.VERCEL_PROJECT_ID || ""; +export const VERCEL_AUTH_BEARER_TOKEN = + process.env.VERCEL_AUTH_BEARER_TOKEN || ""; +export const VERCEL_TEAM_ID = process.env.VERCEL_TEAM_ID || ""; +export const VERCEL_PROJECT_ID = process.env.VERCEL_PROJECT_ID || ""; export const __vercel_core = new VercelCore({ bearerToken: VERCEL_AUTH_BEARER_TOKEN, }); -// export const domainsDeleteDomain = (domain: string) => -// _domainsDeleteDomain(__vercel_core, { -// teamId: VERCEL_TEAM_ID, -// domain, -// }); - -export const projectsVerifyProjectDomain = (domain: string) => - _projectsVerifyProjectDomain(__vercel_core, { - teamId: VERCEL_TEAM_ID, - idOrName: VERCEL_PROJECT_ID, - domain, +/** + * Fetch domain configuration from Vercel REST API. + * + * We intentionally use raw fetch (not the SDK model) because the REST endpoint + * returns additional fields (e.g. `recommendedCNAME`, `recommendedIPv4`, `conflicts`) + * that are useful for DNS instructions, and the generated SDK schemas may strip them. + */ +export async function vercelGetDomainConfig(domain: string) { + const url = new URL( + `https://api.vercel.com/v6/domains/${encodeURIComponent(domain)}/config` + ); + if (VERCEL_TEAM_ID) url.searchParams.set("teamId", VERCEL_TEAM_ID); + + const res = await fetch(url.toString(), { + method: "GET", + headers: { + Authorization: `Bearer ${VERCEL_AUTH_BEARER_TOKEN}`, + Accept: "application/json", + }, + // Never cache; this is used for UI correctness, not performance. + cache: "no-store", }); + + const bodyText = await res.text().catch(() => ""); + let json: unknown = null; + try { + json = bodyText ? JSON.parse(bodyText) : null; + } catch { + json = { raw: bodyText } satisfies Record; + } + + if (!res.ok) { + const message = + typeof (json as { error?: { message?: unknown } })?.error?.message === + "string" + ? (json as { error: { message: string } }).error.message + : `Vercel getDomainConfig failed (${res.status}).`; + const err = new Error(message) as Error & { status?: number; body?: unknown }; + err.status = res.status; + err.body = json; + throw err; + } + + return json; +} + +export const projectsAddProjectDomain = async (domain: string) => + unwrapAsync( + _projectsAddProjectDomain(__vercel_core, { + teamId: VERCEL_TEAM_ID, + idOrName: VERCEL_PROJECT_ID, + requestBody: { name: domain }, + }) + ); + +export const projectsGetProjectDomain = async (domain: string) => + unwrapAsync( + _projectsGetProjectDomain(__vercel_core, { + teamId: VERCEL_TEAM_ID, + idOrName: VERCEL_PROJECT_ID, + domain, + }) + ); + +export const projectsGetProjectDomains = async () => + unwrapAsync( + _projectsGetProjectDomains(__vercel_core, { + teamId: VERCEL_TEAM_ID, + idOrName: VERCEL_PROJECT_ID, + }) + ); + +export const projectsRemoveProjectDomain = async (domain: string) => + unwrapAsync( + _projectsRemoveProjectDomain(__vercel_core, { + teamId: VERCEL_TEAM_ID, + idOrName: VERCEL_PROJECT_ID, + domain, + }) + ); + +export const projectsVerifyProjectDomain = async (domain: string) => + unwrapAsync( + _projectsVerifyProjectDomain(__vercel_core, { + teamId: VERCEL_TEAM_ID, + idOrName: VERCEL_PROJECT_ID, + domain, + }) + ); + +// TODO: add typed wrappers for other endpoints as needed. + +export const domainsDeleteDomain = async (domain: string) => + unwrapAsync( + _domainsDeleteDomain(__vercel_core, { + teamId: VERCEL_TEAM_ID, + domain, + }) + ); diff --git a/editor/components/copy-to-clipboard-input/index.tsx b/editor/components/copy-to-clipboard-input/index.tsx index 452d398ec1..8c343a14ac 100644 --- a/editor/components/copy-to-clipboard-input/index.tsx +++ b/editor/components/copy-to-clipboard-input/index.tsx @@ -1,51 +1,57 @@ "use client"; -import React, { useState } from "react"; +import * as React from "react"; import { CheckIcon, CopyIcon } from "@radix-ui/react-icons"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; import { toast } from "sonner"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group"; + export function CopyToClipboardInput({ value }: { value: string }) { - const [isCopied, setIsCopied] = useState(false); + const inputId = React.useId(); + const [isCopied, setIsCopied] = React.useState(false); - const onCopyClick = () => { - navigator.clipboard.writeText(value); - setIsCopied(true); - toast.success("Copied to clipboard"); - setTimeout(() => { - setIsCopied(false); - }, 2000); - }; + const onCopyClick = React.useCallback(async () => { + try { + await navigator.clipboard.writeText(value); + setIsCopied(true); + toast.success("Copied to clipboard"); + setTimeout(() => setIsCopied(false), 2000); + } catch { + toast.error("Failed to copy"); + } + }, [value]); return ( -
- - -
+ {isCopied ? ( + + ) : ( + + )} + + + ); } diff --git a/editor/components/resource-type-icon.tsx b/editor/components/resource-type-icon.tsx index 74829af8c1..23c834453c 100644 --- a/editor/components/resource-type-icon.tsx +++ b/editor/components/resource-type-icon.tsx @@ -28,6 +28,7 @@ import { TagIcon, LockKeyholeIcon, BookUserIcon, + GlobeIcon, } from "lucide-react"; import { SupabaseLogo } from "./logos"; @@ -55,6 +56,7 @@ export type ResourceTypeIconName = | "chart-pie" | "commerce" | "connect" + | "domain" | "campaign" | "customer" | "customer-portal" @@ -122,6 +124,8 @@ export function ResourceTypeIcon({ return ; case "connect": return ; + case "domain": + return ; case "v0_site": return ; case "v0_canvas": diff --git a/editor/lib/domains/index.test.ts b/editor/lib/domains/index.test.ts new file mode 100644 index 0000000000..1560b37f63 --- /dev/null +++ b/editor/lib/domains/index.test.ts @@ -0,0 +1,113 @@ +import { + DEFAULT_PLATFORM_APEX_DOMAIN, + getDomainKind, + isBlacklistedHostname, + isPlatformSiteHostname, + isReservedAppHostname, + normalizeHostname, + platformSiteHostnameForTenant, + platformSiteTenantFromHostname, +} from "./index"; + +describe("lib/domains", () => { + describe("normalizeHostname", () => { + test("lowercases and strips trailing dot", () => { + expect(normalizeHostname("ExAmPlE.CoM.")).toBe("example.com"); + }); + + test("rejects scheme, path, query, fragment", () => { + expect(normalizeHostname("https://example.com")).toBeNull(); + expect(normalizeHostname("example.com/foo")).toBeNull(); + expect(normalizeHostname("example.com?x=1")).toBeNull(); + expect(normalizeHostname("example.com#hash")).toBeNull(); + }); + + test("rejects ports", () => { + expect(normalizeHostname("example.com:3000")).toBeNull(); + }); + + test("rejects empty / whitespace", () => { + expect(normalizeHostname("")).toBeNull(); + expect(normalizeHostname(" ")).toBeNull(); + }); + }); + + describe("isBlacklistedHostname", () => { + test('blocks anything containing "vercel"', () => { + expect(isBlacklistedHostname("foo.vercel.app")).toBe(true); + expect(isBlacklistedHostname("VERCEL-dns.com")).toBe(true); + expect( + isBlacklistedHostname("i-bought-vercel-keyword-containing-domain.com") + ).toBe(true); + }); + + test("does not block normal domains", () => { + expect(isBlacklistedHostname("example.com")).toBe(false); + expect(isBlacklistedHostname("tenant.grida.site")).toBe(false); + }); + }); + + describe("isReservedAppHostname", () => { + test("reserves app apex domains and all subdomains", () => { + expect(isReservedAppHostname("grida.co")).toBe(true); + expect(isReservedAppHostname("hacked.grida.co")).toBe(true); + + expect(isReservedAppHostname("bridged.xyz")).toBe(true); + expect(isReservedAppHostname("anything.bridged.xyz")).toBe(true); + }); + + test("does not reserve platform site suffixes", () => { + expect(isReservedAppHostname("grida.site")).toBe(false); + expect(isReservedAppHostname("tenant.grida.site")).toBe(false); + }); + }); + + describe("isPlatformSiteHostname", () => { + test("matches platform apexes and any subdomains", () => { + expect(isPlatformSiteHostname("grida.site")).toBe(true); + expect(isPlatformSiteHostname("tenant.grida.site")).toBe(true); + expect(isPlatformSiteHostname("grida.app")).toBe(true); + expect(isPlatformSiteHostname("tenant.grida.app")).toBe(true); + }); + + test("does not match other apexes", () => { + expect(isPlatformSiteHostname("grida.co")).toBe(false); + expect(isPlatformSiteHostname("example.com")).toBe(false); + }); + }); + + describe("platformSiteTenantFromHostname", () => { + test("extracts tenant and apex", () => { + expect(platformSiteTenantFromHostname("tenant.grida.site")).toEqual({ + tenant: "tenant", + apex: "grida.site", + }); + }); + + test("returns null for apex-only", () => { + expect(platformSiteTenantFromHostname("grida.site")).toBeNull(); + }); + + test("supports nested subdomains (tenant identity includes dots)", () => { + expect(platformSiteTenantFromHostname("a.b.grida.site")).toEqual({ + tenant: "a.b", + apex: "grida.site", + }); + }); + }); + + describe("platformSiteHostnameForTenant", () => { + test("uses default platform apex", () => { + expect(platformSiteHostnameForTenant("tenant")).toBe( + `tenant.${DEFAULT_PLATFORM_APEX_DOMAIN}` + ); + }); + }); + + describe("getDomainKind", () => { + test("treats two labels as apex, more as subdomain", () => { + expect(getDomainKind("example.com")).toBe("apex"); + expect(getDomainKind("app.example.com")).toBe("subdomain"); + }); + }); +}); diff --git a/editor/lib/domains/index.ts b/editor/lib/domains/index.ts new file mode 100644 index 0000000000..3be4085868 --- /dev/null +++ b/editor/lib/domains/index.ts @@ -0,0 +1,147 @@ +export type DomainKind = "apex" | "subdomain"; +export type DomainStatus = "pending" | "active" | "error"; + +export type DomainRegistryEntry = { + hostname: string; // normalized hostname (lowercase, no port) + www_name: string; // tenant identifier (grida_www.www.name) + canonical_host: string; // the tenant's canonical hostname (may be platform host) +}; + +/** + * Normalizes user / request host input into a hostname. + * + * Rules: + * - lowercase + * - no scheme, path, query, fragment + * - no port + * - no trailing dot + */ +export function normalizeHostname(input: string): string | null { + const raw = input.trim().toLowerCase(); + if (!raw) return null; + + // Disallow obvious non-hostname forms. + if (raw.includes("://")) return null; + if (raw.includes("/") || raw.includes("?") || raw.includes("#")) return null; + + // Ports are not part of domain identity. + if (raw.includes(":")) return null; + + const normalized = raw.endsWith(".") ? raw.slice(0, -1) : raw; + if (!normalized) return null; + + // Validate using URL parsing (hostname only). + try { + const u = new URL(`https://${normalized}`); + if (u.hostname !== normalized) return null; + } catch { + return null; + } + + return normalized; +} + +export function getDomainKind(hostname: string): DomainKind { + // Heuristic: if it has exactly one dot, it is an apex domain (example.com). + // Any additional label makes it a subdomain (app.example.com). + const parts = hostname.split(".").filter(Boolean); + return parts.length === 2 ? "apex" : "subdomain"; +} + +export function isPlatformHostname(hostname: string, platformSuffix: string) { + if (hostname === platformSuffix) return true; + return hostname.endsWith(`.${platformSuffix}`); +} + +/** + * Reserved app apex domains. + * + * These are *not* tenant domains and must never be claimable by user-owned custom domains. + * + * Rationale: + * - prevent "hijacking" Grida-controlled hostnames like `hacked.grida.co` + * - keep host-based routing invariant predictable + * + * This is intentionally **code-managed** (not env-managed) for stronger guarantees. + */ +export const APP_APEX_DOMAINS = new Set(["grida.co", "bridged.xyz"]); + +/** + * Platform-owned apex domains used for tenant sites. + * + * Users can choose tenant subdomains under these suffixes (e.g. `my-site.grida.site`). + * This is code-managed as a source of truth. + */ +export const PLATFORM_APEX_DOMAINS = new Set(["grida.site", "grida.app"]); + +/** + * Canonical platform suffix when no custom domain is set. + * + * We enforce a single canonical hostname per tenant; when a request comes in via + * another platform suffix (e.g. `.grida.app`), we redirect to this default unless + * a custom domain is canonical. + */ +export const DEFAULT_PLATFORM_APEX_DOMAIN = "grida.site"; + +export function isPlatformSiteHostname(hostname: string) { + const h = hostname.trim().toLowerCase(); + if (!h) return false; + for (const apex of PLATFORM_APEX_DOMAINS) { + if (h === apex) return true; + if (h.endsWith(`.${apex}`)) return true; + } + return false; +} + +export function platformSiteTenantFromHostname(hostname: string) { + const h = hostname.trim().toLowerCase(); + if (!h) return null; + for (const apex of PLATFORM_APEX_DOMAINS) { + const suffix = `.${apex}`; + if (h.endsWith(suffix)) { + const sub = h.slice(0, -suffix.length); + return sub ? { tenant: sub, apex } : null; + } + } + return null; +} + +export function platformSiteHostnameForTenant( + tenant: string, + apex: string = DEFAULT_PLATFORM_APEX_DOMAIN +) { + return `${tenant}.${apex}`; +} + +/** + * True if the hostname is part of Grida's own app namespace. + * This reserves the apex *and* all subdomains under it. + */ +export function isReservedAppHostname(hostname: string) { + const h = hostname.trim().toLowerCase(); + if (!h) return false; + for (const apex of APP_APEX_DOMAINS) { + if (h === apex) return true; + if (h.endsWith(`.${apex}`)) return true; + } + return false; +} + +/** + * Hostname blacklist (code-owned). + * + * Why so strict: + * - Some provider-owned hostnames (notably Vercel) can be *immediately* verified/linked, + * which creates a hijack surface if we allow users to attach them. + * - We do not have a reliable, complete, and up-to-date way to enumerate all Vercel-provided + * hostnames or suffixes (and we do not want to maintain one). + * + * Therefore, we intentionally block any hostname containing the substring "vercel". + * This includes `.vercel.app`, `vercel-dns.com`, and also user-owned domains that merely + * contain the keyword (e.g. `i-bought-vercel-keyword-containing-domain.com`). + */ +export function isBlacklistedHostname(hostname: string) { + const h = hostname.trim().toLowerCase(); + if (!h) return false; + return h.includes("vercel"); +} diff --git a/editor/lib/supabase/service-role-cookie-free-clients.ts b/editor/lib/supabase/service-role-cookie-free-clients.ts new file mode 100644 index 0000000000..707a5c9b4d --- /dev/null +++ b/editor/lib/supabase/service-role-cookie-free-clients.ts @@ -0,0 +1,53 @@ +/** + * Cookie-free, side-effect-free service-role Supabase clients. + * + * Why this file exists: + * - `editor/lib/supabase/server.ts` is Server Components / Route Handlers oriented and imports + * `next/headers` + `@supabase/ssr`. Importing that module from `proxy.ts` (Edge) is risky. + * - For Edge request routing, we want a cookie-free, side-effect-free service-role client. + * + * Note: our hostname resolver RPC is in `public` per `supabase/AGENTS.md` ("public is API surface"), + * while the underlying domain tables live in `grida_www`. + */ + +import type { Database } from "@app/database"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; + +let _publicClient: SupabaseClient | null = null; + +function env() { + // Server-side fallback: allow `SUPABASE_URL` (server-only) as well. + // Prefer `NEXT_PUBLIC_SUPABASE_URL` when present for consistency with other modules. + const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SECRET_KEY; + if (!url || !key) { + throw new Error( + "SUPABASE_SECRET_KEY and (NEXT_PUBLIC_SUPABASE_URL or SUPABASE_URL) are required" + ); + } + return { url, key }; +} + +/** + * Service role client scoped to `public`. + * + * Use this for `public.*` RPC calls like `public.www_resolve_hostname`. + */ +export function serviceRolePublicClient(): SupabaseClient { + if (_publicClient) return _publicClient; + + const { url, key } = env(); + + const client = createClient(url, key, { + db: { schema: "public" }, + auth: { + // Prevent implicit session persistence / refreshes. + persistSession: false, + autoRefreshToken: false, + detectSessionInUrl: false, + }, + }); + + _publicClient = client; + return client; +} diff --git a/editor/lib/tenant/middleware.ts b/editor/lib/tenant/middleware.ts index 4f3f3445cc..e7f906db95 100644 --- a/editor/lib/tenant/middleware.ts +++ b/editor/lib/tenant/middleware.ts @@ -1,10 +1,16 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { Env } from "@/env"; +import { serviceRolePublicClient } from "@/lib/supabase/service-role-cookie-free-clients"; +import { + DEFAULT_PLATFORM_APEX_DOMAIN, + isPlatformSiteHostname, + isReservedAppHostname, + platformSiteHostnameForTenant, + platformSiteTenantFromHostname, +} from "@/lib/domains"; + export namespace TanantMiddleware { - /** - * grida.co is the apex domain - * - * can be other like canary.grida.co for branching - */ - const EDITOR_DOMAIN = process.env.NEXT_PUBLIC_URL; + const IS_DEV = process.env.NODE_ENV === "development"; export const analyze = function ( url: URL, @@ -33,11 +39,13 @@ export namespace TanantMiddleware { } } - if (hostname === EDITOR_DOMAIN) { + // App hosts (e.g. `*.grida.co`) are never tenant identities. + // This is code-managed via `APP_APEX_DOMAINS`. + if (isReservedAppHostname(hostname)) { return { name: null, - apex: EDITOR_DOMAIN, - domain: EDITOR_DOMAIN, + apex: hostname, + domain: hostname, }; } @@ -50,4 +58,231 @@ export namespace TanantMiddleware { domain: hostname, }; }; + + /** + * Host-based tenant routing for Next.js `proxy.ts`. + * + * This centralizes the routing invariants so `editor/proxy.ts` stays short: + * - localhost tenant routing (`tenant.localhost`) + * - app hosts (reserved) are never tenant identities + * - platform sites (`*.grida.site`, `*.grida.app`) + canonicalization + * - user-imported custom domains (via DB/Vercel) + canonicalization + */ + export async function routeProxyRequest( + req: NextRequest, + res: NextResponse + ): Promise { + const host = req.headers.get("host"); + if (!host) return null; + + // Ignore Vercel preview hosts entirely (they are not tenant/custom-domain identities). + if ( + Env.server.IS_HOSTED && + (host === process.env.VERCEL_URL || + host === process.env.VERCEL_BRANCH_URL) + ) { + return null; + } + + let url: URL; + try { + url = new URL(`https://${host}`); + } catch { + return null; + } + + const hostname = url.hostname; + + // App hosts are never tenant identities. Still block direct `/~/...` access. + if (isReservedAppHostname(hostname)) { + if (req.nextUrl.pathname.startsWith("/~/")) { + return NextResponse.redirect(new URL("/", req.url)); + } + return null; + } + + const tenant = analyze(url, !Env.server.IS_HOSTED); + + // www. => main app host + if (tenant.name === "www" && isPlatformSiteHostname(hostname)) { + return NextResponse.redirect(new URL("/", Env.web.HOST), { status: 301 }); + } + + // Local dev tenant routing: `tenant.localhost:3000` -> `/~//**` + if (!Env.server.IS_HOSTED) { + const isLocalTenantHost = tenant.apex === "localhost" && !!tenant.name; + if (isLocalTenantHost) { + const prefix = `/~/${tenant.name}`; + if (!req.nextUrl.pathname.startsWith(prefix)) { + const rewritten = req.nextUrl.clone(); + rewritten.pathname = `${prefix}${req.nextUrl.pathname}`; + return NextResponse.rewrite(rewritten, { + request: { headers: req.headers }, + status: res.status, + }); + } + } + } + + const isPlatformHost = isPlatformSiteHostname(hostname); + + // Hosted custom-domain routing & canonical redirects + if (Env.server.IS_HOSTED) { + // Prefer internal resolver route (cached/observable), called via deploy host to avoid recursion. + const internalToken = process.env.GRIDA_INTERNAL_PROXY_TOKEN; + const deployHost = process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : null; + + if (internalToken && deployHost) { + try { + const u = new URL("/internal/resolve-host", deployHost); + u.searchParams.set("host", hostname); + const r = await fetch(u.toString(), { + method: "GET", + headers: { "x-grida-internal-token": internalToken }, + }); + + if (r.ok) { + const json = (await r.json().catch(() => null)) as { + data: { www_name: string; canonical_host: string } | null; + } | null; + const data = json?.data ?? null; + if (data?.www_name) { + if (data.canonical_host && data.canonical_host !== hostname) { + const redirectTo = new URL( + req.nextUrl.pathname + req.nextUrl.search, + `https://${data.canonical_host}` + ); + return NextResponse.redirect(redirectTo, { status: 301 }); + } + + const rewritten = req.nextUrl.clone(); + rewritten.pathname = `/~/${data.www_name}${req.nextUrl.pathname}`; + return NextResponse.rewrite(rewritten, { + request: { headers: req.headers }, + status: res.status, + }); + } + } + } catch { + // fall back to DB resolution below + } + } + + // Reliability-first: resolve from DB using cookie-free service role client. + try { + const pub = serviceRolePublicClient(); + + // A) Custom domain request: resolve by hostname mapping. + if (!isPlatformHost) { + const { data: rows, error } = await pub.rpc("www_resolve_hostname", { + p_hostname: hostname, + }); + + if (!error && Array.isArray(rows) && rows.length > 0) { + const row = rows[0] as unknown as { + www_name: string; + canonical_hostname: string | null; + }; + + if (row?.www_name) { + const canonicalHost = + row.canonical_hostname ?? + platformSiteHostnameForTenant( + row.www_name, + DEFAULT_PLATFORM_APEX_DOMAIN + ); + + if (canonicalHost !== hostname) { + const redirectTo = new URL( + req.nextUrl.pathname + req.nextUrl.search, + `https://${canonicalHost}` + ); + return NextResponse.redirect(redirectTo, { status: 301 }); + } + + const rewritten = req.nextUrl.clone(); + rewritten.pathname = `/~/${row.www_name}${req.nextUrl.pathname}`; + return NextResponse.rewrite(rewritten, { + request: { headers: req.headers }, + status: res.status, + }); + } + } + } + + // B) Platform host request: enforce canonical by querying canonical custom domain (if any). + if (tenant.name && isPlatformHost) { + const { data: rows, error } = await pub.rpc( + "www_get_canonical_hostname", + { p_www_name: tenant.name } + ); + + if (!error && Array.isArray(rows) && rows.length > 0) { + const first = rows[0] as unknown; + const canonical = + typeof (first as { canonical_hostname?: unknown }) + ?.canonical_hostname === "string" + ? (first as { canonical_hostname: string }).canonical_hostname + : null; + if (canonical && canonical !== hostname) { + const redirectTo = new URL( + req.nextUrl.pathname + req.nextUrl.search, + `https://${canonical}` + ); + return NextResponse.redirect(redirectTo, { status: 301 }); + } + } + + // If no canonical custom domain is set, enforce canonical platform suffix. + const platformParsed = platformSiteTenantFromHostname(hostname); + if ( + platformParsed?.tenant && + platformParsed.apex !== DEFAULT_PLATFORM_APEX_DOMAIN + ) { + const redirectTo = new URL( + req.nextUrl.pathname + req.nextUrl.search, + `https://${platformSiteHostnameForTenant( + platformParsed.tenant, + DEFAULT_PLATFORM_APEX_DOMAIN + )}` + ); + return NextResponse.redirect(redirectTo, { status: 301 }); + } + + const rewritten = req.nextUrl.clone(); + rewritten.pathname = `/~/${tenant.name}${req.nextUrl.pathname}`; + return NextResponse.rewrite(rewritten, { + request: { headers: req.headers }, + status: res.status, + }); + } + } catch { + // ignore and fall through to hosted fallback + } + } + + // Hosted fallback: preserve existing platform behavior even if DB lookup fails. + if (tenant.name && isPlatformHost) { + const rewritten = req.nextUrl.clone(); + rewritten.pathname = `/~/${tenant.name}${req.nextUrl.pathname}`; + return NextResponse.rewrite(rewritten, { + request: { headers: req.headers }, + status: res.status, + }); + } + + // Block direct access to tenant layout on app host (localhost / editor apex). + // Allow direct `/~/...` only on tenant hosts (e.g. `tenant.localhost` or platform/custom domains). + // In hosted env, `Env.web.HOST` includes scheme; use `NEXT_PUBLIC_URL` (host only) to detect editor apex. + const editorHost = process.env.NEXT_PUBLIC_URL; + const isEditorApexHost = + hostname === "localhost" || (!!editorHost && hostname === editorHost); + if (isEditorApexHost && req.nextUrl.pathname.startsWith("/~/")) { + return NextResponse.redirect(new URL("/", req.url)); + } + + return null; + } } diff --git a/editor/proxy.ts b/editor/proxy.ts index 52f14f5f8d..4134dcf3df 100644 --- a/editor/proxy.ts +++ b/editor/proxy.ts @@ -1,3 +1,14 @@ +/** + * @fileoverview + * Next.js Proxy entrypoint. + * + * Starting in **Next.js 16**, `proxy.ts` is the new name for what used to be + * `middleware.ts` (same runtime + semantics) — it is *not* a custom concept in + * this repo. Keep implementing and maintaining this file exactly as we would + * have done in `middleware.ts`. + * + * Reference: https://nextjs.org/docs/app/getting-started/proxy + */ import { NextResponse } from "next/server"; import { get } from "@vercel/edge-config"; import type { NextRequest } from "next/server"; @@ -64,61 +75,8 @@ export async function proxy(req: NextRequest) { } // ------------------------------------------------------------ - // #region tanent matching - - const host = req.headers.get("host"); - // Host can be null (or malformed) in some proxy / local contributor setups. - // If we can't reliably parse it, skip tenant matching rather than throwing. - if (!host) { - return res; - } - - let url: URL; - try { - url = new URL(`https://${host}`); - } catch { - return res; - } - - const hostname = url.hostname; - - // ignore if vercel preview url - if ( - Env.server.IS_HOSTED && - (host === process.env.VERCEL_URL || host === process.env.VERCEL_BRANCH_URL) - ) { - return res; - } - - const tanentwww = TanantMiddleware.analyze(url, !Env.server.IS_HOSTED); - - // www.grida.site => grida.co - if (tanentwww.name === "www") { - const website = new URL("/", Env.web.HOST); - return NextResponse.redirect(website, { - status: 301, - }); - } - - // tenant.grida.site => "/~/[tenant]/**" - if (tanentwww.name) { - const url = req.nextUrl.clone(); - url.pathname = `/~/${tanentwww.name}${req.nextUrl.pathname}`; - - return NextResponse.rewrite(url, { - request: { headers: req.headers }, - status: res.status, - }); - } - - // block direct access to the tanent layout - if ( - hostname === (IS_DEV ? "localhost" : process.env.NEXT_PUBLIC_URL) && - req.nextUrl.pathname.startsWith("/~/") - ) { - return NextResponse.redirect(new URL("/", req.url)); - } - // #endregion tanent + const routed = await TanantMiddleware.routeProxyRequest(req, res); + if (routed) return routed; return res; } diff --git a/supabase/AGENTS.md b/supabase/AGENTS.md index 3354c11039..f9268401ac 100644 --- a/supabase/AGENTS.md +++ b/supabase/AGENTS.md @@ -64,6 +64,25 @@ So you must manage **three layers**: --- +## Schema conventions (API surface vs internal organization) + +This codebase currently contains **multiple schema conventions** and is not perfectly aligned. Going forward, our **best-effort standard** is: + +- **`public` is the API surface**. + - For Supabase/PostgREST and any “public API” access, we aim to expose only what is intentionally supported under `public`. + - Prefer exposing **views** (and, when necessary, **RPC functions**) in `public` as the stable interface. +- **Non-`public` schemas are for internal organization**. + - Use domain schemas (e.g. `grida_*`) to organize and isolate underlying tables, especially when it improves maintainability and security review. + - These schemas should be treated as **implementation detail**, not a contract. +- **Wrap, don’t leak**. + - If underlying tables live outside `public`, expose the relevant, permissioned subset via `public.` (or a carefully audited `public` RPC) rather than granting direct access to non-`public` relations. + - Keep wrappers tenant-safe (RLS-safe, no widening joins) and backed by pgTAP tests. +- **Be explicit and defensive**. + - Minimize grants; avoid accidental exposure via default privileges. + - For any `SECURITY DEFINER` in `public`, set a safe `search_path`, fully qualify referenced relations, validate tenant boundary inside the function, and prove behavior with tests. + +--- + ## RLS policy patterns (recommended) - **Always write both sides**: diff --git a/supabase/migrations/20260202120000_grida_www_custom_domains.sql b/supabase/migrations/20260202120000_grida_www_custom_domains.sql new file mode 100644 index 0000000000..64b9eb4ef8 --- /dev/null +++ b/supabase/migrations/20260202120000_grida_www_custom_domains.sql @@ -0,0 +1,120 @@ +-- Custom domains for grida_www (hostname -> www tenant mapping) + +CREATE TABLE IF NOT EXISTS grida_www.domain ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + www_id UUID NOT NULL REFERENCES grida_www.www(id) ON DELETE CASCADE, + hostname TEXT NOT NULL, + + -- pending: user declared / awaiting DNS; active: usable in routing; error: last provider error + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'error')), + + -- exactly one canonical hostname per tenant (enforced by partial unique index) + canonical BOOLEAN NOT NULL DEFAULT false, + + -- derived: apex vs subdomain + kind TEXT GENERATED ALWAYS AS ( + CASE + WHEN array_length(string_to_array(hostname, '.'), 1) = 2 THEN 'apex' + ELSE 'subdomain' + END + ) STORED, + + vercel JSONB, + -- last time we attempted to sync/verify the domain with the provider + last_checked_at TIMESTAMPTZ, + last_verified_at TIMESTAMPTZ, + -- stable internal classification for last_error + last_error_code TEXT, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Case-insensitive uniqueness for hostname +CREATE UNIQUE INDEX IF NOT EXISTS grida_www_domain_hostname_lower_uniq +ON grida_www.domain ((lower(hostname))); + +-- At most 1 canonical domain per www +CREATE UNIQUE INDEX IF NOT EXISTS grida_www_domain_canonical_per_www_uniq +ON grida_www.domain (www_id) +WHERE canonical AND status = 'active'; + +-- Keep updated_at fresh +DROP TRIGGER IF EXISTS handle_updated_at ON grida_www.domain; +CREATE TRIGGER handle_updated_at +BEFORE UPDATE ON grida_www.domain +FOR EACH ROW +EXECUTE FUNCTION extensions.moddatetime('updated_at'); + +ALTER TABLE grida_www.domain ENABLE ROW LEVEL SECURITY; + +-- Editors: manage domains for a www they can access. +DROP POLICY IF EXISTS access_based_on_www_editor ON grida_www.domain; +CREATE POLICY access_based_on_www_editor +ON grida_www.domain +USING (grida_www.rls_www(www_id)) +WITH CHECK (grida_www.rls_www(www_id)); + +-- Harden against public enumeration. +-- Domain mapping is identity; listing mappings should remain tenant-scoped. +DROP POLICY IF EXISTS public_read_active ON grida_www.domain; + +-- Resolve a hostname to a tenant (www) and its canonical hostname (if configured). +-- +-- Public API surface: +-- - Exposed as `public.www_resolve_hostname` (per `supabase/AGENTS.md`) +-- - Executable only by `service_role` +-- +-- Behavior: +-- - If `p_hostname` matches an active custom domain, returns that tenant. +-- - `canonical_hostname` is the active canonical custom hostname (or NULL if none). +CREATE OR REPLACE FUNCTION public.www_resolve_hostname(p_hostname TEXT) +RETURNS TABLE ( + www_id UUID, + www_name TEXT, + canonical_hostname TEXT +) AS $$ + WITH requested AS ( + SELECT d.www_id + FROM grida_www.domain d + WHERE lower(d.hostname) = lower(p_hostname) + AND d.status = 'active' + LIMIT 1 + ), + canon AS ( + SELECT d2.hostname AS canonical_hostname + FROM grida_www.domain d2 + JOIN requested r ON r.www_id = d2.www_id + WHERE d2.status = 'active' + AND d2.canonical = true + LIMIT 1 + ) + SELECT w.id, w.name, (SELECT canonical_hostname FROM canon) + FROM requested r + JOIN grida_www.www w ON w.id = r.www_id; +$$ LANGUAGE sql STABLE; + +REVOKE ALL ON FUNCTION public.www_resolve_hostname(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.www_resolve_hostname(TEXT) FROM anon; +REVOKE ALL ON FUNCTION public.www_resolve_hostname(TEXT) FROM authenticated; +GRANT EXECUTE ON FUNCTION public.www_resolve_hostname(TEXT) TO service_role; + +-- Resolve a tenant name to its canonical custom hostname (if configured). +-- Returns NULL when the tenant has no canonical active custom domain. +CREATE OR REPLACE FUNCTION public.www_get_canonical_hostname(p_www_name TEXT) +RETURNS TABLE ( + canonical_hostname TEXT +) AS $$ + SELECT d.hostname AS canonical_hostname + FROM grida_www.domain d + JOIN grida_www.www w ON w.id = d.www_id + WHERE lower(w.name) = lower(p_www_name) + AND d.status = 'active' + AND d.canonical = true + LIMIT 1; +$$ LANGUAGE sql STABLE; + +REVOKE ALL ON FUNCTION public.www_get_canonical_hostname(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.www_get_canonical_hostname(TEXT) FROM anon; +REVOKE ALL ON FUNCTION public.www_get_canonical_hostname(TEXT) FROM authenticated; +GRANT EXECUTE ON FUNCTION public.www_get_canonical_hostname(TEXT) TO service_role; diff --git a/supabase/schemas/grida_www.sql b/supabase/schemas/grida_www.sql index ceac2ba790..b20b09c682 100644 --- a/supabase/schemas/grida_www.sql +++ b/supabase/schemas/grida_www.sql @@ -100,6 +100,107 @@ CREATE TRIGGER set_www_name BEFORE INSERT ON grida_www.www FOR EACH ROW WHEN (NE ALTER TABLE grida_www.www ENABLE ROW LEVEL SECURITY; CREATE POLICY "access_based_on_project_membership" ON grida_www.www USING (public.rls_project(project_id)) WITH CHECK (public.rls_project(project_id)); +--------------------------------------------------------------------- +-- [Custom Domains] -- hostname -> www mapping +--------------------------------------------------------------------- +CREATE TABLE grida_www.domain ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + www_id UUID NOT NULL REFERENCES grida_www.www(id) ON DELETE CASCADE, + hostname TEXT NOT NULL, + + -- pending: user declared / awaiting DNS; active: usable in routing; error: last provider error + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'error')), + + -- exactly one canonical hostname per tenant (enforced by partial unique index) + canonical BOOLEAN NOT NULL DEFAULT false, + + -- derived: apex vs subdomain + kind TEXT GENERATED ALWAYS AS ( + CASE + WHEN array_length(string_to_array(hostname, '.'), 1) = 2 THEN 'apex' + ELSE 'subdomain' + END + ) STORED, + + vercel JSONB, + -- last time we attempted to sync/verify the domain with the provider + last_checked_at TIMESTAMPTZ, + last_verified_at TIMESTAMPTZ, + -- stable internal classification for last_error + last_error_code TEXT, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX grida_www_domain_hostname_lower_uniq +ON grida_www.domain ((lower(hostname))); + +CREATE UNIQUE INDEX grida_www_domain_canonical_per_www_uniq +ON grida_www.domain (www_id) +WHERE canonical AND status = 'active'; + +CREATE TRIGGER handle_updated_at +BEFORE UPDATE ON grida_www.domain +FOR EACH ROW +EXECUTE FUNCTION extensions.moddatetime('updated_at'); + +ALTER TABLE grida_www.domain ENABLE ROW LEVEL SECURITY; +CREATE POLICY "access_based_on_www_editor" ON grida_www.domain USING (grida_www.rls_www(www_id)) WITH CHECK (grida_www.rls_www(www_id)); + +-- Harden against public enumeration. +-- Domain mapping is identity; listing mappings should remain tenant-scoped. +DROP POLICY IF EXISTS public_read_active ON grida_www.domain; + +CREATE OR REPLACE FUNCTION public.www_resolve_hostname(p_hostname TEXT) +RETURNS TABLE ( + www_id UUID, + www_name TEXT, + canonical_hostname TEXT +) AS $$ + WITH requested AS ( + SELECT d.www_id + FROM grida_www.domain d + WHERE lower(d.hostname) = lower(p_hostname) + AND d.status = 'active' + LIMIT 1 + ), + canon AS ( + SELECT d2.hostname AS canonical_hostname + FROM grida_www.domain d2 + JOIN requested r ON r.www_id = d2.www_id + WHERE d2.status = 'active' + AND d2.canonical = true + LIMIT 1 + ) + SELECT w.id, w.name, (SELECT canonical_hostname FROM canon) + FROM requested r + JOIN grida_www.www w ON w.id = r.www_id; +$$ LANGUAGE sql STABLE; + +REVOKE ALL ON FUNCTION public.www_resolve_hostname(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.www_resolve_hostname(TEXT) FROM anon; +REVOKE ALL ON FUNCTION public.www_resolve_hostname(TEXT) FROM authenticated; +GRANT EXECUTE ON FUNCTION public.www_resolve_hostname(TEXT) TO service_role; + +CREATE OR REPLACE FUNCTION public.www_get_canonical_hostname(p_www_name TEXT) +RETURNS TABLE ( + canonical_hostname TEXT +) AS $$ + SELECT d.hostname AS canonical_hostname + FROM grida_www.domain d + JOIN grida_www.www w ON w.id = d.www_id + WHERE lower(w.name) = lower(p_www_name) + AND d.status = 'active' + AND d.canonical = true + LIMIT 1; +$$ LANGUAGE sql STABLE; + +REVOKE ALL ON FUNCTION public.www_get_canonical_hostname(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.www_get_canonical_hostname(TEXT) FROM anon; +REVOKE ALL ON FUNCTION public.www_get_canonical_hostname(TEXT) FROM authenticated; +GRANT EXECUTE ON FUNCTION public.www_get_canonical_hostname(TEXT) TO service_role; + --------------------------------------------------------------------- -- [rls_www] -- diff --git a/supabase/tests/test_grida_www_domain_rls_test.sql b/supabase/tests/test_grida_www_domain_rls_test.sql new file mode 100644 index 0000000000..69f80d021f --- /dev/null +++ b/supabase/tests/test_grida_www_domain_rls_test.sql @@ -0,0 +1,281 @@ +BEGIN; +SELECT plan(13); + +-- Setup: create throwaway projects and tenant www under seeded orgs. +DO $$ +DECLARE + local_org_id bigint; + acme_org_id bigint; + local_project_id bigint; + acme_project_id bigint; + local_www_id uuid; + acme_www_id uuid; + local_www_name text := + 'tloc-' || substr(replace(gen_random_uuid()::text, '-', ''), 1, 8); + acme_www_name text := + 'tacm-' || substr(replace(gen_random_uuid()::text, '-', ''), 1, 8); +BEGIN + SELECT id INTO local_org_id FROM public.organization WHERE name = 'local' LIMIT 1; + IF local_org_id IS NULL THEN + RAISE EXCEPTION 'seed org "local" not found'; + END IF; + + SELECT id INTO acme_org_id FROM public.organization WHERE name = 'acme' LIMIT 1; + IF acme_org_id IS NULL THEN + RAISE EXCEPTION 'seed org "acme" not found'; + END IF; + + SET LOCAL ROLE service_role; + + INSERT INTO public.project (organization_id, name) + VALUES (local_org_id, 'tloc-' || substr(replace(gen_random_uuid()::text, '-', ''), 1, 8)) + RETURNING id INTO local_project_id; + + INSERT INTO public.project (organization_id, name) + VALUES (acme_org_id, 'tacm-' || substr(replace(gen_random_uuid()::text, '-', ''), 1, 8)) + RETURNING id INTO acme_project_id; + + -- `grida_www.www` is 1:1 with project and may be auto-created in some setups. + -- Upsert by project_id to keep this test robust. + INSERT INTO grida_www.www (project_id, name) + VALUES (local_project_id, local_www_name) + ON CONFLICT (project_id) DO UPDATE SET name = EXCLUDED.name + RETURNING id INTO local_www_id; + + INSERT INTO grida_www.www (project_id, name) + VALUES (acme_project_id, acme_www_name) + ON CONFLICT (project_id) DO UPDATE SET name = EXCLUDED.name + RETURNING id INTO acme_www_id; + + -- Create active canonical domain for local tenant + INSERT INTO grida_www.domain (www_id, hostname, status, canonical) + VALUES (local_www_id, 'example.com', 'active', true); + + -- Create active domain for acme tenant + INSERT INTO grida_www.domain (www_id, hostname, status, canonical) + VALUES (acme_www_id, 'acme.example.com', 'active', true); + + RESET ROLE; + + PERFORM set_config('test.www_id_local', local_www_id::text, false); + PERFORM set_config('test.www_id_acme', acme_www_id::text, false); + PERFORM set_config('test.www_name_local', local_www_name::text, false); + PERFORM set_config('test.www_name_acme', acme_www_name::text, false); +END $$; + +-- Helper: set auth context as authenticated user. +CREATE OR REPLACE FUNCTION test_set_auth(user_email text) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + user_id uuid; +BEGIN + SELECT id INTO user_id FROM auth.users WHERE email = user_email; + IF user_id IS NULL THEN + RAISE EXCEPTION 'seed user not found: %', user_email; + END IF; + PERFORM set_config('request.jwt.claim.sub', user_id::text, true); + SET LOCAL ROLE authenticated; +END; +$$; + +-- Helper: reset auth context. +CREATE OR REPLACE FUNCTION test_reset_auth() +RETURNS void +LANGUAGE sql +AS $$ + SELECT set_config('request.jwt.claim.sub', '', true); + RESET ROLE; +$$; + +-- 1) Service role can see local active domain +SET ROLE service_role; +SELECT ok( + EXISTS ( + SELECT 1 FROM grida_www.domain + WHERE www_id = current_setting('test.www_id_local')::uuid + AND hostname = 'example.com' + AND status = 'active' + ), + 'service_role can read local active domain' +); +RESET ROLE; + +-- 2) Service role can see acme active domain +SET ROLE service_role; +SELECT ok( + EXISTS ( + SELECT 1 FROM grida_www.domain + WHERE www_id = current_setting('test.www_id_acme')::uuid + AND hostname = 'acme.example.com' + AND status = 'active' + ), + 'service_role can read acme active domain' +); +RESET ROLE; + +-- 3) Insider (local org member) can read local domain +SELECT test_set_auth('insider@grida.co'); +SELECT ok( + EXISTS ( + SELECT 1 FROM grida_www.domain + WHERE www_id = current_setting('test.www_id_local')::uuid + AND hostname = 'example.com' + ), + 'insider can read local domain' +); +SELECT test_reset_auth(); + +-- 4) Alice (acme member) can read acme domain +SELECT test_set_auth('alice@acme.com'); +SELECT ok( + EXISTS ( + SELECT 1 FROM grida_www.domain + WHERE www_id = current_setting('test.www_id_acme')::uuid + AND hostname = 'acme.example.com' + ), + 'alice can read acme domain' +); +SELECT test_reset_auth(); + +-- 5) Insider cannot read acme domain (cross-tenant rejection) +SELECT test_set_auth('insider@grida.co'); +SELECT ok( + NOT EXISTS ( + SELECT 1 FROM grida_www.domain + WHERE www_id = current_setting('test.www_id_acme')::uuid + AND hostname = 'acme.example.com' + ), + 'insider cannot read acme domain' +); +SELECT test_reset_auth(); + +-- 6) Random user cannot read local domain (rejection) +SELECT test_set_auth('random@example.com'); +SELECT ok( + NOT EXISTS ( + SELECT 1 FROM grida_www.domain + WHERE www_id = current_setting('test.www_id_local')::uuid + AND hostname = 'example.com' + ), + 'random cannot read local domain' +); +SELECT test_reset_auth(); + +-- 7) Anon cannot read domain rows (even active) +SET ROLE anon; +SELECT ok( + NOT EXISTS ( + SELECT 1 FROM grida_www.domain + WHERE hostname IN ('example.com', 'acme.example.com') + ), + 'anon cannot enumerate domain mappings' +); +RESET ROLE; + +-- 8) Insider can insert domain for local www +SELECT test_set_auth('insider@grida.co'); +DO $$ +BEGIN + BEGIN + INSERT INTO grida_www.domain (www_id, hostname, status, canonical) + VALUES (current_setting('test.www_id_local')::uuid, 'app.example.com', 'pending', false); + PERFORM set_config('test.insert_insider_ok', 'true', true); + EXCEPTION WHEN others THEN + PERFORM set_config('test.insert_insider_ok', 'false', true); + END; +END $$; +SELECT is(current_setting('test.insert_insider_ok'), 'true', 'insider can insert domain for local www'); +SELECT test_reset_auth(); + +-- 9) Random cannot insert domain for local www +SELECT test_set_auth('random@example.com'); +DO $$ +BEGIN + BEGIN + INSERT INTO grida_www.domain (www_id, hostname, status, canonical) + VALUES (current_setting('test.www_id_local')::uuid, 'blocked.example.com', 'pending', false); + PERFORM set_config('test.insert_random_ok', 'true', true); + EXCEPTION WHEN others THEN + PERFORM set_config('test.insert_random_ok', 'false', true); + END; +END $$; +SELECT is(current_setting('test.insert_random_ok'), 'false', 'random cannot insert domain for local www'); +SELECT test_reset_auth(); + +-- 10) Insider can update a local domain row +SELECT test_set_auth('insider@grida.co'); +DO $$ +DECLARE + rc integer; +BEGIN + BEGIN + UPDATE grida_www.domain + SET last_error = 'test', + last_error_code = 'DNS_MISCONFIGURED', + last_checked_at = now() + WHERE www_id = current_setting('test.www_id_local')::uuid + AND hostname = 'example.com'; + GET DIAGNOSTICS rc = ROW_COUNT; + PERFORM set_config('test.update_insider_rowcount', rc::text, true); + EXCEPTION WHEN others THEN + PERFORM set_config('test.update_insider_rowcount', '-1', true); + END; +END $$; +SELECT is(current_setting('test.update_insider_rowcount'), '1', 'insider can update local domain row (1 row)'); +SELECT test_reset_auth(); + +-- 11) Random cannot update local domain row +SELECT test_set_auth('random@example.com'); +DO $$ +DECLARE + rc integer; +BEGIN + BEGIN + UPDATE grida_www.domain + SET last_error = 'hacked', + last_error_code = 'VERCEL_API_ERROR', + last_checked_at = now() + WHERE www_id = current_setting('test.www_id_local')::uuid + AND hostname = 'example.com'; + GET DIAGNOSTICS rc = ROW_COUNT; + PERFORM set_config('test.update_random_rowcount', rc::text, true); + EXCEPTION WHEN others THEN + PERFORM set_config('test.update_random_rowcount', '-1', true); + END; +END $$; +SELECT is(current_setting('test.update_random_rowcount'), '0', 'random cannot update local domain row (0 rows)'); +SELECT test_reset_auth(); + +-- 12) Resolve function is not executable by anon (hardening) +SET ROLE anon; +DO $$ +BEGIN + BEGIN + PERFORM public.www_resolve_hostname('example.com'); + PERFORM set_config('test.resolve_anon_ok', 'true', true); + EXCEPTION WHEN others THEN + PERFORM set_config('test.resolve_anon_ok', 'false', true); + END; +END $$; +SELECT is(current_setting('test.resolve_anon_ok'), 'false', 'anon cannot execute www_resolve_hostname after hardening'); +SELECT test_reset_auth(); + +-- 13) Canonical lookup function is not executable by anon (hardening) +SET ROLE anon; +DO $$ +BEGIN + BEGIN + PERFORM public.www_get_canonical_hostname('example'); + PERFORM set_config('test.canon_anon_ok', 'true', true); + EXCEPTION WHEN others THEN + PERFORM set_config('test.canon_anon_ok', 'false', true); + END; +END $$; +SELECT is(current_setting('test.canon_anon_ok'), 'false', 'anon cannot execute www_get_canonical_hostname after hardening'); +RESET ROLE; + +SELECT * FROM finish(); +ROLLBACK; +