278 lines
9.0 KiB
TypeScript
278 lines
9.0 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { requirePlatformRole } from "@/lib/session";
|
|
import {
|
|
getTenantRequestById,
|
|
updateTenantRequestStatus,
|
|
clearEncryptedSecrets,
|
|
recordTenantCreated,
|
|
recordSkillEvents,
|
|
recordSuspensionEvent,
|
|
} from "@/lib/db";
|
|
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
|
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
|
|
import { decryptSecrets } from "@/lib/crypto";
|
|
import { writePackageSecrets } from "@/lib/openbao";
|
|
import {
|
|
getDefaultSoulMd,
|
|
getDefaultAgentsMd,
|
|
generateToolsMd,
|
|
} from "@/lib/workspace-defaults";
|
|
import { deriveTenantName } from "@/lib/tenant-naming";
|
|
import { safeError } from "@/lib/errors";
|
|
|
|
/**
|
|
* POST /api/admin/requests/[id]/approve
|
|
*
|
|
* Approve a request. Two paths depending on request_type:
|
|
*
|
|
* Provision (the original purpose):
|
|
* 1. Decrypt stored package secrets (if any)
|
|
* 2. Write each package's secrets to OpenBao
|
|
* 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.
|
|
* Supports re-approving a previously rejected request (clears admin notes).
|
|
*
|
|
* Resume (Bug 37a):
|
|
* 1. PATCH spec.suspend=false on the existing PiecedTenant CR.
|
|
* 2. Clear the `pieced.ch/resume-request-pending` annotation so the
|
|
* operator knows the request is settled (and doesn't pause its
|
|
* 60-day TTL forever — though now that the tenant isn't suspended,
|
|
* the timer is moot).
|
|
* 3. Mark request approved, notify customer.
|
|
* No CR creation, no secret materialisation, no workspace files.
|
|
*/
|
|
export async function POST(
|
|
request: Request,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
try {
|
|
await requirePlatformRole();
|
|
} catch {
|
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
}
|
|
|
|
const { id } = await params;
|
|
const body = await request.json().catch(() => ({}));
|
|
const adminNotes = body.adminNotes as string | undefined;
|
|
|
|
const tenantRequest = await getTenantRequestById(id);
|
|
if (!tenantRequest) {
|
|
return NextResponse.json(
|
|
{ error: "Request not found" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
if (
|
|
tenantRequest.status !== "pending" &&
|
|
tenantRequest.status !== "rejected"
|
|
) {
|
|
return NextResponse.json(
|
|
{ error: `Request is already ${tenantRequest.status}` },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Resume request: short path. Just patch the existing tenant, clear
|
|
// the annotation, mark approved.
|
|
if (tenantRequest.requestType === "resume") {
|
|
if (!tenantRequest.tenantName) {
|
|
// Shouldn't happen — resume requests are created with tenant_name
|
|
// set. Defensive 500 if it does.
|
|
return NextResponse.json(
|
|
{ error: "Resume request has no tenant_name" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
try {
|
|
await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
|
|
|
|
// Billing — Phase 1: record the resume so monthly proration
|
|
// counts the suspended segment correctly. Best-effort; if
|
|
// logging fails, the approval still succeeds.
|
|
try {
|
|
await recordSuspensionEvent(
|
|
tenantRequest.tenantName,
|
|
tenantRequest.zitadelOrgId,
|
|
"resumed"
|
|
);
|
|
} catch (e) {
|
|
console.error(
|
|
"billing: failed to record resumed suspension event:",
|
|
e
|
|
);
|
|
}
|
|
|
|
// Clear the annotation that pauses the operator's 60-day TTL.
|
|
// Best-effort — annotation cleanup is also done by the operator
|
|
// when it sees suspend=false on the next reconcile (it clears
|
|
// status.suspendedAt), but explicitly clearing here keeps the
|
|
// CR clean.
|
|
try {
|
|
await setTenantAnnotation(
|
|
tenantRequest.tenantName,
|
|
"pieced.ch/resume-request-pending",
|
|
null
|
|
);
|
|
} catch (e) {
|
|
console.warn(
|
|
"post-approve annotation clear failed; not blocking",
|
|
e
|
|
);
|
|
}
|
|
|
|
await updateTenantRequestStatus(id, "approved", { adminNotes });
|
|
|
|
await sendResumeApprovalEmail(
|
|
tenantRequest.contactEmail,
|
|
tenantRequest.contactName,
|
|
tenantRequest.companyName
|
|
).catch((e) => console.error("resume approval email failed:", e));
|
|
|
|
return NextResponse.json({
|
|
message: "Resume approved. Tenant is reactivating.",
|
|
tenantName: tenantRequest.tenantName,
|
|
});
|
|
} catch (e: any) {
|
|
console.error("Resume approval failed:", e);
|
|
return NextResponse.json(
|
|
{ error: safeError(e, "Failed to approve resume") },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
const isReApproval = tenantRequest.status === "rejected";
|
|
|
|
// Build the CR name: see `lib/tenant-naming.ts` for the format spec.
|
|
// Slice 4: for personal accounts the slug is replaced by the literal
|
|
// "p-" prefix so no PII is embedded in the K8s namespace name.
|
|
const tenantName = deriveTenantName(
|
|
tenantRequest.isPersonal ? "personal" : "company",
|
|
tenantRequest.companyName,
|
|
tenantRequest.id
|
|
);
|
|
|
|
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
|
|
);
|
|
}
|
|
// Step 2: Null the encrypted column — secrets are now safely in OpenBao
|
|
await clearEncryptedSecrets(id);
|
|
}
|
|
|
|
// 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.
|
|
// displayName precedence:
|
|
// 1. customer-chosen instance name (Slice 3 multi-tenant)
|
|
// 2. for personal accounts, the contact name (avoids exposing the
|
|
// synthetic "{name} (Personal)" company name in the OpenClaw UI)
|
|
// 3. company name otherwise
|
|
const displayName =
|
|
tenantRequest.instanceName && tenantRequest.instanceName.trim().length > 0
|
|
? tenantRequest.instanceName.trim()
|
|
: tenantRequest.isPersonal
|
|
? tenantRequest.contactName || "Assistant"
|
|
: tenantRequest.companyName;
|
|
|
|
await createTenant(
|
|
tenantName,
|
|
{
|
|
displayName,
|
|
agentName: tenantRequest.agentName,
|
|
packages,
|
|
workspaceFiles,
|
|
},
|
|
{
|
|
"pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId,
|
|
// Bug 7: stamp the personal flag on the CR so callers (notably
|
|
// the tenant detail page) can hide assignment-related UI
|
|
// without an extra DB join. Slice 4 already tracks this on the
|
|
// request row; the CR label is the same fact at the K8s layer.
|
|
// Legacy tenants approved before this change won't carry the
|
|
// label — operators can backfill with `kubectl label`.
|
|
...(tenantRequest.isPersonal
|
|
? { "pieced.ch/personal": "true" }
|
|
: {}),
|
|
}
|
|
);
|
|
|
|
// Billing — Phase 1: record the tenant's creation and initial
|
|
// package state. Anchored at "now" rather than the CR's
|
|
// creationTimestamp because we don't get the timestamp back from
|
|
// createTenant — the few-millisecond skew vs the CR's actual
|
|
// creationTimestamp is irrelevant for monthly billing.
|
|
//
|
|
// Best-effort: tracking failures must never block provisioning.
|
|
// The backfill helper can repair any gaps later if needed.
|
|
const billingAnchor = new Date();
|
|
try {
|
|
await recordTenantCreated(
|
|
tenantName,
|
|
tenantRequest.zitadelOrgId,
|
|
billingAnchor
|
|
);
|
|
await recordSkillEvents(
|
|
tenantName,
|
|
tenantRequest.zitadelOrgId,
|
|
packages,
|
|
[],
|
|
billingAnchor
|
|
);
|
|
} catch (e) {
|
|
console.error(
|
|
"billing: failed to record tenant creation / initial skill events:",
|
|
e
|
|
);
|
|
}
|
|
|
|
// 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 6: Notify customer
|
|
await sendApprovalEmail(
|
|
tenantRequest.contactEmail,
|
|
tenantRequest.contactName,
|
|
tenantRequest.companyName
|
|
);
|
|
|
|
return NextResponse.json({
|
|
message: "Tenant approved and provisioning started.",
|
|
request: updated,
|
|
tenantName,
|
|
});
|
|
} catch (e: any) {
|
|
console.error("Failed to create tenant:", e);
|
|
return NextResponse.json(
|
|
{ error: safeError(e, "Failed to create tenant") },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|