Coolify was failing all Gitea clones with "Permission denied (publickey)"
because the helper container's SSH hits git.vibnai.com:22 (Ubuntu host
sshd, which doesn't know Gitea keys), while Gitea's builtin SSH is on
host port 22222 (not publicly reachable).
Rather than fight the SSH topology, switch every Vibn-provisioned app
to clone over HTTPS with the workspace bot's PAT embedded in the URL.
The PAT is already stored encrypted per workspace and scoped to that
org, so this gives equivalent isolation with zero SSH dependency.
Changes:
- lib/naming.ts: add giteaHttpsUrl() + redactGiteaHttpsUrl(); mark
giteaSshUrl() as deprecated-for-deploys with a comment.
- lib/coolify.ts: extend CreatePublicAppOpts with install/build/start
commands, base_directory, dockerfile_location, docker_compose_location,
manual_webhook_secret_gitea so it's at parity with the SSH variant.
- app/api/mcp/route.ts:
- apps.create now uses createPublicApp(giteaHttpsUrl(...)) and pulls
the bot PAT via getWorkspaceBotCredentials(). No more private-
deploy-key path for new apps.
- apps.update adds git_commit_sha + docker_compose_location to the
whitelist.
- New apps.rewire_git tool: re-points an app's git_repository at the
canonical HTTPS+PAT URL. Unblocks older apps stuck on SSH URLs
and provides a path for PAT rotation without rebuilding the app.
- lib/gitea.ts: createUser() now issues an immediate PATCH to set
active: true. Gitea's admin-create endpoint creates users as inactive
by default, and inactive users fail permission checks even though
they're org members. GiteaUser gains optional `active` field.
- scripts/activate-workspace-bots.ts: idempotent backfill that flips
active=true for any existing workspace bot that was created before
this fix. Safe to re-run.
- AI_CAPABILITIES.md: document apps.rewire_git; clarify apps.create
uses HTTPS+PAT (no SSH).
Already unblocked in prod for the mark workspace:
- vibn-bot-mark activated.
- twenty-crm's git_repository PATCHed to HTTPS+PAT form; git clone
now succeeds (remaining unrelated error: docker-compose file path).
Made-with: Cursor
109 lines
3.8 KiB
TypeScript
109 lines
3.8 KiB
TypeScript
/**
|
|
* Canonical name + domain derivation for workspace-scoped resources.
|
|
*
|
|
* AI-generated Coolify apps live under a single subdomain namespace
|
|
* per workspace:
|
|
*
|
|
* https://{app-slug}.{workspace-slug}.vibnai.com
|
|
*
|
|
* e.g. `api.mark.vibnai.com` for the `api` app in workspace `mark`.
|
|
*
|
|
* The DNS record `*.vibnai.com` (or its subdomain) must resolve to
|
|
* the Coolify server. Traefik picks up the Host header and Coolify's
|
|
* per-app ACME handshake provisions a Let's Encrypt cert per FQDN.
|
|
*/
|
|
|
|
const VIBN_BASE_DOMAIN = process.env.VIBN_BASE_DOMAIN ?? 'vibnai.com';
|
|
const SLUG_STRIP = /[^a-z0-9-]+/g;
|
|
|
|
/** Lowercase, dash-sanitize a free-form name into a DNS-safe slug. */
|
|
export function slugify(name: string): string {
|
|
return name
|
|
.toLowerCase()
|
|
.replace(/[_\s]+/g, '-')
|
|
.replace(SLUG_STRIP, '')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 40) || 'app';
|
|
}
|
|
|
|
/**
|
|
* The default public FQDN for an app inside a workspace, given the
|
|
* workspace's slug (e.g. `mark`) and an app slug (e.g. `my-api`).
|
|
*
|
|
* workspaceAppFqdn('mark', 'my-api') === 'my-api.mark.vibnai.com'
|
|
*/
|
|
export function workspaceAppFqdn(workspaceSlug: string, appSlug: string): string {
|
|
return `${appSlug}.${workspaceSlug}.${VIBN_BASE_DOMAIN}`;
|
|
}
|
|
|
|
/** `https://{fqdn}` — what Coolify's `domains` field expects. */
|
|
export function toDomainsString(fqdns: string[]): string {
|
|
return fqdns.map(f => (f.startsWith('http') ? f : `https://${f}`)).join(',');
|
|
}
|
|
|
|
/** Parse a Coolify `domains` CSV back into bare FQDNs. */
|
|
export function parseDomainsString(domains: string | null | undefined): string[] {
|
|
if (!domains) return [];
|
|
return domains
|
|
.split(/[,\s]+/)
|
|
.map(d => d.trim())
|
|
.filter(Boolean)
|
|
.map(d => d.replace(/^https?:\/\//, '').replace(/\/+$/, ''));
|
|
}
|
|
|
|
/** Guard against cross-workspace or disallowed domains. */
|
|
export function isDomainUnderWorkspace(fqdn: string, workspaceSlug: string): boolean {
|
|
const f = fqdn.replace(/^https?:\/\//, '').toLowerCase();
|
|
return f === `${workspaceSlug}.${VIBN_BASE_DOMAIN}` || f.endsWith(`.${workspaceSlug}.${VIBN_BASE_DOMAIN}`);
|
|
}
|
|
|
|
/**
|
|
* Build a Gitea SSH clone URL for a repo in a workspace's org.
|
|
*
|
|
* NOTE: As of 2026-04 this is deprecated for Coolify-driven deploys on
|
|
* vibnai.com — Gitea's builtin SSH is bound to host port 22222 which is
|
|
* not publicly reachable, and the default port 22 hits Ubuntu's host
|
|
* sshd which doesn't know about Gitea keys. Use {@link giteaHttpsUrl}
|
|
* instead and embed the workspace bot's PAT. Kept for read-only places
|
|
* (e.g. UI display) that want the canonical "clone with SSH" form.
|
|
*/
|
|
export function giteaSshUrl(org: string, repo: string, giteaHost = 'git.vibnai.com'): string {
|
|
return `git@${giteaHost}:${org}/${repo}.git`;
|
|
}
|
|
|
|
/**
|
|
* Build a Gitea HTTPS clone URL with basic-auth credentials embedded.
|
|
*
|
|
* https://{username}:{token}@{host}/{org}/{repo}.git
|
|
*
|
|
* This is what we pass to Coolify's `git_repository` field for every
|
|
* Vibn-provisioned app. Works regardless of SSH topology and scopes
|
|
* access to whatever the bot user can see. The token is usually the
|
|
* per-workspace Gitea bot PAT.
|
|
*
|
|
* We URL-encode the username and token so PATs with special chars
|
|
* (`:`, `/`, `@`, `#`, `?`, etc.) don't break URL parsing.
|
|
*/
|
|
export function giteaHttpsUrl(
|
|
org: string,
|
|
repo: string,
|
|
username: string,
|
|
token: string,
|
|
giteaHost = 'git.vibnai.com'
|
|
): string {
|
|
const u = encodeURIComponent(username);
|
|
const t = encodeURIComponent(token);
|
|
return `https://${u}:${t}@${giteaHost}/${org}/${repo}.git`;
|
|
}
|
|
|
|
/**
|
|
* Redact the credentials from a Gitea HTTPS URL for safe logging /
|
|
* display. Leaves the repo path intact. No-op for non-HTTPS URLs.
|
|
*/
|
|
export function redactGiteaHttpsUrl(url: string): string {
|
|
return url.replace(/^(https?:\/\/)[^@]+@/, '$1***:***@');
|
|
}
|
|
|
|
export { VIBN_BASE_DOMAIN };
|