Add initial Portal version
This commit is contained in:
75
src/lib/auth.ts
Normal file
75
src/lib/auth.ts
Normal 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
132
src/lib/k8s.ts
Normal 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
33
src/lib/litellm.ts
Normal 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
92
src/lib/openbao.ts
Normal 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
104
src/lib/packages.ts
Normal 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
19
src/lib/session.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user