Runbook · Migrar al dominio paladio.io
Encargo (2026-08-03). Se ha adquirido
paladio.ioen 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
| Dominio | Superficie | Quién lo sirve |
|---|---|---|
node-web.com | sólo la landing | el MISMO deploy de Vercel |
nodeworkspace.com | la app entera | el 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:
| Subdominio | Para qué | Dónde vive |
|---|---|---|
www. | la app | Vercel |
clerk. | Frontend API de Clerk — carga clerk-js y resuelve la sesión | CNAME a Clerk |
accounts. | Account Portal de Clerk | CNAME a Clerk |
om. | OpenMetadata | A → IP de la VM de GCP |
(jupyter.) | JupyterHub | referenciado en .claude/settings.json; hoy el servicio vive en Railway |
⚠️ El comodín
*.nodeworkspace.comNO 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:44construye
https://www.<dominio>/w/<slug>, y no hay una sola línea en el repo que fabrique
un host<slug>.. El docstring deslug.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 |
|---|---|
| Dominios | node-web.com, www.node-web.com, nodeworkspace.com, www.nodeworkspace.com, y el comodín *.nodeworkspace.com (vestigio — ver §1) |
| Variables | ver 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_URL | ✅ sí | base de los callbacks de webhook (webhook-manager.ts:17), la URL que se enseña en WebhookPanel, y dos adapters |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | ✅ sí | ⭐ codifica 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_URL | ❌ NO — no crearla | next-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és | construye 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 | ❌ no | sigue en om.nodeworkspace.com (Tanda 6) |
NEXT_PUBLIC_JUPYTER_HOST | 🔴 sí, y estaba ROTO de antes | ver abajo |
🔴 Corrección (2026-08-04). Se dijo que esta variable «es un host de Railway y
nunca llevó nuestro dominio». Falso: valíajupyter.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 failedal 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, sinhttps://). EsNEXT_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 API | clerk.nodeworkspace.com |
| Account Portal | accounts.nodeworkspace.com |
| Hosts en la CSP | next.config.ts:163-168 — hardcodeados |
Hosts en images.remotePatterns | next.config.ts:35-36 |
afterSignOutUrl | WorkspaceShell.tsx:976 → https://www.nodeworkspace.com/sign-in |
redirectUrl de invitaciones | InviteModal.tsx:86 → https://nodeworkspace.com/sign-up |
| Verificación del emisor | services/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 queapp.paladio.ioesté registrado en
Clerk, ese host sólo sirve contenido anónimo. Por esoAPP_ORIGINno 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 tipoclkmaily 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ático | infra/openmetadata/Caddyfile:29 → om.nodeworkspace.com |
frame-ancestors — lo que permite el enclave dentro de Carbon | Caddyfile:49, con los tres orígenes de la app |
| Dominio del principal de OM | docker-compose.override.yml:23-25 — AUTHORIZER_PRINCIPAL_DOMAIN, AUTHORIZER_ALLOWED_REGISTRATION_DOMAIN, AUTHENTICATION_PUBLIC_KEYS |
| Bot de Carbon | carbon@nodeworkspace.com (runbook) |
🔴 Dos trampas aquí. (a)
AUTHORIZER_PRINCIPAL_DOMAINdecide 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ón Resultado 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.16Clave de host ✅ COINCIDE con known_hosts:1(SHA256:Ck6+hE5+adJEXy1RexoDx14r+wGcG0tbDh+lMV1jQfA)Nuestra clave ✅ existe y se ofrece ( SHA256:xt4s2sHK4aWaM/tcfjE2fVM3IXr1DIbkD3XwnZL2ZkY)Autenticación ❌ Permission denied (publickey)· el servidor sólo admitepublickey, 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á enauthorized_keys». La
primera mitad es falsa —el host key valida contraknown_hostssin 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_keysy los metadatos por completo.Cómo recuperarlo (
gcloudNO está instalado en local):
- GCP Console → Compute Engine → la VM → Edit → Metadata → ver si existe
enable-oslogin. Ese dato decide el camino.- Si no está: Edit → SSH Keys → Add item y pegar la línea completa de
~/.ssh/id_ed25519.pub. GCE recrea elauthorized_keysdel usuario.- Si sí está: los metadatos no valen; hay que registrarla por OS Login, y
entonces el usuario no serácarbon-openmetadatasino el derivado del
correo.- Alternativa que evita elegir: instalar
gcloudy usargcloud compute ssh,
que aprovisiona la clave por el camino que corresponda.✅ Pero
frame-ancestors(Caddyfile:49) sí hay que tocarlo —y eso sí
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:
| Servicio | Host |
|---|---|
| Lakekeeper | heroic-victory-production-41dd.up.railway.app |
| duck-server | duck-production-ef4a.up.railway.app |
| Keycloak | charming-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:
Dominio Puerto Fallo node-production-c2a0.up.railway.app8082 resolvía, pero nadie escuchaba en ese puerto jupyter.nodeworkspace.com8080 puerto 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-hikaricon 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/api→200 {"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
| Fichero | Qué hay |
|---|---|
proxy.ts:75-92 | el enrutado por Host (classifyHost) y APP_ORIGIN |
next.config.ts | CSP con los hosts de Clerk · remotePatterns |
lib/workspace/url.ts:16 | APP_DOMAIN = 'nodeworkspace.com' — share links y getAppRootUrl |
lib/workspace/slug.ts:12 | docstring rancio: describe un subdominio por workspace que no existe |
components/landing/Navbar.tsx:287,339 | APP_ORIGIN y la detección del host de marketing |
WorkspaceShell.tsx · InviteModal.tsx | URLs absolutas de Clerk (§2.2) |
LiveDeploymentTab.tsx:134 · snippetBuilders.test.ts:32 | ejemplos 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:23 | el default de la SDK en el kernel |
TrustpilotWidget.tsx | URL de reseñas (cosmético) |
shopify.app.toml | handle 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 tieneNODE_API_BASEapuntando 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:82verifica 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_BASEes 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.classifyHosttrata 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.yaccounts.son hosts obligatorios de Clerk, yom.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 unawait 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ónde | Respuesta |
|---|---|
| GoDaddy | ✅ paladio.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/dashboarddevuelve 404: el código desplegado aún
clasifica ese host comodev, así queauth.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 rolparked— medido, 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
Adel
apex y delCNAMElos 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.io → Redirect 301 a paladio.io.
② GoDaddy después — crear exactamente lo que dijo Vercel:
| Tipo | Nombre | Valor | TTL |
|---|---|---|---|
A | @ | (el que muestre Vercel) | 600 |
CNAME | www | (el que muestre Vercel) | 600 |
CNAME | app | (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
| Fichero | Cambio |
|---|---|
lib/workspace/hosts.ts ⭐ nuevo | el censo de hosts, en un solo sitio: APP_ORIGIN, classifyHost e isLandingHost |
proxy.ts | importa de ahí; classifyHost y APP_ORIGIN dejan de vivir aquí. Nueva rama parked |
Navbar.tsx | importa de ahí; se borra su copia local de APP_ORIGIN e isLandingHost |
lib/workspace/hosts.test.ts ⭐ nuevo | 13 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.tsyNavbar.tsx
—dos listas de hosts y dos copias deAPP_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 ahosts.ts.(b)
app.paladio.ioNO se clasifica comoapp, sino comoparked(rol
nuevo: rebota todo aAPP_ORIGINconservando 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 iniciarla —auth.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
parkedaappen la misma línea en que se mueveAPP_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ónde | Cambio |
|---|---|
✅ next.config.ts | HECHO — 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 |
| GoDaddy | CNAME clerk y CNAME accounts → Clerk (los valores exactos del panel). Crear el dominio en Clerk y esperar la verificación, sin activarlo |
| Vercel | Añ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.ioen 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_3CP3SBUHAfirjtoxRhET96gznytEl 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. Elkides 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):
| Fichero | Cambio |
|---|---|
hosts.ts | APP_ORIGIN → https://app.paladio.io. Los roles se intercambian: app.paladio.io parked→app; nodeworkspace.com y subdominios app→parked |
proxy.ts | ⭐ la SDK se exceptúa del rebote — ver abajo |
url.ts | deriva de APP_ORIGIN; se borran las dos copias de la lógica de host y el www. |
WorkspaceShell.tsx · InviteModal.tsx | las 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 tieneNODE_API_BASEgrabado 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 / panel | Cambio |
|---|---|
| Clerk | Activar el dominio nuevo. ← aquí caen las sesiones vivas |
proxy.ts:92 | APP_ORIGIN → https://app.paladio.io |
url.ts:16 | APP_DOMAIN → paladio.io, y quitar el www. de getWorkspaceShareUrl / getAppRootUrl (pasan a app.) |
Navbar.tsx:287 | APP_ORIGIN |
WorkspaceShell.tsx:976 | afterSignOutUrl → https://app.paladio.io/sign-in |
InviteModal.tsx:86 | redirectUrl → https://app.paladio.io/sign-up |
| Vercel | Variables → dominios nuevos |
| JupyterHub (Railway) | NODE_API_BASE → https://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.
⚠️
mainestaba sin compilar y lo tapaba este mismo despliegue.aa14f67
subióUnderDevelopmentBanner.tsximportandoBRAND_NAME/BRAND_COLORde
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.tsentró en
8674e7epor 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:
- Un usuario vuelve a entrar y llega a
app.paladio.io/<slug>/dashboard. - Un share link
app.paladio.io/w/<slug>abre y cambia de workspace. - 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).
- 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.comNO 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 denodeworkspace.com
ya está hecho y en el sitio correcto: enproxy.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 porHost: *.nodeworkspace.comy comprobar que ya no entra
tráfico a/api/sdk/*. Mientras lo haya, hay un kernel o un notebook vivo ahí
fuera.
| Dónde | Qué |
|---|---|
| Vercel | só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.md | actualizar el server publicado. La SDK sigue aceptando los dos |
| Shopify Partners | application_url y redirect_urls |
LiveDeploymentTab.tsx · snippetBuilders.test.ts | ejemplos que ve el usuario |
TrustpilotWidget.tsx | cosmético |
slug.ts:12 | arreglar el docstring rancio del subdominio por workspace |
.claude/settings.json | el 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 tocarAPP_ORIGIN - T3 CSP ·
remotePatterns·clerk_authenticator.py· JupyterHubNEXT_PUBLIC_APP_URL· Caddyframe-ancestors· CNAMEclerk/accountsverificados sin activar - T4 Clerk activo +
APP_ORIGIN·url.ts·Navbar·WorkspaceShell·InviteModal· vars de Vercel · JupyterHubNODE_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.