import type { Pod, PodInput } from '@podman/shared'; import { collections, getDb, type UserPodContextDoc } from '../memory/db.js'; const NO_ID = { projection: { _id: 0 } } as const; const MAX_NAME = 120; const MAX_REPO = 140; const MAX_DESCRIPTION = 2000; const MAX_MEMBERS = 50; const MAX_MEMBER_LEN = 80; function slugify(name: string): string { return name .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } /** Validate + normalize a string field. Throws (-> 400) on type/length errors. */ function str(value: unknown, field: string, max: number, required = false): string | undefined { if (value === undefined || value === null) { if (required) throw new Error(`${field} is required`); return undefined; } if (typeof value !== 'string') throw new Error(`${field} must be a string`); const trimmed = value.trim(); if (required && !trimmed) throw new Error(`${field} is required`); if (trimmed.length > max) throw new Error(`${field} too long (max ${max})`); return trimmed; } /** Validate, trim, length-cap, de-dupe (case-insensitive), and limit count. */ function cleanMembers(members: unknown): string[] { if (members === undefined || members === null) return []; if (!Array.isArray(members)) throw new Error('members must be an array'); const seen = new Set(); const out: string[] = []; for (const raw of members) { if (typeof raw !== 'string') throw new Error('member names must be strings'); const name = raw.trim(); if (name.length > MAX_MEMBER_LEN) throw new Error(`member name too long (max ${MAX_MEMBER_LEN})`); const key = name.toLowerCase(); if (name && !seen.has(key)) { seen.add(key); out.push(name); } } if (out.length > MAX_MEMBERS) throw new Error(`too many members (max ${MAX_MEMBERS})`); return out; } function isDuplicateKey(err: unknown): boolean { return (err as { code?: number })?.code === 11000; } function now(): string { return new Date().toISOString(); } function regexExact(value: string): RegExp { return new RegExp(`^${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i'); } function profileString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } function matchesMember(doc: UserPodContextDoc, member: string): boolean { const key = member.trim().toLowerCase(); return [doc.memberName, doc.metadata?.identity, doc.metadata?.email] .filter(Boolean) .some((value) => String(value).trim().toLowerCase() === key); } async function hydrateMemberProfiles(pod: Pod): Promise { if (!pod.members.length) return pod; const db = await getDb(); const matchers = pod.members.map(regexExact); const docs = await db .collection('user_pod_context') .find( { $or: [ { memberName: { $in: matchers } }, { 'metadata.identity': { $in: matchers } }, { 'metadata.email': { $in: matchers } }, ], }, { projection: { _id: 0 } }, ) .sort({ observedAt: -1 }) .limit(500) .toArray(); const memberProfiles: NonNullable = {}; for (const member of pod.members) { const doc = docs.find((row) => matchesMember(row, member)); const imageUrl = profileString(doc?.metadata?.imageUrl); if (!doc || !imageUrl) continue; memberProfiles[member] = { displayName: doc.memberName ?? member, email: profileString(doc.metadata?.email), imageUrl, }; } return Object.keys(memberProfiles).length ? { ...pod, memberProfiles } : pod; } async function hydratePods(pods: Pod[]): Promise { return Promise.all(pods.map((pod) => hydrateMemberProfiles(pod))); } export async function listPods(): Promise { const c = await collections(); return hydratePods(await c.pods.find({}, NO_ID).sort({ createdAt: 1 }).toArray()); } export async function getPod(id: string): Promise { const c = await collections(); const pod = await c.pods.findOne({ id }, NO_ID); return pod ? hydrateMemberProfiles(pod) : null; } export async function createPod(input: PodInput): Promise { const name = str(input.name, 'name', MAX_NAME, true)!; const repo = str(input.repo, 'repo', MAX_REPO) ?? ''; const description = str(input.description, 'description', MAX_DESCRIPTION); const members = cleanMembers(input.members); const c = await collections(); const ts = now(); const base = slugify(name) || 'pod'; // Atomic create: let the unique index arbitrate slug collisions. On a // duplicate-key error, bump the suffix and retry — no check-then-insert race. for (let suffix = 1; suffix <= MAX_MEMBERS + 50; suffix++) { const pod: Pod = { id: suffix === 1 ? base : `${base}-${suffix}`, name, repo, description: description || undefined, members, createdAt: ts, updatedAt: ts, }; try { await c.pods.insertOne({ ...pod }); return hydrateMemberProfiles(pod); } catch (err) { if (isDuplicateKey(err)) continue; throw err; } } throw new Error('could not allocate a unique pod id'); } export async function updatePod(id: string, patch: PodInput): Promise { const set: Partial = { updatedAt: now() }; if (patch.name !== undefined) { const name = str(patch.name, 'name', MAX_NAME); if (!name) throw new Error('name cannot be empty'); set.name = name; } if (patch.repo !== undefined) set.repo = str(patch.repo, 'repo', MAX_REPO) ?? ''; if (patch.description !== undefined) { set.description = str(patch.description, 'description', MAX_DESCRIPTION) || undefined; } if (patch.members !== undefined) set.members = cleanMembers(patch.members); const c = await collections(); const updated = await c.pods.findOneAndUpdate( { id }, { $set: set }, { returnDocument: 'after', projection: { _id: 0 } }, ); return updated ? hydrateMemberProfiles(updated) : null; } export async function deletePod(id: string): Promise { const c = await collections(); const r = await c.pods.deleteOne({ id }); return r.deletedCount > 0; } async function setMembers(id: string, members: string[]): Promise { const c = await collections(); const updated = await c.pods.findOneAndUpdate( { id }, { $set: { members, updatedAt: now() } }, { returnDocument: 'after', projection: { _id: 0 } }, ); return updated ? hydrateMemberProfiles(updated) : null; } export async function addMember(id: string, rawName: unknown): Promise { if (typeof rawName !== 'string') throw new Error('member name must be a string'); const name = rawName.trim(); if (!name) throw new Error('member name is required'); if (name.length > MAX_MEMBER_LEN) throw new Error(`member name too long (max ${MAX_MEMBER_LEN})`); const pod = await getPod(id); if (!pod) return null; if (pod.members.some((m) => m.toLowerCase() === name.toLowerCase())) return pod; if (pod.members.length >= MAX_MEMBERS) throw new Error(`too many members (max ${MAX_MEMBERS})`); return setMembers(id, [...pod.members, name]); } export async function removeMember(id: string, rawName: string): Promise { const name = String(rawName).trim().toLowerCase(); const pod = await getPod(id); if (!pod) return null; const filtered = pod.members.filter((m) => m.toLowerCase() !== name); if (filtered.length === pod.members.length) return pod; // no-op: don't write / bump updatedAt return setMembers(id, filtered); } /** * Seed the default pods into a fresh DB. Idempotent and race-safe: gated on an * empty collection, and uses per-doc upserts so concurrent startups can't * create duplicates. Won't resurrect a default a user later deletes. */ export async function seedDefaultPods(): Promise { const c = await collections(); if ((await c.pods.estimatedDocumentCount()) > 0) return; const ts = now(); const defaults: Pod[] = [ { id: 'demo-pod', name: 'Demo Pod', repo: 'karti-ai/podman', description: 'The full crew — used for the live demo.', members: ['Karti', 'Yahya', 'Ramis', 'Zander', 'Shakthi'], createdAt: ts, updatedAt: ts, }, { id: 'frontend-pod', name: 'Frontend Pod', repo: 'karti-ai/podman', description: 'PWA, capture, and PodMan card UI.', members: ['Karti', 'Zander'], createdAt: ts, updatedAt: ts, }, { id: 'backend-pod', name: 'Backend Pod', repo: 'karti-ai/podman', description: 'LiveKit agent, vision, collision detector.', members: ['Yahya', 'Ramis'], createdAt: ts, updatedAt: ts, }, ]; await Promise.all( defaults.map((p) => c.pods.updateOne({ id: p.id }, { $setOnInsert: p }, { upsert: true })), ); console.log('[pods] seeded default pods'); }