Add initial Portal version

This commit is contained in:
2026-04-09 22:16:22 +02:00
commit d526c1ff4a
51 changed files with 10752 additions and 0 deletions

75
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,75 @@
import NextAuth from "next-auth";
import type { NextAuthConfig } from "next-auth";
import type { PlatformRole, SessionUser, ZitadelClaims } from "@/types";
const PLATFORM_ROLES: PlatformRole[] = ["platform_admin", "platform_operator"];
function extractRoles(
rolesObj?: Record<string, Record<string, string>>
): PlatformRole[] {
if (!rolesObj) return [];
return Object.keys(rolesObj) as PlatformRole[];
}
export const authConfig: NextAuthConfig = {
providers: [
{
id: "zitadel",
name: "ZITADEL",
type: "oidc",
issuer: process.env.ZITADEL_ISSUER!,
clientId: process.env.ZITADEL_CLIENT_ID!,
clientSecret: process.env.ZITADEL_CLIENT_SECRET!,
authorization: {
params: {
scope:
"openid profile email urn:zitadel:iam:org:project:roles urn:zitadel:iam:user:resourceowner",
},
},
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
};
},
},
],
callbacks: {
async jwt({ token, account, profile }) {
if (account && profile) {
const claims = profile as unknown as ZitadelClaims;
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
token.orgName = claims["urn:zitadel:iam:user:resourceowner:name"];
token.roles = extractRoles(
claims["urn:zitadel:iam:org:project:roles"]
);
token.accessToken = account.access_token;
}
return token;
},
async session({ session, token }) {
const roles = (token.roles as PlatformRole[]) ?? [];
const sessionUser: SessionUser = {
id: token.sub!,
name: session.user?.name ?? "",
email: session.user?.email ?? "",
orgId: token.orgId as string,
orgName: token.orgName as string,
roles,
isPlatform: roles.some((r) => PLATFORM_ROLES.includes(r)),
};
(session as any).platformUser = sessionUser;
return session;
},
},
pages: {
signIn: "/login",
},
session: {
strategy: "jwt",
maxAge: 8 * 60 * 60,
},
};
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);

132
src/lib/k8s.ts Normal file
View File

@@ -0,0 +1,132 @@
import * as k8s from "@kubernetes/client-node";
import type { PiecedTenant, PiecedTenantSpec } from "@/types";
import { readFileSync } from "fs";
const kc = new k8s.KubeConfig();
if (process.env.KUBERNETES_SERVICE_HOST) {
kc.loadFromCluster();
} else {
kc.loadFromDefault();
}
const API_VERSION = "pieced.ch/v1alpha1";
const PLURAL = "piecedtenants";
// Raw K8s API client — avoids @kubernetes/client-node API surface instability
// across versions. The REST API itself is stable.
function getBaseUrl(): string {
const cluster = kc.getCurrentCluster();
if (!cluster) throw new Error("No active K8s cluster in kubeconfig");
return cluster.server;
}
function getAuthHeaders(): Record<string, string> {
// In-cluster: read SA token
if (process.env.KUBERNETES_SERVICE_HOST) {
const token = readFileSync(
"/var/run/secrets/kubernetes.io/serviceaccount/token",
"utf8"
);
return { Authorization: `Bearer ${token}` };
}
// Local dev: extract token from kubeconfig current user
const user = kc.getCurrentUser();
if (user?.token) {
return { Authorization: `Bearer ${user.token}` };
}
return {};
}
async function k8sRequest<T>(
path: string,
method: string = "GET",
body?: unknown
): Promise<T> {
const url = `${getBaseUrl()}/apis/${API_VERSION}/${PLURAL}${path}`;
const res = await fetch(url, {
method,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
...getAuthHeaders(),
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text();
const err = new Error(`K8s ${method} ${path}: ${res.status} ${text}`);
(err as any).statusCode = res.status;
throw err;
}
return res.json() as Promise<T>;
}
export async function listTenants(): Promise<PiecedTenant[]> {
const result = await k8sRequest<{ items: PiecedTenant[] }>("");
return result.items ?? [];
}
export async function getTenant(name: string): Promise<PiecedTenant | null> {
try {
return await k8sRequest<PiecedTenant>(`/${name}`);
} catch (e: any) {
if (e.statusCode === 404) return null;
throw e;
}
}
export async function createTenant(
name: string,
spec: PiecedTenantSpec,
labels?: Record<string, string>
): Promise<PiecedTenant> {
return k8sRequest<PiecedTenant>("", "POST", {
apiVersion: API_VERSION,
kind: "PiecedTenant",
metadata: { name, labels },
spec,
});
}
export async function updateTenantSpec(
name: string,
spec: Partial<PiecedTenantSpec>
): Promise<PiecedTenant> {
const existing = await getTenant(name);
if (!existing) throw new Error(`Tenant ${name} not found`);
return k8sRequest<PiecedTenant>(`/${name}`, "PUT", {
...existing,
spec: { ...existing.spec, ...spec },
});
}
export async function deleteTenant(name: string): Promise<void> {
await k8sRequest(`/${name}`, "DELETE");
}
export async function patchTenantSpec(
name: string,
spec: Partial<PiecedTenantSpec>
): Promise<PiecedTenant> {
const url = `${getBaseUrl()}/apis/${API_VERSION}/${PLURAL}/${name}`;
const res = await fetch(url, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/merge-patch+json",
...getAuthHeaders(),
},
body: JSON.stringify({ spec }),
});
if (!res.ok) {
const text = await res.text();
const err = new Error(`K8s PATCH /${name}: ${res.status} ${text}`);
(err as any).statusCode = res.status;
throw err;
}
return res.json() as Promise<PiecedTenant>;
}

33
src/lib/litellm.ts Normal file
View File

@@ -0,0 +1,33 @@
const LITELLM_URL =
process.env.LITELLM_INTERNAL_URL ?? "http://litellm.inference.svc:4000";
const LITELLM_MASTER_KEY = process.env.LITELLM_MASTER_KEY!;
async function litellmFetch(path: string, init?: RequestInit) {
const res = await fetch(`${LITELLM_URL}${path}`, {
...init,
headers: {
Authorization: `Bearer ${LITELLM_MASTER_KEY}`,
"Content-Type": "application/json",
...init?.headers,
},
});
if (!res.ok) {
throw new Error(`LiteLLM ${path}: ${res.status} ${await res.text()}`);
}
return res.json();
}
export async function getTeamInfo(teamId: string) {
return litellmFetch(`/team/info?team_id=${encodeURIComponent(teamId)}`);
}
export async function getTeamSpendLogs(
teamId: string,
startDate?: string,
endDate?: string
) {
const params = new URLSearchParams({ team_id: teamId });
if (startDate) params.set("start_date", startDate);
if (endDate) params.set("end_date", endDate);
return litellmFetch(`/global/spend/logs?${params}`);
}

92
src/lib/openbao.ts Normal file
View File

@@ -0,0 +1,92 @@
import { readFileSync } from "fs";
const OPENBAO_ADDR =
process.env.OPENBAO_ADDR || "http://openbao.openbao.svc:8200";
const SA_TOKEN_PATH =
"/var/run/secrets/kubernetes.io/serviceaccount/token";
const K8S_AUTH_ROLE = process.env.OPENBAO_K8S_ROLE || "pieced-portal";
const K8S_AUTH_MOUNT = process.env.OPENBAO_K8S_MOUNT || "kubernetes";
let cachedToken: { token: string; expiresAt: number } | null = null;
async function authenticate(): Promise<string> {
if (cachedToken && Date.now() < cachedToken.expiresAt - 30_000) {
return cachedToken.token;
}
const jwt = readFileSync(SA_TOKEN_PATH, "utf-8").trim();
const res = await fetch(
`${OPENBAO_ADDR}/v1/auth/${K8S_AUTH_MOUNT}/login`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: K8S_AUTH_ROLE, jwt }),
}
);
if (!res.ok) {
const body = await res.text();
throw new Error(`OpenBao K8s auth failed: ${res.status} ${body}`);
}
const data = await res.json();
const token = data.auth.client_token as string;
const leaseDuration = (data.auth.lease_duration as number) || 3600;
cachedToken = {
token,
expiresAt: Date.now() + leaseDuration * 1000,
};
return token;
}
/**
* Write secrets for a tenant package to OpenBao KV v2.
* Path: secret/data/tenants/{tenantId}/{packageId}
*/
export async function writePackageSecrets(
tenantId: string,
packageId: string,
secrets: Record<string, string>
): Promise<void> {
const token = await authenticate();
const path = `secret/data/tenants/${tenantId}/${packageId}`;
const res = await fetch(`${OPENBAO_ADDR}/v1/${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Vault-Token": token,
},
body: JSON.stringify({ data: secrets }),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`OpenBao write failed: ${res.status} ${body}`);
}
}
/**
* Delete secrets for a tenant package from OpenBao KV v2.
* Uses metadata delete to remove all versions.
*/
export async function deletePackageSecrets(
tenantId: string,
packageId: string
): Promise<void> {
const token = await authenticate();
const path = `secret/metadata/tenants/${tenantId}/${packageId}`;
const res = await fetch(`${OPENBAO_ADDR}/v1/${path}`, {
method: "DELETE",
headers: { "X-Vault-Token": token },
});
if (!res.ok && res.status !== 404) {
const body = await res.text();
throw new Error(`OpenBao delete failed: ${res.status} ${body}`);
}
}

104
src/lib/packages.ts Normal file
View File

@@ -0,0 +1,104 @@
export interface PackageDef {
id: string;
name: string;
descriptionKey: string; // i18n key
icon: string; // emoji or lucide icon name
requiresSecrets: boolean;
secrets?: {
key: string;
labelKey: string;
placeholderKey: string;
}[];
customerInstructionsKey?: string; // i18n key for how-to
disclaimerKey?: string; // i18n key
category: "channel" | "skill";
}
export const PACKAGE_CATALOG: PackageDef[] = [
{
id: "telegram",
name: "Telegram",
descriptionKey: "packages.telegram.description",
icon: "MessageCircle",
requiresSecrets: true,
secrets: [
{
key: "bot-token",
labelKey: "packages.telegram.botTokenLabel",
placeholderKey: "packages.telegram.botTokenPlaceholder",
},
],
customerInstructionsKey: "packages.telegram.instructions",
disclaimerKey: "packages.telegram.disclaimer",
category: "channel",
},
{
id: "discord",
name: "Discord",
descriptionKey: "packages.discord.description",
icon: "Hash",
requiresSecrets: true,
secrets: [
{
key: "bot-token",
labelKey: "packages.discord.botTokenLabel",
placeholderKey: "packages.discord.botTokenPlaceholder",
},
],
customerInstructionsKey: "packages.discord.instructions",
disclaimerKey: "packages.discord.disclaimer",
category: "channel",
},
{
id: "email",
name: "Email",
descriptionKey: "packages.email.description",
icon: "Mail",
requiresSecrets: true,
secrets: [
{
key: "smtp-host",
labelKey: "packages.email.smtpHostLabel",
placeholderKey: "packages.email.smtpHostPlaceholder",
},
{
key: "smtp-user",
labelKey: "packages.email.smtpUserLabel",
placeholderKey: "packages.email.smtpUserPlaceholder",
},
{
key: "smtp-password",
labelKey: "packages.email.smtpPasswordLabel",
placeholderKey: "packages.email.smtpPasswordPlaceholder",
},
{
key: "imap-host",
labelKey: "packages.email.imapHostLabel",
placeholderKey: "packages.email.imapHostPlaceholder",
},
],
customerInstructionsKey: "packages.email.instructions",
disclaimerKey: "packages.email.disclaimer",
category: "channel",
},
{
id: "web-search",
name: "Web Search",
descriptionKey: "packages.webSearch.description",
icon: "Search",
requiresSecrets: false,
category: "skill",
},
{
id: "document-processing",
name: "Document Processing",
descriptionKey: "packages.documentProcessing.description",
icon: "FileText",
requiresSecrets: false,
category: "skill",
},
];
export function getPackageDef(id: string): PackageDef | undefined {
return PACKAGE_CATALOG.find((p) => p.id === id);
}

19
src/lib/session.ts Normal file
View File

@@ -0,0 +1,19 @@
import { auth } from "@/lib/auth";
import type { SessionUser } from "@/types";
export async function getSessionUser(): Promise<SessionUser | null> {
const session = await auth();
return (session as any)?.platformUser ?? null;
}
export async function requireSession(): Promise<SessionUser> {
const user = await getSessionUser();
if (!user) throw new Error("Unauthorized");
return user;
}
export async function requirePlatformRole(): Promise<SessionUser> {
const user = await requireSession();
if (!user.isPlatform) throw new Error("Forbidden: platform role required");
return user;
}