Rip out Theia, ship P5.1 attach E2E + Justine UI work-in-progress
Theia rip-out: - Delete app/api/theia-auth/route.ts (Traefik ForwardAuth shim) - Delete app/api/projects/[projectId]/workspace/route.ts and app/api/projects/prewarm/route.ts (Cloud Run Theia provisioning) - Delete lib/cloud-run-workspace.ts and lib/coolify-workspace.ts - Strip provisionTheiaWorkspace + theiaWorkspaceUrl/theiaAppUuid/ theiaError from app/api/projects/create/route.ts response - Remove Theia callbackUrl branch in app/auth/page.tsx - Drop "Open in Theia" button + xterm/Theia PTY copy in build/page.tsx - Drop theiaWorkspaceUrl from deployment/page.tsx Project type - Strip Theia IDE line + theia-code-os from advisor + agent-chat context strings - Scrub Theia mention from lib/auth/workspace-auth.ts comment P5.1 (custom apex domains + DNS): - lib/coolify.ts + lib/opensrs.ts: nameserver normalization, OpenSRS XML auth, Cloud DNS plumbing - scripts/smoke-attach-e2e.ts: full prod GCP + sandbox OpenSRS + prod Coolify smoke covering register/zone/A/NS/PATCH/cleanup In-progress (Justine onboarding/build, MVP setup, agent telemetry): - New (justine)/stories, project (home) layouts, mvp-setup, run, tasks routes + supporting components - Project shell + sidebar + nav refactor for the Stackless palette - Agent session API hardening (sessions, events, stream, approve, retry, stop) + atlas-chat, advisor, design-surfaces refresh - New scripts/sync-db-url-from-coolify.mjs + scripts/prisma-db-push.mjs + docker-compose.local-db.yml for local Prisma workflows - lib/dev-bypass.ts, lib/chat-context-refs.ts, lib/prd-sections.ts - Misc: stories CSS, debug/prisma route, modal-theme, BuildLivePlanPanel Made-with: Cursor
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
* 2. Per-workspace bearer API key (`Authorization: Bearer vibn_sk_...`)
|
||||
*
|
||||
* Either way we resolve a `WorkspacePrincipal` that is scoped to one
|
||||
* workspace. Routes that touch Coolify/Gitea/Theia must call
|
||||
* workspace. Routes that touch Coolify/Gitea must call
|
||||
* `requireWorkspacePrincipal()` and use `principal.workspace` to fetch
|
||||
* the right Coolify Project / Gitea org.
|
||||
*/
|
||||
|
||||
49
lib/chat-context-refs.ts
Normal file
49
lib/chat-context-refs.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* References attached from the overview sidebar so the user can point Vibn at a
|
||||
* PRD section or discovery phase. Sent with chat; not shown in stored user text.
|
||||
*/
|
||||
export type ChatContextRef =
|
||||
| { kind: "section"; label: string; phaseId: string | null }
|
||||
| { kind: "phase"; label: string; phaseId: string }
|
||||
| { kind: "app"; label: string; path: string };
|
||||
|
||||
export function contextRefKey(r: ChatContextRef): string {
|
||||
if (r.kind === "section") return `s:${r.label}`;
|
||||
if (r.kind === "phase") return `p:${r.phaseId}`;
|
||||
return `a:${r.path}`;
|
||||
}
|
||||
|
||||
export function augmentAtlasMessage(
|
||||
message: string,
|
||||
refs: ChatContextRef[] | null | undefined
|
||||
): string {
|
||||
const list = refs?.filter(Boolean) ?? [];
|
||||
if (!list.length) return message;
|
||||
const lines = list.map(r =>
|
||||
r.kind === "section"
|
||||
? `- PRD section: "${r.label}"${r.phaseId ? ` (discovery phase: ${r.phaseId})` : ""}`
|
||||
: r.kind === "phase"
|
||||
? `- Discovery phase: "${r.label}" (id: ${r.phaseId})`
|
||||
: `- Monorepo workspace / package: "${r.label}" (path: ${r.path})`
|
||||
);
|
||||
return `[The user attached these project references for this message. Prioritize them in your reply:\n${lines.join("\n")}\n]\n\n${message}`;
|
||||
}
|
||||
|
||||
export function parseContextRefs(raw: unknown): ChatContextRef[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: ChatContextRef[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const o = item as Record<string, unknown>;
|
||||
if (o.kind === "section" && typeof o.label === "string") {
|
||||
const phaseId: string | null =
|
||||
o.phaseId === null ? null : typeof o.phaseId === "string" ? o.phaseId : null;
|
||||
out.push({ kind: "section", label: o.label, phaseId });
|
||||
} else if (o.kind === "phase" && typeof o.label === "string" && typeof o.phaseId === "string") {
|
||||
out.push({ kind: "phase", label: o.label, phaseId: o.phaseId });
|
||||
} else if (o.kind === "app" && typeof o.label === "string" && typeof o.path === "string") {
|
||||
out.push({ kind: "app", label: o.label, path: o.path });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* Cloud Run Workspace Provisioning
|
||||
*
|
||||
* Provisions a dedicated Theia IDE instance per Vibn project using
|
||||
* Google Cloud Run. Each workspace:
|
||||
* - Gets its own Cloud Run service: theia-{slug}
|
||||
* - Scales to zero when idle (zero cost when not in use)
|
||||
* - Starts in ~5-15s from cached image on demand
|
||||
* - Is accessible at the Cloud Run URL stored on the project record
|
||||
* - Auth is enforced by our Vibn session before the URL is revealed
|
||||
*/
|
||||
|
||||
import { GoogleAuth, JWT } from 'google-auth-library';
|
||||
|
||||
const PROJECT_ID = 'master-ai-484822';
|
||||
const REGION = 'northamerica-northeast1';
|
||||
const IMAGE = `${REGION}-docker.pkg.dev/${PROJECT_ID}/vibn-ide/theia:latest`;
|
||||
const VIBN_URL = process.env.NEXTAUTH_URL ?? 'https://vibnai.com';
|
||||
|
||||
const CLOUD_RUN_API = `https://run.googleapis.com/v2/projects/${PROJECT_ID}/locations/${REGION}/services`;
|
||||
const SCOPES = ['https://www.googleapis.com/auth/cloud-platform'];
|
||||
|
||||
async function getAccessToken(): Promise<string> {
|
||||
// Prefer an explicit service account key (avoids GCE metadata scope limitations).
|
||||
// Stored as base64 to survive Docker ARG/ENV special-character handling.
|
||||
const keyB64 = process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64;
|
||||
if (keyB64) {
|
||||
const keyJson = Buffer.from(keyB64, 'base64').toString('utf-8');
|
||||
const key = JSON.parse(keyJson) as {
|
||||
client_email: string;
|
||||
private_key: string;
|
||||
};
|
||||
const jwt = new JWT({
|
||||
email: key.client_email,
|
||||
key: key.private_key,
|
||||
scopes: SCOPES,
|
||||
});
|
||||
const token = await jwt.getAccessToken();
|
||||
if (!token.token) throw new Error('Failed to get GCP access token from service account key');
|
||||
return token.token as string;
|
||||
}
|
||||
|
||||
// Fall back to ADC (works locally or on GCE with cloud-platform scope)
|
||||
const auth = new GoogleAuth({ scopes: SCOPES });
|
||||
const client = await auth.getClient();
|
||||
const token = await client.getAccessToken();
|
||||
if (!token.token) throw new Error('Failed to get GCP access token');
|
||||
return token.token;
|
||||
}
|
||||
|
||||
export interface ProvisionResult {
|
||||
serviceUrl: string; // https://theia-{slug}-xxx.run.app
|
||||
serviceName: string; // theia-{slug}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Cloud Run service for a Vibn project workspace.
|
||||
* The service scales to zero when idle and starts on first request.
|
||||
*/
|
||||
export async function provisionTheiaWorkspace(
|
||||
slug: string,
|
||||
projectId: string,
|
||||
giteaRepo: string | null,
|
||||
): Promise<ProvisionResult> {
|
||||
const token = await getAccessToken();
|
||||
const serviceName = `theia-${slug}`.slice(0, 49); // Cloud Run max 49 chars
|
||||
|
||||
// Cloud Run v2: name must be empty in the body — it's passed via ?serviceId= in the URL
|
||||
const serviceBody = {
|
||||
template: {
|
||||
scaling: {
|
||||
minInstanceCount: 0, // scale to zero when idle
|
||||
maxInstanceCount: 1, // one instance per workspace
|
||||
},
|
||||
containers: [{
|
||||
image: IMAGE,
|
||||
ports: [{ containerPort: 3000 }],
|
||||
resources: {
|
||||
limits: { cpu: '1', memory: '2Gi' },
|
||||
cpuIdle: true, // only allocate CPU during requests
|
||||
},
|
||||
env: [
|
||||
{ name: 'VIBN_PROJECT_ID', value: projectId },
|
||||
{ name: 'VIBN_PROJECT_SLUG', value: slug },
|
||||
{ name: 'VIBN_API_URL', value: VIBN_URL },
|
||||
{ name: 'GITEA_REPO', value: giteaRepo ?? '' },
|
||||
{ name: 'GITEA_API_URL', value: process.env.GITEA_API_URL ?? 'https://git.vibnai.com' },
|
||||
// Token lets the startup script clone and push to the project's repo
|
||||
{ name: 'GITEA_TOKEN', value: process.env.GITEA_API_TOKEN ?? '' },
|
||||
// Gemini API key — needed by startup.sh to configure AI features in Theia
|
||||
{ name: 'GOOGLE_API_KEY', value: process.env.GOOGLE_API_KEY ?? '' },
|
||||
],
|
||||
// 5 minute startup timeout — Theia needs time to initialise
|
||||
startupProbe: {
|
||||
httpGet: { path: '/', port: 3000 },
|
||||
failureThreshold: 30,
|
||||
periodSeconds: 10,
|
||||
},
|
||||
}],
|
||||
// Keep container alive for 15 minutes of idle before scaling to zero
|
||||
timeout: '900s',
|
||||
},
|
||||
ingress: 'INGRESS_TRAFFIC_ALL',
|
||||
};
|
||||
|
||||
const createRes = await fetch(`${CLOUD_RUN_API}?serviceId=${serviceName}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(serviceBody),
|
||||
});
|
||||
|
||||
if (!createRes.ok) {
|
||||
const body = await createRes.text();
|
||||
|
||||
// 409 = service already exists — fetch its URL instead of failing
|
||||
if (createRes.status === 409) {
|
||||
console.log(`[workspace] Cloud Run service already exists: ${serviceName} — fetching existing URL`);
|
||||
const serviceUrl = await waitForServiceUrl(serviceName, token);
|
||||
await allowUnauthenticated(serviceName, token);
|
||||
console.log(`[workspace] Linked to existing service: ${serviceName} → ${serviceUrl}`);
|
||||
return { serviceUrl, serviceName };
|
||||
}
|
||||
|
||||
throw new Error(`Cloud Run create service failed (${createRes.status}): ${body}`);
|
||||
}
|
||||
|
||||
// Make service publicly accessible (auth handled by Vibn before URL is revealed)
|
||||
await allowUnauthenticated(serviceName, token);
|
||||
|
||||
// Poll until the service URL is available (usually 10-30s)
|
||||
const serviceUrl = await waitForServiceUrl(serviceName, token);
|
||||
|
||||
console.log(`[workspace] Cloud Run service ready: ${serviceName} → ${serviceUrl}`);
|
||||
return { serviceUrl, serviceName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants allUsers invoker access so the service URL works without GCP auth.
|
||||
* Vibn controls access by only sharing the URL with the project owner.
|
||||
*/
|
||||
async function allowUnauthenticated(serviceName: string, token: string): Promise<void> {
|
||||
await fetch(`${CLOUD_RUN_API}/${serviceName}:setIamPolicy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
policy: {
|
||||
bindings: [{ role: 'roles/run.invoker', members: ['allUsers'] }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls until Cloud Run reports the service URL (service is ready).
|
||||
*/
|
||||
async function waitForServiceUrl(serviceName: string, token: string, maxWaitMs = 60_000): Promise<string> {
|
||||
const deadline = Date.now() + maxWaitMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
const res = await fetch(`${CLOUD_RUN_API}/${serviceName}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const svc = await res.json() as { urls?: string[] };
|
||||
if (svc.urls?.[0]) return svc.urls[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Return expected URL pattern even if polling timed out
|
||||
return `https://${serviceName}-${PROJECT_ID.slice(-6)}.${REGION}.run.app`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a warm-up request to a workspace so the container is ready
|
||||
* before the user clicks "Open IDE". Call this on user login.
|
||||
*/
|
||||
export async function prewarmWorkspace(serviceUrl: string): Promise<void> {
|
||||
try {
|
||||
await fetch(`${serviceUrl}/`, { signal: AbortSignal.timeout(5000) });
|
||||
} catch {
|
||||
// Ignore — fire and forget
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a Cloud Run workspace service.
|
||||
*/
|
||||
export async function deleteTheiaWorkspace(serviceName: string): Promise<void> {
|
||||
const token = await getAccessToken();
|
||||
await fetch(`${CLOUD_RUN_API}/${serviceName}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
/**
|
||||
* Coolify Workspace Provisioning
|
||||
*
|
||||
* Provisions a dedicated Theia IDE instance per Vibn project using the
|
||||
* Coolify Docker image application API. Each workspace is:
|
||||
* - Hosted at {slug}.ide.vibnai.com
|
||||
* - Protected by the vibn-auth ForwardAuth (project-owner only)
|
||||
* - Running ghcr.io/eclipse-theia/theia-blueprint/theia-ide:latest
|
||||
*/
|
||||
|
||||
const COOLIFY_URL = process.env.COOLIFY_URL ?? 'http://34.19.250.135:8000';
|
||||
const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? '';
|
||||
|
||||
// Coolify resource IDs (stable — tied to the Vibn server/project setup)
|
||||
const COOLIFY_PROJECT_UUID = 'f4owwggokksgw0ogo0844os0'; // "Vibn" project
|
||||
const COOLIFY_ENVIRONMENT = 'production';
|
||||
const COOLIFY_SERVER_UUID = 'jws4g4cgssss4cw48s488woc'; // localhost (Coolify host)
|
||||
|
||||
const THEIA_IMAGE_NAME = 'ghcr.io/eclipse-theia/theia-blueprint/theia-ide';
|
||||
const THEIA_IMAGE_TAG = 'latest';
|
||||
const THEIA_PORT = '3000';
|
||||
const IDE_DOMAIN_SUFFIX = '.ide.vibnai.com';
|
||||
|
||||
function coolifyHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${COOLIFY_API_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the newline-separated Traefik label string that Coolify stores
|
||||
* as custom_labels. We add vibn-auth@file to the HTTPS router middleware
|
||||
* chain after Coolify's generated labels.
|
||||
*
|
||||
* Router naming convention observed in Coolify:
|
||||
* https-0-{uuid} → the TLS router for the app
|
||||
*/
|
||||
function buildCustomLabels(appUuid: string): string {
|
||||
const routerName = `https-0-${appUuid}`;
|
||||
return [
|
||||
'traefik.enable=true',
|
||||
`traefik.http.routers.${routerName}.middlewares=vibn-auth@file,gzip`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export interface ProvisionResult {
|
||||
appUuid: string;
|
||||
workspaceUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Coolify Docker-image application for a Vibn project Theia workspace.
|
||||
* Sets the vibn-auth ForwardAuth middleware so only the project owner can access it.
|
||||
*/
|
||||
export async function provisionTheiaWorkspace(
|
||||
slug: string,
|
||||
projectId: string,
|
||||
giteaRepo: string | null,
|
||||
): Promise<ProvisionResult> {
|
||||
const workspaceUrl = `https://${slug}${IDE_DOMAIN_SUFFIX}`;
|
||||
const appName = `theia-${slug}`;
|
||||
|
||||
// ── Step 1: Create the app ────────────────────────────────────────────────
|
||||
const createRes = await fetch(`${COOLIFY_URL}/api/v1/applications/dockerimage`, {
|
||||
method: 'POST',
|
||||
headers: coolifyHeaders(),
|
||||
body: JSON.stringify({
|
||||
project_uuid: COOLIFY_PROJECT_UUID,
|
||||
environment_name: COOLIFY_ENVIRONMENT,
|
||||
server_uuid: COOLIFY_SERVER_UUID,
|
||||
docker_registry_image_name: THEIA_IMAGE_NAME,
|
||||
docker_registry_image_tag: THEIA_IMAGE_TAG,
|
||||
name: appName,
|
||||
description: `Theia IDE for Vibn project ${slug}`,
|
||||
ports_exposes: THEIA_PORT,
|
||||
domains: workspaceUrl,
|
||||
instant_deploy: false, // we deploy after patching labels
|
||||
}),
|
||||
});
|
||||
|
||||
if (!createRes.ok) {
|
||||
const body = await createRes.text();
|
||||
throw new Error(`Coolify create app failed (${createRes.status}): ${body}`);
|
||||
}
|
||||
|
||||
const { uuid: appUuid } = await createRes.json() as { uuid: string };
|
||||
|
||||
// ── Step 2: Patch with vibn-auth Traefik labels ───────────────────────────
|
||||
const patchRes = await fetch(`${COOLIFY_URL}/api/v1/applications/${appUuid}`, {
|
||||
method: 'PATCH',
|
||||
headers: coolifyHeaders(),
|
||||
body: JSON.stringify({
|
||||
custom_labels: buildCustomLabels(appUuid),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!patchRes.ok) {
|
||||
console.warn(`[workspace] PATCH labels failed (${patchRes.status}) — continuing`);
|
||||
}
|
||||
|
||||
// ── Step 3: Set environment variables ────────────────────────────────────
|
||||
const giteaBaseUrl = process.env.GITEA_URL ?? 'https://git.vibnai.com';
|
||||
const giteaToken = process.env.GITEA_TOKEN ?? '';
|
||||
// Authenticated clone URL so Theia can git clone on startup
|
||||
const giteaCloneUrl = giteaRepo
|
||||
? `https://${giteaToken ? `oauth2:${giteaToken}@` : ''}${giteaBaseUrl.replace(/^https?:\/\//, '')}/${giteaRepo}.git`
|
||||
: '';
|
||||
|
||||
const envVars = [
|
||||
{ key: 'VIBN_PROJECT_ID', value: projectId, is_preview: false },
|
||||
{ key: 'VIBN_PROJECT_SLUG', value: slug, is_preview: false },
|
||||
{ key: 'GITEA_REPO', value: giteaRepo ?? '', is_preview: false },
|
||||
{ key: 'GITEA_CLONE_URL', value: giteaCloneUrl, is_preview: false },
|
||||
{ key: 'GITEA_API_URL', value: giteaBaseUrl, is_preview: false },
|
||||
// Theia opens this path as its workspace root
|
||||
{ key: 'THEIA_WORKSPACE_ROOT', value: `/home/theia/${slug}`, is_preview: false },
|
||||
];
|
||||
|
||||
await fetch(`${COOLIFY_URL}/api/v1/applications/${appUuid}/envs/bulk`, {
|
||||
method: 'POST',
|
||||
headers: coolifyHeaders(),
|
||||
body: JSON.stringify({ data: envVars }),
|
||||
});
|
||||
|
||||
// ── Step 4: Deploy ────────────────────────────────────────────────────────
|
||||
await fetch(`${COOLIFY_URL}/api/v1/applications/${appUuid}/start`, {
|
||||
method: 'POST',
|
||||
headers: coolifyHeaders(),
|
||||
});
|
||||
|
||||
console.log(`[workspace] Provisioned ${appName} → ${workspaceUrl} (uuid: ${appUuid})`);
|
||||
|
||||
return { appUuid, workspaceUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a provisioned Theia workspace from Coolify.
|
||||
*/
|
||||
export async function deleteTheiaWorkspace(appUuid: string): Promise<void> {
|
||||
await fetch(`${COOLIFY_URL}/api/v1/applications/${appUuid}`, {
|
||||
method: 'DELETE',
|
||||
headers: coolifyHeaders(),
|
||||
});
|
||||
}
|
||||
@@ -408,6 +408,16 @@ export async function setApplicationDomains(
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
return `https://${trimmed}`;
|
||||
});
|
||||
// Coolify API: send `domains` (NOT `fqdn`). The controller maps it to
|
||||
// the DB's `fqdn` column internally, but only when the destination
|
||||
// server has `proxy.type=TRAEFIK` (or CADDY) AND `is_build_server=false`
|
||||
// — i.e. when Server::isProxyShouldRun() returns true. If either is
|
||||
// misconfigured, the controller silently drops the field (PATCH returns
|
||||
// 200, fqdn unchanged). We hit this on the missinglettr-test app on
|
||||
// 2026-04-22; the underlying server had proxy.type=null and
|
||||
// is_build_server=true. Fix is in Coolify server-config (UI/DB), not
|
||||
// the client. Sending `fqdn` directly is rejected with 422 ("This
|
||||
// field is not allowed").
|
||||
return updateApplication(uuid, {
|
||||
domains: normalized.join(','),
|
||||
force_domain_override: opts.forceOverride ?? true,
|
||||
|
||||
10
lib/dev-bypass.ts
Normal file
10
lib/dev-bypass.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Local dev: treat the app as signed-in for data fetching when bypass is on.
|
||||
* Server: lib/auth/session-server.ts must use the same env flag.
|
||||
*/
|
||||
export function isClientDevProjectBypass(): boolean {
|
||||
return (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
process.env.NEXT_PUBLIC_DEV_BYPASS_PROJECT_AUTH === "true"
|
||||
);
|
||||
}
|
||||
@@ -420,10 +420,11 @@ export async function registerDomain(
|
||||
const regUsername = input.regUsername ?? generateHandle(input.domain);
|
||||
const regPassword = input.regPassword ?? generateRandomPassword();
|
||||
|
||||
const nameservers =
|
||||
const nameservers = (
|
||||
input.nameservers && input.nameservers.length >= 2
|
||||
? input.nameservers
|
||||
: defaultNameservers(cfg.mode);
|
||||
: defaultNameservers(cfg.mode)
|
||||
).map(ns => ns.trim().replace(/\.+$/, '').toLowerCase());
|
||||
|
||||
const contactSet = {
|
||||
owner: input.contact,
|
||||
@@ -492,6 +493,10 @@ export async function updateDomainNameservers(
|
||||
if (!nameservers || nameservers.length < 2) {
|
||||
throw new Error('At least two nameservers are required');
|
||||
}
|
||||
// OpenSRS rejects FQDN-style nameservers (with trailing dot). Cloud DNS
|
||||
// returns NS records as `ns-cloud-eX.googledomains.com.` so we must strip
|
||||
// the trailing dot before handing them to the registrar.
|
||||
const normalized = nameservers.map(ns => ns.trim().replace(/\.+$/, '').toLowerCase());
|
||||
const cfg = configFromEnv(overrides);
|
||||
const parsed = await rawCall(
|
||||
'advanced_update_nameservers',
|
||||
@@ -499,7 +504,7 @@ export async function updateDomainNameservers(
|
||||
{
|
||||
domain,
|
||||
op_type: 'assign',
|
||||
assign_ns: nameservers,
|
||||
assign_ns: normalized,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
|
||||
33
lib/prd-sections.ts
Normal file
33
lib/prd-sections.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/** Shared PRD plan sections (discovery phases → document headings). */
|
||||
|
||||
export const DISCOVERY_PHASE_ORDER = [
|
||||
"big_picture",
|
||||
"users_personas",
|
||||
"features_scope",
|
||||
"business_model",
|
||||
"screens_data",
|
||||
"risks_questions",
|
||||
] as const;
|
||||
|
||||
export type DiscoveryPhaseId = (typeof DISCOVERY_PHASE_ORDER)[number];
|
||||
|
||||
export const PRD_PLAN_SECTIONS = [
|
||||
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" as const },
|
||||
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" as const },
|
||||
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" as const },
|
||||
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" as const },
|
||||
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" as const },
|
||||
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" as const },
|
||||
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" as const },
|
||||
{ id: "business_model", label: "Business Model", phaseId: "business_model" as const },
|
||||
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" as const },
|
||||
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: null },
|
||||
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" as const },
|
||||
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" as const },
|
||||
] as const;
|
||||
|
||||
/** Matches legacy PrdContent: sections without a phase are never marked done in the checklist. */
|
||||
export function isSectionFilled(phaseId: string | null, savedPhaseIds: Set<string>): boolean {
|
||||
if (phaseId === null) return false;
|
||||
return savedPhaseIds.has(phaseId);
|
||||
}
|
||||
Reference in New Issue
Block a user