Published

Runbook · Migrar al dominio paladio.io

Connect any source, model it as an ontology, transform it, and operationalize it, analytics, automation and machine learning, under one governed, self-hostable roof. --- Most teams stitch the...

Runbook · Migrar al dominio paladio.io

Encargo (2026-08-03). Se ha adquirido paladio.io en GoDaddy y sustituirá a los
dos dominios actuales. Este documento es el censo + el plan de ejecución:
dónde está clavado el dominio hoy, qué se rompe si se cambia sin orden, las
decisiones ya tomadas (§3), y las tandas de trabajo (§4).

⚠️ La pieza peligrosa es Clerk, no Vercel. Cambiar el dominio de la app es un
registro DNS; cambiar el de Clerk cambia el emisor de los JWT, invalida las
sesiones y deja fuera a todo lo que verifica ese emisor. Va con su propio orden
y su propio rollback (§4, Tanda 4).


1 · Lo que hay hoy: dos dominios, no uno

DominioSuperficieQuién lo sirve
node-web.comsólo la landingel MISMO deploy de Vercel
nodeworkspace.comla app enterael MISMO deploy de Vercel

Los dos apuntan al mismo despliegue y se separan por cabecera Host en proxy.ts: node-web.com → landing; nodeworkspace.com → app, con / redirigido a /dashboard.

Y cuatro subdominios de nodeworkspace.com están en uso:

SubdominioPara quéDónde vive
www.la appVercel
clerk.Frontend API de Clerk — carga clerk-js y resuelve la sesiónCNAME a Clerk
accounts.Account Portal de ClerkCNAME a Clerk
om.OpenMetadataA → IP de la VM de GCP
(jupyter.)JupyterHubreferenciado en .claude/settings.json; hoy el servicio vive en Railway

⚠️ El comodín *.nodeworkspace.com NO sirve share links. Existe en Vercel por
un diseño anterior —subdominio por workspace— que nunca se llegó a implementar.
Los share links son rutas: url.ts:44 construye
https://www.<dominio>/w/<slug>, y no hay una sola línea en el repo que fabrique
un host <slug>.. El docstring de slug.ts:12
está rancio y lo documenta como si estuviera vivo. El comodín no se replica
(decisión ②, §3).


2 · El censo, sistema por sistema

2.1 · Vercel

QuéDetalle
Dominiosnode-web.com, www.node-web.com, nodeworkspace.com, www.nodeworkspace.com, y el comodín *.nodeworkspace.com (vestigio — ver §1)
Variablesver la tabla de abajo — el censo anterior era incorrecto

Las variables, verificadas contra el código (no contra este documento):

Variable¿Cambia?Por qué
NEXT_PUBLIC_APP_URLbase de los callbacks de webhook (webhook-manager.ts:17), la URL que se enseña en WebhookPanel, y dos adapters
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYcodifica el host del Frontend API. pk_live_… es base64 de clerk.<dominio>$. Es la variable que restaura el login, y no estaba en el censo
NEXTAUTH_URLNO — no crearlanext-auth no está instalado. Un solo uso (sql-editor/execute:102), un self-fetch. Como nunca existió, cae al fallback localhost:3000 ⇒ ese camino ya estaba roto. Crearla no actualiza un dominio: enciende código muerto sin probar
NEXT_PUBLIC_BASE_URL⏸️ despuésconstruye el redirect_uri de Microsoft (connect/route.ts:13). Debe cambiarse a la vez que el registro en Azure, o el OAuth muere con redirect_uri_mismatch
GOOGLE_{GMAIL,CALENDAR,DRIVE}_REDIRECT_URI⏸️ después⚠️ no estaban en el censo. Tres redirect URIs de OAuth (refreshAccessToken.ts:137-146); van con Google Cloud Console en lockstep
NEXT_PUBLIC_OPENMETADATA_URL❌ nosigue en om.nodeworkspace.com (Tanda 6)
NEXT_PUBLIC_JUPYTER_HOST🔴 sí, y estaba ROTO de antesver abajo

🔴 Corrección (2026-08-04). Se dijo que esta variable «es un host de Railway y
nunca llevó nuestro dominio». Falso: valía jupyter.nodeworkspace.com, y ese
nombre no resuelve — no existe el registro DNS, ni en la zona vieja. Síntoma:
Network error talking to Hub … fetch failed al crear un notebook.

No lo rompió la migración: llevaba roto y nadie lo había tocado. Se arregla
apuntando al dominio público del servicio en Railway
(jupyter-production-xxxx.up.railway.app, sin https://). Es NEXT_PUBLIC_
exige redeploy.

La lección, otra vez la misma: que una variable exista no dice que su valor
resuelva. El censo la dio por buena porque nadie la había consultado nunca.

🔸 Decisión del owner (2026-08-04): las integraciones de Google/Microsoft y
los webhooks se consideran legacy y no bloquean la migración. Se quedan
apuntando al dominio viejo, que sigue sirviendo.

⚠️ Los webhooks ya registrados llevan el dominio viejo grabado.
getWebhookBaseUrl() compone la URL en el registro, así que cambiar la
variable sólo afecta a los nuevos. Y el 301 de la Tanda 5 no los salva: son
POST con cuerpo, y una redirección sobre POST no la siguen todos los clientes
con el body intacto. O el dominio viejo responde de verdad, o hay que
re-registrarlos.

2.2 · Clerk — la pieza crítica

QuéDónde
Frontend APIclerk.nodeworkspace.com
Account Portalaccounts.nodeworkspace.com
Hosts en la CSPnext.config.ts:163-168hardcodeados
Hosts en images.remotePatternsnext.config.ts:35-36
afterSignOutUrlWorkspaceShell.tsx:976https://www.nodeworkspace.com/sign-in
redirectUrl de invitacionesInviteModal.tsx:86https://nodeworkspace.com/sign-up
Verificación del emisorservices/jupyterhub/clerk_authenticator.py — acepta una lista de hosts (dev + prod)

🔴 Por qué es la pieza crítica. El dominio de Clerk es el emisor (iss) de
los JWT
. Al cambiarlo: las sesiones vivas se invalidan, y todo lo que valida
el token tiene que aceptar el emisor nuevo antes de que empiece a emitirlo

JupyterHub y la SDK incluidos. Si se cambia primero en Clerk y después en el
código, hay una ventana en la que nadie puede entrar.

🔴 Y su corolario, que ordena todo el plan: la app no puede servir una sesión
desde un host que Clerk no conoce
. Hasta que app.paladio.io esté registrado en
Clerk, ese host sólo sirve contenido anónimo. Por eso APP_ORIGIN no se mueve
hasta la Tanda 4 — ver el gate de esa tanda.

2.3 · GoDaddy (DNS)

Lo que hay que crear en la zona de paladio.io, y en qué tanda:

T1   A      @        → Vercel      (valor exacto: el que muestre el panel)
T1   CNAME  www      → Vercel      (301 al apex, configurado en Vercel)
T1   CNAME  app      → Vercel      ← explícito: NO hay comodín que lo cubra
T3   CNAME  clerk    → Clerk       ⚠️ Clerk pide MÁS de dos registros — ver abajo
T3   CNAME  accounts → Clerk
T6   A      om       → <IP de la VM de GCP>     (diferido)

⚠️ Clerk no son dos registros. Una instancia de producción pide además los de
correo —del tipo clkmail y dos claves DKIM— sin los cuales los emails de
invitación no salen
(InviteModal.tsx
depende de eso). El número y los valores exactos los da su panel al crear el
dominio: cópialos de ahí, no de este documento, y créalos todos en la Tanda 3.

⚠️ Ningún valor de Vercel ni de Clerk se transcribe desde un runbook. Cambian
por proyecto y por antigüedad de cuenta. El runbook dice qué registros hacen
falta; el panel dice cuáles.

2.4 · La VM de GCP (OpenMetadata)

QuéFichero
Vhost + TLS automáticoinfra/openmetadata/Caddyfile:29om.nodeworkspace.com
frame-ancestors — lo que permite el enclave dentro de CarbonCaddyfile:49, con los tres orígenes de la app
Dominio del principal de OMdocker-compose.override.yml:23-25AUTHORIZER_PRINCIPAL_DOMAIN, AUTHORIZER_ALLOWED_REGISTRATION_DOMAIN, AUTHENTICATION_PUBLIC_KEYS
Bot de Carboncarbon@nodeworkspace.com (runbook)

🔴 Dos trampas aquí. (a) AUTHORIZER_PRINCIPAL_DOMAIN decide quién puede
entrar en OM
: la identidad del usuario deriva del dominio de su correo, así que
cambiarlo sin migrar los usuarios existentes los deja fuera. (b) Caddy pedirá un
certificado nuevo, y para eso necesita el puerto 80 abierto y el DNS ya
propagado
— no al revés.

⚠️ Y hoy no hay acceso. Diagnosticado a fondo el 2026-08-04:

ComprobaciónResultado
10.138.0.2:22❌ inalcanzable — es la IP privada de la VPC, no se enruta desde fuera
34.168.67.167:22✅ abierto · banner SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.16
Clave de hostCOINCIDE con known_hosts:1 (SHA256:Ck6+hE5+adJEXy1RexoDx14r+wGcG0tbDh+lMV1jQfA)
Nuestra clave✅ existe y se ofrece (SHA256:xt4s2sHK4aWaM/tcfjE2fVM3IXr1DIbkD3XwnZL2ZkY)
AutenticaciónPermission denied (publickey) · el servidor sólo admite publickey, sin contraseña

🔴 Corrección: la clave de host NO cambió. Este runbook decía «se reconstruyó,
cambió la clave de host y nuestra pública ya no está en authorized_keys». La
primera mitad es falsa —el host key valida contra known_hosts sin rechistar,
así que la VM no se reconstruyó—; la segunda es cierta: la clave se ofrece y
el servidor la rechaza.

⇒ El fallo está sólo en la autorización de la clave, no en la identidad de la
máquina. Eso lo hace mucho más fácil de arreglar de lo que este documento decía.

Pista sobre el origen: el comentario de nuestra clave pública es
carbon-openmetadata, que es justo la convención con la que GCE deriva el
usuario Linux de una clave puesta en metadatos
. O sea, se aprovisionó por
metadatos y algo la retiró — o se activó OS Login, que hace que GCE ignore
authorized_keys y los metadatos por completo.

Cómo recuperarlo (gcloud NO está instalado en local):

  1. GCP Console → Compute Engine → la VM → Edit → Metadata → ver si existe
    enable-oslogin
    . Ese dato decide el camino.
  2. Si no está: Edit → SSH Keys → Add item y pegar la línea completa de
    ~/.ssh/id_ed25519.pub. GCE recrea el authorized_keys del usuario.
  3. Si está: los metadatos no valen; hay que registrarla por OS Login, y
    entonces el usuario no será carbon-openmetadata sino el derivado del
    correo.
  4. Alternativa que evita elegir: instalar gcloud y usar gcloud compute ssh,
    que aprovisiona la clave por el camino que corresponda.

Pero frame-ancestors (Caddyfile:49) sí hay que tocarlo —y eso
requiere el SSH— porque si no, el enclave de OM deja de renderizar dentro de
app.paladio.io. Es lo único de OM que bloquea la Tanda 4.

2.5 · Railway

Los servicios usan subdominios *.up.railway.app, no dominios propios, así que la migración no les afecta… con una excepción:

ServicioHost
Lakekeeperheroic-victory-production-41dd.up.railway.app
duck-serverduck-production-ef4a.up.railway.app
Keycloakcharming-consideration-production.up.railway.app
ml-runner · warehouse-writer · node*.up.railway.app
JupyterHub⚠️ el servicio se llama node en Railway, no «jupyter» — host real node-production-c2a0.up.railway.app (buildIframeUrl.ts:30). Su NEXT_PUBLIC_APP_URL es la lista de ancestros permitidos del iframe (jupyterhub_config.py:1197)

🔴 El Hub llevaba roto por DOS motivos distintos a la vez (2026-08-04). En
Public Networking había dos dominios, y cada uno fallaba por su lado:

DominioPuertoFallo
node-production-c2a0.up.railway.app8082resolvía, pero nadie escuchaba en ese puerto
jupyter.nodeworkspace.com8080puerto correcto, pero el registro DNS nunca se creó

Quien montó el dominio personalizado acertó el puerto y no hizo el DNS; el
generado tenía el DNS y el puerto equivocado. Ninguno funcionó nunca.

Cómo se distinguió: el 404 traía server: railway-hikari con cuerpo vacío — es
el edge de Railway diciendo «no hay nadie en ese puerto», no un 404 de la
aplicación. Un Hub caído habría dado 502.

Arreglado poniendo el dominio generado en 8080. Verificado:
/hub/api200 {"version": "5.5.0"}.

La lección: «la variable apunta a un host que existe» y «ese host llega al
proceso» son dos afirmaciones distintas, y aquí fallaban las dos por separado.

Consecuencia útil: Keycloak, Lakekeeper y el resto del warehouse no se
enteran
de este cambio. Toda la gobernanza que construimos hoy es indiferente
al dominio.

2.6 · El código

FicheroQué hay
proxy.ts:75-92el enrutado por Host (classifyHost) y APP_ORIGIN
next.config.tsCSP con los hosts de Clerk · remotePatterns
lib/workspace/url.ts:16APP_DOMAIN = 'nodeworkspace.com' — share links y getAppRootUrl
lib/workspace/slug.ts:12docstring rancio: describe un subdominio por workspace que no existe
components/landing/Navbar.tsx:287,339APP_ORIGIN y la detección del host de marketing
WorkspaceShell.tsx · InviteModal.tsxURLs absolutas de Clerk (§2.2)
LiveDeploymentTab.tsx:134 · snippetBuilders.test.ts:32ejemplos de endpoint que ve el usuario
docs/sdk-v1.openapi.yaml:35 · docs/sdk-v1-contract.md⚠️ NODE_API_BASE es contrato publicado
services/jupyterhub/node-sdk/node/__init__.py:23el default de la SDK en el kernel
TrustpilotWidget.tsxURL de reseñas (cosmético)
shopify.app.tomlhandle y redirect_urls — se cambian en el panel de Shopify Partners

🔴 La SDK es la única huella con consumidores fuera de nuestro control. Un
kernel de Jupyter o un notebook de un cliente tiene NODE_API_BASE apuntando al
dominio viejo. El dominio viejo debe seguir respondiendo (redirección 301) al
menos un ciclo — o se rompen sus notebooks sin avisar.

Pero la SDK NO valida dominios, y eso simplifica mucho.
auth.ts:82 verifica el kernel JWT contra
issuer: 'node-platform'una cadena literal, no un host
(types.ts:41). El servidor no necesita ningún cambio:
sirve cualquier host que le llegue. La exposición real es sólo del lado cliente:
NODE_API_BASE es una variable de entorno inyectada en el kernel
(transforms.py:650).
Traducción: (a) JupyterHub debe inyectar el dominio nuevo en los kernels
nuevos (Tanda 4) y (b) el viejo debe seguir respondiendo para los kernels y
notebooks ya existentes (Tanda 5). Nada más.


3 · ⭐ Decisiones tomadas (2026-08-03)

① Forma A — dos hosts bajo un dominio registrable.

www.paladio.io   → landing   (paladio.io → 308 a www)
app.paladio.io   → la app

📌 Enmienda (2026-08-03): el canónico es www, no el apex. El plan decía apex,
pero al configurar Vercel quedó paladio.io → 308 → www.paladio.io — y eso es lo
consistente
: el dominio viejo ya hace exactamente lo mismo
(node-web.com → 308 → www.node-web.com, medido). Darle la vuelta ahora sería un
cambio sin beneficio en mitad de la migración. classifyHost trata los dos como
landing, así que funciona igual; lo que se fija aquí es cuál es el canónico
para el SEO y para el 301 de la Tanda 5.

Se conserva el enrutado por Host. Motivos:

  • La premisa "ahora sólo hay un dominio" no se sostiene: clerk. y accounts. son hosts obligatorios de Clerk, y om. seguirá existiendo. Nunca se deja de enrutar por host; sólo se decide si la app es uno de esos hosts.
  • Colapsar landing y app en un host único vuelve / ambiguo: tendría que decidir entre landing y dashboard según la sesión, metiendo un await auth() en la ruta de entrada de todo visitante anónimo y perdiendo el cacheo estático de la landing. Es un modo de fallo nuevo estrenándose en la misma semana que se cambia el emisor de los JWT — si algo se rompe, no se sabría cuál de los dos cambios fue.
  • classifyHost (proxy.ts:71) son seis líneas: mantenerlo cuesta cambiar dos literales.
  • Conserva la opción de dos CSP (la app quiere estricta; la landing, widgets de marketing). Hoy la CSP es global y compartida, así que esto es opción futura, no ganancia inmediata.

② El comodín se retira. No se pide * en GoDaddy ni en Vercel para paladio.io. app va como CNAME explícito. RESERVED_SLUGS ya contiene 'app' (slug.ts:35), así que no hay colisión con un workspace. El comodín de nodeworkspace.com se deja intacto hasta apagar el dominio viejo.

③ Los dominios viejos redirigen 301 durante al menos un ciclo — por la SDK (§2.6) y por el SEO de la landing. No se apagan en esta migración.

om.paladio.io se difiere (Tanda 6). Se puede quedar en om.nodeworkspace.com sin que nada se rompa, y AUTHORIZER_PRINCIPAL_DOMAIN toca a los usuarios de OM. Pero frame-ancestors del Caddyfile sí entra en la Tanda 3 — eso sí bloquea.


4 · ⭐ El trabajo, por tandas

Cada tanda es desplegable y reversible por sí sola. Sólo la Tanda 4 tiene una
ventana de indisponibilidad. Las tandas 1-3 son puramente aditivas: no quitan
ni un host viejo, así que producción no se entera.

Tanda 0 · Preparación · ✅ CERRADA (2026-08-03)

DóndeRespuesta
GoDaddypaladio.io está en la cuenta, con la zona DNS gestionada ahí
Panel de Clerk🔴 NO hay dominio satélite. app.paladio.io no se puede pre-verificar ⇒ la Tanda 4 es un corte duro: las sesiones vivas caen y no hay solape. Ventana baja obligatoria y aviso previo
GCP⏭️ SSH diferido al final (decisión del owner) ⇒ ver la consecuencia acotada en la Tanda 3
Calendario⏳ pendiente: elegir la ventana de la Tanda 4

Tanda 1 · DNS + Vercel · ✅ CERRADA (2026-08-03)

Medido, no supuesto — resolvers públicos (Google + Cloudflare) y HTTP real:

paladio.io      A      216.198.79.1                          ← UNA sola IP
www.paladio.io  CNAME  39ea05ae330c6a0b.vercel-dns-017.com
app.paladio.io  CNAME  39ea05ae330c6a0b.vercel-dns-017.com

https://paladio.io/          308 → https://www.paladio.io/       TLS ✅
https://paladio.io/legal     308 → https://www.paladio.io/legal  (preserva ruta)
https://www.paladio.io/      200                                 TLS ✅
https://app.paladio.io/      200                                 TLS ✅

Y los viejos, intactos — el enrutado por Host sigue exactamente igual:

https://node-web.com/               308 → https://www.node-web.com/
https://www.node-web.com/dashboard  307 → https://www.nodeworkspace.com/dashboard
https://nodeworkspace.com/          308 → https://www.nodeworkspace.com/
https://www.nodeworkspace.com/      307 → /dashboard

Un 404 que confirma el diseño de la Tanda 2. Hoy
https://app.paladio.io/dashboard devuelve 404: el código desplegado aún
clasifica ese host como dev, así que auth.protect() corre sin que Clerk
reconozca el origen, y sin sesión responde 404 en vez de redirigir. Es
exactamente el fallo que evita el rol parkedmedido, no teórico.

Lo que hubo que arreglar por el camino: GoDaddy traía un A @ → WebsiteBuilder Site (= 13.248.243.5 + 76.223.105.230) conviviendo con el de Vercel ⇒ dos de cada tres visitas caían en el aparcamiento, al azar. Y un CNAME www → paladio.io que heredaba las tres IPs. Un CNAME no puede coexistir con ningún otro registro del mismo nombre (RFC 1034) — por eso GoDaddy rechazaba añadir un A www, y la solución era sustituir, no añadir. El error DNSZoneExternalNameserver que congelaba la zona se resolvió devolviendo la pestaña Servidores de nombres a GoDaddy (recomendado).


Tanda 1 · el plan original (referencia)

⚠️ El orden es Vercel → GoDaddy, no al revés. Los valores exactos del A del
apex y del CNAME los dicta Vercel al añadir el dominio y varían por proyecto
y por antigüedad de la cuenta. No se copian de un runbook: se copian del panel.

① Vercel primero — añadir paladio.io, www.paladio.io y app.paladio.io al proyecto. Quedan en Invalid Configuration y Vercel muestra los registros exactos. Configurar www.paladio.ioRedirect 301 a paladio.io.

② GoDaddy después — crear exactamente lo que dijo Vercel:

TipoNombreValorTTL
A@(el que muestre Vercel)600
CNAMEwww(el que muestre Vercel)600
CNAMEapp(el mismo que www)600

🔴 La trampa de GoDaddy: la zona nueva ya viene con un A @ de aparcamiento y un CNAME www. Hay que EDITARLOS, no añadir otro al lado — dos A @ sirven tráfico a los dos sitios de forma aleatoria. Y revisar que Domain Forwarding esté apagado: reinyecta registros por su cuenta y pisa lo que pongas.

NO se crea: el comodín * (decisión ②), ni clerk/accounts (van en la Tanda 3, con los valores del panel de Clerk), ni om (Tanda 6).

TTL a 600s durante toda la migración. Se sube a 3600 en la Tanda 7. Un TTL de una hora convierte un rollback de cinco minutos en una tarde de espera.

Gate: curl -I https://app.paladio.io devuelve 200 del deploy actual. Hasta la Tanda 2, classifyHost clasifica los hosts nuevos como dev → sirven todo sin redirecciones. Es benigno y esperado.

Rollback: borrar los dominios en Vercel. Los viejos no se han tocado.


Tanda 2 · Código · las superficies · ✅ HECHA (2026-08-03) · sin desplegar

FicheroCambio
lib/workspace/hosts.ts ⭐ nuevoel censo de hosts, en un solo sitio: APP_ORIGIN, classifyHost e isLandingHost
proxy.tsimporta de ahí; classifyHost y APP_ORIGIN dejan de vivir aquí. Nueva rama parked
Navbar.tsximporta de ahí; se borra su copia local de APP_ORIGIN e isLandingHost
lib/workspace/hosts.test.ts ⭐ nuevo13 casos que fijan el invariante de la migración

Dos cambios respecto a lo planeado, y por qué.

(a) El censo se centraliza. Estaba duplicado entre proxy.ts y Navbar.tsx
—dos listas de hosts y dos copias de APP_ORIGIN—, que es exactamente el fallo
que se paga caro en una migración: se añade el dominio en un sitio y se olvida en
el otro. Ahora el flip de la Tanda 4 se reduce a hosts.ts.

(b) app.paladio.io NO se clasifica como app, sino como parked (rol
nuevo: rebota todo a APP_ORIGIN conservando la ruta). El plan original decía
app, y estaba mal: con Clerk todavía sin conocer ese host, cualquiera que
entrase se quedaría sin sesión y sin poder iniciarlaauth.protect() lo
mandaría a un sign-in servido desde un origen que Clerk no reconoce—. Aparcado,
el host existe, tiene TLS y lleva a la app que funciona. En la Tanda 4 pasa de
parked a app en la misma línea en que se mueve APP_ORIGIN.

⚠️ APP_ORIGIN sigue en https://www.nodeworkspace.com. Es la línea que no se toca hasta el flip.

Verificado en local: hosts.test.ts 13/13 ✅ · npm run typecheck sin errores ✅. El enrutado por Host no se puede ejercitar en localhost —por diseño, localhost clasifica como dev—, así que el gate real es post-despliegue.

Gate (al desplegar):

curl -sI https://paladio.io/            # 200, la landing
curl -sI https://paladio.io/dashboard   # 307 → https://www.nodeworkspace.com/dashboard
curl -sI https://app.paladio.io/x/y     # 307 → https://www.nodeworkspace.com/x/y
curl -sI https://www.nodeworkspace.com/ # 307 → /dashboard, igual que antes

Y a mano: entrar en la app por el dominio viejo y comprobar que la sesión sigue viva.

Rollback: revertir el commit. Un deploy.


Tanda 3 · Aceptar el mundo nuevo, sin abandonar el viejo · aditiva

Ésta es la tanda que hace que la Tanda 4 sea un flip y no un salto al vacío.
Todo se AÑADE; no se quita nada.

DóndeCambio
next.config.tsHECHO — CSP (CLERK_HOSTS) y remotePatterns aceptan ya clerk.paladio.io y accounts.paladio.io, junto a los viejos
clerk_authenticator.py🔄 NO es código, es una variable. Lee CLERK_FRONTEND_API como lista separada por comas (líneas 29-34) ⇒ en Railway: …,clerk.nodeworkspace.com,clerk.paladio.io. Desplegar y comprobar que sigue funcionando con el emisor VIEJO
jupyterhub_config.py:279 (Railway)NEXT_PUBLIC_APP_URL: añadir https://app.paladio.io a los ancestros del iframe
Caddyfile:49 (VM de GCP)🔴 DIFERIDO — requiere SSH, que se pospuso. Consecuencia aceptada: desde la Tanda 4 y hasta que se recupere el SSH, el enclave de OpenMetadata no renderiza dentro de app.paladio.io (el navegador lo bloquea por frame-ancestors). El resto de OM sigue funcionando en su propia URL; sólo se pierde el iframe embebido. Se cierra en la Tanda 6
GoDaddyCNAME clerk y CNAME accounts → Clerk (los valores exactos del panel). Crear el dominio en Clerk y esperar la verificación, sin activarlo
VercelAñadir las variables nuevas (NEXT_PUBLIC_APP_URL…) — todavía apuntando al dominio viejo; se cambian en la Tanda 4

Gate: todo lo anterior desplegado y producción intacta con el emisor antiguo. Clerk muestra el dominio nuevo como verificado pero no activo.

Rollback: revertir los commits; los CNAME nuevos pueden quedarse (son inertes).


Tanda 4 · ⭐ El flip · 🔴 DISPARADA SIN QUERER (2026-08-04) · código ✅ listo

🔴 Incidente

Añadir paladio.io en Clerk no añadió un dominio: cambió el primario, y con
él se retiró el certificado del anterior. Medido con dos stacks TLS
independientes (.NET y schannel/curl):

clerk.nodeworkspace.com  → handshake TLS RECHAZADO (alert fatal)
clerk.paladio.io         → 200, kid ins_3CP3SBUHAfirjtoxRhET96gznyt

El DNS seguía bien (CNAME → frontend-api.clerk.services): lo que faltaba era el
certificado. Y la clave que servía producción era
pk_live_Y2xlcmsubm9kZXdvcmtzcGFjZS5jb20k, que decodifica a
clerk.nodeworkspace.com$
⇒ clerk-js no cargaba, nadie podía entrar.

La lección: en Clerk, "añadir un dominio" a una instancia de producción es
el paso destructivo. No hay fase aditiva que valga — el paso ④ empieza en el
momento en que se toca el panel, no cuando uno decide que empieza.

Por qué se fue hacia adelante y no hacia atrás: recuperar el host viejo exige
que Clerk re-emita un certificado, algo ni instantáneo ni bajo nuestro
control. El kid es idéntico en ambos hosts ⇒ misma instancia, mismas claves de
firma: sólo cambia la publicable. Y el argumento de "esperar a la ventana baja"
ya no aplicaba — la ventana era ya, porque estaba caído.

Código del flip — hecho y verificado (14/14 tests, typecheck limpio):

FicheroCambio
hosts.tsAPP_ORIGINhttps://app.paladio.io. Los roles se intercambian: app.paladio.io parkedapp; nodeworkspace.com y subdominios appparked
proxy.tsla SDK se exceptúa del rebote — ver abajo
url.tsderiva de APP_ORIGIN; se borran las dos copias de la lógica de host y el www.
WorkspaceShell.tsx · InviteModal.tsxlas URLs absolutas hardcodeadas pasan a getAppRootUrl()

El dominio viejo no se apaga: se convierte en parked. Sigue resolviendo,
con TLS, y reenvía conservando la ruta. No puede servir la app —allí clerk-js
ya no carga—, pero un enlace viejo lleva a donde debe.

Con una excepción: /api/sdk/* se sirve DIRECTAMENTE en el host viejo. Un
kernel o el notebook de un cliente tiene NODE_API_BASE grabado apuntando ahí, y
esas llamadas son POST con cuerpo — una redirección no la sigue todo cliente
con el body intacto. Y no necesitan Clerk: auth.ts:82
valida un JWT de kernel cuyo emisor es la cadena literal 'node-platform', no
un host
. Servirlas directamente es estrictamente más seguro que redirigirlas.

Un solo despliegue, atómico con la activación en Clerk:

Fichero / panelCambio
ClerkActivar el dominio nuevo. ← aquí caen las sesiones vivas
proxy.ts:92APP_ORIGINhttps://app.paladio.io
url.ts:16APP_DOMAINpaladio.io, y quitar el www. de getWorkspaceShareUrl / getAppRootUrl (pasan a app.)
Navbar.tsx:287APP_ORIGIN
WorkspaceShell.tsx:976afterSignOutUrlhttps://app.paladio.io/sign-in
InviteModal.tsx:86redirectUrlhttps://app.paladio.io/sign-up
VercelVariables → dominios nuevos
JupyterHub (Railway)NODE_API_BASEhttps://app.paladio.io — es lo que se inyecta en cada kernel nuevo (§2.6). Los kernels ya vivos siguen con el viejo y dependen del 301 de la Tanda 5

✅ Desplegado y verificado — commit 8674e7e (2026-08-04)

El build tardó ~6 min en aterrizar (NEXT_PUBLIC_* se incrusta en tiempo de build, así que la clave nueva no aplica hasta reconstruir). Medido en vivo:

app.paladio.io/sign-in            → pk decodifica a clerk.paladio.io$   ⭐
app.paladio.io/                   → 307 → /dashboard
paladio.io/                       → 308 → www.paladio.io/

www.nodeworkspace.com/            → 307 → https://app.paladio.io/
www.nodeworkspace.com/w/acme      → 307 → https://app.paladio.io/w/acme
www.nodeworkspace.com/acme/dash…  → 307 → https://app.paladio.io/acme/dash…

La excepción de la SDK, probada con una ruta real y su gemela:

www.nodeworkspace.com/api/sdk/v1/whoami → 401 {"error":"AUTH_TOKEN_MISSING"}
app.paladio.io/api/sdk/v1/whoami        → 401 {"error":"AUTH_TOKEN_MISSING"}   ← idéntico
www.nodeworkspace.com/api/health        → 307 → app.paladio.io/api/health      ← control

El handler corre en el dominio retirado y responde igual que en el nuevo, mientras una ruta cualquiera de app sí se reenvía. Los notebooks existentes con NODE_API_BASE viejo siguen funcionando sin tocar nada.

⚠️ main estaba sin compilar y lo tapaba este mismo despliegue. aa14f67
subió UnderDevelopmentBanner.tsx importando BRAND_NAME/BRAND_COLOR de
lib/brand/wordmark.ts, que se quedó sin commitear ⇒ cualquier build de
producción fallaba. Se detectó porque el commit se aisló y se typecheckeó el
árbol exacto a desplegar
, no el árbol de trabajo. wordmark.ts entró en
8674e7e por obligación, no por criterio.

La lección: typecheckear el working tree no dice nada sobre lo que se
despliega. Sólo lo dice typecheckear lo que está en el índice.

Pendiente de comprobación humana (no se puede medir desde fuera): entrar y llegar a app.paladio.io/<slug>/dashboard · el iframe de Jupyter (exige redesplegar Railway con CLERK_FRONTEND_API y NEXT_PUBLIC_APP_URL) · los correos de invitación · y el enclave de OM, roto a propósito hasta recuperar el SSH.


Gate — los tres que de verdad cierran la migración:

  1. Un usuario vuelve a entrar y llega a app.paladio.io/<slug>/dashboard.
  2. Un share link app.paladio.io/w/<slug> abre y cambia de workspace.
  3. Un notebook con el dominio viejo sigue funcionando (todavía no hay 301 — debe funcionar por el paso de la Tanda 3, no por la redirección).
  4. Y además: el iframe de Jupyter carga, y el enclave de OM renderiza.

Rollback: devolver el dominio de Clerk al anterior + revertir el deploy. ⚠️ Los CNAME viejos deben seguir existiendo hasta que la migración esté confirmada — borrarlos es lo que convierte un rollback de cinco minutos en una tarde.


Tanda 5 · 301 y contrato · después de 24-48 h estables

🔴 CORRECCIÓN (2026-08-04). El 301 de Vercel sobre nodeworkspace.com NO se
hace — rompería la SDK.
Un redirect a nivel de dominio en Vercel actúa en el
edge, antes del middleware
, así que se aplicaría también a /api/sdk/* y
anularía la excepción verificada en la Tanda 4. El reenvío de nodeworkspace.com
ya está hecho y en el sitio correcto: en proxy.ts, que
distingue rutas. En Vercel no hay nada que añadir para ese dominio.

El mismo razonamiento vale para borrar el dominio: quitarlo de Vercel no
devuelve 404, deja de resolver al deploy ⇒ error de TLS y notebooks rotos sin
aviso. Eso es Tanda 7, y su condición no es el tiempo sino la medida: filtrar
los logs de Vercel por Host: *.nodeworkspace.com y comprobar que ya no entra
tráfico a /api/sdk/*. Mientras lo haya, hay un kernel o un notebook vivo ahí
fuera.

DóndeQué
Vercelsólo node-web.com → 301 a paladio.io. Es marketing puro, sin SDK, y además arregla el contenido duplicado con la landing nueva. nodeworkspace.com no se toca
docs/sdk-v1.openapi.yaml:35 · docs/sdk-v1-contract.mdactualizar el server publicado. La SDK sigue aceptando los dos
Shopify Partnersapplication_url y redirect_urls
LiveDeploymentTab.tsx · snippetBuilders.test.tsejemplos que ve el usuario
TrustpilotWidget.tsxcosmético
slug.ts:12arreglar el docstring rancio del subdominio por workspace
.claude/settings.jsonel host de jupyter.

Gate: un curl -IL a cada dominio viejo termina en el nuevo con 301.


Tanda 6 · OpenMetadata · diferida, opcional

Sólo si se quiere om.paladio.io. Requiere SSH, A om en GoDaddy, puerto 80 abierto antes de reiniciar Caddy, y una decisión sobre AUTHORIZER_PRINCIPAL_DOMAIN que afecta a los usuarios existentes de OM (§2.4). Se puede no hacer nunca.


Tanda 7 · Limpieza · un ciclo después

Retirar del código los hosts viejos (classifyHost, CSP, clerk_authenticator.py), y sólo entonces valorar apagar los dominios. No antes.


5 · Lo que NO se toca

  • Cloudflare / R2 — nada. El acceso es por <account-id>.r2.cloudflarestorage.com (lakehouse.properties:64), un host de Cloudflare. No hay dominio personalizado, no hay bucket público (r2.dev), ningún navegador habla con R2 (todo es servidor a servidor por S3 API) y el DNS vive en GoDaddy, no en Cloudflare. Tampoco hay CORS que revisar.
  • Todo el warehouse: Lakekeeper, Keycloak, duck-server, ml-runner, warehouse-writer, y las cuatro identidades de la Fase A. Ninguno conoce el dominio de la app.
  • Supabase: es un host propio (*.supabase.co).
  • Index Catalog entero — C1 a C5 son indiferentes al dominio.

6 · Checklist plano

  • T0 SSH a la VM de OM recuperado · respuesta de Clerk sobre segundo dominio · ventana elegida
  • T1 GoDaddy @/www/app · Vercel: 3 dominios, sin comodín
  • T2 proxy.ts (classifyHost) · Navbar — sin tocar APP_ORIGIN
  • T3 CSP · remotePatterns · clerk_authenticator.py · JupyterHub NEXT_PUBLIC_APP_URL · Caddy frame-ancestors · CNAME clerk/accounts verificados sin activar
  • T4 Clerk activo + APP_ORIGIN · url.ts · Navbar · WorkspaceShell · InviteModal · vars de Vercel · JupyterHub NODE_API_BASE
  • T4-gate entrar · share link · notebook viejo · iframe Jupyter · enclave OM
  • T5 301 · OpenAPI + contrato · Shopify · ejemplos · docstring de slug.ts
  • T6 (opcional) om.paladio.io
  • T7 retirar hosts viejos del código

El gate que de verdad cierra la migración no es que la landing cargue: es que
un usuario ya logueado siga logueado, que un share link abra, y que un notebook
con el dominio viejo siga funcionando.
Los tres fallan por motivos distintos y
ninguno se ve desde la portada.