All the MD files via Database

This commit is contained in:
2026-04-11 21:14:09 +02:00
parent c67259ebe0
commit fdb56490dd
14 changed files with 1004 additions and 240 deletions

View File

@@ -1,19 +1,29 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { getTenantRequestById, updateTenantRequestStatus, clearEncryptedSecrets } from "@/lib/db";
import {
getTenantRequestById,
updateTenantRequestStatus,
clearEncryptedSecrets,
} from "@/lib/db";
import { createTenant } from "@/lib/k8s";
import { sendApprovalEmail } from "@/lib/email";
import { decryptSecrets } from "@/lib/crypto";
import { writePackageSecrets } from "@/lib/openbao";
import {
getDefaultSoulMd,
getDefaultAgentsMd,
generateToolsMd,
} from "@/lib/workspace-defaults";
/**
* POST /api/admin/requests/[id]/approve
* Approve a tenant request:
* 1. Decrypt stored package secrets (if any)
* 2. Write each package's secrets to OpenBao at secret/data/tenants/{tenant-name}/{package}
* 3. Null the encrypted_secrets column
* 4. Create PiecedTenant CR
* 5. Update request status, notify customer.
* 1. Decrypt stored package secrets (if any)
* 2. Write each package's secrets to OpenBao at secret/data/tenants/{tenant-name}/{package}
* 3. Null the encrypted_secrets column
* 4. Build workspace files (SOUL.md, AGENTS.md, TOOLS.md)
* 5. Create PiecedTenant CR
* 6. Update request status, notify customer.
* Also supports re-approving a previously rejected request (clears admin notes).
*/
export async function POST(
@@ -38,7 +48,10 @@ export async function POST(
);
}
if (tenantRequest.status !== "pending" && tenantRequest.status !== "rejected") {
if (
tenantRequest.status !== "pending" &&
tenantRequest.status !== "rejected"
) {
return NextResponse.json(
{ error: `Request is already ${tenantRequest.status}` },
{ status: 400 }
@@ -48,47 +61,64 @@ export async function POST(
const isReApproval = tenantRequest.status === "rejected";
// Derive tenant name from company name: lowercase, alphanumeric + hyphens
const tenantName = tenantRequest.companyName
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 63) || `tenant-${tenantRequest.id.slice(0, 8)}`;
const tenantName =
tenantRequest.companyName
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 63) || `tenant-${tenantRequest.id.slice(0, 8)}`;
try {
// Step 1: Decrypt and write package secrets to OpenBao (if collected during wizard)
if (tenantRequest.encryptedSecrets) {
const secrets = await decryptSecrets(tenantRequest.encryptedSecrets);
for (const [packageId, pkgSecrets] of Object.entries(secrets)) {
await writePackageSecrets(`tenant-${tenantName}`, packageId, pkgSecrets);
await writePackageSecrets(
`tenant-${tenantName}`,
packageId,
pkgSecrets
);
}
// Step 2: Null the encrypted column — secrets are now safely in OpenBao
await clearEncryptedSecrets(id);
}
// Step 3: Create the PiecedTenant CR
// Step 3: Build workspace files
const packages = tenantRequest.packages ?? [];
const soulMd =
tenantRequest.soulMd ||
(await getDefaultSoulMd(tenantRequest.companyName));
const agentsMd = tenantRequest.agentsMd || (await getDefaultAgentsMd());
const toolsMd = await generateToolsMd(packages);
const workspaceFiles: Record<string, string> = {
"SOUL.md": soulMd,
"AGENTS.md": agentsMd,
"TOOLS.md": toolsMd,
};
// Step 4: Create the PiecedTenant CR
await createTenant(
tenantName,
{
displayName: tenantRequest.companyName,
agentName: tenantRequest.agentName,
packages: tenantRequest.packages,
workspaceFiles: tenantRequest.soulMd
? { "SOUL.md": tenantRequest.soulMd }
: undefined,
packages,
workspaceFiles,
},
{
"pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId,
}
);
// Step 4: Update request status — clear admin notes on re-approval
// Step 5: Update request status — clear admin notes on re-approval
const updated = await updateTenantRequestStatus(id, "provisioning", {
adminNotes: isReApproval ? null : adminNotes,
tenantName,
clearAdminNotes: isReApproval,
});
// Step 5: Notify customer
// Step 6: Notify customer
await sendApprovalEmail(
tenantRequest.contactEmail,
tenantRequest.contactName,

View File

@@ -15,11 +15,7 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Sync provisioning statuses before listing
await syncProvisioningStatuses(async (tenantName: string) => {
const tenant = await getTenant(tenantName);
return tenant?.status?.phase ?? null;
});
await syncProvisioningStatuses();
const { searchParams } = new URL(request.url);
const status = searchParams.get("status") as any;

View File

@@ -14,6 +14,7 @@ import { z } from "zod";
const onboardingSchema = z.object({
agentName: z.string().min(1).max(50),
soulMd: z.string().max(10_000).optional(),
agentsMd: z.string().max(10_000).optional(),
packages: z.array(z.string()).optional(),
packageSecrets: z
.record(z.string(), z.record(z.string(), z.string()))
@@ -25,20 +26,20 @@ const onboardingSchema = z.object({
postalCode: z.string().optional(),
country: z.string().optional(),
}),
billingNotes: z.string().max(2000).optional(),
billingNotes: z.string().max(2_000).optional(),
});
/**
* GET /api/onboarding
* Returns the current onboarding status for the logged-in user's org.
* Used by the wizard/provisioning UI to poll state.
* Check the current onboarding state for the logged-in user's org.
*/
export async function GET() {
const user = await getSessionUser();
if (!user)
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check if tenant already exists
// Check if there's already a running tenant for this org
const allTenants = await listTenants();
const myTenant = allTenants.find(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
@@ -46,13 +47,9 @@ export async function GET() {
if (myTenant) {
return NextResponse.json({
state: "provisioned",
tenant: {
name: myTenant.metadata.name,
phase: myTenant.status?.phase ?? "Pending",
message: myTenant.status?.message,
conditions: myTenant.status?.conditions,
},
state: "active",
tenantName: myTenant.metadata.name,
phase: myTenant.status?.phase ?? "Unknown",
});
}
@@ -63,29 +60,17 @@ export async function GET() {
return NextResponse.json({ state: "no_request" });
}
// If approved and tenant_name set, check provisioning status
if (
request.status === "provisioning" &&
request.tenantName
) {
const tenant = await getTenant(request.tenantName);
if (tenant) {
return NextResponse.json({
state: "provisioning",
request,
tenant: {
name: tenant.metadata.name,
phase: tenant.status?.phase ?? "Pending",
message: tenant.status?.message,
conditions: tenant.status?.conditions,
},
});
}
}
return NextResponse.json({
state: request.status,
request,
request: {
id: request.id,
agentName: request.agentName,
packages: request.packages,
status: request.status,
adminNotes: request.adminNotes,
tenantName: request.tenantName,
createdAt: request.createdAt,
},
});
}
@@ -101,8 +86,18 @@ export async function GET() {
*/
export async function POST(request: Request) {
const user = await getSessionUser();
if (!user)
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const parsed = onboardingSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
// Check for existing request
const existing = await getTenantRequestByOrgId(user.orgId);
@@ -123,23 +118,20 @@ export async function POST(request: Request) {
const myTenant = allTenants.find(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
if (myTenant) {
return NextResponse.json(
{ error: "Tenant already exists." },
{
error: "You already have a tenant provisioned.",
tenantName: myTenant.metadata.name,
},
{ status: 409 }
);
}
const body = await request.json();
const parsed = onboardingSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() },
{ status: 400 }
);
}
const input: OnboardingInput & { packageSecrets?: Record<string, Record<string, string>> } = parsed.data;
const input: OnboardingInput & {
packageSecrets?: Record<string, Record<string, string>>;
} = parsed.data;
// Encrypt package secrets if provided
let encryptedSecrets: Buffer | undefined;
@@ -159,10 +151,11 @@ export async function POST(request: Request) {
zitadelOrgId: user.orgId,
zitadelUserId: user.id,
companyName: user.orgName,
contactName: user.name || user.email,
contactName: user.name,
contactEmail: user.email,
agentName: input.agentName,
soulMd: input.soulMd,
agentsMd: input.agentsMd,
packages: input.packages ?? [],
billingAddress: input.billingAddress,
billingNotes: input.billingNotes,
@@ -170,14 +163,18 @@ export async function POST(request: Request) {
});
// Notify admin about the new request
await sendAdminNotificationEmail(
user.orgName,
user.name || user.email,
user.email
);
try {
await sendAdminNotificationEmail(
tenantRequest.contactEmail,
tenantRequest.contactName,
tenantRequest.companyName
);
} catch (e) {
console.error("Failed to send admin notification:", e);
}
return NextResponse.json(
{ message: "Onboarding request submitted.", request: tenantRequest },
{ message: "Request submitted.", request: tenantRequest },
{ status: 201 }
);
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import {
getDefaultSoulMd,
getDefaultAgentsMd,
generateToolsMd,
} from "@/lib/workspace-defaults";
/**
* GET /api/workspace-defaults?orgName=...&packages=telegram,web-search
* Returns default content for SOUL.md, AGENTS.md, and TOOLS.md.
* Used by the onboarding wizard to pre-fill textareas.
*/
export async function GET(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const orgName =
req.nextUrl.searchParams.get("orgName") || user.orgName || "Your Company";
const packagesParam = req.nextUrl.searchParams.get("packages") || "";
const packages = packagesParam ? packagesParam.split(",").filter(Boolean) : [];
const [soulMd, agentsMd, toolsMd] = await Promise.all([
getDefaultSoulMd(orgName),
getDefaultAgentsMd(),
generateToolsMd(packages),
]);
return NextResponse.json({ soulMd, agentsMd, toolsMd });
}