From ce19762f0b803af3c2df550f5f6731b7921f8618 Mon Sep 17 00:00:00 2001 From: Universe Date: Fri, 30 Jan 2026 02:39:52 +0900 Subject: [PATCH 01/16] feat: add devcontainer configuration for universal environment --- .devcontainer/universal/devcontainer.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .devcontainer/universal/devcontainer.json 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": {} + } +} From 34408a642e157d97aa64bb2005eafe4a3862cadb Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 2 Feb 2026 01:03:38 +0900 Subject: [PATCH 02/16] feat: add platform working group documentation - Introduced a new index file for the Platform working group, outlining infrastructure topics. - Added a detailed document on Multi-tenant Custom Domains on Vercel, covering architectural decisions, domain ownership models, and routing semantics. --- docs/wg/platform/index.md | 11 + .../multi-tenant-custom-domain-vercel.md | 320 ++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 docs/wg/platform/index.md create mode 100644 docs/wg/platform/multi-tenant-custom-domain-vercel.md 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..82f89fc4b0 --- /dev/null +++ b/docs/wg/platform/multi-tenant-custom-domain-vercel.md @@ -0,0 +1,320 @@ +--- +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. + +--- + +## 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. + +--- + +## 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. From 9f37e287c59d8a6e70458861cef472f1a461c489 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 3 Feb 2026 20:44:08 +0900 Subject: [PATCH 03/16] Add schema conventions for API surface and internal organization in AGENTS.md --- supabase/AGENTS.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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**: From 3bd70e6aebeb663272eda14d28d3843a550471af Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 3 Feb 2026 20:53:57 +0900 Subject: [PATCH 04/16] db type override fileoverview and guidelines --- database/database.types.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) 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 = { From 77cc6b7466b837ba5d6e29cc47b4d9efdd499c8f Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 3 Feb 2026 21:03:13 +0900 Subject: [PATCH 05/16] Enhance CI workflow for database tests by ensuring signing key file exists and generating a key only if necessary --- .github/workflows/database-tests.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/database-tests.yml b/.github/workflows/database-tests.yml index 16d7b7a55a..953b448374 100644 --- a/.github/workflows/database-tests.yml +++ b/.github/workflows/database-tests.yml @@ -20,6 +20,20 @@ jobs: - uses: supabase/setup-cli@v1 with: version: latest - - run: printf '[]' > supabase/signing_keys.json && supabase gen signing-key --append --yes + - name: Ensure signing key exists (CI) + run: | + set -euo pipefail + + # `supabase gen signing-key` expects the file to exist. + # `signing_keys.json` is gitignored, so CI starts without it. + if [ ! -f supabase/signing_keys.json ]; then + printf '[]' > supabase/signing_keys.json + fi + + # Work around older CLI panics when `--append` is used with an empty array. + # Only generate a key if the file currently has no keys. + if [ "$(tr -d ' \n\t' < supabase/signing_keys.json)" = "[]" ]; then + supabase gen signing-key --yes + fi - run: supabase db start - run: supabase test db From fca1222e81c25dfbb94da1e90a24ca9a99fb22ab Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 3 Feb 2026 21:07:55 +0900 Subject: [PATCH 06/16] pin ver --- .github/workflows/database-tests.yml | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/.github/workflows/database-tests.yml b/.github/workflows/database-tests.yml index 953b448374..882c0b856c 100644 --- a/.github/workflows/database-tests.yml +++ b/.github/workflows/database-tests.yml @@ -19,21 +19,7 @@ jobs: - uses: actions/checkout@v4 - uses: supabase/setup-cli@v1 with: - version: latest - - name: Ensure signing key exists (CI) - run: | - set -euo pipefail - - # `supabase gen signing-key` expects the file to exist. - # `signing_keys.json` is gitignored, so CI starts without it. - if [ ! -f supabase/signing_keys.json ]; then - printf '[]' > supabase/signing_keys.json - fi - - # Work around older CLI panics when `--append` is used with an empty array. - # Only generate a key if the file currently has no keys. - if [ "$(tr -d ' \n\t' < supabase/signing_keys.json)" = "[]" ]; then - supabase gen signing-key --yes - fi + version: 2.72.7 + - run: printf '[]' > supabase/signing_keys.json && supabase gen signing-key --append --yes - run: supabase db start - run: supabase test db From f4d80b3917aad5739280b9a36871fe77c588e441 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 3 Feb 2026 21:32:27 +0900 Subject: [PATCH 07/16] add proxy.ts description --- editor/proxy.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/editor/proxy.ts b/editor/proxy.ts index 52f14f5f8d..b60ad52eec 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"; From 0a14068ed55342c7dc52aa552f1b9a7ec7f49745 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 3 Feb 2026 23:39:43 +0900 Subject: [PATCH 08/16] chore --- .../[proj]/(console)/(new)/new/referral/welcome-dialog.tsx | 7 +++++++ 1 file changed, 7 insertions(+) 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. +
Date: Tue, 3 Feb 2026 23:47:06 +0900 Subject: [PATCH 09/16] Update .env.example with new environment variables and add service-role cookie-free Supabase client implementation --- editor/.env.example | 3 +- .../service-role-cookie-free-clients.ts | 53 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 editor/lib/supabase/service-role-cookie-free-clients.ts diff --git a/editor/.env.example b/editor/.env.example index 59e27efd99..9215afcb45 100644 --- a/editor/.env.example +++ b/editor/.env.example @@ -1,7 +1,8 @@ # [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_..." 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; +} From 30c92f38258187b6e34002a08d9e2d1fdfdcfa88 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 3 Feb 2026 23:49:10 +0900 Subject: [PATCH 10/16] Implement project domain management functions in Vercel client, including adding, retrieving, and removing project domains. --- editor/clients/vercel/index.ts | 42 +++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/editor/clients/vercel/index.ts b/editor/clients/vercel/index.ts index 47251cb2dd..d4994ccffa 100644 --- a/editor/clients/vercel/index.ts +++ b/editor/clients/vercel/index.ts @@ -1,20 +1,46 @@ 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"; -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 projectsAddProjectDomain = (domain: string) => + _projectsAddProjectDomain(__vercel_core, { + teamId: VERCEL_TEAM_ID, + idOrName: VERCEL_PROJECT_ID, + requestBody: { name: domain }, + }); + +export const projectsGetProjectDomain = (domain: string) => + _projectsGetProjectDomain(__vercel_core, { + teamId: VERCEL_TEAM_ID, + idOrName: VERCEL_PROJECT_ID, + domain, + }); + +export const projectsGetProjectDomains = () => + _projectsGetProjectDomains(__vercel_core, { + teamId: VERCEL_TEAM_ID, + idOrName: VERCEL_PROJECT_ID, + }); + +export const projectsRemoveProjectDomain = (domain: string) => + _projectsRemoveProjectDomain(__vercel_core, { + teamId: VERCEL_TEAM_ID, + idOrName: VERCEL_PROJECT_ID, + domain, + }); export const projectsVerifyProjectDomain = (domain: string) => _projectsVerifyProjectDomain(__vercel_core, { From 4074bdfcbeb99fa7d76cf8430b611c90264774f6 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 4 Feb 2026 01:21:36 +0900 Subject: [PATCH 11/16] Add domain navigation and icon support in layout and resource type components --- .../[org]/[proj]/(console)/(resources)/layout.tsx | 8 ++++++++ editor/components/resource-type-icon.tsx | 4 ++++ 2 files changed, 12 insertions(+) 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/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": From f47a0ec43e4f0243b741d78254b283c7a5564c58 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 4 Feb 2026 01:29:02 +0900 Subject: [PATCH 12/16] Refactor CopyToClipboardInput component to use InputGroup for improved layout and add error handling for clipboard copy operation --- .../copy-to-clipboard-input/index.tsx | 84 ++++++++++--------- 1 file changed, 45 insertions(+), 39 deletions(-) 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 ? ( + + ) : ( + + )} + + + ); } From 3bc031b2471fb843cd9ba8707407cb43a7dba909 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 4 Feb 2026 01:30:30 +0900 Subject: [PATCH 13/16] Add domain management UI components for project settings, including new DomainsPage and CustomDomainsSection. Remove deprecated SiteDomainsSection and related domain handling logic from ProjectWWWSettingsPage. --- .../(console)/(resources)/domains/page.tsx | 115 +++ .../(resources)/domains/section-domains.tsx | 704 ++++++++++++++++++ .../[proj]/(console)/(resources)/www/page.tsx | 51 +- .../(resources)/www/section-domain.tsx | 142 ---- 4 files changed, 827 insertions(+), 185 deletions(-) create mode 100644 editor/app/(workbench)/[org]/[proj]/(console)/(resources)/domains/page.tsx create mode 100644 editor/app/(workbench)/[org]/[proj]/(console)/(resources)/domains/section-domains.tsx delete mode 100644 editor/app/(workbench)/[org]/[proj]/(console)/(resources)/www/section-domain.tsx diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/domains/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/domains/page.tsx new file mode 100644 index 0000000000..493cda3b13 --- /dev/null +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/domains/page.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { CustomDomainsSection } from "./section-domains"; +import { createBrowserWWWClient } from "@/lib/supabase/client"; +import { useProject } from "@/scaffolds/workspace"; +import { useCallback, useMemo } from "react"; +import useSWR, { mutate } from "swr"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + DEFAULT_PLATFORM_APEX_DOMAIN, + platformSiteHostnameForTenant, +} from "@/lib/domains"; + +type ProjectWWWMinimal = { + id: string; + name: string; + project_id: number; +}; + +function useDomainsPageData() { + const project = useProject(); + const client = useMemo(() => 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..5d0fe56e02 --- /dev/null +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/domains/section-domains.tsx @@ -0,0 +1,704 @@ +"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: any | 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 vercelDomain = domain.vercel?.domain as + | { name?: string; apexName?: string; verification?: any[] } + | 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: any) => { + const type = v.type ?? v.recordType ?? v.kind ?? "DNS"; + const name = v.domain ?? v.name ?? v.recordName ?? "@"; + const value = v.value ?? v.target ?? v.expectedValue ?? ""; + return { + type: String(type).toUpperCase(), + name: String(name), + value: String(value), + }; + }) + .filter((x: any) => 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)/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"} - -
- - - - - - -
-
- ); -} From cd1b7fce91a0b930acc01b2204783679bacd1f4f Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 4 Feb 2026 01:52:41 +0900 Subject: [PATCH 14/16] Add custom domain management functionality, including database schema for domain mapping, new SQL functions for hostname resolution, and comprehensive tests for role-based access control. Update type definitions to reflect new domain structure and enhance documentation with edge cases and operational guidelines. --- database/database-generated.types.ts | 79 ++++- .../multi-tenant-custom-domain-vercel.md | 96 ++++++ ...0260202120000_grida_www_custom_domains.sql | 120 ++++++++ supabase/schemas/grida_www.sql | 101 +++++++ .../tests/test_grida_www_domain_rls_test.sql | 281 ++++++++++++++++++ 5 files changed, 676 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/20260202120000_grida_www_custom_domains.sql create mode 100644 supabase/tests/test_grida_www_domain_rls_test.sql 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/docs/wg/platform/multi-tenant-custom-domain-vercel.md b/docs/wg/platform/multi-tenant-custom-domain-vercel.md index 82f89fc4b0..26efc56b01 100644 --- a/docs/wg/platform/multi-tenant-custom-domain-vercel.md +++ b/docs/wg/platform/multi-tenant-custom-domain-vercel.md @@ -188,6 +188,74 @@ 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 @@ -208,6 +276,34 @@ 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: 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; + From 8f6f6abdc19ab5ec07be67b7f01127f919b9293f Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 4 Feb 2026 01:54:02 +0900 Subject: [PATCH 15/16] md oxc --- .vscode/settings.json | 3 +++ 1 file changed, 3 insertions(+) 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" + }, } From a53838dd35507230bbef9d0262cf3e4121d311cf Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 4 Feb 2026 02:12:53 +0900 Subject: [PATCH 16/16] Enhance domain management functionality by introducing new API routes for domain verification, refresh, and canonicalization. Implement domain handling logic in the Vercel client, including domain addition and removal. Update environment configuration with new required variables and improve type definitions for better clarity. Add tests for domain-related utilities to ensure robustness. --- editor/.env.example | 17 + .../app/(api)/internal/resolve-host/route.ts | 130 +++++++ .../private/domains/[domain]/verify/route.ts | 12 - .../[proj]/www/domains/[hostname]/_refresh.ts | 231 ++++++++++++ .../www/domains/[hostname]/canonical/route.ts | 101 ++++++ .../www/domains/[hostname]/refresh/route.ts | 9 + .../[proj]/www/domains/[hostname]/route.ts | 118 ++++++ .../www/domains/[hostname]/verify/route.ts | 10 + .../www/domains/platform/canonical/route.ts | 53 +++ .../~/[org]/[proj]/www/domains/route.ts | 341 ++++++++++++++++++ .../(resources)/domains/section-domains.tsx | 28 +- editor/clients/vercel/index.ts | 123 +++++-- editor/lib/domains/index.test.ts | 113 ++++++ editor/lib/domains/index.ts | 147 ++++++++ editor/lib/tenant/middleware.ts | 253 ++++++++++++- editor/proxy.ts | 57 +-- 16 files changed, 1629 insertions(+), 114 deletions(-) create mode 100644 editor/app/(api)/internal/resolve-host/route.ts delete mode 100644 editor/app/(api)/private/domains/[domain]/verify/route.ts create mode 100644 editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/_refresh.ts create mode 100644 editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/canonical/route.ts create mode 100644 editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/refresh/route.ts create mode 100644 editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/route.ts create mode 100644 editor/app/(api)/private/~/[org]/[proj]/www/domains/[hostname]/verify/route.ts create mode 100644 editor/app/(api)/private/~/[org]/[proj]/www/domains/platform/canonical/route.ts create mode 100644 editor/app/(api)/private/~/[org]/[proj]/www/domains/route.ts create mode 100644 editor/lib/domains/index.test.ts create mode 100644 editor/lib/domains/index.ts diff --git a/editor/.env.example b/editor/.env.example index 9215afcb45..04b54d83df 100644 --- a/editor/.env.example +++ b/editor/.env.example @@ -6,6 +6,14 @@ 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":"..."}' @@ -36,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 @@ -44,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)/(resources)/domains/section-domains.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/domains/section-domains.tsx index 5d0fe56e02..f17f41707a 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/domains/section-domains.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/domains/section-domains.tsx @@ -47,7 +47,7 @@ type DomainRow = { canonical: boolean; kind: DomainKind; last_error: string | null; - vercel: any | null; + vercel: unknown | null; }; type ApiListResponse = { @@ -89,9 +89,15 @@ function dnsInstructions(domain: DomainRow): DnsInstruction[] { // 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 vercelDomain = domain.vercel?.domain as - | { name?: string; apexName?: string; verification?: any[] } - | undefined; + 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( @@ -119,17 +125,19 @@ function dnsInstructions(domain: DomainRow): DnsInstruction[] { const verification = vercelDomain?.verification; const provider: DnsInstruction[] = Array.isArray(verification) ? verification - .map((v: any) => { - const type = v.type ?? v.recordType ?? v.kind ?? "DNS"; - const name = v.domain ?? v.name ?? v.recordName ?? "@"; - const value = v.value ?? v.target ?? v.expectedValue ?? ""; + .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: any) => x?.type && x?.name && x?.value) + .filter((x): x is DnsInstruction => Boolean(x?.type && x?.name && x?.value)) : []; // Dedupe. diff --git a/editor/clients/vercel/index.ts b/editor/clients/vercel/index.ts index d4994ccffa..9e7183f86c 100644 --- a/editor/clients/vercel/index.ts +++ b/editor/clients/vercel/index.ts @@ -5,6 +5,7 @@ import { projectsGetProjectDomain as _projectsGetProjectDomain } from "@vercel/s 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"; export const VERCEL_AUTH_BEARER_TOKEN = process.env.VERCEL_AUTH_BEARER_TOKEN || ""; @@ -15,36 +16,102 @@ export const __vercel_core = new VercelCore({ bearerToken: VERCEL_AUTH_BEARER_TOKEN, }); -export const projectsAddProjectDomain = (domain: string) => - _projectsAddProjectDomain(__vercel_core, { - teamId: VERCEL_TEAM_ID, - idOrName: VERCEL_PROJECT_ID, - requestBody: { name: 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); -export const projectsGetProjectDomain = (domain: string) => - _projectsGetProjectDomain(__vercel_core, { - teamId: VERCEL_TEAM_ID, - idOrName: VERCEL_PROJECT_ID, - domain, + 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", }); -export const projectsGetProjectDomains = () => - _projectsGetProjectDomains(__vercel_core, { - teamId: VERCEL_TEAM_ID, - idOrName: VERCEL_PROJECT_ID, - }); + const bodyText = await res.text().catch(() => ""); + let json: unknown = null; + try { + json = bodyText ? JSON.parse(bodyText) : null; + } catch { + json = { raw: bodyText } satisfies Record; + } -export const projectsRemoveProjectDomain = (domain: string) => - _projectsRemoveProjectDomain(__vercel_core, { - teamId: VERCEL_TEAM_ID, - idOrName: VERCEL_PROJECT_ID, - domain, - }); + 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; + } -export const projectsVerifyProjectDomain = (domain: string) => - _projectsVerifyProjectDomain(__vercel_core, { - teamId: VERCEL_TEAM_ID, - idOrName: VERCEL_PROJECT_ID, - domain, - }); + 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/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/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 b60ad52eec..4134dcf3df 100644 --- a/editor/proxy.ts +++ b/editor/proxy.ts @@ -75,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; }