diff --git a/.woodpecker.yml b/.woodpecker.yml
index c815936..da69c51 100644
--- a/.woodpecker.yml
+++ b/.woodpecker.yml
@@ -57,12 +57,239 @@ steps:
event: push
# COMPONENT_STEPS_BELOW
+
+ # Woodpecker CI step for creator-ui React app
+ # Add this step to your .woodpecker.yml
+
+ build-creator-ui:
+ depends_on: [preflight]
+ image: woodpeckerci/plugin-kaniko
+ settings:
+ registry: registry.threesix.ai
+ repo: persona-community-2/creator-ui
+ tags:
+ - latest
+ - ${CI_COMMIT_SHA:0:8}
+ context: .
+ dockerfile: apps/creator-ui/Dockerfile
+ cache: true
+ skip-tls-verify: true
+ when:
+ branch: main
+ event: push
+
+ verify-creator-ui:
+ depends_on: [build-creator-ui]
+ image: alpine/curl
+ failure: ignore
+ commands:
+ - |
+ TAG="${CI_COMMIT_SHA:0:8}"
+ REPO="persona-community-2/creator-ui"
+ REGISTRY="registry.threesix.ai"
+ echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
+ HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
+ --insecure \
+ --connect-timeout 10 \
+ --max-time 15 \
+ "https://$REGISTRY/v2/$REPO/manifests/$TAG" \
+ -H "Accept: application/vnd.docker.distribution.manifest.v2+json")
+ if [ "$HTTP_CODE" = "200" ]; then
+ echo "==> Image verified: $REGISTRY/$REPO:$TAG"
+ exit 0
+ elif [ "$HTTP_CODE" = "404" ]; then
+ echo "==> WARNING: Image $REGISTRY/$REPO:$TAG not found in registry"
+ echo " Build may have failed. Deploy will be skipped."
+ exit 1
+ else
+ echo "==> WARNING: Registry check returned HTTP $HTTP_CODE"
+ exit 0
+ fi
+ when:
+ branch: main
+ event: push
+
+ deploy-creator-ui:
+ depends_on: [verify-creator-ui]
+ image: bitnami/kubectl:latest
+ commands:
+ - echo "==> Deploying creator-ui with image tag ${CI_COMMIT_SHA:0:8}"
+ - kubectl set image deployment/persona-community-2-creator-ui persona-community-2-creator-ui=registry.threesix.ai/persona-community-2/creator-ui:${CI_COMMIT_SHA:0:8} -n projects
+ - kubectl patch deployment/persona-community-2-creator-ui -n projects -p '{"spec":{"replicas":1}}'
+ - |
+ echo "==> Verifying deployment persona-community-2-creator-ui"
+ ACTUAL_IMAGE=$(kubectl get deployment/persona-community-2-creator-ui -n projects -o jsonpath='{.spec.template.spec.containers[0].image}')
+ EXPECTED_IMAGE="registry.threesix.ai/persona-community-2/creator-ui:${CI_COMMIT_SHA:0:8}"
+ if [ "$ACTUAL_IMAGE" != "$EXPECTED_IMAGE" ]; then
+ echo "FATAL: Image mismatch after deploy"
+ echo " expected: $EXPECTED_IMAGE"
+ echo " actual: $ACTUAL_IMAGE"
+ exit 1
+ fi
+ echo "==> Image confirmed: $ACTUAL_IMAGE"
+ echo "==> Waiting for rollout (timeout 120s)..."
+ kubectl rollout status deployment/persona-community-2-creator-ui -n projects --timeout=120s
+ when:
+ branch: main
+ event: push
+
+ # Woodpecker CI step for media-worker worker
+ # Add this step to your .woodpecker.yml
+
+ build-media-worker:
+ depends_on: [preflight]
+ image: woodpeckerci/plugin-kaniko
+ settings:
+ registry: registry.threesix.ai
+ repo: persona-community-2/media-worker
+ tags:
+ - latest
+ - ${CI_COMMIT_SHA:0:8}
+ context: .
+ dockerfile: workers/media-worker/Dockerfile
+ cache: true
+ skip-tls-verify: true
+ when:
+ branch: main
+ event: push
+
+ verify-media-worker:
+ depends_on: [build-media-worker]
+ image: alpine/curl
+ failure: ignore
+ commands:
+ - |
+ TAG="${CI_COMMIT_SHA:0:8}"
+ REPO="persona-community-2/media-worker"
+ REGISTRY="registry.threesix.ai"
+ echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
+ HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
+ --insecure \
+ --connect-timeout 10 \
+ --max-time 15 \
+ "https://$REGISTRY/v2/$REPO/manifests/$TAG" \
+ -H "Accept: application/vnd.docker.distribution.manifest.v2+json")
+ if [ "$HTTP_CODE" = "200" ]; then
+ echo "==> Image verified: $REGISTRY/$REPO:$TAG"
+ exit 0
+ elif [ "$HTTP_CODE" = "404" ]; then
+ echo "==> WARNING: Image $REGISTRY/$REPO:$TAG not found in registry"
+ echo " Build may have failed. Deploy will be skipped."
+ exit 1
+ else
+ echo "==> WARNING: Registry check returned HTTP $HTTP_CODE"
+ exit 0
+ fi
+ when:
+ branch: main
+ event: push
+
+ deploy-media-worker:
+ depends_on: [verify-media-worker]
+ image: bitnami/kubectl:latest
+ commands:
+ - echo "==> Deploying media-worker with image tag ${CI_COMMIT_SHA:0:8}"
+ - kubectl set image deployment/persona-community-2-media-worker persona-community-2-media-worker=registry.threesix.ai/persona-community-2/media-worker:${CI_COMMIT_SHA:0:8} -n projects
+ - kubectl patch deployment/persona-community-2-media-worker -n projects -p '{"spec":{"replicas":1}}'
+ - |
+ echo "==> Verifying deployment persona-community-2-media-worker"
+ ACTUAL_IMAGE=$(kubectl get deployment/persona-community-2-media-worker -n projects -o jsonpath='{.spec.template.spec.containers[0].image}')
+ EXPECTED_IMAGE="registry.threesix.ai/persona-community-2/media-worker:${CI_COMMIT_SHA:0:8}"
+ if [ "$ACTUAL_IMAGE" != "$EXPECTED_IMAGE" ]; then
+ echo "FATAL: Image mismatch after deploy"
+ echo " expected: $EXPECTED_IMAGE"
+ echo " actual: $ACTUAL_IMAGE"
+ exit 1
+ fi
+ echo "==> Image confirmed: $ACTUAL_IMAGE"
+ echo "==> Waiting for rollout (timeout 120s)..."
+ kubectl rollout status deployment/persona-community-2-media-worker -n projects --timeout=120s
+ when:
+ branch: main
+ event: push
+
+ # Woodpecker CI step for persona-api service
+ # Add this step to your .woodpecker.yml
+ # NOTE: verify step is replicated in all component templates (service, app-react,
+ # app-astro, app-nextjs, worker). Update all 5 if changing the verify logic.
+
+ build-persona-api:
+ depends_on: [preflight]
+ image: woodpeckerci/plugin-kaniko
+ settings:
+ registry: registry.threesix.ai
+ repo: persona-community-2/persona-api
+ tags:
+ - latest
+ - ${CI_COMMIT_SHA:0:8}
+ context: .
+ dockerfile: services/persona-api/Dockerfile
+ cache: true
+ skip-tls-verify: true
+ when:
+ branch: main
+ event: push
+
+ verify-persona-api:
+ depends_on: [build-persona-api]
+ image: alpine/curl
+ failure: ignore
+ commands:
+ - |
+ TAG="${CI_COMMIT_SHA:0:8}"
+ REPO="persona-community-2/persona-api"
+ REGISTRY="registry.threesix.ai"
+ echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
+ HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
+ --insecure \
+ --connect-timeout 10 \
+ --max-time 15 \
+ "https://$REGISTRY/v2/$REPO/manifests/$TAG" \
+ -H "Accept: application/vnd.docker.distribution.manifest.v2+json")
+ if [ "$HTTP_CODE" = "200" ]; then
+ echo "==> Image verified: $REGISTRY/$REPO:$TAG"
+ exit 0
+ elif [ "$HTTP_CODE" = "404" ]; then
+ echo "==> WARNING: Image $REGISTRY/$REPO:$TAG not found in registry"
+ echo " Build may have failed. Deploy will be skipped."
+ exit 1
+ else
+ echo "==> WARNING: Registry check returned HTTP $HTTP_CODE"
+ exit 0
+ fi
+ when:
+ branch: main
+ event: push
+
+ deploy-persona-api:
+ depends_on: [verify-persona-api]
+ image: bitnami/kubectl:latest
+ commands:
+ - echo "==> Deploying persona-api with image tag ${CI_COMMIT_SHA:0:8}"
+ - kubectl set image deployment/persona-community-2-persona-api persona-community-2-persona-api=registry.threesix.ai/persona-community-2/persona-api:${CI_COMMIT_SHA:0:8} -n projects
+ - kubectl patch deployment/persona-community-2-persona-api -n projects -p '{"spec":{"replicas":1}}'
+ - |
+ echo "==> Verifying deployment persona-community-2-persona-api"
+ ACTUAL_IMAGE=$(kubectl get deployment/persona-community-2-persona-api -n projects -o jsonpath='{.spec.template.spec.containers[0].image}')
+ EXPECTED_IMAGE="registry.threesix.ai/persona-community-2/persona-api:${CI_COMMIT_SHA:0:8}"
+ if [ "$ACTUAL_IMAGE" != "$EXPECTED_IMAGE" ]; then
+ echo "FATAL: Image mismatch after deploy"
+ echo " expected: $EXPECTED_IMAGE"
+ echo " actual: $ACTUAL_IMAGE"
+ exit 1
+ fi
+ echo "==> Image confirmed: $ACTUAL_IMAGE"
+ echo "==> Waiting for rollout (timeout 120s)..."
+ kubectl rollout status deployment/persona-community-2-persona-api -n projects --timeout=120s
+ when:
+ branch: main
+ event: push
# Do not remove the marker above - component steps are inserted here
# Sync point after all component builds/deploys complete
# depends_on is updated dynamically when components are added
build-complete:
- depends_on: [preflight] # BUILD_COMPLETE_DEPS
+ depends_on: [preflight, deploy-persona-api, deploy-media-worker, deploy-creator-ui] # BUILD_COMPLETE_DEPS
image: alpine:3.19
commands:
- echo "All component builds complete"
diff --git a/CLAUDE.md b/CLAUDE.md
index 2dab7a3..ad98993 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -94,4 +94,9 @@ persona-community-2/
## Components
-
+| Component | Type | Path |
+|-----------|------|------|
+| **persona-api** | API service | `services/persona-api/` |
+| **media-worker** | Background worker | `workers/media-worker/` |
+| **creator-ui** | React app | `apps/creator-ui/` |
+
diff --git a/Procfile b/Procfile
index 8e897c6..322b2dd 100644
--- a/Procfile
+++ b/Procfile
@@ -1,2 +1,5 @@
# Local development processes
# Components will be added below as they're created
+persona-api: cd services/persona-api && make run
+media-worker: cd workers/media-worker && make run
+creator-ui: cd apps/creator-ui && npm run dev
diff --git a/apps/creator-ui/.eslintrc.cjs b/apps/creator-ui/.eslintrc.cjs
new file mode 100644
index 0000000..4c99537
--- /dev/null
+++ b/apps/creator-ui/.eslintrc.cjs
@@ -0,0 +1,18 @@
+module.exports = {
+ root: true,
+ env: { browser: true, es2020: true },
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react-hooks/recommended',
+ ],
+ ignorePatterns: ['dist', '.eslintrc.cjs'],
+ parser: '@typescript-eslint/parser',
+ plugins: ['react-refresh'],
+ rules: {
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ },
+};
diff --git a/apps/creator-ui/Dockerfile b/apps/creator-ui/Dockerfile
new file mode 100644
index 0000000..27d2fc9
--- /dev/null
+++ b/apps/creator-ui/Dockerfile
@@ -0,0 +1,34 @@
+# Build stage - using pnpm for workspace dependency resolution
+FROM node:20-alpine AS build
+
+# Install pnpm
+RUN npm install -g pnpm
+
+WORKDIR /workspace
+
+# Copy workspace configuration files
+COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* ./
+
+# Copy shared packages (required for workspace:* dependencies)
+COPY packages/ ./packages/
+
+# Copy the app component
+COPY apps/creator-ui/ ./apps/creator-ui/
+
+# Install dependencies using pnpm (resolves workspace:* correctly)
+RUN pnpm install --frozen-lockfile || pnpm install
+
+# Build the app
+WORKDIR /workspace/apps/creator-ui
+RUN pnpm build
+
+# Production stage
+FROM nginx:alpine
+
+# Copy built assets
+COPY --from=build /workspace/apps/creator-ui/dist /usr/share/nginx/html
+COPY apps/creator-ui/nginx.conf /etc/nginx/conf.d/default.conf
+
+EXPOSE 3001
+
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/apps/creator-ui/component.yaml b/apps/creator-ui/component.yaml
new file mode 100644
index 0000000..4b6f5db
--- /dev/null
+++ b/apps/creator-ui/component.yaml
@@ -0,0 +1,6 @@
+name: creator-ui
+type: app
+port: 3001
+path: apps/creator-ui
+stack: react
+dependencies: []
diff --git a/apps/creator-ui/index.html b/apps/creator-ui/index.html
new file mode 100644
index 0000000..cbbee85
--- /dev/null
+++ b/apps/creator-ui/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ creator-ui | persona-community-2
+
+
+
+
+
+
diff --git a/apps/creator-ui/nginx.conf b/apps/creator-ui/nginx.conf
new file mode 100644
index 0000000..1e23deb
--- /dev/null
+++ b/apps/creator-ui/nginx.conf
@@ -0,0 +1,26 @@
+server {
+ listen 3001;
+ server_name localhost;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # Gzip compression
+ gzip on;
+ gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
+
+ # Cache static assets
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+
+ # SPA fallback
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Security headers
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-XSS-Protection "1; mode=block" always;
+}
diff --git a/apps/creator-ui/package.json b/apps/creator-ui/package.json
new file mode 100644
index 0000000..957247a
--- /dev/null
+++ b/apps/creator-ui/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "creator-ui",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite --port 3001",
+ "build": "tsc && vite build",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview --port 3001",
+ "format": "prettier --write src/"
+ },
+ "dependencies": {
+ "@persona-community-2/ai-client": "workspace:*",
+ "@persona-community-2/api-client": "workspace:*",
+ "@persona-community-2/auth": "workspace:*",
+ "@persona-community-2/layout": "workspace:*",
+ "@persona-community-2/logger": "workspace:*",
+ "@persona-community-2/realtime": "workspace:*",
+ "@persona-community-2/ui": "workspace:*",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.23.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@typescript-eslint/eslint-plugin": "^7.13.1",
+ "@typescript-eslint/parser": "^7.13.1",
+ "@vitejs/plugin-react": "^4.3.1",
+ "autoprefixer": "^10.4.19",
+ "eslint": "^8.57.0",
+ "eslint-plugin-react-hooks": "^4.6.2",
+ "eslint-plugin-react-refresh": "^0.4.7",
+ "postcss": "^8.4.38",
+ "prettier": "^3.3.2",
+ "tailwindcss": "^3.4.4",
+ "typescript": "^5.5.3",
+ "vite": "^5.4.1"
+ }
+}
diff --git a/apps/creator-ui/postcss.config.cjs b/apps/creator-ui/postcss.config.cjs
new file mode 100644
index 0000000..12a703d
--- /dev/null
+++ b/apps/creator-ui/postcss.config.cjs
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/apps/creator-ui/public/vite.svg b/apps/creator-ui/public/vite.svg
new file mode 100644
index 0000000..6a41099
--- /dev/null
+++ b/apps/creator-ui/public/vite.svg
@@ -0,0 +1 @@
+
diff --git a/apps/creator-ui/src/App.tsx b/apps/creator-ui/src/App.tsx
new file mode 100644
index 0000000..eb225a1
--- /dev/null
+++ b/apps/creator-ui/src/App.tsx
@@ -0,0 +1,449 @@
+import { useState, useEffect } from 'react';
+import { Routes, Route, useLocation, useNavigate, useSearchParams, Link } from 'react-router-dom';
+import { AuthProvider, useAuth, ProtectedRoute } from '@persona-community-2/auth';
+import { DashboardShell, Sidebar, Header, type NavItem } from '@persona-community-2/layout';
+import {
+ Button,
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ Badge,
+ Home,
+ ImageIcon,
+ Users,
+ Settings,
+ BarChart3,
+ MessageSquare,
+ Sparkles,
+ Loader2,
+ AlertCircle,
+} from '@persona-community-2/ui';
+import { LoginPage } from './pages/LoginPage';
+import { RegisterPage } from './pages/RegisterPage';
+import { ForgotPasswordPage } from './pages/ForgotPasswordPage';
+import { ResetPasswordPage } from './pages/ResetPasswordPage';
+import { VerifyEmailPage } from './pages/VerifyEmailPage';
+import { SessionsPage } from './pages/SessionsPage';
+import { ChatPage } from './pages/ChatPage';
+import { GeneratePage } from './pages/GeneratePage';
+import { MediaPage } from './pages/MediaPage';
+
+const navItems: NavItem[] = [
+ { label: 'Dashboard', href: '/', icon: Home },
+ { label: 'Chat', href: '/chat', icon: MessageSquare },
+ { label: 'Generate', href: '/generate', icon: Sparkles },
+ { label: 'Media', href: '/media', icon: ImageIcon },
+ { label: 'Analytics', href: '/analytics', icon: BarChart3 },
+ { label: 'Users', href: '/users', icon: Users, badge: '12' },
+ { label: 'Settings', href: '/settings', icon: Settings },
+];
+
+const pageTitles: Record = {
+ '/': 'Dashboard',
+ '/chat': 'Chat',
+ '/generate': 'Generate',
+ '/media': 'Media',
+ '/analytics': 'Analytics',
+ '/users': 'Users',
+ '/settings': 'Settings',
+ '/settings/sessions': 'Sessions',
+ '/settings/verify-email': 'Verify Email',
+};
+
+function DashboardPage() {
+ return (
+
+
+
+ Welcome to creator-ui
+
+ This is part of the{' '}
+
+ persona-community-2
+ {' '}
+ monorepo, using the shared UI library and layout components.
+
+
+
+
+ Get Started
+ Documentation
+
+
+
+
+
+
+
+ Total Users
+ 1,234
+
+
+ +12% from last month
+
+
+
+
+
+ Active Sessions
+ 567
+
+
+ Live
+
+
+
+
+
+ API Requests
+ 89.2k
+
+
+ High traffic
+
+
+
+
+
+ Edit this file at{' '}
+
+ apps/creator-ui/src/App.tsx
+
+
+
+ );
+}
+
+function UsersPage() {
+ return (
+
+
+
+
All Users
+
Manage your team members and their roles.
+
+
Add User
+
+
+
+ {[
+ { name: 'Alice Chen', role: 'Admin', status: 'Active' },
+ { name: 'Bob Martinez', role: 'Editor', status: 'Active' },
+ { name: 'Carol Singh', role: 'Viewer', status: 'Invited' },
+ ].map((user) => (
+
+
+ {user.name}
+ {user.role}
+
+
+
+ {user.status}
+
+
+
+ ))}
+
+
+ );
+}
+
+function AnalyticsPage() {
+ return (
+
+
+
+
+ Page Views
+ 24.5k
+
+
+ +8% this week
+
+
+
+
+
+ Bounce Rate
+ 32%
+
+
+ -3% improvement
+
+
+
+
+
+ Avg. Session
+ 4m 12s
+
+
+ Stable
+
+
+
+
+
+
+ Traffic Sources
+ Where your visitors are coming from this month.
+
+
+
+ {[
+ { source: 'Direct', visits: '8,421', pct: 34 },
+ { source: 'Search', visits: '6,312', pct: 26 },
+ { source: 'Social', visits: '5,105', pct: 21 },
+ { source: 'Referral', visits: '4,662', pct: 19 },
+ ].map((row) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+function SettingsPage() {
+ const { logout } = useAuth();
+ const navigate = useNavigate();
+
+ const handleLogout = async () => {
+ await logout();
+ navigate('/login');
+ };
+
+ return (
+
+
+
+ General
+ Manage your application settings and preferences.
+
+
+
+ {[
+ { label: 'Application Name', value: 'persona-community-2' },
+ { label: 'Environment', value: 'Production' },
+ { label: 'Region', value: 'US West' },
+ ].map((setting) => (
+
+ {setting.label}
+ {setting.value}
+
+ ))}
+
+
+
+
+
+
+ Account
+ Manage your account settings.
+
+
+
+
+
Sign Out
+
Sign out of your account on this device.
+
+
Sign Out
+
+
+
+
+
+
+ Danger Zone
+ Irreversible actions for your application.
+
+
+
+
+
Delete Application
+
Permanently remove this application and all its data.
+
+
Delete
+
+
+
+
+ );
+}
+
+function LoadingScreen() {
+ return (
+
+ );
+}
+
+function MagicLinkCallbackPage() {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const { loginWithMagicLink } = useAuth();
+ const [error, setError] = useState(null);
+ const [verifying, setVerifying] = useState(true);
+
+ useEffect(() => {
+ const token = searchParams.get('token');
+ const email = searchParams.get('email');
+
+ if (!token || !email) {
+ setError('Invalid magic link. Missing token or email.');
+ setVerifying(false);
+ return;
+ }
+
+ loginWithMagicLink({ email, token })
+ .then(() => {
+ navigate('/', { replace: true });
+ })
+ .catch((err) => {
+ setError(err instanceof Error ? err.message : 'Magic link verification failed.');
+ setVerifying(false);
+ });
+ }, [searchParams, loginWithMagicLink, navigate]);
+
+ return (
+
+
+
+ {verifying ? 'Verifying Magic Link' : 'Verification Failed'}
+
+ {verifying
+ ? 'Please wait while we verify your magic link...'
+ : 'We could not verify your magic link.'}
+
+
+
+ {verifying ? (
+
+ ) : (
+ <>
+
+
+ Back to Login
+
+ >
+ )}
+
+
+
+ );
+}
+
+function AppLayout() {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { user } = useAuth();
+
+ const itemsWithActive = navItems.map((item) => ({
+ ...item,
+ active: location.pathname === item.href,
+ }));
+
+ const pageTitle = pageTitles[location.pathname] || 'Dashboard';
+
+ return (
+ persona-community-2
+ }
+ items={itemsWithActive}
+ onNavigate={(href) => navigate(href)}
+ footer={
+
+ {user?.email || 'v0.0.1'}
+
+ }
+ />
+ }
+ header={
+
+ }
+ >
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
+
+function AppRoutes() {
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ return (
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ {
+ // Navigate to login, storing current location for redirect after login
+ navigate(path, { state: { from: location.pathname }, replace: true });
+ }}
+ fallback={ }
+ >
+
+
+ }
+ />
+
+ );
+}
+
+function App() {
+ // Determine API base URL from environment or current origin
+ const apiBaseUrl = import.meta.env.VITE_API_URL || '';
+
+ return (
+
+
+
+ );
+}
+
+export default App;
diff --git a/apps/creator-ui/src/index.css b/apps/creator-ui/src/index.css
new file mode 100644
index 0000000..062ec2b
--- /dev/null
+++ b/apps/creator-ui/src/index.css
@@ -0,0 +1,6 @@
+/* Import design system tokens */
+@import '@persona-community-2/ui/styles';
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/apps/creator-ui/src/lib/logger.ts b/apps/creator-ui/src/lib/logger.ts
new file mode 100644
index 0000000..524e021
--- /dev/null
+++ b/apps/creator-ui/src/lib/logger.ts
@@ -0,0 +1,11 @@
+import { createLogger, installGlobalHandlers } from '@persona-community-2/logger';
+
+export const logger = createLogger({
+ level: import.meta.env.DEV ? 'debug' : 'info',
+ service: 'creator-ui',
+ // Set endpoint to send logs to your backend:
+ // endpoint: '/api/logs',
+});
+
+// Install global error handlers
+installGlobalHandlers(logger);
diff --git a/apps/creator-ui/src/main.tsx b/apps/creator-ui/src/main.tsx
new file mode 100644
index 0000000..7757f92
--- /dev/null
+++ b/apps/creator-ui/src/main.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+import App from './App.tsx';
+import './index.css';
+import './lib/logger';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+);
diff --git a/apps/creator-ui/src/pages/ChatPage.tsx b/apps/creator-ui/src/pages/ChatPage.tsx
new file mode 100644
index 0000000..440b6d0
--- /dev/null
+++ b/apps/creator-ui/src/pages/ChatPage.tsx
@@ -0,0 +1,190 @@
+import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
+import { useAuth } from '@persona-community-2/auth';
+import { useChat } from '@persona-community-2/realtime';
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ ChatBubble,
+ ChatInput,
+ Badge,
+ ProviderBadge,
+} from '@persona-community-2/ui';
+
+interface TimelineMessage {
+ id: string;
+ content: string;
+ role: 'user' | 'assistant' | 'system';
+ timestamp: Date;
+ provider?: string;
+ isStreaming?: boolean;
+}
+
+export function ChatPage() {
+ const { user, getToken } = useAuth();
+ const messagesEndRef = useRef(null);
+
+ // API base URL from environment
+ const apiBaseUrl = import.meta.env.VITE_API_URL || '';
+
+ const authHeaders = useMemo(() => {
+ const token = getToken();
+ return token ? { Authorization: `Bearer ${token}` } : undefined;
+ }, [getToken]);
+
+ const {
+ messages,
+ aiMessages,
+ streamingMessages,
+ sendMessage,
+ connectionState,
+ onlineUsers,
+ } = useChat({
+ endpoint: `${apiBaseUrl}/api/{{SERVICE_NAME}}/chat/messages`,
+ sseEndpoint: `${apiBaseUrl}/api/{{SERVICE_NAME}}/events`,
+ channel: 'channel:general',
+ userId: user?.id || 'anonymous',
+ userName: user?.name || user?.email || 'Anonymous',
+ headers: authHeaders,
+ });
+
+ // Track send errors for user feedback
+ const [sendError, setSendError] = useState(null);
+
+ // Merge user messages + AI messages into a single sorted timeline
+ const timeline = useMemo(() => {
+ const combined: TimelineMessage[] = [];
+
+ for (const msg of messages) {
+ combined.push({
+ id: msg.id,
+ content: msg.content,
+ role: msg.userId === user?.id ? 'user' : 'assistant',
+ timestamp: new Date(msg.timestamp),
+ });
+ }
+
+ for (const msg of aiMessages) {
+ combined.push({
+ id: msg.id,
+ content: msg.content,
+ role: 'assistant',
+ timestamp: new Date(msg.timestamp),
+ provider: msg.provider,
+ });
+ }
+
+ // Add in-progress streaming messages
+ for (const [, stream] of streamingMessages) {
+ combined.push({
+ id: stream.streamId,
+ content: stream.content,
+ role: 'assistant',
+ timestamp: new Date(stream.timestamp),
+ isStreaming: true,
+ });
+ }
+
+ combined.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
+ return combined;
+ }, [messages, aiMessages, streamingMessages, user?.id]);
+
+ // Handle sending a message (wraps async sendMessage for ChatInput)
+ const handleSendMessage = useCallback((content: string) => {
+ sendMessage(content).catch(() => {
+ setSendError('Failed to send message. Please try again.');
+ setTimeout(() => setSendError(null), 3000);
+ });
+ }, [sendMessage]);
+
+ // Auto-scroll to bottom when new messages arrive
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [timeline]);
+
+ const connectionBadge = () => {
+ switch (connectionState) {
+ case 'connected':
+ return Connected ;
+ case 'connecting':
+ return Connecting... ;
+ case 'disconnected':
+ return Disconnected ;
+ case 'error':
+ return Error ;
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+
+
+
+ AI Chat
+
+ Chat with AI in real-time
+
+
+
+
+ {onlineUsers.length} online
+
+ {connectionBadge()}
+
+
+
+
+ {/* Messages area */}
+
+ {timeline.length === 0 ? (
+
+
+ No messages yet. Start the conversation!
+
+
+ ) : (
+ timeline.map((msg) => (
+
+
+ {msg.provider && (
+
+ )}
+
+ ))
+ )}
+
+
+
+ {/* Input area */}
+
+ {sendError && (
+
+ {sendError}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/creator-ui/src/pages/ForgotPasswordPage.tsx b/apps/creator-ui/src/pages/ForgotPasswordPage.tsx
new file mode 100644
index 0000000..05339b0
--- /dev/null
+++ b/apps/creator-ui/src/pages/ForgotPasswordPage.tsx
@@ -0,0 +1,110 @@
+import { useState } from 'react';
+import { Link } from 'react-router-dom';
+import {
+ Button,
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ CardFooter,
+ FormField,
+ Alert,
+ AlertDescription,
+ Loader2,
+} from '@persona-community-2/ui';
+
+export function ForgotPasswordPage() {
+ const [isLoading, setIsLoading] = useState(false);
+ const [sent, setSent] = useState(false);
+ const [error, setError] = useState(null);
+
+ const apiPrefix = import.meta.env.VITE_API_URL || '';
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setIsLoading(true);
+
+ const formData = new FormData(e.currentTarget);
+ const email = formData.get('email') as string;
+
+ try {
+ const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/forgot-password`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email }),
+ });
+
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new Error(body.error?.message || body.message || 'Request failed');
+ }
+
+ setSent(true);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'An error occurred');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Reset your password
+
+ {sent
+ ? 'Check your email for a reset link'
+ : "Enter your email and we'll send you a reset link"}
+
+
+
+ {!sent ? (
+
+ ) : (
+
+
+ If an account exists with that email, you will receive a password reset link.
+
+
+ In dev mode, check the server console for the reset token.
+
+
+ Back to sign in
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/creator-ui/src/pages/GeneratePage.tsx b/apps/creator-ui/src/pages/GeneratePage.tsx
new file mode 100644
index 0000000..d266117
--- /dev/null
+++ b/apps/creator-ui/src/pages/GeneratePage.tsx
@@ -0,0 +1,248 @@
+import { useState, useMemo } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAuth } from '@persona-community-2/auth';
+import { useMediaGeneration } from '@persona-community-2/realtime';
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ Button,
+ FormField,
+ Select,
+ SelectTrigger,
+ SelectValue,
+ SelectContent,
+ SelectItem,
+ ImageGrid,
+ VideoGrid,
+ GenerationProgress,
+ ProviderBadge,
+ Loader2,
+} from '@persona-community-2/ui';
+
+type GenerateMode = 'image' | 'video';
+
+interface ImageResult {
+ images: Array<{ data: string; isUrl: boolean; seed?: number }>;
+ provider: string;
+ latencyMs: number;
+}
+
+interface VideoResult {
+ videos: Array<{ data: string; isUrl: boolean; mimeType: string }>;
+ provider: string;
+ latencyMs: number;
+}
+
+export function GeneratePage() {
+ const { user, getToken } = useAuth();
+ const navigate = useNavigate();
+ const [mode, setMode] = useState('image');
+ const [prompt, setPrompt] = useState('');
+ const [aspectRatio, setAspectRatio] = useState('1:1');
+ const [count, setCount] = useState(1);
+ const [duration, setDuration] = useState('5s');
+
+ const apiPrefix = import.meta.env.VITE_API_URL || '';
+
+ const authHeaders = useMemo(() => {
+ const token = getToken();
+ return token ? { Authorization: `Bearer ${token}` } : undefined;
+ }, [getToken]);
+
+ const imageGen = useMediaGeneration({
+ endpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/generate/image`,
+ sseEndpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/events`,
+ userId: user?.id || 'anonymous',
+ headers: authHeaders,
+ });
+
+ const videoGen = useMediaGeneration({
+ endpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/generate/video`,
+ sseEndpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/events`,
+ userId: user?.id || 'anonymous',
+ headers: authHeaders,
+ });
+
+ const gen = mode === 'image' ? imageGen : videoGen;
+ const isGenerating = gen.status === 'pending' || gen.status === 'generating';
+
+ const handleGenerate = async () => {
+ if (!prompt.trim()) return;
+ gen.reset();
+ const request = mode === 'image'
+ ? { prompt, count, aspectRatio }
+ : { prompt, aspectRatio, duration };
+ await gen.generate(request);
+ };
+
+ return (
+
+
+
+ AI Generation
+
+ Generate images and videos using AI (Gemini / LaoZhang)
+
+
+
+ {/* Mode toggle */}
+
+ setMode('image')}
+ disabled={isGenerating}
+ >
+ Images
+
+ {
+ setMode('video');
+ if (aspectRatio === '1:1') setAspectRatio('16:9');
+ }}
+ disabled={isGenerating}
+ >
+ Video
+
+
+
+ setPrompt(e.target.value)}
+ placeholder={
+ mode === 'image'
+ ? 'A serene mountain landscape at sunset...'
+ : 'A cat playing piano in a jazz club...'
+ }
+ />
+
+
+
+ Aspect Ratio
+ setAspectRatio(v)}>
+
+
+
+
+ {mode === 'image' && Square (1:1) }
+ Landscape (16:9)
+ Portrait (9:16)
+
+
+
+
+ {mode === 'image' ? (
+
+ Count
+ setCount(Number(v))}>
+
+
+
+
+ 1 image
+ 2 images
+ 4 images
+
+
+
+ ) : (
+
+ Duration
+ setDuration(v)}>
+
+
+
+
+ 5 seconds
+ 10 seconds
+
+
+
+ )}
+
+
+
+ {isGenerating && }
+ {isGenerating ? 'Generating...' : `Generate ${mode === 'image' ? 'Images' : 'Video'}`}
+
+
+
+
+ {isGenerating && (
+
+
+
+ {gen.message || 'Starting...'}
+ {gen.progress}%
+
+
+ {gen.sseState !== 'connected' && (
+
+ SSE {gen.sseState} — events may be delayed
+
+ )}
+
+
+ )}
+
+ {gen.status === 'failed' && gen.error && (
+
+
+ {gen.error}
+
+
+ )}
+
+ {gen.status === 'complete' && imageGen.result && mode === 'image' && (
+
+
+ Results
+
+ {imageGen.result.provider &&
}
+
navigate('/media')}>
+ View in Library
+
+
+
+
+ ({
+ src: img.isUrl ? img.data : `data:image/png;base64,${img.data}`,
+ alt: prompt,
+ }))}
+ columns={imageGen.result.images.length > 1 ? 2 : 1}
+ />
+
+
+ )}
+
+ {gen.status === 'complete' && videoGen.result && mode === 'video' && (
+
+
+ Results
+
+ {videoGen.result.provider &&
}
+
navigate('/media')}>
+ View in Library
+
+
+
+
+ ({
+ src: vid.isUrl ? vid.data : `data:${vid.mimeType};base64,${vid.data}`,
+ mimeType: vid.mimeType,
+ alt: prompt,
+ }))}
+ />
+
+
+ )}
+
+ );
+}
diff --git a/apps/creator-ui/src/pages/LoginPage.tsx b/apps/creator-ui/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..030581d
--- /dev/null
+++ b/apps/creator-ui/src/pages/LoginPage.tsx
@@ -0,0 +1,270 @@
+import { useState } from 'react';
+import { useNavigate, useLocation, Link } from 'react-router-dom';
+import { useAuth } from '@persona-community-2/auth';
+import {
+ Button,
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ CardFooter,
+ FormField,
+ useFormErrors,
+ Alert,
+ AlertDescription,
+ Loader2,
+} from '@persona-community-2/ui';
+import { isApiClientError } from '@persona-community-2/api-client';
+
+type LoginTab = 'password' | 'otp' | 'magic-link';
+
+export function LoginPage() {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const { login, sendOTP, loginWithOTP, sendMagicLink, isLoading } = useAuth();
+ const { setErrors, clearErrors, getError } = useFormErrors();
+ const [generalError, setGeneralError] = useState(null);
+ const [activeTab, setActiveTab] = useState('password');
+ const [otpSent, setOtpSent] = useState(false);
+ const [otpEmail, setOtpEmail] = useState('');
+ const [magicLinkSent, setMagicLinkSent] = useState(false);
+
+ const from = (location.state as { from?: string })?.from || '/';
+
+ const handlePasswordLogin = async (e: React.FormEvent) => {
+ e.preventDefault();
+ clearErrors();
+ setGeneralError(null);
+
+ const formData = new FormData(e.currentTarget);
+ const email = formData.get('email') as string;
+ const password = formData.get('password') as string;
+
+ try {
+ await login({ email, password });
+ navigate(from, { replace: true });
+ } catch (error) {
+ if (isApiClientError(error)) {
+ if (error.isValidationError()) {
+ setErrors(error.getFieldErrors());
+ } else {
+ setGeneralError(error.message);
+ }
+ } else if (error instanceof Error) {
+ setGeneralError(error.message);
+ } else {
+ setGeneralError('An unexpected error occurred.');
+ }
+ }
+ };
+
+ const handleSendOTP = async (e: React.FormEvent) => {
+ e.preventDefault();
+ clearErrors();
+ setGeneralError(null);
+
+ const formData = new FormData(e.currentTarget);
+ const email = formData.get('email') as string;
+
+ try {
+ await sendOTP(email);
+ setOtpEmail(email);
+ setOtpSent(true);
+ } catch (error) {
+ setGeneralError(error instanceof Error ? error.message : 'Failed to send code');
+ }
+ };
+
+ const handleVerifyOTP = async (e: React.FormEvent) => {
+ e.preventDefault();
+ clearErrors();
+ setGeneralError(null);
+
+ const formData = new FormData(e.currentTarget);
+ const code = formData.get('code') as string;
+
+ try {
+ await loginWithOTP({ email: otpEmail, code });
+ navigate(from, { replace: true });
+ } catch (error) {
+ setGeneralError(error instanceof Error ? error.message : 'Invalid code');
+ }
+ };
+
+ const handleSendMagicLink = async (e: React.FormEvent) => {
+ e.preventDefault();
+ clearErrors();
+ setGeneralError(null);
+
+ const formData = new FormData(e.currentTarget);
+ const email = formData.get('email') as string;
+
+ try {
+ await sendMagicLink(email);
+ setMagicLinkSent(true);
+ } catch (error) {
+ setGeneralError(error instanceof Error ? error.message : 'Failed to send link');
+ }
+ };
+
+ const tabClass = (tab: LoginTab) =>
+ `flex-1 py-2 text-sm font-medium text-center rounded-md transition-colors ${
+ activeTab === tab
+ ? 'bg-[var(--surface-100)] text-[var(--text-primary)] shadow-sm'
+ : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
+ }`;
+
+ return (
+
+
+
+ Welcome back
+ Sign in to your persona-community-2 account
+
+
+
+ {/* Tab switcher */}
+
+ { setActiveTab('password'); clearErrors(); setGeneralError(null); }}>
+ Password
+
+ { setActiveTab('otp'); clearErrors(); setGeneralError(null); setOtpSent(false); }}>
+ OTP
+
+ { setActiveTab('magic-link'); clearErrors(); setGeneralError(null); setMagicLinkSent(false); }}>
+ Magic Link
+
+
+
+ {generalError && (
+
+ {generalError}
+
+ )}
+
+ {/* Password tab */}
+ {activeTab === 'password' && (
+
+ )}
+
+ {/* OTP tab */}
+ {activeTab === 'otp' && !otpSent && (
+
+ )}
+
+ {activeTab === 'otp' && otpSent && (
+
+ )}
+
+ {/* Magic Link tab */}
+ {activeTab === 'magic-link' && !magicLinkSent && (
+
+ )}
+
+ {activeTab === 'magic-link' && magicLinkSent && (
+
+
Check your email
+
+ We sent a sign-in link to your email. Click it to continue.
+
+
+ In dev mode, check the server console for the link token.
+
+
+ )}
+
+
+
+
+ Don't have an account?{' '}
+
+ Sign up
+
+
+
+
+
+ );
+}
diff --git a/apps/creator-ui/src/pages/MediaPage.tsx b/apps/creator-ui/src/pages/MediaPage.tsx
new file mode 100644
index 0000000..8e3b081
--- /dev/null
+++ b/apps/creator-ui/src/pages/MediaPage.tsx
@@ -0,0 +1,129 @@
+import { useState, useEffect, useMemo, useCallback } from 'react';
+import { useAuth } from '@persona-community-2/auth';
+import { useMediaUpload } from '@persona-community-2/realtime';
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ MediaUploader,
+ MediaLibrary,
+ type MediaItem,
+ Badge,
+} from '@persona-community-2/ui';
+
+export function MediaPage() {
+ const { getToken } = useAuth();
+ const [items, setItems] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [deleteError, setDeleteError] = useState(null);
+
+ const apiPrefix = import.meta.env.VITE_API_URL || '';
+
+ const authHeaders = useMemo(() => {
+ const token = getToken();
+ return token ? { Authorization: `Bearer ${token}` } : undefined;
+ }, [getToken]);
+
+ const mediaUpload = useMediaUpload({
+ apiPrefix,
+ serviceName: '{{SERVICE_NAME}}',
+ headers: authHeaders,
+ });
+
+ const [fetchError, setFetchError] = useState(null);
+
+ const fetchMedia = useCallback(async () => {
+ setFetchError(null);
+ try {
+ const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/media`, {
+ headers: { ...authHeaders },
+ });
+ if (!res.ok) {
+ setFetchError(`Failed to load media (${res.status})`);
+ return;
+ }
+ const data = await res.json();
+ setItems(data.items || []);
+ } catch (err) {
+ setFetchError(err instanceof Error ? err.message : 'Failed to load media');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [apiPrefix, authHeaders]);
+
+ useEffect(() => {
+ fetchMedia();
+ }, [fetchMedia]);
+
+ const handleUploadComplete = useCallback(() => {
+ fetchMedia();
+ }, [fetchMedia]);
+
+ const handleDelete = useCallback(async (id: string) => {
+ setDeleteError(null);
+ try {
+ const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/media/${id}`, {
+ method: 'DELETE',
+ headers: { ...authHeaders },
+ });
+ if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
+ setItems((prev) => prev.filter((item) => item.id !== id));
+ } catch (err) {
+ setDeleteError(err instanceof Error ? err.message : 'Delete failed');
+ }
+ }, [apiPrefix, authHeaders]);
+
+ return (
+
+
+
+ Upload Media
+
+ Upload images and videos to your media library
+
+
+
+ console.error('Upload error:', err)}
+ />
+ {mediaUpload.error && (
+ {mediaUpload.error}
+ )}
+
+
+
+
+
+
+ Media Library
+
+ Your uploaded and generated media files
+
+
+ {items.length > 0 && (
+ {items.length} files
+ )}
+
+
+ {(deleteError || fetchError) && (
+ {deleteError || fetchError}
+ )}
+ {isLoading ? (
+ Loading...
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/creator-ui/src/pages/RegisterPage.tsx b/apps/creator-ui/src/pages/RegisterPage.tsx
new file mode 100644
index 0000000..1d64f49
--- /dev/null
+++ b/apps/creator-ui/src/pages/RegisterPage.tsx
@@ -0,0 +1,128 @@
+import { useState } from 'react';
+import { useNavigate, Link } from 'react-router-dom';
+import { useAuth } from '@persona-community-2/auth';
+import {
+ Button,
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ CardFooter,
+ FormField,
+ useFormErrors,
+ Alert,
+ AlertDescription,
+ Loader2,
+} from '@persona-community-2/ui';
+
+export function RegisterPage() {
+ const navigate = useNavigate();
+ const { register, isLoading } = useAuth();
+ const { setErrors, clearErrors, getError } = useFormErrors();
+ const [generalError, setGeneralError] = useState(null);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ clearErrors();
+ setGeneralError(null);
+
+ const formData = new FormData(e.currentTarget);
+ const email = formData.get('email') as string;
+ const password = formData.get('password') as string;
+ const confirmPassword = formData.get('confirmPassword') as string;
+ const name = formData.get('name') as string;
+
+ if (password !== confirmPassword) {
+ setErrors({ confirmPassword: 'Passwords do not match' });
+ return;
+ }
+
+ try {
+ await register({ email, password, name });
+ navigate('/', { replace: true });
+ } catch (error) {
+ if (error instanceof Error) {
+ setGeneralError(error.message);
+ } else {
+ setGeneralError('An unexpected error occurred.');
+ }
+ }
+ };
+
+ return (
+
+
+
+ Create an account
+ Get started with persona-community-2
+
+
+
+
+
+ );
+}
diff --git a/apps/creator-ui/src/pages/ResetPasswordPage.tsx b/apps/creator-ui/src/pages/ResetPasswordPage.tsx
new file mode 100644
index 0000000..679efb5
--- /dev/null
+++ b/apps/creator-ui/src/pages/ResetPasswordPage.tsx
@@ -0,0 +1,135 @@
+import { useState } from 'react';
+import { useNavigate, useSearchParams, Link } from 'react-router-dom';
+import {
+ Button,
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ CardFooter,
+ FormField,
+ useFormErrors,
+ Alert,
+ AlertDescription,
+ Loader2,
+} from '@persona-community-2/ui';
+
+export function ResetPasswordPage() {
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const { setErrors, clearErrors, getError } = useFormErrors();
+ const [isLoading, setIsLoading] = useState(false);
+ const [generalError, setGeneralError] = useState(null);
+
+ const token = searchParams.get('token') || '';
+ const email = searchParams.get('email') || '';
+ const apiPrefix = import.meta.env.VITE_API_URL || '';
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ clearErrors();
+ setGeneralError(null);
+
+ const formData = new FormData(e.currentTarget);
+ const newPassword = formData.get('newPassword') as string;
+ const confirmPassword = formData.get('confirmPassword') as string;
+
+ if (newPassword !== confirmPassword) {
+ setErrors({ confirmPassword: 'Passwords do not match' });
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/reset-password`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, token, newPassword }),
+ });
+
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new Error(body.error?.message || body.message || 'Reset failed');
+ }
+
+ navigate('/login', { state: { message: 'Password reset successfully. Please sign in.' } });
+ } catch (err) {
+ setGeneralError(err instanceof Error ? err.message : 'An error occurred');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ if (!token || !email) {
+ return (
+
+
+
+ Invalid reset link
+ This password reset link is missing required parameters.
+
+
+
+ Request a new reset link
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Set new password
+ Enter your new password below
+
+
+
+
+
+ );
+}
diff --git a/apps/creator-ui/src/pages/SessionsPage.tsx b/apps/creator-ui/src/pages/SessionsPage.tsx
new file mode 100644
index 0000000..1829a43
--- /dev/null
+++ b/apps/creator-ui/src/pages/SessionsPage.tsx
@@ -0,0 +1,178 @@
+import { useState, useEffect, useCallback } from 'react';
+import { useAuth, type Session } from '@persona-community-2/auth';
+import {
+ Button,
+ Card,
+ CardContent,
+ Badge,
+ Alert,
+ AlertDescription,
+ Loader2,
+ Trash2,
+} from '@persona-community-2/ui';
+
+export function SessionsPage() {
+ const { getToken } = useAuth();
+ const [sessions, setSessions] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [revokingId, setRevokingId] = useState(null);
+
+ const apiPrefix = import.meta.env.VITE_API_URL || '';
+
+ const authHeaders = useCallback(() => {
+ const token = getToken();
+ return {
+ 'Content-Type': 'application/json',
+ ...(token ? { 'Authorization': `Bearer ${token}` } : {}),
+ };
+ }, [getToken]);
+
+ const loadSessions = useCallback(async () => {
+ setError(null);
+ try {
+ const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/sessions`, {
+ headers: authHeaders(),
+ });
+ if (!res.ok) throw new Error('Failed to load sessions');
+ const data = await res.json();
+ setSessions(data.data || data || []);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load sessions');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [apiPrefix, authHeaders]);
+
+ useEffect(() => {
+ loadSessions();
+ }, [loadSessions]);
+
+ const revokeSession = async (sessionId: string) => {
+ setRevokingId(sessionId);
+ try {
+ const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/sessions/${sessionId}`, {
+ method: 'DELETE',
+ headers: authHeaders(),
+ });
+ if (!res.ok) throw new Error('Failed to revoke session');
+ setSessions(prev => prev.filter(s => s.id !== sessionId));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to revoke session');
+ } finally {
+ setRevokingId(null);
+ }
+ };
+
+ const revokeAll = async () => {
+ setIsLoading(true);
+ try {
+ const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/sessions`, {
+ method: 'DELETE',
+ headers: authHeaders(),
+ });
+ if (!res.ok) throw new Error('Failed to revoke sessions');
+ await loadSessions();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to revoke sessions');
+ setIsLoading(false);
+ }
+ };
+
+ const formatDate = (dateStr: string) => {
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffMin = Math.floor(diffMs / 60000);
+ const diffHr = Math.floor(diffMs / 3600000);
+ const diffDay = Math.floor(diffMs / 86400000);
+
+ if (diffMin < 1) return 'Just now';
+ if (diffMin < 60) return `${diffMin}m ago`;
+ if (diffHr < 24) return `${diffHr}h ago`;
+ if (diffDay < 7) return `${diffDay}d ago`;
+ return date.toLocaleDateString();
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
Active Sessions
+
+ Manage your active login sessions across devices.
+
+
+ {sessions.length > 1 && (
+
+ Revoke all other sessions
+
+ )}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {sessions.length === 0 ? (
+
+
+ No active sessions found.
+
+
+ ) : (
+ sessions.map((session) => (
+
+
+
+
+
+ {session.deviceLabel || 'Unknown device'}
+
+ {session.isCurrent && (
+ Current
+ )}
+
+
+
+ {session.ipAddress || 'Unknown IP'}
+
+
+ Last active: {formatDate(session.lastActiveAt)}
+
+
+
+
+ {!session.isCurrent && (
+ revokeSession(session.id)}
+ disabled={revokingId === session.id}
+ >
+ {revokingId === session.id ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/apps/creator-ui/src/pages/VerifyEmailPage.tsx b/apps/creator-ui/src/pages/VerifyEmailPage.tsx
new file mode 100644
index 0000000..5de6cb7
--- /dev/null
+++ b/apps/creator-ui/src/pages/VerifyEmailPage.tsx
@@ -0,0 +1,161 @@
+import { useState } from 'react';
+import { useAuth } from '@persona-community-2/auth';
+import {
+ Button,
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ FormField,
+ Alert,
+ AlertDescription,
+ Loader2,
+ Check,
+} from '@persona-community-2/ui';
+
+export function VerifyEmailPage() {
+ const { user, getToken } = useAuth();
+ const [isLoading, setIsLoading] = useState(false);
+ const [codeSent, setCodeSent] = useState(false);
+ const [verified, setVerified] = useState(false);
+ const [error, setError] = useState(null);
+
+ const apiPrefix = import.meta.env.VITE_API_URL || '';
+
+ const authHeaders = () => {
+ const token = getToken();
+ return {
+ 'Content-Type': 'application/json',
+ ...(token ? { 'Authorization': `Bearer ${token}` } : {}),
+ };
+ };
+
+ const handleSendCode = async () => {
+ setError(null);
+ setIsLoading(true);
+
+ try {
+ const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/verify-email/send`, {
+ method: 'POST',
+ headers: authHeaders(),
+ });
+
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new Error(body.error?.message || body.message || 'Failed to send code');
+ }
+
+ setCodeSent(true);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'An error occurred');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleVerify = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setIsLoading(true);
+
+ const formData = new FormData(e.currentTarget);
+ const code = formData.get('code') as string;
+
+ try {
+ const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/verify-email`, {
+ method: 'POST',
+ headers: authHeaders(),
+ body: JSON.stringify({ code }),
+ });
+
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new Error(body.error?.message || body.message || 'Verification failed');
+ }
+
+ setVerified(true);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'An error occurred');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ if (verified) {
+ return (
+
+
+
+
+
+
+ Email verified
+ Your email address has been verified successfully.
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Verify your email
+
+ {codeSent
+ ? `Enter the 6-digit code sent to ${user?.email}`
+ : 'Verify your email address to access all features'}
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {!codeSent ? (
+
+
+ We'll send a verification code to {user?.email}
+
+
+ {isLoading && }
+ Send verification code
+
+
+ In dev mode, check the server console for the code.
+
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/creator-ui/src/vite-env.d.ts b/apps/creator-ui/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/apps/creator-ui/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/creator-ui/tailwind.config.ts b/apps/creator-ui/tailwind.config.ts
new file mode 100644
index 0000000..b4d1f21
--- /dev/null
+++ b/apps/creator-ui/tailwind.config.ts
@@ -0,0 +1,17 @@
+import type { Config } from 'tailwindcss';
+
+const config: Config = {
+ content: [
+ './index.html',
+ './src/**/*.{js,ts,jsx,tsx}',
+ // Include shared packages for Tailwind classes
+ '../../packages/ui/src/**/*.{js,ts,jsx,tsx}',
+ '../../packages/layout/src/**/*.{js,ts,jsx,tsx}',
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};
+
+export default config;
diff --git a/apps/creator-ui/tsconfig.json b/apps/creator-ui/tsconfig.json
new file mode 100644
index 0000000..a7fc6fb
--- /dev/null
+++ b/apps/creator-ui/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/apps/creator-ui/tsconfig.node.json b/apps/creator-ui/tsconfig.node.json
new file mode 100644
index 0000000..97ede7e
--- /dev/null
+++ b/apps/creator-ui/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/apps/creator-ui/vite.config.ts b/apps/creator-ui/vite.config.ts
new file mode 100644
index 0000000..87e1fa5
--- /dev/null
+++ b/apps/creator-ui/vite.config.ts
@@ -0,0 +1,34 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 3001,
+ proxy: {
+ // SSE events endpoint — must disable buffering for streaming
+ '/api/{{SERVICE_NAME}}/events': {
+ target: 'http://localhost:{{SERVICE_PORT}}',
+ changeOrigin: true,
+ // Disable response buffering so SSE events stream immediately
+ configure: (proxy) => {
+ proxy.on('proxyRes', (proxyRes) => {
+ // Prevent Vite from buffering SSE responses
+ if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
+ proxyRes.headers['cache-control'] = 'no-cache';
+ proxyRes.headers['x-accel-buffering'] = 'no';
+ }
+ });
+ },
+ },
+ '/api': {
+ target: 'http://localhost:{{SERVICE_PORT}}',
+ changeOrigin: true,
+ },
+ },
+ },
+ preview: {
+ port: 3001,
+ },
+});
diff --git a/go.work b/go.work
index 5c79122..bf77df7 100644
--- a/go.work
+++ b/go.work
@@ -1,4 +1,6 @@
go 1.25
use ./pkg
+use ./services/persona-api
+use ./workers/media-worker
// Component modules will be added below
diff --git a/services/persona-api/.env.example b/services/persona-api/.env.example
new file mode 100644
index 0000000..909a0a9
--- /dev/null
+++ b/services/persona-api/.env.example
@@ -0,0 +1,31 @@
+# persona-api Service Configuration
+
+# Server
+SERVER_PORT=8001
+SERVER_HOST=0.0.0.0
+
+# App
+APP_NAME=persona-api
+APP_ENVIRONMENT=development
+APP_DEBUG=true
+
+# Logging
+LOG_LEVEL=debug
+LOG_FORMAT=text
+
+# Auth (set AUTH_ENABLED=true to require JWT for protected routes)
+AUTH_ENABLED=false
+JWT_SECRET=dev-secret-change-in-production # Required — server refuses to start with empty secret
+REGISTRATION_ENABLED=true
+
+# Email delivery (notify service)
+# When NOTIFY_URL is empty, auth codes are logged to stdout (dev mode).
+# NOTIFY_URL=https://notify.threesix.ai
+# NOTIFY_API_KEY=notify_send_xxx
+# NOTIFY_HOST=myapp.threesix.ai
+# NOTIFY_FROM=noreply@myapp.threesix.ai
+
+# Database (if needed)
+# Local dev: PostgreSQL via docker-compose. Production: CockroachDB (platform-provisioned).
+# The postgres:// scheme works for both — CockroachDB is wire-compatible.
+DATABASE_URL=postgres://dev:dev@localhost:5432/persona-community-2?sslmode=disable
diff --git a/services/persona-api/Dockerfile b/services/persona-api/Dockerfile
new file mode 100644
index 0000000..349ec91
--- /dev/null
+++ b/services/persona-api/Dockerfile
@@ -0,0 +1,35 @@
+# Build stage
+FROM golang:1.25-alpine AS builder
+
+RUN apk add --no-cache git
+
+# Configure Go private modules
+# Disable workspace mode - each component builds independently with replace directives
+ENV GOPRIVATE=git.threesix.ai/*
+ENV GOWORK=off
+
+WORKDIR /app
+
+# Copy shared pkg and this service only
+COPY pkg/ ./pkg/
+COPY services/persona-api/ ./services/persona-api/
+
+# Download dependencies (populates go.sum if empty)
+RUN cd pkg && go mod download
+RUN cd services/persona-api && go mod download
+
+# Build from the service directory (uses replace directive for ../pkg)
+RUN cd services/persona-api && CGO_ENABLED=0 go build -o /persona-api ./cmd/server
+
+# Production stage
+FROM alpine:3.19
+
+RUN apk add --no-cache ca-certificates tzdata
+
+WORKDIR /
+
+COPY --from=builder /persona-api /persona-api
+
+EXPOSE 8001
+
+ENTRYPOINT ["/persona-api"]
diff --git a/services/persona-api/Makefile b/services/persona-api/Makefile
new file mode 100644
index 0000000..a16afcd
--- /dev/null
+++ b/services/persona-api/Makefile
@@ -0,0 +1,38 @@
+.PHONY: build run dev test lint fmt docker-build clean
+
+SERVICE := persona-api
+BINARY := bin/$(SERVICE)
+GO_MODULE := git.threesix.ai/jordan/persona-community-2
+
+# Build the service binary
+build:
+ go build -o $(BINARY) ./cmd/server
+
+# Run the service locally
+run:
+ go run ./cmd/server
+
+# Run the service in development mode (alias for run)
+dev:
+ go run ./cmd/server
+
+# Run tests
+test:
+ go test -v ./...
+
+# Run linter
+lint:
+ golangci-lint run ./...
+
+# Format code
+fmt:
+ gofmt -w .
+ goimports -w -local $(GO_MODULE) .
+
+# Build Docker image (run from monorepo root)
+docker-build:
+ docker build -t $(SERVICE):latest -f Dockerfile ../..
+
+# Clean build artifacts
+clean:
+ rm -rf bin/
diff --git a/services/persona-api/cmd/server/main.go b/services/persona-api/cmd/server/main.go
new file mode 100644
index 0000000..e839764
--- /dev/null
+++ b/services/persona-api/cmd/server/main.go
@@ -0,0 +1,420 @@
+// Package main is the entry point for the persona-api service.
+package main
+
+import (
+ "context"
+ "embed"
+ "flag"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/redis/go-redis/v9"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/album"
+ "git.threesix.ai/jordan/persona-community-2/pkg/app"
+ "git.threesix.ai/jordan/persona-community-2/pkg/personagen"
+ "git.threesix.ai/jordan/persona-community-2/pkg/database"
+ "git.threesix.ai/jordan/persona-community-2/pkg/gemini"
+ "git.threesix.ai/jordan/persona-community-2/pkg/laozhang"
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/pkg/mediagen"
+ mediagenAdapters "git.threesix.ai/jordan/persona-community-2/pkg/mediagen/adapters"
+ "git.threesix.ai/jordan/persona-community-2/pkg/generation"
+ emailpkg "git.threesix.ai/jordan/persona-community-2/pkg/email"
+ "git.threesix.ai/jordan/persona-community-2/pkg/notify"
+ "git.threesix.ai/jordan/persona-community-2/pkg/queue"
+ "git.threesix.ai/jordan/persona-community-2/pkg/realtime"
+ "git.threesix.ai/jordan/persona-community-2/pkg/storage"
+ "git.threesix.ai/jordan/persona-community-2/pkg/textgen"
+ textgenAdapters "git.threesix.ai/jordan/persona-community-2/pkg/textgen/adapters"
+ emailadapter "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/adapter/email"
+ componentemail "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/email"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/adapter/memory"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/adapter/postgres"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/api"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/config"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/service"
+)
+
+//go:embed migrations/*.sql
+var migrationsFS embed.FS
+
+func main() {
+ // Parse flags
+ exportOpenAPI := flag.Bool("export-openapi", false, "Export OpenAPI spec to stdout and exit")
+ flag.Parse()
+
+ // If exporting OpenAPI, generate spec and exit (used by CI for docs generation)
+ if *exportOpenAPI {
+ spec := api.NewServiceSpec()
+ jsonBytes, err := spec.JSON()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "failed to generate OpenAPI spec: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Println(string(jsonBytes))
+ os.Exit(0)
+ }
+
+ // Load config
+ cfg := config.Load()
+
+ // Create logger
+ logger := logging.Default()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ // Create SSE hub for async event delivery (generation progress, chat, etc.)
+ sseHub := realtime.NewSSEHub(logger.Logger)
+
+ // Initialize storage backend (before queue, since standalone queue handlers use it).
+ // GCS_BUCKET set = production (GCS). Otherwise = dev (in-memory).
+ listenPort := fmt.Sprintf("%d", 8001)
+ var mediaStore storage.Store
+ if bucket := os.Getenv("GCS_BUCKET"); bucket != "" {
+ gcsStore, err := storage.NewGCSStore(ctx, bucket, os.Getenv("GCS_SERVICE_ACCOUNT_JSON"), logger.Logger)
+ if err != nil {
+ logger.Error("failed to create GCS store", "error", err)
+ os.Exit(1)
+ }
+ defer func() { _ = gcsStore.Close() }()
+ mediaStore = gcsStore
+ logger.Info("storage initialized (GCS)", "bucket", bucket)
+ } else {
+ memStore := storage.NewMemoryStore("http://localhost:" + listenPort + "/storage")
+ mediaStore = memStore
+ logger.Info("storage initialized (in-memory dev mode)")
+ }
+
+ // Select backend based on DATABASE_URL availability.
+ // With DATABASE_URL: Postgres repos + DB queue (production)
+ // Without DATABASE_URL: in-memory repos + in-process AI (development)
+ exampleRepo := memory.NewExampleRepository()
+ albumRepo := memory.NewAlbumRepository()
+ var userRepo port.UserRepository
+ var sessionRepo port.SessionRepository
+ var authCodeRepo port.AuthCodeRepository
+ var mediaRepo port.MediaRepository
+ var jobQueue queue.Producer
+ var jobReader queue.JobReader
+
+ if cfg.Database.URL != "" {
+ // Connect to database (shared pool for queue + auth repos).
+ dbPool, err := database.Connect(ctx, cfg.Database.URL, database.Options{
+ MaxOpenConns: cfg.Database.MaxOpenConns,
+ MaxIdleConns: cfg.Database.MaxIdleConns,
+ ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
+ })
+ if err != nil {
+ logger.Error("failed to connect to database", "error", err)
+ os.Exit(1)
+ }
+ logger.Info("connected to database")
+
+ // Verify the database connection is actually alive before proceeding.
+ if err := dbPool.DB.PingContext(ctx); err != nil {
+ logger.Error("database health check failed", "error", err)
+ os.Exit(1)
+ }
+ logger.Info("database health check passed")
+
+ // Run auth migrations.
+ if err := database.RunMigrations(ctx, dbPool, migrationsFS, "migrations"); err != nil {
+ logger.Error("failed to run auth migrations", "error", err)
+ os.Exit(1)
+ }
+ logger.Info("auth migrations complete")
+
+ // Postgres-backed repositories.
+ userRepo = postgres.NewUserRepository(dbPool.DB)
+ sessionRepo = postgres.NewSessionRepository(dbPool.DB)
+ authCodeRepo = postgres.NewAuthCodeRepository(dbPool.DB)
+ mediaRepo = postgres.NewMediaObjectRepository(dbPool.DB)
+
+ // DB-backed queue.
+ jobQueue, jobReader = setupDBQueue(ctx, cfg, dbPool, sseHub, logger)
+ } else {
+ logger.Info("DATABASE_URL not set — running in standalone mode (in-memory queue + in-process AI)")
+ userRepo = memory.NewUserRepository(cfg.DevUserEmail, cfg.DevUserPassword)
+ sessionRepo = memory.NewSessionRepository()
+ authCodeRepo = memory.NewAuthCodeRepository()
+ mediaRepo = memory.NewMediaRepository()
+ jobQueue, jobReader = setupStandaloneQueue(ctx, mediaStore, albumRepo, sseHub, logger)
+ }
+
+ // Validate required config.
+ if cfg.JWTSecret == "" {
+ logger.Error("JWT_SECRET must be set (even in development)")
+ os.Exit(1)
+ }
+
+ // Load email renderer (HTML templates embedded at build time).
+ emailRenderer, err := emailpkg.NewRendererFromFS(componentemail.TemplateFS, "templates", emailpkg.BrandConfig{
+ AppName: cfg.AppName,
+ AppURL: cfg.AppURL,
+ SupportEmail: cfg.SupportEmail,
+ LogoURL: cfg.LogoURL,
+ PrimaryColor: cfg.BrandColor,
+ })
+ if err != nil {
+ logger.Error("failed to load email templates", "error", err)
+ os.Exit(1)
+ }
+ logger.Info("email renderer loaded", "templates", len(emailRenderer.Purposes()))
+
+ // Create email sender — notify service in production (NOTIFY_URL set), log-only for dev.
+ var emailSender port.EmailSender
+ if cfg.NotifyURL != "" {
+ notifyClient, err := notify.NewClient(notify.Config{
+ URL: cfg.NotifyURL,
+ APIKey: cfg.NotifyAPIKey,
+ Logger: logger.Logger,
+ })
+ if err != nil {
+ logger.Error("failed to create notify client", "error", err)
+ os.Exit(1)
+ }
+ emailSender = emailadapter.NewNotifySender(notifyClient, emailRenderer, cfg.NotifyHost, cfg.NotifyFrom, logger)
+ logger.Info("email sender initialized (notify)", "url", cfg.NotifyURL, "host", cfg.NotifyHost)
+ } else {
+ emailSender = emailadapter.NewLogSender(logger)
+ logger.Info("email sender initialized (log-only dev mode)")
+ }
+
+ // Create services (business logic)
+ exampleService := service.NewExampleService(exampleRepo, logger)
+ albumService := service.NewAlbumService(albumRepo, jobQueue, logger)
+ authService := service.NewAuthService(
+ userRepo, sessionRepo, authCodeRepo, emailSender,
+ cfg.JWTSecret, cfg.RegistrationEnabled, logger,
+ )
+
+ // Create application
+ application := app.New("persona-api", app.WithDefaultPort(8001))
+
+ // Mount in-memory storage HTTP handler for dev mode
+ if memStore, ok := mediaStore.(*storage.MemoryStore); ok {
+ application.Router().Handle("/storage/*", memStore)
+ }
+
+ // Register routes with dependency injection
+ api.RegisterRoutes(application, &api.Dependencies{
+ ExampleService: exampleService,
+ AuthService: authService,
+ AlbumService: albumService,
+ Queue: jobQueue,
+ JobReader: jobReader,
+ SSEHub: sseHub,
+ Store: mediaStore,
+ MediaRepo: mediaRepo,
+ EmailRenderer: emailRenderer,
+ })
+
+ // Start background cleanup of expired sessions and auth codes.
+ go runCleanup(ctx, sessionRepo, authCodeRepo, logger)
+
+ // Start server
+ application.Run()
+}
+
+// setupDBQueue initializes the production queue backend using the shared database pool + optional Redis.
+// Returns both Producer (for enqueue) and JobReader (for status polling).
+func setupDBQueue(ctx context.Context, cfg *config.Config, pool *database.Pool, sseHub *realtime.SSEHub, logger *logging.Logger) (queue.Producer, queue.JobReader) {
+ if err := queue.RunMigrations(ctx, pool); err != nil {
+ logger.Error("failed to run queue migrations", "error", err)
+ os.Exit(1)
+ }
+ logger.Info("queue migrations complete")
+
+ jobQueue := queue.NewQueue(pool.DB, logger)
+
+ // Start Redis SSE subscriber if configured.
+ if cfg.RedisURL != "" {
+ opts, err := redis.ParseURL(cfg.RedisURL)
+ if err != nil {
+ logger.Error("failed to parse REDIS_URL", "error", err)
+ os.Exit(1)
+ }
+ redisClient := redis.NewClient(opts)
+ if err := redisClient.Ping(ctx).Err(); err != nil {
+ logger.Error("failed to connect to Redis", "error", err)
+ os.Exit(1)
+ }
+ logger.Info("connected to Redis")
+
+ go func() {
+ if err := realtime.RunSSESubscriber(ctx, redisClient, sseHub, logger.Logger); err != nil {
+ logger.Error("SSE Redis subscriber stopped", "error", err)
+ }
+ }()
+ } else {
+ logger.Warn("REDIS_URL not set — SSE events from worker will not be delivered")
+ }
+
+ return jobQueue, jobQueue
+}
+
+// setupStandaloneQueue initializes an in-memory queue with in-process AI handlers.
+// This mode requires no database or Redis — everything runs in a single process.
+// Returns both Producer (for enqueue) and JobReader (for status polling).
+func setupStandaloneQueue(ctx context.Context, store storage.Store, albumUpdater album.AlbumUpdater, sseHub *realtime.SSEHub, logger *logging.Logger) (queue.Producer, queue.JobReader) {
+ memQueue := queue.NewMemoryQueue(logger.Logger)
+
+ // LocalPublisher delivers events directly to the SSE hub (no Redis needed).
+ pub := realtime.NewLocalPublisher(sseHub)
+
+ // Initialize AI providers
+ mediagenManager := initMediagen(ctx, logger)
+ textgenManager := initTextgen(ctx, logger)
+
+ // Register job handlers (same handlers the worker uses).
+ if mediagenManager != nil {
+ memQueue.RegisterHandler("generate_image", generation.ImageHandler(mediagenManager, store, pub, logger))
+ memQueue.RegisterHandler("generate_video", generation.VideoHandler(mediagenManager, store, pub, logger))
+ memQueue.RegisterHandler("generate_anchor", album.AnchorHandler(mediagenManager, store, pub, albumUpdater, logger))
+ memQueue.RegisterHandler("generate_shot", album.ShotHandler(mediagenManager, store, pub, albumUpdater, logger))
+ }
+ if textgenManager != nil {
+ memQueue.RegisterHandler("generate_text", generation.TextHandler(textgenManager, pub, logger))
+ memQueue.RegisterHandler("ai_chat_response", generation.ChatResponseHandler(textgenManager, pub, logger))
+ }
+
+ // Persona generation requires both textgen (5-stage LLM pipeline) and mediagen (20 images + 4 videos).
+ if textgenManager != nil && mediagenManager != nil {
+ memQueue.RegisterHandler("persona_generate", personagen.QueueHandler(textgenManager, mediagenManager, store, pub, logger.Logger))
+ }
+
+ return memQueue, memQueue
+}
+
+// initMediagen creates a mediagen manager from available AI provider credentials.
+func initMediagen(ctx context.Context, logger *logging.Logger) *mediagen.Manager {
+ var laozhangMediaProvider *mediagenAdapters.LaoZhangProvider
+ var geminiMediaProvider *mediagenAdapters.GeminiProvider
+
+ if apiKey := os.Getenv("LAOZHANG_API_KEY"); apiKey != "" {
+ client, err := laozhang.NewClient(laozhang.Config{
+ APIKey: apiKey,
+ VideoTimeout: 5 * time.Minute,
+ Logger: logger.Logger,
+ })
+ if err != nil {
+ logger.Warn("failed to create LaoZhang client", "error", err)
+ } else {
+ laozhangMediaProvider = mediagenAdapters.NewLaoZhangProvider(client)
+ logger.Info("LaoZhang media provider initialized")
+ }
+ }
+
+ if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
+ client, err := gemini.NewClient(ctx, gemini.Config{
+ APIKey: apiKey,
+ Logger: logger.Logger,
+ })
+ if err != nil {
+ logger.Warn("failed to create Gemini client", "error", err)
+ } else {
+ geminiMediaProvider = mediagenAdapters.NewGeminiProvider(client)
+ logger.Info("Gemini media provider initialized")
+ }
+ }
+
+ if laozhangMediaProvider == nil && geminiMediaProvider == nil {
+ logger.Warn("no media generation providers available (set LAOZHANG_API_KEY or GEMINI_API_KEY)")
+ return nil
+ }
+
+ mgCfg := mediagen.ProductionConfig(mediagen.ProviderSet{
+ LaoZhang: laozhangMediaProvider,
+ Gemini: geminiMediaProvider,
+ }, mediagen.WithLogger(logger.Logger))
+ if laozhangMediaProvider != nil {
+ mgCfg.VideoProviders = append(mgCfg.VideoProviders, laozhangMediaProvider)
+ }
+ if geminiMediaProvider != nil {
+ mgCfg.VideoProviders = append(mgCfg.VideoProviders, geminiMediaProvider)
+ }
+
+ mgr, err := mediagen.NewManager(mgCfg)
+ if err != nil {
+ logger.Warn("failed to create mediagen manager", "error", err)
+ return nil
+ }
+ logger.Info("mediagen manager initialized (image + video)")
+ return mgr
+}
+
+// initTextgen creates a textgen manager from available AI provider credentials.
+func initTextgen(ctx context.Context, logger *logging.Logger) *textgen.Manager {
+ var textProviders []textgen.TextGenerator
+
+ if apiKey := os.Getenv("LAOZHANG_API_KEY"); apiKey != "" {
+ client, err := laozhang.NewClient(laozhang.Config{
+ APIKey: apiKey,
+ Logger: logger.Logger,
+ })
+ if err != nil {
+ logger.Warn("failed to create LaoZhang text client", "error", err)
+ } else {
+ textProviders = append(textProviders, textgenAdapters.NewLaoZhangTextProvider(client, ""))
+ }
+ }
+
+ if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
+ provider, err := textgenAdapters.NewGeminiTextProvider(ctx, textgenAdapters.GeminiTextConfig{
+ APIKey: apiKey,
+ })
+ if err != nil {
+ logger.Warn("failed to create Gemini text provider", "error", err)
+ } else {
+ textProviders = append(textProviders, provider)
+ }
+ }
+
+ if len(textProviders) == 0 {
+ logger.Warn("no text generation providers available")
+ return nil
+ }
+
+ tgCfg := textgen.ProductionConfig(textgen.ProviderSet{}, textgen.WithLogger(logger.Logger))
+ tgCfg.Providers = textProviders
+
+ mgr, err := textgen.NewManager(tgCfg)
+ if err != nil {
+ logger.Warn("failed to create textgen manager", "error", err)
+ return nil
+ }
+ logger.Info("textgen manager initialized")
+ return mgr
+}
+
+// runCleanup periodically removes expired sessions and auth codes.
+// Runs every hour. Stops when ctx is cancelled.
+func runCleanup(ctx context.Context, sessions port.SessionRepository, codes port.AuthCodeRepository, logger *logging.Logger) {
+ ticker := time.NewTicker(1 * time.Hour)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ sessCount, err := sessions.DeleteExpired(ctx)
+ if err != nil {
+ logger.Warn("failed to cleanup expired sessions", "error", err)
+ } else if sessCount > 0 {
+ logger.Info("cleaned up expired sessions", "count", sessCount)
+ }
+
+ codeCount, err := codes.DeleteExpired(ctx)
+ if err != nil {
+ logger.Warn("failed to cleanup expired auth codes", "error", err)
+ } else if codeCount > 0 {
+ logger.Info("cleaned up expired auth codes", "count", codeCount)
+ }
+ }
+ }
+}
diff --git a/services/persona-api/cmd/server/migrations/.gitkeep b/services/persona-api/cmd/server/migrations/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/services/persona-api/cmd/server/migrations/001_create_users.sql b/services/persona-api/cmd/server/migrations/001_create_users.sql
new file mode 100644
index 0000000..13877c8
--- /dev/null
+++ b/services/persona-api/cmd/server/migrations/001_create_users.sql
@@ -0,0 +1,79 @@
+-- 001_create_users.sql
+-- Auth tables for user management, sessions, and authentication codes.
+-- Compatible with both PostgreSQL (local dev) and CockroachDB (production).
+
+-- Core user identity. Email is the primary identifier for humans.
+CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY,
+ email TEXT NOT NULL UNIQUE,
+ email_verified BOOL NOT NULL DEFAULT FALSE,
+ name TEXT NOT NULL DEFAULT '',
+ avatar_url TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'active',
+ last_login_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Password credentials. Separate table because OAuth-only users have no password.
+CREATE TABLE IF NOT EXISTS user_passwords (
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+ password_hash TEXT NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- OAuth provider connections (Google, GitHub, Apple, etc.).
+CREATE TABLE IF NOT EXISTS oauth_connections (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ provider TEXT NOT NULL,
+ provider_user_id TEXT NOT NULL,
+ provider_email TEXT NOT NULL DEFAULT '',
+ access_token TEXT NOT NULL DEFAULT '',
+ refresh_token TEXT NOT NULL DEFAULT '',
+ token_expires_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (provider, provider_user_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_oauth_connections_user_id ON oauth_connections (user_id);
+
+-- Verification codes for OTP login, magic links, password reset, and email verification.
+CREATE TABLE IF NOT EXISTS auth_codes (
+ id TEXT PRIMARY KEY,
+ user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
+ email TEXT NOT NULL,
+ code TEXT NOT NULL,
+ purpose TEXT NOT NULL,
+ expires_at TIMESTAMPTZ NOT NULL,
+ used_at TIMESTAMPTZ,
+ ip_address TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_auth_codes_email_purpose ON auth_codes (email, purpose, expires_at)
+ WHERE used_at IS NULL;
+
+-- Sessions track where and when users are logged in.
+CREATE TABLE IF NOT EXISTS sessions (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ ip_address TEXT NOT NULL DEFAULT '',
+ user_agent TEXT NOT NULL DEFAULT '',
+ device_label TEXT NOT NULL DEFAULT '',
+ last_active_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ NOT NULL,
+ revoked_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id)
+ WHERE revoked_at IS NULL;
+
+-- User roles. Separate table so users can have multiple roles.
+CREATE TABLE IF NOT EXISTS user_roles (
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ role TEXT NOT NULL,
+ granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (user_id, role)
+);
diff --git a/services/persona-api/cmd/server/migrations/002_add_indexes.sql b/services/persona-api/cmd/server/migrations/002_add_indexes.sql
new file mode 100644
index 0000000..1013dbd
--- /dev/null
+++ b/services/persona-api/cmd/server/migrations/002_add_indexes.sql
@@ -0,0 +1,9 @@
+-- 002_add_indexes.sql
+-- Additional indexes for query performance.
+-- Compatible with both PostgreSQL (local dev) and CockroachDB (production).
+
+-- Speed up OTP/magic-link/reset token lookup in FindValid queries.
+-- The existing partial index on (email, purpose, expires_at) doesn't cover the code column,
+-- so queries filtering on code must scan all matching rows.
+CREATE INDEX IF NOT EXISTS idx_auth_codes_code ON auth_codes (code)
+ WHERE used_at IS NULL;
diff --git a/services/persona-api/cmd/server/migrations/003_create_media_objects.sql b/services/persona-api/cmd/server/migrations/003_create_media_objects.sql
new file mode 100644
index 0000000..8e96b5c
--- /dev/null
+++ b/services/persona-api/cmd/server/migrations/003_create_media_objects.sql
@@ -0,0 +1,22 @@
+-- 003_create_media_objects.sql
+-- Media metadata table for tracking uploads, generation provenance, and soft deletes.
+-- Compatible with both PostgreSQL (local dev) and CockroachDB (production).
+
+CREATE TABLE IF NOT EXISTS media_objects (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ path TEXT NOT NULL UNIQUE,
+ filename TEXT NOT NULL DEFAULT '',
+ content_type TEXT NOT NULL DEFAULT '',
+ size BIGINT NOT NULL DEFAULT 0,
+ generation_job_id TEXT NOT NULL DEFAULT '',
+ deleted_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_media_objects_user_id ON media_objects (user_id, created_at DESC)
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_media_objects_generation_job ON media_objects (generation_job_id)
+ WHERE generation_job_id != '';
diff --git a/services/persona-api/component.yaml b/services/persona-api/component.yaml
new file mode 100644
index 0000000..2a75cb5
--- /dev/null
+++ b/services/persona-api/component.yaml
@@ -0,0 +1,9 @@
+name: persona-api
+type: service
+port: 8001
+path: services/persona-api
+dependencies: []
+# Add dependencies as needed:
+# - postgres
+# - redis
+# - other-service
diff --git a/services/persona-api/go.mod b/services/persona-api/go.mod
new file mode 100644
index 0000000..ef5a43f
--- /dev/null
+++ b/services/persona-api/go.mod
@@ -0,0 +1,8 @@
+module git.threesix.ai/jordan/persona-community-2/services/persona-api
+
+go 1.25
+
+require git.threesix.ai/jordan/persona-community-2/pkg v0.0.0
+
+// Use local workspace modules (for Docker builds without go.work)
+replace git.threesix.ai/jordan/persona-community-2/pkg => ../../pkg
diff --git a/services/persona-api/go.sum b/services/persona-api/go.sum
new file mode 100644
index 0000000..e69de29
diff --git a/services/persona-api/internal/adapter/email/log.go b/services/persona-api/internal/adapter/email/log.go
new file mode 100644
index 0000000..c614a55
--- /dev/null
+++ b/services/persona-api/internal/adapter/email/log.go
@@ -0,0 +1,32 @@
+// Package email provides email sending adapters for authentication flows.
+package email
+
+import (
+ "context"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+)
+
+// Compile-time interface check.
+var _ port.EmailSender = (*LogSender)(nil)
+
+// LogSender logs emails to the console instead of sending them.
+// Useful for development and testing when no notify service is configured.
+type LogSender struct {
+ logger *logging.Logger
+}
+
+// NewLogSender creates a new log-based email sender.
+func NewLogSender(logger *logging.Logger) *LogSender {
+ return &LogSender{logger: logger.WithComponent("EmailSender")}
+}
+
+func (s *LogSender) SendAuthCode(_ context.Context, email, code, purpose string) error {
+ s.logger.Warn("DEV MODE — email not sent, code logged",
+ "to", email,
+ "purpose", purpose,
+ "code", code,
+ )
+ return nil
+}
diff --git a/services/persona-api/internal/adapter/email/notify.go b/services/persona-api/internal/adapter/email/notify.go
new file mode 100644
index 0000000..15502c5
--- /dev/null
+++ b/services/persona-api/internal/adapter/email/notify.go
@@ -0,0 +1,112 @@
+package email
+
+import (
+ "context"
+ "fmt"
+
+ emailpkg "git.threesix.ai/jordan/persona-community-2/pkg/email"
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/pkg/notify"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+)
+
+// Compile-time interface check.
+var _ port.EmailSender = (*NotifySender)(nil)
+
+// NotifySender sends transactional emails via the orchard9 notify service.
+// It renders HTML using the email.Renderer before sending so every email
+// has a styled layout with inline CSS.
+type NotifySender struct {
+ client *notify.Client
+ renderer *emailpkg.Renderer
+ host string
+ from string
+ logger *logging.Logger
+}
+
+// NewNotifySender creates a notify-backed email sender with HTML rendering.
+func NewNotifySender(client *notify.Client, renderer *emailpkg.Renderer, host, from string, logger *logging.Logger) *NotifySender {
+ return &NotifySender{
+ client: client,
+ renderer: renderer,
+ host: host,
+ from: from,
+ logger: logger.WithComponent("EmailSender"),
+ }
+}
+
+func (s *NotifySender) SendAuthCode(ctx context.Context, toEmail, code, purpose string) error {
+ // Map (purpose, code) to the correct template context.
+ emailCtx := purposeToContext(purpose, code)
+
+ rendered, err := s.renderer.Render(purpose, emailCtx)
+ if err != nil {
+ s.logger.Error("failed to render email template", "purpose", purpose, "error", err)
+ return fmt.Errorf("render email %s: %w", purpose, err)
+ }
+ if rendered.CSSInlineErr != nil {
+ s.logger.Warn("CSS inlining failed for email, styles may be degraded in some clients",
+ "purpose", purpose, "error", rendered.CSSInlineErr)
+ }
+
+ resp, err := s.client.SendEmail(ctx, ¬ify.SendRequest{
+ To: toEmail,
+ From: s.from,
+ Content: notify.Content{
+ Subject: rendered.Subject,
+ HTML: rendered.HTML,
+ Text: rendered.PlainText,
+ },
+ Meta: notify.Meta{
+ Host: s.host,
+ Category: "critical",
+ Tags: []string{"auth", purpose},
+ },
+ Options: notify.Options{
+ // Stable idempotency key: same user + same code = same key, safe to retry.
+ IdempotencyKey: fmt.Sprintf("auth:%s:%s:%s", toEmail, purpose, code),
+ },
+ })
+ if err != nil {
+ s.logger.Error("failed to send email via notify", "to", toEmail, "purpose", purpose, "error", err)
+ return fmt.Errorf("send email: %w", err)
+ }
+
+ s.logger.Info("email queued via notify", "to", toEmail, "purpose", purpose, "message_id", resp.MessageID)
+ return nil
+}
+
+// purposeToContext maps (purpose, code) to an EmailContext for template rendering.
+// code may be an OTP digit string or a URL depending on the purpose.
+func purposeToContext(purpose, code string) emailpkg.EmailContext {
+ switch purpose {
+ case "login_otp":
+ return emailpkg.EmailContext{
+ Code: code,
+ ExpiresIn: 10,
+ Purpose: "sign in",
+ }
+ case "magic_link":
+ return emailpkg.EmailContext{
+ ActionURL: emailpkg.SafeURL(code),
+ ButtonText: "Sign In \u2192",
+ ExpiresIn: 15,
+ }
+ case "password_reset":
+ return emailpkg.EmailContext{
+ ActionURL: emailpkg.SafeURL(code),
+ ButtonText: "Reset Password \u2192",
+ ExpiresIn: 60,
+ }
+ case "email_verify":
+ return emailpkg.EmailContext{
+ Code: code,
+ ExpiresIn: 30,
+ Purpose: "verify your email",
+ }
+ default:
+ return emailpkg.EmailContext{
+ Code: code,
+ }
+ }
+}
diff --git a/services/persona-api/internal/adapter/memory/album.go b/services/persona-api/internal/adapter/memory/album.go
new file mode 100644
index 0000000..2109467
--- /dev/null
+++ b/services/persona-api/internal/adapter/memory/album.go
@@ -0,0 +1,177 @@
+package memory
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "time"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/album"
+)
+
+// AlbumRepository is an in-memory implementation of port.AlbumRepository.
+// Used in standalone dev mode (no DATABASE_URL). Not safe for persistence across restarts.
+type AlbumRepository struct {
+ mu sync.RWMutex
+ albums map[album.AlbumID]*album.Album
+}
+
+// NewAlbumRepository creates an in-memory album repository.
+func NewAlbumRepository() *AlbumRepository {
+ return &AlbumRepository{
+ albums: make(map[album.AlbumID]*album.Album),
+ }
+}
+
+// Create persists a new album. The caller must set ID, Name, SubjectDesc, Shots before calling.
+func (r *AlbumRepository) Create(ctx context.Context, a *album.Album) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ now := time.Now().UTC()
+ a.CreatedAt = now
+ a.UpdatedAt = now
+ copy := *a
+ r.albums[a.ID] = ©
+ return nil
+}
+
+// Get returns an album by ID and userID. Returns error if not found or wrong user.
+func (r *AlbumRepository) Get(ctx context.Context, id album.AlbumID, userID string) (*album.Album, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ a, ok := r.albums[id]
+ if !ok || a.UserID != userID {
+ return nil, fmt.Errorf("album not found: %s", id)
+ }
+ copy := *a
+ shots := make([]album.Shot, len(a.Shots))
+ copy.Shots = shots
+ for i, s := range a.Shots {
+ shots[i] = s
+ }
+ return ©, nil
+}
+
+// List returns all albums for a user, ordered by CreatedAt DESC.
+func (r *AlbumRepository) List(ctx context.Context, userID string) ([]album.Album, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ var result []album.Album
+ for _, a := range r.albums {
+ if a.UserID != userID {
+ continue
+ }
+ copy := *a
+ shots := make([]album.Shot, len(a.Shots))
+ for i, s := range a.Shots {
+ shots[i] = s
+ }
+ copy.Shots = shots
+ result = append(result, copy)
+ }
+ // Sort by CreatedAt DESC (simple insertion sort — in-memory is small).
+ for i := 1; i < len(result); i++ {
+ for j := i; j > 0 && result[j].CreatedAt.After(result[j-1].CreatedAt); j-- {
+ result[j], result[j-1] = result[j-1], result[j]
+ }
+ }
+ return result, nil
+}
+
+// Delete removes an album by ID and userID.
+func (r *AlbumRepository) Delete(ctx context.Context, id album.AlbumID, userID string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ a, ok := r.albums[id]
+ if !ok || a.UserID != userID {
+ return fmt.Errorf("album not found: %s", id)
+ }
+ delete(r.albums, id)
+ return nil
+}
+
+// UpdateAnchor stores the generated anchor URL.
+func (r *AlbumRepository) UpdateAnchor(ctx context.Context, id album.AlbumID, userID, anchorURL, anchorJobID string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ a, ok := r.albums[id]
+ if !ok || a.UserID != userID {
+ return fmt.Errorf("album not found: %s", id)
+ }
+ a.AnchorURL = anchorURL
+ a.AnchorJobID = anchorJobID
+ a.UpdatedAt = time.Now().UTC()
+ return nil
+}
+
+// UpdateShot stores the generated image URL and status for a specific shot.
+func (r *AlbumRepository) UpdateShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int, imageURL string, status album.ShotStatus, shotError string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ a, ok := r.albums[id]
+ if !ok || a.UserID != userID {
+ return fmt.Errorf("album not found: %s", id)
+ }
+ if shotIndex < 0 || shotIndex >= len(a.Shots) {
+ return fmt.Errorf("shot index out of range: %d", shotIndex)
+ }
+ now := time.Now().UTC()
+ a.Shots[shotIndex].ImageURL = imageURL
+ a.Shots[shotIndex].Status = status
+ a.Shots[shotIndex].Error = shotError
+ if status == album.ShotComplete {
+ a.Shots[shotIndex].GeneratedAt = &now
+ }
+ a.UpdatedAt = now
+ return nil
+}
+
+// ResetShot clears a shot back to pending.
+func (r *AlbumRepository) ResetShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ a, ok := r.albums[id]
+ if !ok || a.UserID != userID {
+ return fmt.Errorf("album not found: %s", id)
+ }
+ if shotIndex < 0 || shotIndex >= len(a.Shots) {
+ return fmt.Errorf("shot index out of range: %d", shotIndex)
+ }
+ a.Shots[shotIndex].ImageURL = ""
+ a.Shots[shotIndex].JobID = ""
+ a.Shots[shotIndex].Status = album.ShotPending
+ a.Shots[shotIndex].Error = ""
+ a.Shots[shotIndex].GeneratedAt = nil
+ a.UpdatedAt = time.Now().UTC()
+ return nil
+}
+
+// UpdateAnchorJobID stores the anchor job ID when the anchor generation is enqueued.
+func (r *AlbumRepository) UpdateAnchorJobID(ctx context.Context, id album.AlbumID, userID, jobID string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ a, ok := r.albums[id]
+ if !ok || a.UserID != userID {
+ return fmt.Errorf("album not found: %s", id)
+ }
+ a.AnchorJobID = jobID
+ a.UpdatedAt = time.Now().UTC()
+ return nil
+}
+
+// UpdateShotJobID stores the job ID for a shot when its generation is enqueued.
+func (r *AlbumRepository) UpdateShotJobID(ctx context.Context, id album.AlbumID, userID string, shotIndex int, jobID string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ a, ok := r.albums[id]
+ if !ok || a.UserID != userID {
+ return fmt.Errorf("album not found: %s", id)
+ }
+ if shotIndex < 0 || shotIndex >= len(a.Shots) {
+ return fmt.Errorf("shot index out of range: %d", shotIndex)
+ }
+ a.Shots[shotIndex].JobID = jobID
+ a.Shots[shotIndex].Status = album.ShotGenerating
+ a.UpdatedAt = time.Now().UTC()
+ return nil
+}
diff --git a/services/persona-api/internal/adapter/memory/auth_code.go b/services/persona-api/internal/adapter/memory/auth_code.go
new file mode 100644
index 0000000..55cf03e
--- /dev/null
+++ b/services/persona-api/internal/adapter/memory/auth_code.go
@@ -0,0 +1,87 @@
+package memory
+
+import (
+ "context"
+ "log/slog"
+ "sync"
+ "time"
+
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+)
+
+// Compile-time interface check.
+var _ port.AuthCodeRepository = (*AuthCodeRepository)(nil)
+
+// AuthCodeRepository is an in-memory auth code store for standalone development.
+type AuthCodeRepository struct {
+ mu sync.RWMutex
+ codes map[string]*domain.AuthCode
+}
+
+// NewAuthCodeRepository creates a new in-memory auth code repository.
+func NewAuthCodeRepository() *AuthCodeRepository {
+ return &AuthCodeRepository{
+ codes: make(map[string]*domain.AuthCode),
+ }
+}
+
+func (r *AuthCodeRepository) Create(_ context.Context, code *domain.AuthCode) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ cp := *code
+ r.codes[code.ID] = &cp
+
+ // In standalone dev mode the code lives only in memory and is lost on restart.
+ // Always log it so the developer can copy-paste the code from the terminal
+ // even when NOTIFY_URL is set and an email is also being delivered.
+ slog.Warn("[DEV] auth code created — use this code to log in",
+ "email", code.Email,
+ "purpose", code.Purpose,
+ "code", code.Code,
+ "expires_at", code.ExpiresAt.Format("15:04:05"),
+ )
+ return nil
+}
+
+func (r *AuthCodeRepository) FindValid(_ context.Context, email string, code string, purpose domain.AuthCodePurpose) (*domain.AuthCode, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ for _, c := range r.codes {
+ if c.Email == email && c.Code == code && c.Purpose == purpose && c.IsValid() {
+ cp := *c
+ return &cp, nil
+ }
+ }
+ return nil, domain.ErrInvalidAuthCode
+}
+
+func (r *AuthCodeRepository) MarkUsed(_ context.Context, id string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ c, ok := r.codes[id]
+ if !ok {
+ return domain.ErrInvalidAuthCode
+ }
+ now := time.Now()
+ c.UsedAt = &now
+ return nil
+}
+
+func (r *AuthCodeRepository) DeleteExpired(_ context.Context) (int, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ now := time.Now()
+ deleted := 0
+ for id, c := range r.codes {
+ if now.After(c.ExpiresAt) {
+ delete(r.codes, id)
+ deleted++
+ }
+ }
+ return deleted, nil
+}
diff --git a/services/persona-api/internal/adapter/memory/example.go b/services/persona-api/internal/adapter/memory/example.go
new file mode 100644
index 0000000..df36cce
--- /dev/null
+++ b/services/persona-api/internal/adapter/memory/example.go
@@ -0,0 +1,106 @@
+// Package memory provides in-memory implementations of repository interfaces.
+// Useful for development, testing, and prototyping.
+package memory
+
+import (
+ "context"
+ "sync"
+
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+)
+
+// Compile-time verification that ExampleRepository implements port.ExampleRepository.
+var _ port.ExampleRepository = (*ExampleRepository)(nil)
+
+// ExampleRepository is a thread-safe in-memory implementation of port.ExampleRepository.
+type ExampleRepository struct {
+ mu sync.RWMutex
+ examples map[domain.ExampleID]*domain.Example
+}
+
+// NewExampleRepository creates a new in-memory example repository.
+func NewExampleRepository() *ExampleRepository {
+ return &ExampleRepository{
+ examples: make(map[domain.ExampleID]*domain.Example),
+ }
+}
+
+// List returns all examples.
+func (r *ExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ result := make([]domain.Example, 0, len(r.examples))
+ for _, e := range r.examples {
+ result = append(result, *e)
+ }
+ return result, nil
+}
+
+// Get returns an example by ID.
+// Returns domain.ErrExampleNotFound if not found.
+func (r *ExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ e, ok := r.examples[id]
+ if !ok {
+ return nil, domain.ErrExampleNotFound
+ }
+ // Return a copy to prevent external mutation
+ copy := *e
+ return ©, nil
+}
+
+// Create stores a new example.
+func (r *ExampleRepository) Create(ctx context.Context, example *domain.Example) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ // Store a copy to prevent external mutation
+ copy := *example
+ r.examples[example.ID] = ©
+ return nil
+}
+
+// Update modifies an existing example.
+// Returns domain.ErrExampleNotFound if not found.
+func (r *ExampleRepository) Update(ctx context.Context, example *domain.Example) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ if _, ok := r.examples[example.ID]; !ok {
+ return domain.ErrExampleNotFound
+ }
+ // Store a copy to prevent external mutation
+ copy := *example
+ r.examples[example.ID] = ©
+ return nil
+}
+
+// Delete removes an example by ID.
+// Returns domain.ErrExampleNotFound if not found.
+func (r *ExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ if _, ok := r.examples[id]; !ok {
+ return domain.ErrExampleNotFound
+ }
+ delete(r.examples, id)
+ return nil
+}
+
+// ExistsByName checks if an example with the given name exists.
+func (r *ExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ for _, e := range r.examples {
+ if e.Name == name {
+ return true, nil
+ }
+ }
+ return false, nil
+}
diff --git a/services/persona-api/internal/adapter/memory/media.go b/services/persona-api/internal/adapter/memory/media.go
new file mode 100644
index 0000000..b75717b
--- /dev/null
+++ b/services/persona-api/internal/adapter/memory/media.go
@@ -0,0 +1,135 @@
+package memory
+
+import (
+ "context"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+)
+
+// Compile-time interface check.
+var _ port.MediaRepository = (*MediaRepository)(nil)
+
+// MediaRepository is an in-memory media metadata store for standalone development.
+type MediaRepository struct {
+ mu sync.RWMutex
+ objects map[domain.MediaObjectID]*domain.MediaObject
+ byPath map[string]domain.MediaObjectID
+}
+
+// NewMediaRepository creates a new in-memory media repository.
+func NewMediaRepository() *MediaRepository {
+ return &MediaRepository{
+ objects: make(map[domain.MediaObjectID]*domain.MediaObject),
+ byPath: make(map[string]domain.MediaObjectID),
+ }
+}
+
+func (r *MediaRepository) copyObject(obj *domain.MediaObject) *domain.MediaObject {
+ cp := *obj
+ return &cp
+}
+
+func (r *MediaRepository) Create(_ context.Context, obj *domain.MediaObject) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ r.objects[obj.ID] = r.copyObject(obj)
+ r.byPath[obj.Path] = obj.ID
+ return nil
+}
+
+func (r *MediaRepository) Get(_ context.Context, id domain.MediaObjectID) (*domain.MediaObject, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ obj, ok := r.objects[id]
+ if !ok || obj.DeletedAt != nil {
+ return nil, domain.ErrNotFound
+ }
+ return r.copyObject(obj), nil
+}
+
+func (r *MediaRepository) ListByUser(_ context.Context, userID domain.UserID, opts port.ListMediaOptions) ([]domain.MediaObject, int, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ var all []domain.MediaObject
+ for _, obj := range r.objects {
+ if obj.UserID != userID || obj.DeletedAt != nil {
+ continue
+ }
+ if opts.ContentTypePrefix != "" && !strings.HasPrefix(obj.ContentType, opts.ContentTypePrefix) {
+ continue
+ }
+ all = append(all, *r.copyObject(obj))
+ }
+
+ // Sort by created_at DESC
+ sort.Slice(all, func(i, j int) bool {
+ return all[i].CreatedAt.After(all[j].CreatedAt)
+ })
+
+ total := len(all)
+
+ // Apply pagination
+ limit := opts.Limit
+ if limit <= 0 {
+ limit = 50
+ }
+ offset := opts.Offset
+ if offset > len(all) {
+ offset = len(all)
+ }
+ end := offset + limit
+ if end > len(all) {
+ end = len(all)
+ }
+
+ return all[offset:end], total, nil
+}
+
+func (r *MediaRepository) SoftDelete(_ context.Context, id domain.MediaObjectID) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ obj, ok := r.objects[id]
+ if !ok {
+ return domain.ErrNotFound
+ }
+ now := time.Now()
+ obj.DeletedAt = &now
+ return nil
+}
+
+func (r *MediaRepository) HardDelete(_ context.Context, id domain.MediaObjectID) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ obj, ok := r.objects[id]
+ if !ok {
+ return domain.ErrNotFound
+ }
+ delete(r.byPath, obj.Path)
+ delete(r.objects, id)
+ return nil
+}
+
+func (r *MediaRepository) GetByPath(_ context.Context, path string) (*domain.MediaObject, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ id, ok := r.byPath[path]
+ if !ok {
+ return nil, domain.ErrNotFound
+ }
+ obj, ok := r.objects[id]
+ if !ok || obj.DeletedAt != nil {
+ return nil, domain.ErrNotFound
+ }
+ return r.copyObject(obj), nil
+}
diff --git a/services/persona-api/internal/adapter/memory/session.go b/services/persona-api/internal/adapter/memory/session.go
new file mode 100644
index 0000000..bc7c309
--- /dev/null
+++ b/services/persona-api/internal/adapter/memory/session.go
@@ -0,0 +1,120 @@
+package memory
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+)
+
+// Compile-time interface check.
+var _ port.SessionRepository = (*SessionRepository)(nil)
+
+// SessionRepository is an in-memory session store for standalone development.
+type SessionRepository struct {
+ mu sync.RWMutex
+ sessions map[domain.SessionID]*domain.Session
+}
+
+// NewSessionRepository creates a new in-memory session repository.
+func NewSessionRepository() *SessionRepository {
+ return &SessionRepository{
+ sessions: make(map[domain.SessionID]*domain.Session),
+ }
+}
+
+func (r *SessionRepository) copySession(s *domain.Session) *domain.Session {
+ cp := *s
+ return &cp
+}
+
+func (r *SessionRepository) Create(_ context.Context, session *domain.Session) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ r.sessions[session.ID] = r.copySession(session)
+ return nil
+}
+
+func (r *SessionRepository) Get(_ context.Context, id domain.SessionID) (*domain.Session, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ s, ok := r.sessions[id]
+ if !ok {
+ return nil, domain.ErrSessionNotFound
+ }
+ return r.copySession(s), nil
+}
+
+func (r *SessionRepository) ListByUser(_ context.Context, userID domain.UserID) ([]domain.Session, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ now := time.Now()
+ var result []domain.Session
+ for _, s := range r.sessions {
+ if s.UserID == userID && s.RevokedAt == nil && s.ExpiresAt.After(now) {
+ result = append(result, *r.copySession(s))
+ }
+ }
+ return result, nil
+}
+
+func (r *SessionRepository) UpdateLastActive(_ context.Context, id domain.SessionID) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ s, ok := r.sessions[id]
+ if !ok {
+ return domain.ErrSessionNotFound
+ }
+ s.LastActiveAt = time.Now()
+ return nil
+}
+
+func (r *SessionRepository) Revoke(_ context.Context, id domain.SessionID) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ s, ok := r.sessions[id]
+ if !ok {
+ return domain.ErrSessionNotFound
+ }
+ now := time.Now()
+ s.RevokedAt = &now
+ return nil
+}
+
+func (r *SessionRepository) RevokeAllForUser(_ context.Context, userID domain.UserID, exceptID *domain.SessionID) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ now := time.Now()
+ for _, s := range r.sessions {
+ if s.UserID == userID && s.RevokedAt == nil {
+ if exceptID != nil && s.ID == *exceptID {
+ continue
+ }
+ s.RevokedAt = &now
+ }
+ }
+ return nil
+}
+
+func (r *SessionRepository) DeleteExpired(_ context.Context) (int, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ now := time.Now()
+ deleted := 0
+ for id, s := range r.sessions {
+ if now.After(s.ExpiresAt) {
+ delete(r.sessions, id)
+ deleted++
+ }
+ }
+ return deleted, nil
+}
diff --git a/services/persona-api/internal/adapter/memory/user.go b/services/persona-api/internal/adapter/memory/user.go
new file mode 100644
index 0000000..8a51433
--- /dev/null
+++ b/services/persona-api/internal/adapter/memory/user.go
@@ -0,0 +1,243 @@
+package memory
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/auth"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+)
+
+// Compile-time interface check.
+var _ port.UserRepository = (*UserRepository)(nil)
+
+// UserRepository is an in-memory user store with bcrypt password hashing.
+// Pre-populated with demo users for standalone development.
+type UserRepository struct {
+ mu sync.RWMutex
+ users map[domain.UserID]*domain.User
+ passwords map[domain.UserID]string // bcrypt hashes
+ roles map[domain.UserID][]string // role lists
+ byEmail map[string]domain.UserID // email → user ID index
+}
+
+// NewUserRepository creates a new in-memory user repository seeded with demo users.
+// If devEmail is non-empty, an additional user is seeded with that email and devPassword
+// so the developer's account survives server restarts without re-registering.
+func NewUserRepository(devEmail, devPassword string) *UserRepository {
+ repo := &UserRepository{
+ users: make(map[domain.UserID]*domain.User),
+ passwords: make(map[domain.UserID]string),
+ roles: make(map[domain.UserID][]string),
+ byEmail: make(map[string]domain.UserID),
+ }
+
+ // Seed demo users with bcrypt-hashed passwords.
+ // Passwords meet complexity requirements (min 8 chars, uppercase, lowercase, digit).
+ repo.seedUser("usr_test_001", "test@example.com", "Test User", "Password123", []string{"user"})
+ repo.seedUser("usr_admin_001", "admin@example.com", "Admin User", "Admin1234", []string{"admin", "user"})
+
+ // Seed the developer's own account if DEV_USER_EMAIL is configured.
+ // This ensures the email is always registered after restarts without manual re-registration.
+ if devEmail != "" {
+ repo.seedUser("usr_dev_001", devEmail, "Dev User", devPassword, []string{"admin", "user"})
+ }
+
+ return repo
+}
+
+func (r *UserRepository) seedUser(id, email, name, password string, userRoles []string) {
+ uid := domain.UserID(id)
+ now := time.Now()
+
+ hash, err := auth.HashPassword(password)
+ if err != nil {
+ panic("failed to hash seed password: " + err.Error())
+ }
+
+ r.users[uid] = &domain.User{
+ ID: uid,
+ Email: email,
+ EmailVerified: true,
+ Name: name,
+ Status: domain.UserStatusActive,
+ Roles: userRoles,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ r.passwords[uid] = hash
+ r.roles[uid] = userRoles
+ r.byEmail[email] = uid
+}
+
+func (r *UserRepository) copyUser(u *domain.User) *domain.User {
+ cp := *u
+ cp.Roles = make([]string, len(u.Roles))
+ copy(cp.Roles, u.Roles)
+ return &cp
+}
+
+func (r *UserRepository) Create(_ context.Context, user *domain.User) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ if _, exists := r.byEmail[user.Email]; exists {
+ return domain.ErrDuplicateEmail
+ }
+
+ r.users[user.ID] = r.copyUser(user)
+ r.byEmail[user.Email] = user.ID
+ r.roles[user.ID] = make([]string, len(user.Roles))
+ copy(r.roles[user.ID], user.Roles)
+ return nil
+}
+
+func (r *UserRepository) Get(_ context.Context, id domain.UserID) (*domain.User, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ u, ok := r.users[id]
+ if !ok {
+ return nil, domain.ErrUserNotFound
+ }
+ return r.copyUser(u), nil
+}
+
+func (r *UserRepository) GetByEmail(_ context.Context, email string) (*domain.User, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ uid, ok := r.byEmail[email]
+ if !ok {
+ return nil, domain.ErrUserNotFound
+ }
+ return r.copyUser(r.users[uid]), nil
+}
+
+func (r *UserRepository) Update(_ context.Context, user *domain.User) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ existing, ok := r.users[user.ID]
+ if !ok {
+ return domain.ErrUserNotFound
+ }
+
+ // If email changed, update the index.
+ if existing.Email != user.Email {
+ if _, taken := r.byEmail[user.Email]; taken {
+ return domain.ErrDuplicateEmail
+ }
+ delete(r.byEmail, existing.Email)
+ r.byEmail[user.Email] = user.ID
+ }
+
+ user.UpdatedAt = time.Now()
+ r.users[user.ID] = r.copyUser(user)
+ return nil
+}
+
+func (r *UserRepository) UpdateLastLogin(_ context.Context, id domain.UserID) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ u, ok := r.users[id]
+ if !ok {
+ return domain.ErrUserNotFound
+ }
+ now := time.Now()
+ u.LastLoginAt = &now
+ return nil
+}
+
+func (r *UserRepository) ExistsByEmail(_ context.Context, email string) (bool, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ _, ok := r.byEmail[email]
+ return ok, nil
+}
+
+func (r *UserRepository) SetPassword(_ context.Context, userID domain.UserID, hash string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ if _, ok := r.users[userID]; !ok {
+ return domain.ErrUserNotFound
+ }
+ r.passwords[userID] = hash
+ return nil
+}
+
+func (r *UserRepository) GetPasswordHash(_ context.Context, userID domain.UserID) (string, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ hash := r.passwords[userID]
+ return hash, nil
+}
+
+func (r *UserRepository) HasPassword(_ context.Context, userID domain.UserID) (bool, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ _, ok := r.passwords[userID]
+ return ok, nil
+}
+
+func (r *UserRepository) AddRole(_ context.Context, userID domain.UserID, role string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ u, ok := r.users[userID]
+ if !ok {
+ return domain.ErrUserNotFound
+ }
+
+ for _, existing := range r.roles[userID] {
+ if existing == role {
+ return nil
+ }
+ }
+ r.roles[userID] = append(r.roles[userID], role)
+ u.Roles = make([]string, len(r.roles[userID]))
+ copy(u.Roles, r.roles[userID])
+ return nil
+}
+
+func (r *UserRepository) RemoveRole(_ context.Context, userID domain.UserID, role string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ u, ok := r.users[userID]
+ if !ok {
+ return domain.ErrUserNotFound
+ }
+
+ filtered := make([]string, 0, len(r.roles[userID]))
+ for _, existing := range r.roles[userID] {
+ if existing != role {
+ filtered = append(filtered, existing)
+ }
+ }
+ r.roles[userID] = filtered
+ u.Roles = make([]string, len(filtered))
+ copy(u.Roles, filtered)
+ return nil
+}
+
+func (r *UserRepository) GetRoles(_ context.Context, userID domain.UserID) ([]string, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ if _, ok := r.users[userID]; !ok {
+ return nil, domain.ErrUserNotFound
+ }
+
+ roles := r.roles[userID]
+ result := make([]string, len(roles))
+ copy(result, roles)
+ return result, nil
+}
diff --git a/services/persona-api/internal/adapter/postgres/auth_code.go b/services/persona-api/internal/adapter/postgres/auth_code.go
new file mode 100644
index 0000000..f5ebff8
--- /dev/null
+++ b/services/persona-api/internal/adapter/postgres/auth_code.go
@@ -0,0 +1,120 @@
+package postgres
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/jmoiron/sqlx"
+
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+)
+
+// Compile-time interface check.
+var _ port.AuthCodeRepository = (*AuthCodeRepository)(nil)
+
+// authCodeRow maps to the auth_codes table.
+type authCodeRow struct {
+ ID string `db:"id"`
+ UserID *string `db:"user_id"`
+ Email string `db:"email"`
+ Code string `db:"code"`
+ Purpose string `db:"purpose"`
+ ExpiresAt time.Time `db:"expires_at"`
+ UsedAt *time.Time `db:"used_at"`
+ IPAddress string `db:"ip_address"`
+ CreatedAt time.Time `db:"created_at"`
+}
+
+func (r *authCodeRow) toDomain() *domain.AuthCode {
+ ac := &domain.AuthCode{
+ ID: r.ID,
+ Email: r.Email,
+ Code: r.Code,
+ Purpose: domain.AuthCodePurpose(r.Purpose),
+ ExpiresAt: r.ExpiresAt,
+ UsedAt: r.UsedAt,
+ IPAddress: r.IPAddress,
+ CreatedAt: r.CreatedAt,
+ }
+ if r.UserID != nil {
+ uid := domain.UserID(*r.UserID)
+ ac.UserID = &uid
+ }
+ return ac
+}
+
+// AuthCodeRepository implements port.AuthCodeRepository with PostgreSQL/CockroachDB.
+type AuthCodeRepository struct {
+ db *sqlx.DB
+}
+
+// NewAuthCodeRepository creates a new Postgres-backed auth code repository.
+func NewAuthCodeRepository(db *sqlx.DB) *AuthCodeRepository {
+ return &AuthCodeRepository{db: db}
+}
+
+func (r *AuthCodeRepository) Create(ctx context.Context, code *domain.AuthCode) error {
+ var userID *string
+ if code.UserID != nil {
+ s := string(*code.UserID)
+ userID = &s
+ }
+
+ _, err := r.db.ExecContext(ctx, `
+ INSERT INTO auth_codes (id, user_id, email, code, purpose, expires_at, ip_address, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ `, code.ID, userID, code.Email, code.Code, string(code.Purpose),
+ code.ExpiresAt, code.IPAddress, code.CreatedAt)
+ if err != nil {
+ return fmt.Errorf("insert auth code: %w", err)
+ }
+ return nil
+}
+
+func (r *AuthCodeRepository) FindValid(ctx context.Context, email string, code string, purpose domain.AuthCodePurpose) (*domain.AuthCode, error) {
+ var row authCodeRow
+ err := r.db.GetContext(ctx, &row, `
+ SELECT id, user_id, email, code, purpose, expires_at, used_at, ip_address, created_at
+ FROM auth_codes
+ WHERE email = $1 AND code = $2 AND purpose = $3
+ AND used_at IS NULL AND expires_at > NOW()
+ ORDER BY created_at DESC
+ LIMIT 1
+ `, email, code, string(purpose))
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, domain.ErrInvalidAuthCode
+ }
+ return nil, fmt.Errorf("find valid auth code: %w", err)
+ }
+ return row.toDomain(), nil
+}
+
+func (r *AuthCodeRepository) MarkUsed(ctx context.Context, id string) error {
+ _, err := r.db.ExecContext(ctx, `
+ UPDATE auth_codes SET used_at = NOW() WHERE id = $1
+ `, id)
+ if err != nil {
+ return fmt.Errorf("mark auth code used: %w", err)
+ }
+ return nil
+}
+
+func (r *AuthCodeRepository) DeleteExpired(ctx context.Context) (int, error) {
+ result, err := r.db.ExecContext(ctx, `
+ DELETE FROM auth_codes WHERE expires_at < NOW()
+ `)
+ if err != nil {
+ return 0, fmt.Errorf("delete expired auth codes: %w", err)
+ }
+
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return 0, fmt.Errorf("delete expired rows affected: %w", err)
+ }
+ return int(rows), nil
+}
diff --git a/services/persona-api/internal/adapter/postgres/media.go b/services/persona-api/internal/adapter/postgres/media.go
new file mode 100644
index 0000000..ce45acd
--- /dev/null
+++ b/services/persona-api/internal/adapter/postgres/media.go
@@ -0,0 +1,184 @@
+package postgres
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/jmoiron/sqlx"
+
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+)
+
+// Compile-time interface check.
+var _ port.MediaRepository = (*MediaObjectRepository)(nil)
+
+// mediaObjectRow maps to the media_objects table.
+type mediaObjectRow struct {
+ ID string `db:"id"`
+ UserID string `db:"user_id"`
+ Path string `db:"path"`
+ Filename string `db:"filename"`
+ ContentType string `db:"content_type"`
+ Size int64 `db:"size"`
+ GenerationJobID string `db:"generation_job_id"`
+ DeletedAt *time.Time `db:"deleted_at"`
+ CreatedAt time.Time `db:"created_at"`
+ UpdatedAt time.Time `db:"updated_at"`
+}
+
+func (r *mediaObjectRow) toDomain() *domain.MediaObject {
+ return &domain.MediaObject{
+ ID: domain.MediaObjectID(r.ID),
+ UserID: domain.UserID(r.UserID),
+ Path: r.Path,
+ Filename: r.Filename,
+ ContentType: r.ContentType,
+ Size: r.Size,
+ GenerationJobID: r.GenerationJobID,
+ DeletedAt: r.DeletedAt,
+ CreatedAt: r.CreatedAt,
+ UpdatedAt: r.UpdatedAt,
+ }
+}
+
+// MediaObjectRepository implements port.MediaRepository with PostgreSQL/CockroachDB.
+type MediaObjectRepository struct {
+ db *sqlx.DB
+}
+
+// NewMediaObjectRepository creates a new Postgres-backed media repository.
+func NewMediaObjectRepository(db *sqlx.DB) *MediaObjectRepository {
+ return &MediaObjectRepository{db: db}
+}
+
+func (r *MediaObjectRepository) Create(ctx context.Context, obj *domain.MediaObject) error {
+ _, err := r.db.ExecContext(ctx, `
+ INSERT INTO media_objects (id, user_id, path, filename, content_type, size, generation_job_id, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ `, string(obj.ID), string(obj.UserID), obj.Path, obj.Filename, obj.ContentType,
+ obj.Size, obj.GenerationJobID, obj.CreatedAt, obj.UpdatedAt)
+ if err != nil {
+ return fmt.Errorf("insert media object: %w", err)
+ }
+ return nil
+}
+
+func (r *MediaObjectRepository) Get(ctx context.Context, id domain.MediaObjectID) (*domain.MediaObject, error) {
+ var row mediaObjectRow
+ err := r.db.GetContext(ctx, &row, `
+ SELECT id, user_id, path, filename, content_type, size, generation_job_id, deleted_at, created_at, updated_at
+ FROM media_objects WHERE id = $1 AND deleted_at IS NULL
+ `, string(id))
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, domain.ErrNotFound
+ }
+ return nil, fmt.Errorf("get media object: %w", err)
+ }
+ return row.toDomain(), nil
+}
+
+func (r *MediaObjectRepository) ListByUser(ctx context.Context, userID domain.UserID, opts port.ListMediaOptions) ([]domain.MediaObject, int, error) {
+ limit := opts.Limit
+ if limit <= 0 {
+ limit = 50
+ }
+
+ // Count total matching records
+ countQuery := `SELECT COUNT(*) FROM media_objects WHERE user_id = $1 AND deleted_at IS NULL`
+ args := []any{string(userID)}
+ argIdx := 2
+
+ if opts.ContentTypePrefix != "" {
+ countQuery += fmt.Sprintf(` AND content_type LIKE $%d`, argIdx)
+ args = append(args, opts.ContentTypePrefix+"%")
+ argIdx++
+ }
+
+ var total int
+ if err := r.db.GetContext(ctx, &total, countQuery, args...); err != nil {
+ return nil, 0, fmt.Errorf("count media objects: %w", err)
+ }
+
+ // Fetch paginated results
+ query := `
+ SELECT id, user_id, path, filename, content_type, size, generation_job_id, deleted_at, created_at, updated_at
+ FROM media_objects
+ WHERE user_id = $1 AND deleted_at IS NULL`
+
+ fetchArgs := []any{string(userID)}
+ fetchIdx := 2
+
+ if opts.ContentTypePrefix != "" {
+ query += fmt.Sprintf(` AND content_type LIKE $%d`, fetchIdx)
+ fetchArgs = append(fetchArgs, opts.ContentTypePrefix+"%")
+ fetchIdx++
+ }
+
+ query += ` ORDER BY created_at DESC`
+ query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, fetchIdx, fetchIdx+1)
+ fetchArgs = append(fetchArgs, limit, opts.Offset)
+
+ var rows []mediaObjectRow
+ if err := r.db.SelectContext(ctx, &rows, query, fetchArgs...); err != nil {
+ return nil, 0, fmt.Errorf("list media objects: %w", err)
+ }
+
+ objects := make([]domain.MediaObject, len(rows))
+ for i := range rows {
+ objects[i] = *rows[i].toDomain()
+ }
+ return objects, total, nil
+}
+
+func (r *MediaObjectRepository) SoftDelete(ctx context.Context, id domain.MediaObjectID) error {
+ result, err := r.db.ExecContext(ctx, `
+ UPDATE media_objects SET deleted_at = NOW(), updated_at = NOW()
+ WHERE id = $1 AND deleted_at IS NULL
+ `, string(id))
+ if err != nil {
+ return fmt.Errorf("soft delete media object: %w", err)
+ }
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("soft delete rows affected: %w", err)
+ }
+ if rows == 0 {
+ return domain.ErrNotFound
+ }
+ return nil
+}
+
+func (r *MediaObjectRepository) HardDelete(ctx context.Context, id domain.MediaObjectID) error {
+ result, err := r.db.ExecContext(ctx, `DELETE FROM media_objects WHERE id = $1`, string(id))
+ if err != nil {
+ return fmt.Errorf("hard delete media object: %w", err)
+ }
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("hard delete rows affected: %w", err)
+ }
+ if rows == 0 {
+ return domain.ErrNotFound
+ }
+ return nil
+}
+
+func (r *MediaObjectRepository) GetByPath(ctx context.Context, path string) (*domain.MediaObject, error) {
+ var row mediaObjectRow
+ err := r.db.GetContext(ctx, &row, `
+ SELECT id, user_id, path, filename, content_type, size, generation_job_id, deleted_at, created_at, updated_at
+ FROM media_objects WHERE path = $1 AND deleted_at IS NULL
+ `, path)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, domain.ErrNotFound
+ }
+ return nil, fmt.Errorf("get media object by path: %w", err)
+ }
+ return row.toDomain(), nil
+}
diff --git a/services/persona-api/internal/adapter/postgres/session.go b/services/persona-api/internal/adapter/postgres/session.go
new file mode 100644
index 0000000..dc5f325
--- /dev/null
+++ b/services/persona-api/internal/adapter/postgres/session.go
@@ -0,0 +1,162 @@
+package postgres
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/jmoiron/sqlx"
+
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+)
+
+// Compile-time interface check.
+var _ port.SessionRepository = (*SessionRepository)(nil)
+
+// sessionRow maps to the sessions table.
+type sessionRow struct {
+ ID string `db:"id"`
+ UserID string `db:"user_id"`
+ IPAddress string `db:"ip_address"`
+ UserAgent string `db:"user_agent"`
+ DeviceLabel string `db:"device_label"`
+ LastActiveAt time.Time `db:"last_active_at"`
+ ExpiresAt time.Time `db:"expires_at"`
+ RevokedAt *time.Time `db:"revoked_at"`
+ CreatedAt time.Time `db:"created_at"`
+}
+
+func (r *sessionRow) toDomain() *domain.Session {
+ return &domain.Session{
+ ID: domain.SessionID(r.ID),
+ UserID: domain.UserID(r.UserID),
+ IPAddress: r.IPAddress,
+ UserAgent: r.UserAgent,
+ DeviceLabel: r.DeviceLabel,
+ LastActiveAt: r.LastActiveAt,
+ ExpiresAt: r.ExpiresAt,
+ RevokedAt: r.RevokedAt,
+ CreatedAt: r.CreatedAt,
+ }
+}
+
+// SessionRepository implements port.SessionRepository with PostgreSQL/CockroachDB.
+type SessionRepository struct {
+ db *sqlx.DB
+}
+
+// NewSessionRepository creates a new Postgres-backed session repository.
+func NewSessionRepository(db *sqlx.DB) *SessionRepository {
+ return &SessionRepository{db: db}
+}
+
+func (r *SessionRepository) Create(ctx context.Context, session *domain.Session) error {
+ _, err := r.db.ExecContext(ctx, `
+ INSERT INTO sessions (id, user_id, ip_address, user_agent, device_label, last_active_at, expires_at, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ `, string(session.ID), string(session.UserID), session.IPAddress, session.UserAgent,
+ session.DeviceLabel, session.LastActiveAt, session.ExpiresAt, session.CreatedAt)
+ if err != nil {
+ return fmt.Errorf("insert session: %w", err)
+ }
+ return nil
+}
+
+func (r *SessionRepository) Get(ctx context.Context, id domain.SessionID) (*domain.Session, error) {
+ var row sessionRow
+ err := r.db.GetContext(ctx, &row, `
+ SELECT id, user_id, ip_address, user_agent, device_label, last_active_at, expires_at, revoked_at, created_at
+ FROM sessions WHERE id = $1
+ `, string(id))
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, domain.ErrSessionNotFound
+ }
+ return nil, fmt.Errorf("get session: %w", err)
+ }
+ return row.toDomain(), nil
+}
+
+func (r *SessionRepository) ListByUser(ctx context.Context, userID domain.UserID) ([]domain.Session, error) {
+ var rows []sessionRow
+ err := r.db.SelectContext(ctx, &rows, `
+ SELECT id, user_id, ip_address, user_agent, device_label, last_active_at, expires_at, revoked_at, created_at
+ FROM sessions
+ WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW()
+ ORDER BY last_active_at DESC
+ `, string(userID))
+ if err != nil {
+ return nil, fmt.Errorf("list sessions: %w", err)
+ }
+
+ sessions := make([]domain.Session, len(rows))
+ for i := range rows {
+ sessions[i] = *rows[i].toDomain()
+ }
+ return sessions, nil
+}
+
+func (r *SessionRepository) UpdateLastActive(ctx context.Context, id domain.SessionID) error {
+ _, err := r.db.ExecContext(ctx, `
+ UPDATE sessions SET last_active_at = NOW() WHERE id = $1
+ `, string(id))
+ if err != nil {
+ return fmt.Errorf("update last active: %w", err)
+ }
+ return nil
+}
+
+func (r *SessionRepository) Revoke(ctx context.Context, id domain.SessionID) error {
+ result, err := r.db.ExecContext(ctx, `
+ UPDATE sessions SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL
+ `, string(id))
+ if err != nil {
+ return fmt.Errorf("revoke session: %w", err)
+ }
+
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("revoke session rows affected: %w", err)
+ }
+ if rows == 0 {
+ return domain.ErrSessionNotFound
+ }
+
+ return nil
+}
+
+func (r *SessionRepository) RevokeAllForUser(ctx context.Context, userID domain.UserID, exceptID *domain.SessionID) error {
+ if exceptID != nil {
+ _, err := r.db.ExecContext(ctx, `
+ UPDATE sessions SET revoked_at = NOW()
+ WHERE user_id = $1 AND revoked_at IS NULL AND id != $2
+ `, string(userID), string(*exceptID))
+ if err != nil {
+ return fmt.Errorf("revoke all sessions except: %w", err)
+ }
+ } else {
+ _, err := r.db.ExecContext(ctx, `
+ UPDATE sessions SET revoked_at = NOW()
+ WHERE user_id = $1 AND revoked_at IS NULL
+ `, string(userID))
+ if err != nil {
+ return fmt.Errorf("revoke all sessions: %w", err)
+ }
+ }
+ return nil
+}
+
+func (r *SessionRepository) DeleteExpired(ctx context.Context) (int, error) {
+ result, err := r.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < NOW()`)
+ if err != nil {
+ return 0, fmt.Errorf("delete expired sessions: %w", err)
+ }
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return 0, fmt.Errorf("delete expired sessions rows: %w", err)
+ }
+ return int(rows), nil
+}
diff --git a/services/persona-api/internal/adapter/postgres/user.go b/services/persona-api/internal/adapter/postgres/user.go
new file mode 100644
index 0000000..b3d1168
--- /dev/null
+++ b/services/persona-api/internal/adapter/postgres/user.go
@@ -0,0 +1,260 @@
+package postgres
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/jmoiron/sqlx"
+ "github.com/lib/pq"
+
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+)
+
+// Compile-time interface check.
+var _ port.UserRepository = (*UserRepository)(nil)
+
+// userRow maps to the users table.
+type userRow struct {
+ ID string `db:"id"`
+ Email string `db:"email"`
+ EmailVerified bool `db:"email_verified"`
+ Name string `db:"name"`
+ AvatarURL string `db:"avatar_url"`
+ Status string `db:"status"`
+ LastLoginAt *time.Time `db:"last_login_at"`
+ CreatedAt time.Time `db:"created_at"`
+ UpdatedAt time.Time `db:"updated_at"`
+}
+
+func (r *userRow) toDomain(roles []string) *domain.User {
+ return &domain.User{
+ ID: domain.UserID(r.ID),
+ Email: r.Email,
+ EmailVerified: r.EmailVerified,
+ Name: r.Name,
+ AvatarURL: r.AvatarURL,
+ Status: domain.UserStatus(r.Status),
+ Roles: roles,
+ LastLoginAt: r.LastLoginAt,
+ CreatedAt: r.CreatedAt,
+ UpdatedAt: r.UpdatedAt,
+ }
+}
+
+// UserRepository implements port.UserRepository with PostgreSQL/CockroachDB.
+type UserRepository struct {
+ db *sqlx.DB
+}
+
+// NewUserRepository creates a new Postgres-backed user repository.
+func NewUserRepository(db *sqlx.DB) *UserRepository {
+ return &UserRepository{db: db}
+}
+
+func (r *UserRepository) Create(ctx context.Context, user *domain.User) error {
+ _, err := r.db.ExecContext(ctx, `
+ INSERT INTO users (id, email, email_verified, name, avatar_url, status, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ `, string(user.ID), user.Email, user.EmailVerified, user.Name, user.AvatarURL,
+ string(user.Status), user.CreatedAt, user.UpdatedAt)
+ if err != nil {
+ if isUniqueViolation(err) {
+ return domain.ErrDuplicateEmail
+ }
+ return fmt.Errorf("insert user: %w", err)
+ }
+
+ // Insert roles
+ for _, role := range user.Roles {
+ if _, err := r.db.ExecContext(ctx, `
+ INSERT INTO user_roles (user_id, role) VALUES ($1, $2)
+ ON CONFLICT (user_id, role) DO NOTHING
+ `, string(user.ID), role); err != nil {
+ return fmt.Errorf("insert role: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func (r *UserRepository) Get(ctx context.Context, id domain.UserID) (*domain.User, error) {
+ var row userRow
+ err := r.db.GetContext(ctx, &row, `
+ SELECT id, email, email_verified, name, avatar_url, status, last_login_at, created_at, updated_at
+ FROM users WHERE id = $1
+ `, string(id))
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, domain.ErrUserNotFound
+ }
+ return nil, fmt.Errorf("get user: %w", err)
+ }
+
+ roles, err := r.GetRoles(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+
+ return row.toDomain(roles), nil
+}
+
+func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
+ var row userRow
+ err := r.db.GetContext(ctx, &row, `
+ SELECT id, email, email_verified, name, avatar_url, status, last_login_at, created_at, updated_at
+ FROM users WHERE email = $1
+ `, email)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, domain.ErrUserNotFound
+ }
+ return nil, fmt.Errorf("get user by email: %w", err)
+ }
+
+ roles, err := r.GetRoles(ctx, domain.UserID(row.ID))
+ if err != nil {
+ return nil, err
+ }
+
+ return row.toDomain(roles), nil
+}
+
+func (r *UserRepository) Update(ctx context.Context, user *domain.User) error {
+ result, err := r.db.ExecContext(ctx, `
+ UPDATE users
+ SET email = $2, email_verified = $3, name = $4, avatar_url = $5,
+ status = $6, updated_at = $7
+ WHERE id = $1
+ `, string(user.ID), user.Email, user.EmailVerified, user.Name,
+ user.AvatarURL, string(user.Status), time.Now())
+ if err != nil {
+ if isUniqueViolation(err) {
+ return domain.ErrDuplicateEmail
+ }
+ return fmt.Errorf("update user: %w", err)
+ }
+
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("update user rows affected: %w", err)
+ }
+ if rows == 0 {
+ return domain.ErrUserNotFound
+ }
+
+ return nil
+}
+
+func (r *UserRepository) UpdateLastLogin(ctx context.Context, id domain.UserID) error {
+ result, err := r.db.ExecContext(ctx, `
+ UPDATE users SET last_login_at = NOW() WHERE id = $1
+ `, string(id))
+ if err != nil {
+ return fmt.Errorf("update last login: %w", err)
+ }
+
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("update last login rows affected: %w", err)
+ }
+ if rows == 0 {
+ return domain.ErrUserNotFound
+ }
+
+ return nil
+}
+
+func (r *UserRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) {
+ var exists bool
+ err := r.db.GetContext(ctx, &exists, `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`, email)
+ if err != nil {
+ return false, fmt.Errorf("exists by email: %w", err)
+ }
+ return exists, nil
+}
+
+func (r *UserRepository) SetPassword(ctx context.Context, userID domain.UserID, hash string) error {
+ _, err := r.db.ExecContext(ctx, `
+ INSERT INTO user_passwords (user_id, password_hash, updated_at)
+ VALUES ($1, $2, NOW())
+ ON CONFLICT (user_id) DO UPDATE SET password_hash = $2, updated_at = NOW()
+ `, string(userID), hash)
+ if err != nil {
+ return fmt.Errorf("set password: %w", err)
+ }
+ return nil
+}
+
+func (r *UserRepository) GetPasswordHash(ctx context.Context, userID domain.UserID) (string, error) {
+ var hash string
+ err := r.db.GetContext(ctx, &hash, `
+ SELECT password_hash FROM user_passwords WHERE user_id = $1
+ `, string(userID))
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", nil
+ }
+ return "", fmt.Errorf("get password hash: %w", err)
+ }
+ return hash, nil
+}
+
+func (r *UserRepository) HasPassword(ctx context.Context, userID domain.UserID) (bool, error) {
+ var exists bool
+ err := r.db.GetContext(ctx, &exists, `
+ SELECT EXISTS(SELECT 1 FROM user_passwords WHERE user_id = $1)
+ `, string(userID))
+ if err != nil {
+ return false, fmt.Errorf("has password: %w", err)
+ }
+ return exists, nil
+}
+
+func (r *UserRepository) AddRole(ctx context.Context, userID domain.UserID, role string) error {
+ _, err := r.db.ExecContext(ctx, `
+ INSERT INTO user_roles (user_id, role) VALUES ($1, $2)
+ ON CONFLICT (user_id, role) DO NOTHING
+ `, string(userID), role)
+ if err != nil {
+ return fmt.Errorf("add role: %w", err)
+ }
+ return nil
+}
+
+func (r *UserRepository) RemoveRole(ctx context.Context, userID domain.UserID, role string) error {
+ _, err := r.db.ExecContext(ctx, `
+ DELETE FROM user_roles WHERE user_id = $1 AND role = $2
+ `, string(userID), role)
+ if err != nil {
+ return fmt.Errorf("remove role: %w", err)
+ }
+ return nil
+}
+
+func (r *UserRepository) GetRoles(ctx context.Context, userID domain.UserID) ([]string, error) {
+ var roles []string
+ err := r.db.SelectContext(ctx, &roles, `
+ SELECT role FROM user_roles WHERE user_id = $1 ORDER BY role
+ `, string(userID))
+ if err != nil {
+ return nil, fmt.Errorf("get roles: %w", err)
+ }
+ if roles == nil {
+ roles = []string{}
+ }
+ return roles, nil
+}
+
+// isUniqueViolation checks if a database error is a unique constraint violation.
+// Works with both PostgreSQL (23505) and CockroachDB.
+func isUniqueViolation(err error) bool {
+ var pqErr *pq.Error
+ if errors.As(err, &pqErr) {
+ return pqErr.Code == "23505"
+ }
+ return false
+}
diff --git a/services/persona-api/internal/api/handlers/album.go b/services/persona-api/internal/api/handlers/album.go
new file mode 100644
index 0000000..21285d9
--- /dev/null
+++ b/services/persona-api/internal/api/handlers/album.go
@@ -0,0 +1,291 @@
+package handlers
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+
+ "github.com/go-chi/chi/v5"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/album"
+ "git.threesix.ai/jordan/persona-community-2/pkg/app"
+ "git.threesix.ai/jordan/persona-community-2/pkg/auth"
+ "git.threesix.ai/jordan/persona-community-2/pkg/httperror"
+ "git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/service"
+)
+
+// Album handles HTTP requests for album CRUD and generation endpoints.
+// All generation endpoints are async: they enqueue a job and return 202.
+// Results arrive via SSE events on the user: channel.
+type Album struct {
+ albums *service.AlbumService
+ logger *logging.Logger
+}
+
+// NewAlbum creates a new Album handler.
+func NewAlbum(albums *service.AlbumService, logger *logging.Logger) *Album {
+ return &Album{
+ albums: albums,
+ logger: logger.WithComponent("AlbumHandler"),
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Request/response types
+// ---------------------------------------------------------------------------
+
+// CreateAlbumRequest is the request body for POST /albums.
+type CreateAlbumRequest struct {
+ Name string `json:"name" validate:"required,min=1,max=100"`
+ SubjectDesc string `json:"subjectDesc" validate:"required,min=1,max=500"`
+ Shots []ShotTemplateBody `json:"shots" validate:"required,min=1,max=20"`
+ TemplateSet string `json:"templateSet"` // Optional: "portrait", "product", "character"
+}
+
+// ShotTemplateBody is a single shot spec in the create request.
+type ShotTemplateBody struct {
+ Label string `json:"label" validate:"required"`
+ Direction string `json:"direction" validate:"required"`
+}
+
+// AlbumJobResponse is the response for generation enqueue endpoints.
+type AlbumJobResponse struct {
+ JobID string `json:"jobId"`
+}
+
+// AlbumJobsResponse is the response for bulk generation enqueue.
+type AlbumJobsResponse struct {
+ JobIDs []string `json:"jobIds"`
+}
+
+// ---------------------------------------------------------------------------
+// CRUD
+// ---------------------------------------------------------------------------
+
+// Create handles POST /albums — creates a new album with shot specs.
+func (h *Album) Create(w http.ResponseWriter, r *http.Request) error {
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+
+ var req CreateAlbumRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ // If a template set was provided, use it (overrides explicit shots).
+ var shots []album.ShotTemplate
+ if req.TemplateSet != "" {
+ set, ok := album.ShotTemplateSets[req.TemplateSet]
+ if !ok {
+ return httperror.BadRequest("unknown template set: " + req.TemplateSet)
+ }
+ shots = set
+ } else {
+ // Convert body shots to ShotTemplate.
+ shots = make([]album.ShotTemplate, len(req.Shots))
+ for i, s := range req.Shots {
+ shots[i] = album.ShotTemplate{Label: s.Label, Direction: s.Direction}
+ }
+ }
+
+ a, err := h.albums.Create(r.Context(), user.ID, req.Name, req.SubjectDesc, shots)
+ if err != nil {
+ h.logger.Error("failed to create album", "error", err, "user_id", user.ID)
+ return httperror.BadRequest(err.Error())
+ }
+
+ httpresponse.Created(w, r, a)
+ return nil
+}
+
+// List handles GET /albums — returns all albums for the authenticated user.
+func (h *Album) List(w http.ResponseWriter, r *http.Request) error {
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+
+ albums, err := h.albums.List(r.Context(), user.ID)
+ if err != nil {
+ h.logger.Error("failed to list albums", "error", err, "user_id", user.ID)
+ return httperror.Internal("failed to list albums")
+ }
+
+ if albums == nil {
+ albums = []album.Album{}
+ }
+
+ httpresponse.OK(w, r, albums)
+ return nil
+}
+
+// Get handles GET /albums/{id} — returns a single album with all shot statuses.
+func (h *Album) Get(w http.ResponseWriter, r *http.Request) error {
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+
+ id := album.AlbumID(chi.URLParam(r, "id"))
+ if id == "" {
+ return httperror.BadRequest("album ID is required")
+ }
+
+ a, err := h.albums.Get(r.Context(), id, user.ID)
+ if err != nil {
+ return httperror.NotFound("album not found")
+ }
+
+ httpresponse.OK(w, r, a)
+ return nil
+}
+
+// Delete handles DELETE /albums/{id} — deletes an album.
+func (h *Album) Delete(w http.ResponseWriter, r *http.Request) error {
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+
+ id := album.AlbumID(chi.URLParam(r, "id"))
+ if id == "" {
+ return httperror.BadRequest("album ID is required")
+ }
+
+ if err := h.albums.Delete(r.Context(), id, user.ID); err != nil {
+ return httperror.NotFound("album not found")
+ }
+
+ httpresponse.NoContent(w)
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// Generation (async — returns 202)
+// ---------------------------------------------------------------------------
+
+// GenerateAnchor handles POST /albums/{id}/anchor — enqueues anchor generation.
+// Returns 202 with job ID. Result arrives via album_anchor_complete SSE event.
+func (h *Album) GenerateAnchor(w http.ResponseWriter, r *http.Request) error {
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+
+ id := album.AlbumID(chi.URLParam(r, "id"))
+ if id == "" {
+ return httperror.BadRequest("album ID is required")
+ }
+
+ jobID, err := h.albums.GenerateAnchor(r.Context(), id, user.ID)
+ if err != nil {
+ h.logger.Error("failed to enqueue anchor job", "error", err, "album_id", string(id))
+ return httperror.NotFound("album not found")
+ }
+
+ h.logger.Info("anchor generation enqueued", "album_id", string(id), "job_id", jobID)
+ httpresponse.Accepted(w, r, AlbumJobResponse{JobID: jobID})
+ return nil
+}
+
+// GenerateAllShots handles POST /albums/{id}/shots — enqueues all pending shots.
+// Returns 422 if the album has no anchor yet.
+// Returns 202 with job IDs for all enqueued shots.
+func (h *Album) GenerateAllShots(w http.ResponseWriter, r *http.Request) error {
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+
+ id := album.AlbumID(chi.URLParam(r, "id"))
+ if id == "" {
+ return httperror.BadRequest("album ID is required")
+ }
+
+ jobIDs, err := h.albums.GenerateAllShots(r.Context(), id, user.ID)
+ if err != nil {
+ if errors.Is(err, album.ErrAnchorRequired) {
+ return httperror.UnprocessableEntity("anchor must be generated before shots")
+ }
+ return httperror.NotFound("album not found")
+ }
+
+ if jobIDs == nil {
+ jobIDs = []string{}
+ }
+
+ h.logger.Info("shots enqueued", "album_id", string(id), "count", len(jobIDs))
+ httpresponse.Accepted(w, r, AlbumJobsResponse{JobIDs: jobIDs})
+ return nil
+}
+
+// GenerateShot handles POST /albums/{id}/shots/{index} — enqueues a single shot (for regeneration).
+func (h *Album) GenerateShot(w http.ResponseWriter, r *http.Request) error {
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+
+ id := album.AlbumID(chi.URLParam(r, "id"))
+ if id == "" {
+ return httperror.BadRequest("album ID is required")
+ }
+
+ shotIndex, err := parseShotIndex(chi.URLParam(r, "index"))
+ if err != nil {
+ return httperror.BadRequest("shot index must be a non-negative integer")
+ }
+
+ jobID, err := h.albums.GenerateShot(r.Context(), id, user.ID, shotIndex)
+ if err != nil {
+ if errors.Is(err, album.ErrAnchorRequired) {
+ return httperror.UnprocessableEntity("anchor must be generated before shots")
+ }
+ return httperror.NotFound("album or shot not found")
+ }
+
+ httpresponse.Accepted(w, r, AlbumJobResponse{JobID: jobID})
+ return nil
+}
+
+// ResetShot handles DELETE /albums/{id}/shots/{index} — resets a shot to pending.
+func (h *Album) ResetShot(w http.ResponseWriter, r *http.Request) error {
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+
+ id := album.AlbumID(chi.URLParam(r, "id"))
+ if id == "" {
+ return httperror.BadRequest("album ID is required")
+ }
+
+ shotIndex, err := parseShotIndex(chi.URLParam(r, "index"))
+ if err != nil {
+ return httperror.BadRequest("shot index must be a non-negative integer")
+ }
+
+ if err := h.albums.ResetShot(r.Context(), id, user.ID, shotIndex); err != nil {
+ return httperror.NotFound("album or shot not found")
+ }
+
+ httpresponse.NoContent(w)
+ return nil
+}
+
+// parseShotIndex parses and validates the shot index URL parameter.
+// Returns an error if the value is missing, non-numeric, or negative.
+func parseShotIndex(idx string) (int, error) {
+ if idx == "" {
+ return 0, errors.New("missing shot index")
+ }
+ n, err := strconv.Atoi(idx)
+ if err != nil || n < 0 {
+ return 0, errors.New("shot index must be a non-negative integer")
+ }
+ return n, nil
+}
diff --git a/services/persona-api/internal/api/handlers/auth.go b/services/persona-api/internal/api/handlers/auth.go
new file mode 100644
index 0000000..f4dd839
--- /dev/null
+++ b/services/persona-api/internal/api/handlers/auth.go
@@ -0,0 +1,331 @@
+package handlers
+
+import (
+ "errors"
+ "net"
+ "net/http"
+ "strings"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/app"
+ "git.threesix.ai/jordan/persona-community-2/pkg/auth"
+ "git.threesix.ai/jordan/persona-community-2/pkg/httperror"
+ "git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/service"
+)
+
+// Auth handles authentication HTTP requests.
+type Auth struct {
+ svc *service.AuthService
+ logger *logging.Logger
+}
+
+// NewAuth creates a new Auth handler.
+func NewAuth(svc *service.AuthService, logger *logging.Logger) *Auth {
+ return &Auth{
+ svc: svc,
+ logger: logger.WithComponent("AuthHandler"),
+ }
+}
+
+// --- Request / Response types ---
+
+// LoginRequest is the request body for password login.
+type LoginRequest struct {
+ Email string `json:"email" validate:"required,email"`
+ Password string `json:"password" validate:"required,min=1"`
+}
+
+// RegisterRequest is the request body for registration.
+type RegisterRequest struct {
+ Email string `json:"email" validate:"required,email"`
+ Password string `json:"password" validate:"required,min=8"`
+ Name string `json:"name"`
+}
+
+// LoginResponse is the response for successful login or registration.
+type LoginResponse struct {
+ Token string `json:"token"`
+ User UserResponse `json:"user"`
+}
+
+// UserResponse is the user data returned in auth responses.
+type UserResponse struct {
+ ID string `json:"id"`
+ Email string `json:"email"`
+ Name string `json:"name,omitempty"`
+ AvatarURL string `json:"avatarUrl,omitempty"`
+ EmailVerified bool `json:"emailVerified"`
+ Roles []string `json:"roles,omitempty"`
+}
+
+// UpdateProfileRequest is the request body for updating the user profile.
+type UpdateProfileRequest struct {
+ Name string `json:"name"`
+ AvatarURL string `json:"avatarUrl"`
+}
+
+// ChangePasswordRequest is the request body for changing password.
+type ChangePasswordRequest struct {
+ CurrentPassword string `json:"currentPassword" validate:"required"`
+ NewPassword string `json:"newPassword" validate:"required,min=8"`
+}
+
+// RefreshRequest is the request body for refreshing an access token.
+type RefreshRequest struct {
+ Token string `json:"token" validate:"required"`
+}
+
+// toUserResponse converts a domain.User to UserResponse.
+func toUserResponse(u *domain.User) UserResponse {
+ return UserResponse{
+ ID: string(u.ID),
+ Email: u.Email,
+ Name: u.Name,
+ AvatarURL: u.AvatarURL,
+ EmailVerified: u.EmailVerified,
+ Roles: u.Roles,
+ }
+}
+
+// toLoginResponse creates a LoginResponse from service output.
+func toLoginResponse(out *service.LoginOutput) LoginResponse {
+ return LoginResponse{
+ Token: out.Token,
+ User: toUserResponse(out.User),
+ }
+}
+
+// --- Handlers ---
+
+// Login authenticates a user with email and password.
+//
+// POST /api/{service}/auth/login
+func (h *Auth) Login(w http.ResponseWriter, r *http.Request) error {
+ var req LoginRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ output, err := h.svc.LoginWithPassword(r.Context(), req.Email, req.Password, clientIP(r), r.UserAgent())
+ if err != nil {
+ return mapAuthError(err)
+ }
+
+ httpresponse.OK(w, r, toLoginResponse(output))
+ return nil
+}
+
+// Register creates a new user account.
+//
+// POST /api/{service}/auth/register
+func (h *Auth) Register(w http.ResponseWriter, r *http.Request) error {
+ var req RegisterRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ output, err := h.svc.Register(r.Context(), req.Email, req.Password, req.Name, clientIP(r), r.UserAgent())
+ if err != nil {
+ return mapAuthError(err)
+ }
+
+ httpresponse.Created(w, r, toLoginResponse(output))
+ return nil
+}
+
+// Me returns the current authenticated user.
+//
+// GET /api/{service}/auth/me
+func (h *Auth) Me(w http.ResponseWriter, r *http.Request) error {
+ user, err := auth.GetUserOrError(r.Context())
+ if err != nil {
+ return httperror.Unauthorized("not authenticated")
+ }
+
+ freshUser, err := h.svc.GetCurrentUser(r.Context(), user.ID)
+ if err != nil {
+ return mapAuthError(err)
+ }
+
+ httpresponse.OK(w, r, toUserResponse(freshUser))
+ return nil
+}
+
+// UpdateMe updates the current user's profile.
+//
+// PUT /api/{service}/auth/me
+func (h *Auth) UpdateMe(w http.ResponseWriter, r *http.Request) error {
+ user, err := auth.GetUserOrError(r.Context())
+ if err != nil {
+ return httperror.Unauthorized("not authenticated")
+ }
+
+ var req UpdateProfileRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ updated, err := h.svc.UpdateProfile(r.Context(), user.ID, req.Name, req.AvatarURL)
+ if err != nil {
+ return mapAuthError(err)
+ }
+
+ httpresponse.OK(w, r, toUserResponse(updated))
+ return nil
+}
+
+// ChangePassword changes the current user's password.
+//
+// POST /api/{service}/auth/change-password
+func (h *Auth) ChangePassword(w http.ResponseWriter, r *http.Request) error {
+ user, err := auth.GetUserOrError(r.Context())
+ if err != nil {
+ return httperror.Unauthorized("not authenticated")
+ }
+
+ var req ChangePasswordRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ if err := h.svc.ChangePassword(r.Context(), user.ID, req.CurrentPassword, req.NewPassword); err != nil {
+ return mapAuthError(err)
+ }
+
+ httpresponse.NoContent(w)
+ return nil
+}
+
+// Logout revokes the current session.
+//
+// POST /api/{service}/auth/logout
+func (h *Auth) Logout(w http.ResponseWriter, r *http.Request) error {
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ httpresponse.NoContent(w)
+ return nil
+ }
+
+ sessionID := ""
+ if user.Metadata != nil {
+ if sid, ok := user.Metadata["sid"].(string); ok {
+ sessionID = sid
+ }
+ }
+
+ if err := h.svc.Logout(r.Context(), sessionID); err != nil {
+ h.logger.Warn("logout session revoke failed", "error", err)
+ }
+
+ httpresponse.NoContent(w)
+ return nil
+}
+
+// RefreshToken issues a new access token for an active session.
+//
+// POST /api/{service}/auth/refresh
+func (h *Auth) RefreshToken(w http.ResponseWriter, r *http.Request) error {
+ // The caller sends their current (possibly near-expiry) token.
+ // We parse it to get user ID and session ID, then issue a new one.
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("not authenticated")
+ }
+
+ sessionID := ""
+ if user.Metadata != nil {
+ if sid, ok := user.Metadata["sid"].(string); ok {
+ sessionID = sid
+ }
+ }
+ if sessionID == "" {
+ return httperror.Unauthorized("no session")
+ }
+
+ output, err := h.svc.RefreshToken(r.Context(), sessionID, user.ID)
+ if err != nil {
+ return mapAuthError(err)
+ }
+
+ httpresponse.OK(w, r, toLoginResponse(output))
+ return nil
+}
+
+// --- Helpers ---
+
+// mapAuthError translates domain errors to HTTP errors.
+func mapAuthError(err error) error {
+ switch {
+ case errors.Is(err, domain.ErrInvalidCredentials):
+ return httperror.Unauthorized("invalid email or password")
+ case errors.Is(err, domain.ErrUserNotFound):
+ return httperror.Unauthorized("invalid email or password")
+ case errors.Is(err, domain.ErrUserSuspended):
+ return httperror.Forbidden("account is suspended")
+ case errors.Is(err, domain.ErrDuplicateEmail):
+ return httperror.Conflict("email already registered")
+ case errors.Is(err, domain.ErrWeakPassword):
+ return httperror.BadRequest(err.Error())
+ case errors.Is(err, domain.ErrRegistrationDisabled):
+ return httperror.Forbidden("registration is currently disabled")
+ case errors.Is(err, domain.ErrNameTooLong), errors.Is(err, domain.ErrEmailTooLong):
+ return httperror.BadRequest(err.Error())
+ case errors.Is(err, domain.ErrInvalidAvatarURL):
+ return httperror.BadRequest("avatar URL must use http or https")
+ case errors.Is(err, domain.ErrSessionNotFound):
+ return httperror.NotFound("session not found")
+ case errors.Is(err, domain.ErrSessionRevoked):
+ return httperror.Unauthorized("session has been revoked")
+ case errors.Is(err, domain.ErrInvalidAuthCode):
+ return httperror.Unauthorized("invalid or expired code")
+ default:
+ return err
+ }
+}
+
+// clientIP extracts the client IP from the request.
+// It prefers RemoteAddr (set by the Go HTTP server from the TCP connection) and
+// only uses X-Forwarded-For/X-Real-Ip when the direct connection is from a
+// private/loopback address, indicating a trusted reverse proxy.
+func clientIP(r *http.Request) string {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ host = r.RemoteAddr
+ }
+
+ // Only trust proxy headers when the connection is from a private network.
+ if isPrivateIP(host) {
+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
+ parts := strings.SplitN(xff, ",", 2)
+ ip := strings.TrimSpace(parts[0])
+ if ip != "" {
+ return ip
+ }
+ }
+ if xri := r.Header.Get("X-Real-Ip"); xri != "" {
+ return xri
+ }
+ }
+
+ return host
+}
+
+// isPrivateIP returns true if the address is loopback or RFC 1918 private.
+func isPrivateIP(addr string) bool {
+ ip := net.ParseIP(addr)
+ if ip == nil {
+ return false
+ }
+ return ip.IsLoopback() || ip.IsPrivate()
+}
+
+// sessionID extracts the session ID from the authenticated user's metadata.
+func sessionID(user *auth.User) string {
+ if user == nil || user.Metadata == nil {
+ return ""
+ }
+ sid, _ := user.Metadata["sid"].(string)
+ return sid
+}
diff --git a/services/persona-api/internal/api/handlers/auth_flows.go b/services/persona-api/internal/api/handlers/auth_flows.go
new file mode 100644
index 0000000..2d3c654
--- /dev/null
+++ b/services/persona-api/internal/api/handlers/auth_flows.go
@@ -0,0 +1,288 @@
+package handlers
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/app"
+ "git.threesix.ai/jordan/persona-community-2/pkg/auth"
+ "git.threesix.ai/jordan/persona-community-2/pkg/httperror"
+ "git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+)
+
+// --- Request types for auth flows ---
+
+// EmailRequest is used by OTP send, magic link, and forgot password.
+type EmailRequest struct {
+ Email string `json:"email" validate:"required,email"`
+}
+
+// OTPVerifyRequest verifies a one-time password.
+type OTPVerifyRequest struct {
+ Email string `json:"email" validate:"required,email"`
+ Code string `json:"code" validate:"required,len=6"`
+}
+
+// MagicLinkVerifyRequest verifies a magic link token.
+type MagicLinkVerifyRequest struct {
+ Email string `json:"email" validate:"required,email"`
+ Token string `json:"token" validate:"required"`
+}
+
+// ResetPasswordRequest sets a new password using a reset token.
+type ResetPasswordRequest struct {
+ Email string `json:"email" validate:"required,email"`
+ Token string `json:"token" validate:"required"`
+ NewPassword string `json:"newPassword" validate:"required,min=8"`
+}
+
+// VerifyEmailRequest verifies an email with a code.
+type VerifyEmailRequest struct {
+ Code string `json:"code" validate:"required,len=6"`
+}
+
+// SessionResponse is a single session in the list.
+type SessionResponse struct {
+ ID string `json:"id"`
+ IPAddress string `json:"ipAddress"`
+ DeviceLabel string `json:"deviceLabel"`
+ LastActiveAt string `json:"lastActiveAt"`
+ CreatedAt string `json:"createdAt"`
+ IsCurrent bool `json:"isCurrent"`
+}
+
+// --- OTP handlers ---
+
+// SendOTP sends a one-time password to the user's email.
+//
+// POST /api/{service}/auth/otp/send
+func (h *Auth) SendOTP(w http.ResponseWriter, r *http.Request) error {
+ var req EmailRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ if err := h.svc.SendOTP(r.Context(), req.Email, clientIP(r)); err != nil {
+ return mapAuthError(err)
+ }
+
+ httpresponse.OK(w, r, map[string]string{"message": "If an account exists, a code has been sent"})
+ return nil
+}
+
+// VerifyOTP verifies a one-time password and returns a login token.
+//
+// POST /api/{service}/auth/otp/verify
+func (h *Auth) VerifyOTP(w http.ResponseWriter, r *http.Request) error {
+ var req OTPVerifyRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ output, err := h.svc.VerifyOTP(r.Context(), req.Email, req.Code, clientIP(r), r.UserAgent())
+ if err != nil {
+ return mapAuthError(err)
+ }
+
+ httpresponse.OK(w, r, toLoginResponse(output))
+ return nil
+}
+
+// --- Magic Link handlers ---
+
+// SendMagicLink sends a magic link to the user's email.
+//
+// POST /api/{service}/auth/magic-link
+func (h *Auth) SendMagicLink(w http.ResponseWriter, r *http.Request) error {
+ var req EmailRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ if err := h.svc.SendMagicLink(r.Context(), req.Email, clientIP(r)); err != nil {
+ return mapAuthError(err)
+ }
+
+ httpresponse.OK(w, r, map[string]string{"message": "If an account exists, a link has been sent"})
+ return nil
+}
+
+// VerifyMagicLink verifies a magic link token and returns a login token.
+//
+// POST /api/{service}/auth/magic-link/verify
+func (h *Auth) VerifyMagicLink(w http.ResponseWriter, r *http.Request) error {
+ var req MagicLinkVerifyRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ output, err := h.svc.VerifyMagicLink(r.Context(), req.Email, req.Token, clientIP(r), r.UserAgent())
+ if err != nil {
+ return mapAuthError(err)
+ }
+
+ httpresponse.OK(w, r, toLoginResponse(output))
+ return nil
+}
+
+// --- Forgot / Reset Password handlers ---
+
+// ForgotPassword sends a password reset token.
+//
+// POST /api/{service}/auth/forgot-password
+func (h *Auth) ForgotPassword(w http.ResponseWriter, r *http.Request) error {
+ var req EmailRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ if err := h.svc.ForgotPassword(r.Context(), req.Email, clientIP(r)); err != nil {
+ return mapAuthError(err)
+ }
+
+ httpresponse.OK(w, r, map[string]string{"message": "If an account exists, a reset link has been sent"})
+ return nil
+}
+
+// ResetPassword sets a new password using a reset token.
+//
+// POST /api/{service}/auth/reset-password
+func (h *Auth) ResetPassword(w http.ResponseWriter, r *http.Request) error {
+ var req ResetPasswordRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ if err := h.svc.ResetPassword(r.Context(), req.Email, req.Token, req.NewPassword); err != nil {
+ return mapAuthError(err)
+ }
+
+ httpresponse.OK(w, r, map[string]string{"message": "Password has been reset. Please sign in."})
+ return nil
+}
+
+// --- Email Verification handlers ---
+
+// SendVerifyEmail sends a verification code to the current user's email.
+//
+// POST /api/{service}/auth/verify-email/send
+func (h *Auth) SendVerifyEmail(w http.ResponseWriter, r *http.Request) error {
+ user, err := auth.GetUserOrError(r.Context())
+ if err != nil {
+ return httperror.Unauthorized("not authenticated")
+ }
+
+ if err := h.svc.SendVerifyEmail(r.Context(), user.ID); err != nil {
+ return mapAuthError(err)
+ }
+
+ httpresponse.OK(w, r, map[string]string{"message": "Verification code sent"})
+ return nil
+}
+
+// VerifyEmail verifies the current user's email with a code.
+//
+// POST /api/{service}/auth/verify-email
+func (h *Auth) VerifyEmail(w http.ResponseWriter, r *http.Request) error {
+ user, err := auth.GetUserOrError(r.Context())
+ if err != nil {
+ return httperror.Unauthorized("not authenticated")
+ }
+
+ var req VerifyEmailRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ if err := h.svc.VerifyEmail(r.Context(), user.ID, req.Code); err != nil {
+ return mapAuthError(err)
+ }
+
+ httpresponse.OK(w, r, map[string]string{"message": "Email verified"})
+ return nil
+}
+
+// --- Session Management handlers ---
+
+// ListSessions returns all active sessions for the current user.
+//
+// GET /api/{service}/auth/sessions
+func (h *Auth) ListSessions(w http.ResponseWriter, r *http.Request) error {
+ user, err := auth.GetUserOrError(r.Context())
+ if err != nil {
+ return httperror.Unauthorized("not authenticated")
+ }
+
+ currentSID := sessionID(user)
+
+ sessions, err := h.svc.ListSessions(r.Context(), user.ID)
+ if err != nil {
+ return err
+ }
+
+ result := make([]SessionResponse, 0, len(sessions))
+ for _, s := range sessions {
+ result = append(result, SessionResponse{
+ ID: string(s.ID),
+ IPAddress: s.IPAddress,
+ DeviceLabel: s.DeviceLabel,
+ LastActiveAt: s.LastActiveAt.Format("2006-01-02T15:04:05Z07:00"),
+ CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
+ IsCurrent: string(s.ID) == currentSID,
+ })
+ }
+
+ httpresponse.OK(w, r, result)
+ return nil
+}
+
+// RevokeSession revokes a specific session.
+//
+// DELETE /api/{service}/auth/sessions/{id}
+func (h *Auth) RevokeSession(w http.ResponseWriter, r *http.Request) error {
+ user, err := auth.GetUserOrError(r.Context())
+ if err != nil {
+ return httperror.Unauthorized("not authenticated")
+ }
+
+ sid := chi.URLParam(r, "id")
+ if sid == "" {
+ return httperror.BadRequest("session id required")
+ }
+
+ if err := h.svc.RevokeSession(r.Context(), user.ID, sid); err != nil {
+ if errors.Is(err, domain.ErrSessionNotFound) {
+ return httperror.NotFound("session not found")
+ }
+ return err
+ }
+
+ httpresponse.NoContent(w)
+ return nil
+}
+
+// RevokeAllSessions revokes all sessions except the current one.
+//
+// DELETE /api/{service}/auth/sessions
+func (h *Auth) RevokeAllSessions(w http.ResponseWriter, r *http.Request) error {
+ user, err := auth.GetUserOrError(r.Context())
+ if err != nil {
+ return httperror.Unauthorized("not authenticated")
+ }
+
+ currentSID := sessionID(user)
+ var except *string
+ if currentSID != "" {
+ except = ¤tSID
+ }
+
+ if err := h.svc.LogoutAll(r.Context(), user.ID, except); err != nil {
+ return err
+ }
+
+ httpresponse.NoContent(w)
+ return nil
+}
diff --git a/services/persona-api/internal/api/handlers/chat.go b/services/persona-api/internal/api/handlers/chat.go
new file mode 100644
index 0000000..64b4e08
--- /dev/null
+++ b/services/persona-api/internal/api/handlers/chat.go
@@ -0,0 +1,94 @@
+package handlers
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/google/uuid"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/app"
+ "git.threesix.ai/jordan/persona-community-2/pkg/auth"
+ "git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/pkg/queue"
+ "git.threesix.ai/jordan/persona-community-2/pkg/realtime"
+)
+
+// Chat handles HTTP requests for chat messaging with AI responses.
+// User messages are broadcast immediately via SSE.
+// AI responses are enqueued and processed by the worker with streaming chunks.
+type Chat struct {
+ queue queue.Producer
+ sseHub *realtime.SSEHub
+ logger *logging.Logger
+}
+
+// NewChat creates a new Chat handler.
+func NewChat(q queue.Producer, hub *realtime.SSEHub, logger *logging.Logger) *Chat {
+ return &Chat{
+ queue: q,
+ sseHub: hub,
+ logger: logger.WithComponent("ChatHandler"),
+ }
+}
+
+// SendMessageRequest is the request body for sending a chat message.
+type SendMessageRequest struct {
+ Content string `json:"content" validate:"required,min=1,max=5000"`
+}
+
+// SendMessage broadcasts a chat message to a channel via SSE
+// and enqueues an AI response job for the worker.
+func (h *Chat) SendMessage(w http.ResponseWriter, r *http.Request) error {
+ var req SendMessageRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ // Get user info
+ userID := "anonymous"
+ userName := "Anonymous"
+ if user := auth.GetUser(r.Context()); user != nil {
+ userID = user.ID
+ if name, ok := user.Metadata["name"].(string); ok && name != "" {
+ userName = name
+ } else if user.Email != "" {
+ userName = user.Email
+ }
+ }
+
+ msgID := uuid.New().String()
+ now := time.Now().UTC()
+
+ // Broadcast user message to channel:general immediately (synchronous — users
+ // see their own messages instantly without waiting for the queue)
+ h.sseHub.SendToChannel("channel:general", &realtime.SSEEvent{
+ Type: "chat",
+ Timestamp: now,
+ JobID: msgID,
+ Message: req.Content,
+ Result: map[string]any{
+ "id": msgID,
+ "content": req.Content,
+ "userId": userID,
+ "userName": userName,
+ "timestamp": now.Format(time.RFC3339),
+ },
+ })
+
+ // Enqueue AI response job — worker streams chunks via Redis → SSE
+ if _, err := h.queue.Enqueue(r.Context(), "ai_chat_response", map[string]any{
+ "content": req.Content,
+ "userID": userID,
+ "channel": "channel:general",
+ }); err != nil {
+ h.logger.Error("failed to enqueue AI chat response", "error", err)
+ // Don't fail the request — user message was already delivered
+ }
+
+ httpresponse.OK(w, r, map[string]string{
+ "id": msgID,
+ "status": "sent",
+ })
+ return nil
+}
diff --git a/services/persona-api/internal/api/handlers/example.go b/services/persona-api/internal/api/handlers/example.go
new file mode 100644
index 0000000..c196585
--- /dev/null
+++ b/services/persona-api/internal/api/handlers/example.go
@@ -0,0 +1,170 @@
+package handlers
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/google/uuid"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/app"
+ "git.threesix.ai/jordan/persona-community-2/pkg/httperror"
+ "git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/service"
+)
+
+// Example handles HTTP requests for example resources.
+type Example struct {
+ svc *service.ExampleService
+ logger *logging.Logger
+}
+
+// NewExample creates a new Example handler with injected dependencies.
+func NewExample(svc *service.ExampleService, logger *logging.Logger) *Example {
+ return &Example{
+ svc: svc,
+ logger: logger.WithComponent("ExampleHandler"),
+ }
+}
+
+// CreateRequest is the request body for creating an example.
+type CreateRequest struct {
+ Name string `json:"name" validate:"required,min=1,max=100"`
+ Description string `json:"description" validate:"max=500"`
+}
+
+// UpdateRequest is the request body for updating an example.
+type UpdateRequest struct {
+ Name string `json:"name" validate:"required,min=1,max=100"`
+ Description string `json:"description" validate:"max=500"`
+}
+
+// ExampleResponse is the response for an example resource.
+type ExampleResponse struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+// toResponse converts a domain example to an API response.
+func toResponse(e *domain.Example) ExampleResponse {
+ return ExampleResponse{
+ ID: e.ID.String(),
+ Name: e.Name,
+ Description: e.Description,
+ CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"),
+ UpdatedAt: e.UpdatedAt.Format("2006-01-02T15:04:05Z"),
+ }
+}
+
+// List returns all examples.
+func (h *Example) List(w http.ResponseWriter, r *http.Request) error {
+ examples, err := h.svc.List(r.Context())
+ if err != nil {
+ return err
+ }
+
+ result := make([]ExampleResponse, len(examples))
+ for i, e := range examples {
+ result[i] = toResponse(&e)
+ }
+
+ httpresponse.OK(w, r, result)
+ return nil
+}
+
+// Get returns an example by ID.
+func (h *Example) Get(w http.ResponseWriter, r *http.Request) error {
+ id := chi.URLParam(r, "id")
+
+ // Validate UUID format
+ if _, err := uuid.Parse(id); err != nil {
+ return httperror.BadRequest("invalid id format")
+ }
+
+ example, err := h.svc.Get(r.Context(), domain.ExampleID(id))
+ if err != nil {
+ return mapDomainError(err)
+ }
+
+ httpresponse.OK(w, r, toResponse(example))
+ return nil
+}
+
+// Create creates a new example.
+func (h *Example) Create(w http.ResponseWriter, r *http.Request) error {
+ var req CreateRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ example, err := h.svc.Create(r.Context(), service.CreateInput{
+ Name: req.Name,
+ Description: req.Description,
+ })
+ if err != nil {
+ return mapDomainError(err)
+ }
+
+ httpresponse.Created(w, r, toResponse(example))
+ return nil
+}
+
+// Update updates an existing example.
+func (h *Example) Update(w http.ResponseWriter, r *http.Request) error {
+ id := chi.URLParam(r, "id")
+
+ if _, err := uuid.Parse(id); err != nil {
+ return httperror.BadRequest("invalid id format")
+ }
+
+ var req UpdateRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ example, err := h.svc.Update(r.Context(), domain.ExampleID(id), service.UpdateInput{
+ Name: req.Name,
+ Description: req.Description,
+ })
+ if err != nil {
+ return mapDomainError(err)
+ }
+
+ httpresponse.OK(w, r, toResponse(example))
+ return nil
+}
+
+// Delete removes an example by ID.
+func (h *Example) Delete(w http.ResponseWriter, r *http.Request) error {
+ id := chi.URLParam(r, "id")
+
+ if _, err := uuid.Parse(id); err != nil {
+ return httperror.BadRequest("invalid id format")
+ }
+
+ if err := h.svc.Delete(r.Context(), domain.ExampleID(id)); err != nil {
+ return mapDomainError(err)
+ }
+
+ httpresponse.NoContent(w)
+ return nil
+}
+
+// mapDomainError converts domain errors to HTTP errors.
+func mapDomainError(err error) error {
+ switch {
+ case errors.Is(err, domain.ErrExampleNotFound):
+ return httperror.NotFound("example not found")
+ case errors.Is(err, domain.ErrDuplicateExample):
+ return httperror.Conflict("example with this name already exists")
+ case errors.Is(err, domain.ErrInvalidExampleName):
+ return httperror.BadRequest("invalid example name")
+ default:
+ return err
+ }
+}
diff --git a/services/persona-api/internal/api/handlers/example_test.go b/services/persona-api/internal/api/handlers/example_test.go
new file mode 100644
index 0000000..f51135b
--- /dev/null
+++ b/services/persona-api/internal/api/handlers/example_test.go
@@ -0,0 +1,402 @@
+package handlers
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/service"
+)
+
+// mockExampleRepository implements port.ExampleRepository for testing.
+type mockExampleRepository struct {
+ mu sync.RWMutex
+ examples map[domain.ExampleID]*domain.Example
+}
+
+var _ port.ExampleRepository = (*mockExampleRepository)(nil)
+
+func newMockExampleRepository() *mockExampleRepository {
+ return &mockExampleRepository{
+ examples: make(map[domain.ExampleID]*domain.Example),
+ }
+}
+
+func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ result := make([]domain.Example, 0, len(m.examples))
+ for _, e := range m.examples {
+ result = append(result, *e)
+ }
+ return result, nil
+}
+
+func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ e, ok := m.examples[id]
+ if !ok {
+ return nil, domain.ErrExampleNotFound
+ }
+ copy := *e
+ return ©, nil
+}
+
+func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ copy := *example
+ m.examples[example.ID] = ©
+ return nil
+}
+
+func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if _, ok := m.examples[example.ID]; !ok {
+ return domain.ErrExampleNotFound
+ }
+ copy := *example
+ m.examples[example.ID] = ©
+ return nil
+}
+
+func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if _, ok := m.examples[id]; !ok {
+ return domain.ErrExampleNotFound
+ }
+ delete(m.examples, id)
+ return nil
+}
+
+func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ for _, e := range m.examples {
+ if e.Name == name {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func newTestHandler() (*Example, *mockExampleRepository) {
+ repo := newMockExampleRepository()
+ svc := service.NewExampleService(repo, logging.Nop())
+ handler := NewExample(svc, logging.Nop())
+ return handler, repo
+}
+
+func TestExample_List(t *testing.T) {
+ handler, repo := newTestHandler()
+
+ // Seed data
+ ex, _ := domain.NewExample("test-id-1", "Test Example", "Description")
+ _ = repo.Create(context.Background(), ex)
+
+ r := chi.NewRouter()
+ r.Get("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
+ if err := handler.List(w, r); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/examples", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("expected status 200, got %d", w.Code)
+ }
+
+ var resp map[string]any
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ data, ok := resp["data"]
+ if !ok {
+ t.Fatal("expected 'data' field in response")
+ }
+
+ items, ok := data.([]any)
+ if !ok {
+ t.Fatal("expected 'data' to be an array")
+ }
+
+ if len(items) != 1 {
+ t.Errorf("expected 1 item, got %d", len(items))
+ }
+}
+
+func TestExample_Get(t *testing.T) {
+ handler, repo := newTestHandler()
+
+ // Seed data
+ ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Test Example", "Description")
+ _ = repo.Create(context.Background(), ex)
+
+ tests := []struct {
+ name string
+ id string
+ wantStatus int
+ }{
+ {
+ name: "valid uuid - found",
+ id: "550e8400-e29b-41d4-a716-446655440000",
+ wantStatus: http.StatusOK,
+ },
+ {
+ name: "valid uuid - not found",
+ id: "550e8400-e29b-41d4-a716-446655440001",
+ wantStatus: http.StatusNotFound,
+ },
+ {
+ name: "invalid uuid",
+ id: "not-a-uuid",
+ wantStatus: http.StatusBadRequest,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := chi.NewRouter()
+ r.Get("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
+ if err := handler.Get(w, r); err != nil {
+ // Map error to status for testing
+ switch tt.wantStatus {
+ case http.StatusNotFound:
+ w.WriteHeader(http.StatusNotFound)
+ case http.StatusBadRequest:
+ w.WriteHeader(http.StatusBadRequest)
+ default:
+ w.WriteHeader(http.StatusInternalServerError)
+ }
+ return
+ }
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/examples/"+tt.id, nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != tt.wantStatus {
+ t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
+ }
+ })
+ }
+}
+
+func TestExample_Create(t *testing.T) {
+ handler, repo := newTestHandler()
+
+ // Seed existing data for duplicate test
+ ex, _ := domain.NewExample("existing-id", "Existing Name", "")
+ _ = repo.Create(context.Background(), ex)
+
+ tests := []struct {
+ name string
+ body any
+ wantStatus int
+ }{
+ {
+ name: "valid request",
+ body: CreateRequest{
+ Name: "New Example",
+ Description: "A test description",
+ },
+ wantStatus: http.StatusCreated,
+ },
+ {
+ name: "empty body",
+ body: nil,
+ wantStatus: http.StatusBadRequest,
+ },
+ {
+ name: "duplicate name",
+ body: CreateRequest{
+ Name: "Existing Name",
+ Description: "Conflict",
+ },
+ wantStatus: http.StatusConflict,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := chi.NewRouter()
+ r.Post("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
+ if err := handler.Create(w, r); err != nil {
+ switch tt.wantStatus {
+ case http.StatusBadRequest:
+ w.WriteHeader(http.StatusBadRequest)
+ case http.StatusConflict:
+ w.WriteHeader(http.StatusConflict)
+ default:
+ w.WriteHeader(http.StatusInternalServerError)
+ }
+ return
+ }
+ })
+
+ var body []byte
+ if tt.body != nil {
+ var err error
+ body, err = json.Marshal(tt.body)
+ if err != nil {
+ t.Fatalf("failed to marshal body: %v", err)
+ }
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/examples", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != tt.wantStatus {
+ t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
+ }
+ })
+ }
+}
+
+func TestExample_Delete(t *testing.T) {
+ handler, repo := newTestHandler()
+
+ // Seed data
+ ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "To Delete", "")
+ _ = repo.Create(context.Background(), ex)
+
+ tests := []struct {
+ name string
+ id string
+ wantStatus int
+ }{
+ {
+ name: "existing example",
+ id: "550e8400-e29b-41d4-a716-446655440000",
+ wantStatus: http.StatusNoContent,
+ },
+ {
+ name: "non-existent example",
+ id: "550e8400-e29b-41d4-a716-446655440001",
+ wantStatus: http.StatusNotFound,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := chi.NewRouter()
+ r.Delete("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
+ if err := handler.Delete(w, r); err != nil {
+ if tt.wantStatus == http.StatusNotFound {
+ w.WriteHeader(http.StatusNotFound)
+ } else {
+ w.WriteHeader(http.StatusBadRequest)
+ }
+ return
+ }
+ })
+
+ req := httptest.NewRequest(http.MethodDelete, "/api/v1/examples/"+tt.id, nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != tt.wantStatus {
+ t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
+ }
+ })
+ }
+}
+
+func TestExample_Update(t *testing.T) {
+ handler, repo := newTestHandler()
+
+ // Seed data
+ ex1, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Example 1", "")
+ _ = repo.Create(context.Background(), ex1)
+ ex2, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440001", "Example 2", "")
+ _ = repo.Create(context.Background(), ex2)
+
+ tests := []struct {
+ name string
+ id string
+ body UpdateRequest
+ wantStatus int
+ }{
+ {
+ name: "valid update",
+ id: "550e8400-e29b-41d4-a716-446655440000",
+ body: UpdateRequest{
+ Name: "Updated Name",
+ Description: "Updated",
+ },
+ wantStatus: http.StatusOK,
+ },
+ {
+ name: "name conflict",
+ id: "550e8400-e29b-41d4-a716-446655440000",
+ body: UpdateRequest{
+ Name: "Example 2",
+ Description: "Conflict",
+ },
+ wantStatus: http.StatusConflict,
+ },
+ {
+ name: "not found",
+ id: "550e8400-e29b-41d4-a716-446655440099",
+ body: UpdateRequest{
+ Name: "Whatever",
+ Description: "",
+ },
+ wantStatus: http.StatusNotFound,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := chi.NewRouter()
+ r.Put("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
+ if err := handler.Update(w, r); err != nil {
+ switch tt.wantStatus {
+ case http.StatusNotFound:
+ w.WriteHeader(http.StatusNotFound)
+ case http.StatusConflict:
+ w.WriteHeader(http.StatusConflict)
+ default:
+ w.WriteHeader(http.StatusBadRequest)
+ }
+ return
+ }
+ })
+
+ body, _ := json.Marshal(tt.body)
+ req := httptest.NewRequest(http.MethodPut, "/api/v1/examples/"+tt.id, bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != tt.wantStatus {
+ t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
+ }
+ })
+ }
+}
diff --git a/services/persona-api/internal/api/handlers/generate.go b/services/persona-api/internal/api/handlers/generate.go
new file mode 100644
index 0000000..48ccb7f
--- /dev/null
+++ b/services/persona-api/internal/api/handlers/generate.go
@@ -0,0 +1,234 @@
+package handlers
+
+import (
+ "net/http"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/app"
+ "git.threesix.ai/jordan/persona-community-2/pkg/auth"
+ "git.threesix.ai/jordan/persona-community-2/pkg/httperror"
+ "git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/pkg/queue"
+ "git.threesix.ai/jordan/persona-community-2/pkg/realtime"
+
+ "github.com/go-chi/chi/v5"
+)
+
+// Generate handles HTTP requests for AI generation endpoints.
+// All generation is async: validate request, enqueue job, return 202 with job ID.
+// The worker processes jobs and sends results via Redis → SSE.
+// Job status can be polled via GET /generate/jobs/{id} as a fallback to SSE.
+type Generate struct {
+ queue queue.Producer
+ jobReader queue.JobReader
+ sseHub *realtime.SSEHub
+ logger *logging.Logger
+}
+
+// NewGenerate creates a new Generate handler with injected dependencies.
+func NewGenerate(q queue.Producer, jr queue.JobReader, hub *realtime.SSEHub, logger *logging.Logger) *Generate {
+ return &Generate{
+ queue: q,
+ jobReader: jr,
+ sseHub: hub,
+ logger: logger.WithComponent("GenerateHandler"),
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Image generation (async - returns job ID, results via SSE)
+// ---------------------------------------------------------------------------
+
+// GenerateImageRequest is the request body for image generation.
+type GenerateImageRequest struct {
+ Prompt string `json:"prompt" validate:"required,min=1,max=2000"`
+ Count int `json:"count"`
+ AspectRatio string `json:"aspectRatio"`
+}
+
+// GenerateAccepted is the immediate HTTP response with the job ID.
+type GenerateAccepted struct {
+ JobID string `json:"jobId"`
+}
+
+// GenerateImage queues an image generation job.
+// Returns immediately with job ID. Results come via SSE events:
+// - generation_started: Job accepted
+// - generation_progress: Progress updates
+// - generation_complete: Images available
+// - generation_failed: Error occurred
+//
+// Client should subscribe to SSE channel `user:` before calling.
+func (h *Generate) GenerateImage(w http.ResponseWriter, r *http.Request) error {
+ var req GenerateImageRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ // Set defaults
+ if req.Count == 0 {
+ req.Count = 1
+ }
+ if req.Count > 4 {
+ req.Count = 4
+ }
+
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+
+ jobID, err := h.queue.Enqueue(r.Context(), "generate_image", map[string]any{
+ "prompt": req.Prompt,
+ "count": req.Count,
+ "aspectRatio": req.AspectRatio,
+ "userID": user.ID,
+ })
+ if err != nil {
+ h.logger.Error("failed to enqueue image job", "error", err)
+ return httperror.Internal("failed to queue image generation")
+ }
+
+ h.logger.Info("image generation queued", "jobId", jobID, "userID", user.ID)
+
+ httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// Video generation (async - takes 2-5 minutes)
+// ---------------------------------------------------------------------------
+
+// GenerateVideoRequest is the request body for video generation.
+type GenerateVideoRequest struct {
+ Prompt string `json:"prompt" validate:"required,min=1,max=2000"`
+ AspectRatio string `json:"aspectRatio"`
+ Duration string `json:"duration"`
+}
+
+// GenerateVideo queues a video generation job.
+// Returns immediately with job ID. Results come via SSE events.
+func (h *Generate) GenerateVideo(w http.ResponseWriter, r *http.Request) error {
+ var req GenerateVideoRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ // Validate video aspect ratio (Veo only supports 16:9 and 9:16)
+ if req.AspectRatio != "" && req.AspectRatio != "16:9" && req.AspectRatio != "9:16" {
+ return httperror.BadRequest("video only supports 16:9 and 9:16 aspect ratios")
+ }
+
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+
+ jobID, err := h.queue.Enqueue(r.Context(), "generate_video", map[string]any{
+ "prompt": req.Prompt,
+ "aspectRatio": req.AspectRatio,
+ "duration": req.Duration,
+ "userID": user.ID,
+ })
+ if err != nil {
+ h.logger.Error("failed to enqueue video job", "error", err)
+ return httperror.Internal("failed to queue video generation")
+ }
+
+ h.logger.Info("video generation queued", "jobId", jobID, "userID", user.ID)
+
+ httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// Text generation (async - returns job ID, results via SSE with streaming chunks)
+// ---------------------------------------------------------------------------
+
+// GenerateTextRequest is the request body for text generation.
+type GenerateTextRequest struct {
+ Prompt string `json:"prompt" validate:"required,min=1,max=5000"`
+ SystemPrompt string `json:"systemPrompt"`
+ MaxTokens int `json:"maxTokens"`
+}
+
+// GenerateText queues a text generation job.
+// Returns immediately with job ID. Chunks come via SSE as ai_chat_chunk events.
+func (h *Generate) GenerateText(w http.ResponseWriter, r *http.Request) error {
+ var req GenerateTextRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+
+ jobID, err := h.queue.Enqueue(r.Context(), "generate_text", map[string]any{
+ "prompt": req.Prompt,
+ "systemPrompt": req.SystemPrompt,
+ "maxTokens": req.MaxTokens,
+ "userID": user.ID,
+ })
+ if err != nil {
+ h.logger.Error("failed to enqueue text job", "error", err)
+ return httperror.Internal("failed to queue text generation")
+ }
+
+ h.logger.Info("text generation queued", "jobId", jobID, "userID", user.ID)
+
+ httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// Job status (poll fallback for SSE)
+// ---------------------------------------------------------------------------
+
+// GetJobStatus returns the current status of a generation job.
+// This is a poll-based fallback for clients that can't use SSE.
+func (h *Generate) GetJobStatus(w http.ResponseWriter, r *http.Request) error {
+ jobID := chi.URLParam(r, "id")
+ if jobID == "" {
+ return httperror.BadRequest("job ID is required")
+ }
+
+ job, err := h.jobReader.GetJob(r.Context(), jobID)
+ if err != nil {
+ if err == queue.ErrJobNotFound {
+ return httperror.NotFound("job not found")
+ }
+ h.logger.Error("failed to get job status", "error", err, "job_id", jobID)
+ return httperror.Internal("failed to get job status")
+ }
+
+ resp := map[string]any{
+ "id": job.ID,
+ "type": job.Type,
+ "status": string(job.Status),
+ "createdAt": job.CreatedAt,
+ }
+ if job.StartedAt != nil {
+ resp["startedAt"] = job.StartedAt
+ }
+ if job.CompletedAt != nil {
+ resp["completedAt"] = job.CompletedAt
+ }
+ if job.Error != "" {
+ resp["error"] = job.Error
+ }
+
+ httpresponse.OK(w, r, resp)
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// SSE Events endpoint
+// ---------------------------------------------------------------------------
+
+// Events returns the SSE handler for event subscriptions.
+// Mount at /api/events.
+func (h *Generate) Events() http.Handler {
+ return realtime.NewSSEHandler(h.sseHub, h.logger.Logger)
+}
diff --git a/services/persona-api/internal/api/handlers/health.go b/services/persona-api/internal/api/handlers/health.go
new file mode 100644
index 0000000..04c3594
--- /dev/null
+++ b/services/persona-api/internal/api/handlers/health.go
@@ -0,0 +1,26 @@
+package handlers
+
+import (
+ "net/http"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+)
+
+// Health handles health check endpoints.
+type Health struct {
+ logger *logging.Logger
+}
+
+// NewHealth creates a new Health handler.
+func NewHealth(logger *logging.Logger) *Health {
+ return &Health{logger: logger}
+}
+
+// Check returns the service health status.
+func (h *Health) Check(w http.ResponseWriter, r *http.Request) {
+ httpresponse.OK(w, r, map[string]string{
+ "service": "persona-api",
+ "status": "healthy",
+ })
+}
diff --git a/services/persona-api/internal/api/handlers/media.go b/services/persona-api/internal/api/handlers/media.go
new file mode 100644
index 0000000..3a3df5f
--- /dev/null
+++ b/services/persona-api/internal/api/handlers/media.go
@@ -0,0 +1,372 @@
+package handlers
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/app"
+ "git.threesix.ai/jordan/persona-community-2/pkg/auth"
+ "git.threesix.ai/jordan/persona-community-2/pkg/httperror"
+ "git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/pkg/storage"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/google/uuid"
+)
+
+// maxUploadSize is the maximum allowed file size for uploads (500MB).
+const maxUploadSize = 500 << 20
+
+// allowedMediaTypes is the allowlist of MIME types permitted for upload.
+var allowedMediaTypes = map[string]bool{
+ "image/jpeg": true,
+ "image/png": true,
+ "image/gif": true,
+ "image/webp": true,
+ "image/svg+xml": true,
+ "video/mp4": true,
+ "video/webm": true,
+ "video/quicktime": true,
+ "audio/mpeg": true,
+ "audio/wav": true,
+ "audio/ogg": true,
+ "audio/webm": true,
+ "application/pdf": true,
+}
+
+// Media handles media upload and library operations.
+type Media struct {
+ store storage.Store
+ repo port.MediaRepository
+ logger *logging.Logger
+}
+
+// NewMedia creates a new media handler.
+func NewMedia(store storage.Store, repo port.MediaRepository, logger *logging.Logger) *Media {
+ return &Media{store: store, repo: repo, logger: logger.WithComponent("MediaHandler")}
+}
+
+// Routes returns the media subrouter.
+func (h *Media) Routes() http.Handler {
+ r := chi.NewRouter()
+ r.Post("/upload/init", app.Wrap(h.InitUpload))
+ r.Post("/upload/complete", app.Wrap(h.CompleteUpload))
+ r.Get("/", app.Wrap(h.List))
+ r.Get("/{id}", app.Wrap(h.GetOne))
+ r.Get("/{id}/url", app.Wrap(h.RefreshURL))
+ r.Delete("/{id}", app.Wrap(h.Delete))
+ return r
+}
+
+// sanitizeFilename removes path separators and dangerous characters from filenames.
+func sanitizeFilename(name string) string {
+ // Remove any directory components
+ name = filepath.Base(name)
+ // Replace any remaining path separators (e.g., from URL encoding)
+ name = strings.ReplaceAll(name, "/", "_")
+ name = strings.ReplaceAll(name, "\\", "_")
+ name = strings.ReplaceAll(name, "..", "_")
+ // Remove null bytes
+ name = strings.ReplaceAll(name, "\x00", "")
+ if name == "" || name == "." {
+ name = "unnamed"
+ }
+ return name
+}
+
+// initUploadRequest is the request body for POST /media/upload/init.
+type initUploadRequest struct {
+ Filename string `json:"filename" validate:"required"`
+ ContentType string `json:"contentType" validate:"required"`
+ Size int64 `json:"size"`
+}
+
+// InitUpload returns a presigned URL for direct client-to-storage upload.
+// The metadata record is created in CompleteUpload after the file is actually stored.
+func (h *Media) InitUpload(w http.ResponseWriter, r *http.Request) error {
+ var req initUploadRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ // Validate MIME type against allowlist
+ if !allowedMediaTypes[req.ContentType] {
+ return httperror.BadRequest("unsupported file type: " + req.ContentType)
+ }
+
+ // Validate file size if provided
+ if req.Size > maxUploadSize {
+ return httperror.BadRequest(fmt.Sprintf("file too large: %d bytes (max %d)", req.Size, maxUploadSize))
+ }
+
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+
+ // Sanitize filename to prevent path traversal
+ safeName := sanitizeFilename(req.Filename)
+
+ // Build object path: media/{userID}/{uuid}/{filename}
+ objectPath := fmt.Sprintf("media/%s/%s/%s", user.ID, uuid.New().String(), safeName)
+
+ presigned, err := h.store.UploadPresigned(r.Context(), objectPath, req.ContentType)
+ if err != nil {
+ h.logger.Error("failed to create presigned upload", "error", err)
+ return httperror.Internal("failed to create upload URL")
+ }
+
+ httpresponse.OK(w, r, map[string]any{
+ "uploadURL": presigned.URL,
+ "objectPath": objectPath,
+ "filename": safeName,
+ "headers": presigned.Headers,
+ "method": presigned.Method,
+ "expires": presigned.Expires,
+ })
+ return nil
+}
+
+// completeUploadRequest is the request body for POST /media/upload/complete.
+type completeUploadRequest struct {
+ ObjectPath string `json:"objectPath" validate:"required"`
+ Filename string `json:"filename"`
+ ContentType string `json:"contentType"`
+ Size int64 `json:"size"`
+}
+
+// CompleteUpload confirms an upload is done, creates the metadata record, and returns the final URL.
+func (h *Media) CompleteUpload(w http.ResponseWriter, r *http.Request) error {
+ var req completeUploadRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ // Verify the object path belongs to the authenticated user
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+ expectedPrefix := fmt.Sprintf("media/%s/", user.ID)
+ if !strings.HasPrefix(req.ObjectPath, expectedPrefix) {
+ return httperror.Forbidden("cannot complete upload for another user's media")
+ }
+
+ url, err := h.store.GetURL(r.Context(), req.ObjectPath)
+ if err != nil {
+ h.logger.Error("failed to get object URL", "error", err, "path", req.ObjectPath)
+ return httperror.Internal("failed to confirm upload")
+ }
+
+ // Create the metadata record now that the file is in storage.
+ now := time.Now()
+ filename := sanitizeFilename(req.Filename)
+ if filename == "unnamed" {
+ // Extract filename from the object path (last segment)
+ parts := strings.Split(req.ObjectPath, "/")
+ if len(parts) > 0 {
+ filename = parts[len(parts)-1]
+ }
+ }
+
+ mediaObj := &domain.MediaObject{
+ ID: domain.MediaObjectID("med_" + uuid.New().String()),
+ UserID: domain.UserID(user.ID),
+ Path: req.ObjectPath,
+ Filename: filename,
+ ContentType: req.ContentType,
+ Size: req.Size,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := h.repo.Create(r.Context(), mediaObj); err != nil {
+ h.logger.Error("failed to create media record", "error", err)
+ return httperror.Internal("failed to create upload record")
+ }
+
+ httpresponse.OK(w, r, map[string]any{
+ "id": string(mediaObj.ID),
+ "url": url,
+ "path": req.ObjectPath,
+ })
+ return nil
+}
+
+// List returns the user's media objects with pagination.
+func (h *Media) List(w http.ResponseWriter, r *http.Request) error {
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+
+ opts := port.ListMediaOptions{
+ ContentTypePrefix: r.URL.Query().Get("type"),
+ Limit: intQueryParam(r, "limit", 50),
+ Offset: intQueryParam(r, "offset", 0),
+ }
+
+ objects, total, err := h.repo.ListByUser(r.Context(), domain.UserID(user.ID), opts)
+ if err != nil {
+ h.logger.Error("failed to list media", "error", err)
+ return httperror.Internal("failed to list media")
+ }
+
+ // Enrich each object with a fresh signed URL
+ type mediaItem struct {
+ ID string `json:"id"`
+ Path string `json:"path"`
+ URL string `json:"url"`
+ Filename string `json:"filename"`
+ ContentType string `json:"contentType"`
+ Size int64 `json:"size"`
+ CreatedAt time.Time `json:"createdAt"`
+ }
+
+ items := make([]mediaItem, 0, len(objects))
+ for _, obj := range objects {
+ url, urlErr := h.store.GetURL(r.Context(), obj.Path)
+ if urlErr != nil {
+ h.logger.Warn("failed to get URL for media object", "path", obj.Path, "error", urlErr)
+ continue
+ }
+ items = append(items, mediaItem{
+ ID: string(obj.ID),
+ Path: obj.Path,
+ URL: url,
+ Filename: obj.Filename,
+ ContentType: obj.ContentType,
+ Size: obj.Size,
+ CreatedAt: obj.CreatedAt,
+ })
+ }
+
+ httpresponse.OK(w, r, map[string]any{
+ "items": items,
+ "total": total,
+ "count": len(items),
+ })
+ return nil
+}
+
+// GetOne returns a single media object with a fresh URL.
+func (h *Media) GetOne(w http.ResponseWriter, r *http.Request) error {
+ id := chi.URLParam(r, "id")
+ if id == "" {
+ return httperror.BadRequest("media ID is required")
+ }
+
+ obj, err := h.repo.Get(r.Context(), domain.MediaObjectID(id))
+ if err != nil {
+ if errors.Is(err, domain.ErrNotFound) {
+ return httperror.NotFound("media object not found")
+ }
+ return httperror.Internal("failed to get media object")
+ }
+
+ // Verify ownership
+ user := auth.GetUser(r.Context())
+ if user == nil || domain.UserID(user.ID) != obj.UserID {
+ return httperror.Forbidden("access denied")
+ }
+
+ url, err := h.store.GetURL(r.Context(), obj.Path)
+ if err != nil {
+ h.logger.Error("failed to get URL", "error", err, "path", obj.Path)
+ return httperror.Internal("failed to get media URL")
+ }
+
+ httpresponse.OK(w, r, map[string]any{
+ "id": string(obj.ID),
+ "path": obj.Path,
+ "url": url,
+ "filename": obj.Filename,
+ "contentType": obj.ContentType,
+ "size": obj.Size,
+ "createdAt": obj.CreatedAt,
+ })
+ return nil
+}
+
+// RefreshURL returns a fresh signed URL for a media object.
+func (h *Media) RefreshURL(w http.ResponseWriter, r *http.Request) error {
+ id := chi.URLParam(r, "id")
+ if id == "" {
+ return httperror.BadRequest("media ID is required")
+ }
+
+ obj, err := h.repo.Get(r.Context(), domain.MediaObjectID(id))
+ if err != nil {
+ if errors.Is(err, domain.ErrNotFound) {
+ return httperror.NotFound("media object not found")
+ }
+ return httperror.Internal("failed to get media object")
+ }
+
+ // Verify ownership
+ user := auth.GetUser(r.Context())
+ if user == nil || domain.UserID(user.ID) != obj.UserID {
+ return httperror.Forbidden("access denied")
+ }
+
+ url, err := h.store.GetURL(r.Context(), obj.Path)
+ if err != nil {
+ h.logger.Error("failed to refresh URL", "error", err, "path", obj.Path)
+ return httperror.Internal("failed to refresh media URL")
+ }
+
+ httpresponse.OK(w, r, map[string]any{
+ "id": string(obj.ID),
+ "url": url,
+ })
+ return nil
+}
+
+// Delete soft-deletes a media object.
+func (h *Media) Delete(w http.ResponseWriter, r *http.Request) error {
+ id := chi.URLParam(r, "id")
+ if id == "" {
+ return httperror.BadRequest("media ID is required")
+ }
+
+ obj, err := h.repo.Get(r.Context(), domain.MediaObjectID(id))
+ if err != nil {
+ if errors.Is(err, domain.ErrNotFound) {
+ return httperror.NotFound("media object not found")
+ }
+ return httperror.Internal("failed to get media object")
+ }
+
+ // Verify ownership
+ user := auth.GetUser(r.Context())
+ if user == nil || domain.UserID(user.ID) != obj.UserID {
+ return httperror.Forbidden("cannot delete another user's media")
+ }
+
+ if err := h.repo.SoftDelete(r.Context(), domain.MediaObjectID(id)); err != nil {
+ h.logger.Error("failed to delete media", "error", err, "id", id)
+ return httperror.Internal("failed to delete media")
+ }
+
+ httpresponse.OK(w, r, map[string]any{"deleted": id})
+ return nil
+}
+
+// intQueryParam parses an integer query parameter with a default value.
+func intQueryParam(r *http.Request, key string, defaultVal int) int {
+ val := r.URL.Query().Get(key)
+ if val == "" {
+ return defaultVal
+ }
+ var n int
+ if _, err := fmt.Sscanf(val, "%d", &n); err != nil || n < 0 {
+ return defaultVal
+ }
+ return n
+}
diff --git a/services/persona-api/internal/api/handlers/persona.go b/services/persona-api/internal/api/handlers/persona.go
new file mode 100644
index 0000000..a121def
--- /dev/null
+++ b/services/persona-api/internal/api/handlers/persona.go
@@ -0,0 +1,85 @@
+package handlers
+
+import (
+ "net/http"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/app"
+ "git.threesix.ai/jordan/persona-community-2/pkg/auth"
+ "git.threesix.ai/jordan/persona-community-2/pkg/httperror"
+ "git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/pkg/queue"
+)
+
+// Persona handles HTTP requests for persona generation.
+// All generation is async: validate request, enqueue job, return 202 with job ID.
+// Results are delivered via SSE events to the user's `user:` channel:
+//
+// - persona_spec_started: LLM pipeline started
+// - persona_spec_complete: Persona profile generated
+// - persona_image_started: Starting a specific image position
+// - persona_image_progress: Image position complete with URL
+// - persona_image_complete: All 20 images generated
+// - persona_video_started: Starting a video motion type
+// - persona_video_complete: Video complete with URL
+// - persona_failed: Generation failed (check error field)
+type Persona struct {
+ queue queue.Producer
+ jobReader queue.JobReader
+ logger *logging.Logger
+}
+
+// NewPersona creates a new Persona handler with injected dependencies.
+func NewPersona(q queue.Producer, jr queue.JobReader, logger *logging.Logger) *Persona {
+ return &Persona{
+ queue: q,
+ jobReader: jr,
+ logger: logger.WithComponent("PersonaHandler"),
+ }
+}
+
+// GeneratePersonaRequest is the request body for persona generation.
+type GeneratePersonaRequest struct {
+ // Description is a natural-language persona concept (required).
+ // Example: "mysterious woman with dark hair who loves poetry"
+ Description string `json:"description" validate:"required,min=3,max=1000"`
+
+ // Gender is the gender identity: "woman", "man", or "non_binary" (required).
+ Gender string `json:"gender" validate:"required,oneof=woman man non_binary"`
+
+ // Name is an optional name override for the generated persona.
+ Name string `json:"name"`
+}
+
+// GeneratePersona queues a persona generation job.
+// Returns immediately with job ID. Full lifecycle results come via SSE.
+//
+// Subscribe to SSE channel `user:` at /api/persona-api/events before calling.
+// Poll job status at GET /generate/jobs/{id} as a fallback to SSE.
+func (h *Persona) GeneratePersona(w http.ResponseWriter, r *http.Request) error {
+ var req GeneratePersonaRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+
+ jobID, err := h.queue.Enqueue(r.Context(), "persona_generate", map[string]any{
+ "description": req.Description,
+ "gender": req.Gender,
+ "name": req.Name,
+ "userID": user.ID,
+ })
+ if err != nil {
+ h.logger.Error("failed to enqueue persona job", "error", err)
+ return httperror.Internal("failed to queue persona generation")
+ }
+
+ h.logger.Info("persona generation queued", "jobId", jobID, "userID", user.ID)
+
+ httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
+ return nil
+}
diff --git a/services/persona-api/internal/api/routes.go b/services/persona-api/internal/api/routes.go
new file mode 100644
index 0000000..3cacc01
--- /dev/null
+++ b/services/persona-api/internal/api/routes.go
@@ -0,0 +1,184 @@
+// Package api provides HTTP routing and handlers for the persona-api service.
+package api
+
+import (
+ "time"
+
+ emailpkg "git.threesix.ai/jordan/persona-community-2/pkg/email"
+ "git.threesix.ai/jordan/persona-community-2/pkg/app"
+ "git.threesix.ai/jordan/persona-community-2/pkg/auth"
+ "git.threesix.ai/jordan/persona-community-2/pkg/middleware"
+ "git.threesix.ai/jordan/persona-community-2/pkg/queue"
+ "git.threesix.ai/jordan/persona-community-2/pkg/realtime"
+ "git.threesix.ai/jordan/persona-community-2/pkg/storage"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/api/handlers"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/config"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/service"
+)
+
+// RegisterRoutes registers all HTTP routes for the service.
+// Routes are mounted under /api/persona-api to match the ingress path routing.
+// This allows the monorepo to expose multiple services under a single domain:
+// - https://domain/api/persona-api/health
+// - https://domain/api/persona-api/examples
+// - https://domain/api/persona-api/events?channel=user:123 (SSE)
+func RegisterRoutes(application *app.App, deps *Dependencies) {
+ logger := application.Logger()
+ cfg := config.Load()
+
+ // Initialize handlers with injected services
+ healthHandler := handlers.NewHealth(logger)
+ exampleHandler := handlers.NewExample(deps.ExampleService, logger)
+ authHandler := handlers.NewAuth(deps.AuthService, logger)
+ generateHandler := handlers.NewGenerate(deps.Queue, deps.JobReader, deps.SSEHub, logger)
+ chatHandler := handlers.NewChat(deps.Queue, deps.SSEHub, logger)
+ mediaHandler := handlers.NewMedia(deps.Store, deps.MediaRepo, logger)
+ albumHandler := handlers.NewAlbum(deps.AlbumService, logger)
+ personaHandler := handlers.NewPersona(deps.Queue, deps.JobReader, logger)
+
+ // Build and mount OpenAPI spec
+ spec := NewServiceSpec()
+ application.EnableDocs(spec)
+
+ // JWT validator for protected routes
+ jwtValidator := auth.NewJWTValidator(auth.JWTConfig{
+ Secret: []byte(cfg.JWTSecret),
+ Issuer: "persona-community-2",
+ })
+
+ // Dev email preview (development only — not mounted in production).
+ if cfg.AppConfig.Environment == "development" && deps.EmailRenderer != nil {
+ devHandler := emailpkg.NewDevHandler(deps.EmailRenderer)
+ application.Router().Get("/dev/emails", devHandler.List)
+ application.Router().Get("/dev/emails/{purpose}", devHandler.Preview)
+ }
+
+ // Register API routes under /api/{service-name} to match ingress path routing.
+ // The ingress routes /api/persona-api/* to this service.
+ application.Route("/api/persona-api", func(r app.Router) {
+ r.Get("/health", healthHandler.Check)
+
+ // ----- Public auth routes (rate-limited) -----
+ // Auth attempts: 20/min per IP (login, register, verify, reset).
+ authAttemptLimit := middleware.RateLimit(middleware.RateLimitConfig{Requests: 20, Window: time.Minute})
+ // Code sends: 5/min per IP (prevents email bombing via OTP/magic-link/forgot-password).
+ codeSendLimit := middleware.RateLimit(middleware.RateLimitConfig{Requests: 5, Window: time.Minute})
+
+ r.Group(func(r app.Router) {
+ r.Use(authAttemptLimit)
+ r.Post("/auth/login", app.Wrap(authHandler.Login))
+ r.Post("/auth/register", app.Wrap(authHandler.Register))
+ r.Post("/auth/otp/verify", app.Wrap(authHandler.VerifyOTP))
+ r.Post("/auth/magic-link/verify", app.Wrap(authHandler.VerifyMagicLink))
+ r.Post("/auth/reset-password", app.Wrap(authHandler.ResetPassword))
+ })
+ r.Group(func(r app.Router) {
+ r.Use(codeSendLimit)
+ r.Post("/auth/otp/send", app.Wrap(authHandler.SendOTP))
+ r.Post("/auth/magic-link", app.Wrap(authHandler.SendMagicLink))
+ r.Post("/auth/forgot-password", app.Wrap(authHandler.ForgotPassword))
+ })
+
+ // Refresh accepts expired tokens (still validates signature).
+ // The service layer checks session validity to prevent abuse.
+ r.Group(func(r app.Router) {
+ r.Use(auth.Middleware(auth.MiddlewareConfig{
+ Validator: jwtValidator,
+ AllowExpired: true,
+ }))
+ r.Post("/auth/refresh", app.Wrap(authHandler.RefreshToken))
+ })
+
+ // Session checker for revocation enforcement.
+ sessionChecker := deps.AuthService.CheckSession
+
+ // ----- Protected auth routes -----
+ r.Group(func(r app.Router) {
+ r.Use(auth.Middleware(auth.MiddlewareConfig{
+ Validator: jwtValidator,
+ }))
+ r.Use(auth.SessionCheck(sessionChecker))
+
+ r.Get("/auth/me", app.Wrap(authHandler.Me))
+ r.Put("/auth/me", app.Wrap(authHandler.UpdateMe))
+ r.Post("/auth/change-password", app.Wrap(authHandler.ChangePassword))
+ r.Post("/auth/logout", app.Wrap(authHandler.Logout))
+ r.Post("/auth/verify-email/send", app.Wrap(authHandler.SendVerifyEmail))
+ r.Post("/auth/verify-email", app.Wrap(authHandler.VerifyEmail))
+ r.Get("/auth/sessions", app.Wrap(authHandler.ListSessions))
+ r.Delete("/auth/sessions", app.Wrap(authHandler.RevokeAllSessions))
+ r.Delete("/auth/sessions/{id}", app.Wrap(authHandler.RevokeSession))
+ })
+
+ // ----- SSE Events -----
+ // Server-Sent Events for async job updates (generation progress, etc.)
+ r.Mount("/events", generateHandler.Events())
+
+ // ----- Example routes -----
+ // Public routes (no auth required)
+ r.Get("/examples", app.Wrap(exampleHandler.List))
+ r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
+
+ // Protected routes (auth required when enabled)
+ r.Group(func(r app.Router) {
+ if cfg.AuthEnabled {
+ r.Use(auth.Middleware(auth.MiddlewareConfig{
+ Validator: jwtValidator,
+ }))
+ }
+
+ r.Post("/examples", app.Wrap(exampleHandler.Create))
+ r.Put("/examples/{id}", app.Wrap(exampleHandler.Update))
+ r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
+ })
+
+ // ----- Chat + Generate + Media routes (auth required) -----
+ // Auth is required because SSE events are delivered to user: channels.
+ // Without a real user identity, events go to user:anonymous and never reach the client.
+ r.Group(func(r app.Router) {
+ r.Use(auth.Middleware(auth.MiddlewareConfig{
+ Validator: jwtValidator,
+ }))
+ r.Use(auth.SessionCheck(sessionChecker))
+
+ // Chat messaging
+ r.Post("/chat/messages", app.Wrap(chatHandler.SendMessage))
+
+ // Media generation (all queue-based, returns 202)
+ r.Post("/generate/image", app.Wrap(generateHandler.GenerateImage))
+ r.Post("/generate/video", app.Wrap(generateHandler.GenerateVideo))
+ r.Post("/generate/text", app.Wrap(generateHandler.GenerateText))
+ r.Get("/generate/jobs/{id}", app.Wrap(generateHandler.GetJobStatus))
+
+ // Media library (upload, list, delete)
+ r.Mount("/media", mediaHandler.Routes())
+
+ // Album generation (anchor + shots)
+ r.Get("/albums", app.Wrap(albumHandler.List))
+ r.Post("/albums", app.Wrap(albumHandler.Create))
+ r.Get("/albums/{id}", app.Wrap(albumHandler.Get))
+ r.Delete("/albums/{id}", app.Wrap(albumHandler.Delete))
+ r.Post("/albums/{id}/anchor", app.Wrap(albumHandler.GenerateAnchor))
+ r.Post("/albums/{id}/shots", app.Wrap(albumHandler.GenerateAllShots))
+ r.Post("/albums/{id}/shots/{index}", app.Wrap(albumHandler.GenerateShot))
+ r.Delete("/albums/{id}/shots/{index}", app.Wrap(albumHandler.ResetShot))
+
+ // Persona generation (5-stage LLM + 20 images + 4 videos, all async)
+ r.Post("/persona/generate", app.Wrap(personaHandler.GeneratePersona))
+ })
+ })
+}
+
+// Dependencies holds all service dependencies for route registration.
+type Dependencies struct {
+ ExampleService *service.ExampleService
+ AuthService *service.AuthService
+ AlbumService *service.AlbumService
+ Queue queue.Producer
+ JobReader queue.JobReader
+ SSEHub *realtime.SSEHub
+ Store storage.Store
+ MediaRepo port.MediaRepository
+ EmailRenderer *emailpkg.Renderer
+}
diff --git a/services/persona-api/internal/api/spec.go b/services/persona-api/internal/api/spec.go
new file mode 100644
index 0000000..ae58a06
--- /dev/null
+++ b/services/persona-api/internal/api/spec.go
@@ -0,0 +1,112 @@
+package api
+
+import "git.threesix.ai/jordan/persona-community-2/pkg/openapi"
+
+// NewServiceSpec builds the OpenAPI specification for the persona-api service.
+func NewServiceSpec() *openapi.OpenAPISpec {
+ spec := openapi.NewOpenAPISpec("persona-api API", "1.0.0").
+ WithDescription("REST API for the persona-api service").
+ WithBearerSecurity("bearer", "JWT authentication token").
+ WithTag("Health", "Service health endpoints").
+ WithTag("Examples", "Example CRUD endpoints")
+
+ // Define reusable schemas
+ spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{
+ "id": openapi.UUID().WithDescription("Unique identifier"),
+ "name": openapi.String().WithDescription("Name of the example").WithExample("My Example"),
+ "description": openapi.String().WithDescription("Optional description").WithExample("A description"),
+ "created_at": openapi.DateTime().WithDescription("Creation timestamp"),
+ "updated_at": openapi.DateTime().WithDescription("Last update timestamp"),
+ }, "id", "name"))
+
+ spec.WithSchema("CreateExampleRequest", openapi.Object(map[string]openapi.Schema{
+ "name": openapi.StringWithMinMax(1, 100).WithDescription("Name of the example"),
+ "description": openapi.StringWithMinMax(0, 500).WithDescription("Optional description"),
+ }, "name"))
+
+ spec.WithSchema("UpdateExampleRequest", openapi.Object(map[string]openapi.Schema{
+ "name": openapi.StringWithMinMax(1, 100).WithDescription("Updated name"),
+ "description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"),
+ }))
+
+ // Health
+ spec.AddPath("/api/persona-api/health", "get", map[string]any{
+ "summary": "Health check",
+ "tags": []string{"Health"},
+ "responses": map[string]any{
+ "200": openapi.OpResponse("Service is healthy", openapi.Object(map[string]openapi.Schema{
+ "service": openapi.String(),
+ "status": openapi.String(),
+ })),
+ },
+ })
+
+ // List examples
+ spec.AddPath("/api/persona-api/examples", "get", map[string]any{
+ "summary": "List examples",
+ "description": "Returns a paginated list of examples.",
+ "tags": []string{"Examples"},
+ "parameters": []any{openapi.PageParam(), openapi.PerPageParam()},
+ "responses": map[string]any{
+ "200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.RefArray("Example"))),
+ },
+ })
+
+ // Get example
+ spec.AddPath("/api/persona-api/examples/{id}", "get", map[string]any{
+ "summary": "Get example by ID",
+ "tags": []string{"Examples"},
+ "parameters": []any{openapi.IDParam()},
+ "responses": map[string]any{
+ "200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Example"))),
+ "404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
+ },
+ })
+
+ // Create example
+ spec.AddPath("/api/persona-api/examples", "post", map[string]any{
+ "summary": "Create example",
+ "description": "Creates a new example. Requires authentication.",
+ "tags": []string{"Examples"},
+ "security": []map[string][]string{{"bearer": {}}},
+ "requestBody": openapi.RequestBody(openapi.Ref("CreateExampleRequest"), true),
+ "responses": map[string]any{
+ "201": openapi.OpResponse("Created", openapi.ResponseSchema(openapi.Ref("Example"))),
+ "400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
+ "401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
+ "422": openapi.OpResponse("Validation error", openapi.ErrorResponseSchema()),
+ },
+ })
+
+ // Update example
+ spec.AddPath("/api/persona-api/examples/{id}", "put", map[string]any{
+ "summary": "Update example",
+ "description": "Updates an existing example. Requires authentication.",
+ "tags": []string{"Examples"},
+ "security": []map[string][]string{{"bearer": {}}},
+ "parameters": []any{openapi.IDParam()},
+ "requestBody": openapi.RequestBody(openapi.Ref("UpdateExampleRequest"), true),
+ "responses": map[string]any{
+ "200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("Example"))),
+ "400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
+ "401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
+ "404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
+ },
+ })
+
+ // Delete example
+ spec.AddPath("/api/persona-api/examples/{id}", "delete", map[string]any{
+ "summary": "Delete example",
+ "description": "Deletes an example by ID. Requires authentication.",
+ "tags": []string{"Examples"},
+ "security": []map[string][]string{{"bearer": {}}},
+ "parameters": []any{openapi.IDParam()},
+ "responses": map[string]any{
+ "204": openapi.OpResponseNoContent(),
+ "401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
+ "404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
+ },
+ })
+
+ return spec
+}
diff --git a/services/persona-api/internal/config/config.go b/services/persona-api/internal/config/config.go
new file mode 100644
index 0000000..7a19652
--- /dev/null
+++ b/services/persona-api/internal/config/config.go
@@ -0,0 +1,90 @@
+// Package config provides service-specific configuration.
+package config
+
+import (
+ "os"
+ "strings"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/config"
+)
+
+// Config extends the base config with persona-api-specific settings.
+type Config struct {
+ config.AppConfig
+ Server config.ServerConfig
+ Database config.DatabaseConfig
+ Logging config.LoggingConfig
+
+ // Auth
+ AuthEnabled bool
+ JWTSecret string
+ RegistrationEnabled bool
+
+ // Redis for cross-process SSE event delivery
+ RedisURL string
+
+ // Notify service for email delivery (OTP, magic links, password reset, etc.)
+ // When NotifyURL is empty, emails are logged to stdout (dev mode).
+ NotifyURL string
+ NotifyAPIKey string
+ NotifyHost string
+ NotifyFrom string
+
+ // Email branding — injected into every transactional email.
+ AppName string // APP_NAME, default: "persona-api"
+ AppURL string // APP_URL, default: ""
+ SupportEmail string // SUPPORT_EMAIL, default: NOTIFY_FROM value
+ LogoURL string // LOGO_URL, default: "" (hides logo area)
+ BrandColor string // BRAND_COLOR, default: "#6366f1"
+
+ // Dev mode seed user — seeded into the in-memory user store on startup so the
+ // developer's email is always available without re-registering after each restart.
+ // No effect when DATABASE_URL is set (production uses real persistence).
+ DevUserEmail string // DEV_USER_EMAIL, e.g. "you@example.com"
+ DevUserPassword string // DEV_USER_PASSWORD, default: "DevPassword1"
+}
+
+// Load reads configuration from environment variables.
+func Load() *Config {
+ regEnabled := true
+ if v := os.Getenv("REGISTRATION_ENABLED"); v != "" {
+ regEnabled = strings.EqualFold(v, "true")
+ }
+
+ notifyFrom := getEnvDefault("NOTIFY_FROM", "noreply@persona-community-2.com")
+
+ cfg := &Config{
+ AppConfig: config.ReadAppConfig(),
+ Server: config.ReadServerConfig(),
+ Database: config.ReadDatabaseConfig(),
+ Logging: config.ReadLoggingConfig(),
+
+ AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"),
+ JWTSecret: os.Getenv("JWT_SECRET"),
+ RegistrationEnabled: regEnabled,
+ RedisURL: os.Getenv("REDIS_URL"),
+
+ NotifyURL: os.Getenv("NOTIFY_URL"),
+ NotifyAPIKey: os.Getenv("NOTIFY_API_KEY"),
+ NotifyHost: os.Getenv("NOTIFY_HOST"),
+ NotifyFrom: notifyFrom,
+
+ AppName: getEnvDefault("APP_NAME", "persona-api"),
+ AppURL: os.Getenv("APP_URL"),
+ SupportEmail: getEnvDefault("SUPPORT_EMAIL", notifyFrom),
+ LogoURL: os.Getenv("LOGO_URL"),
+ BrandColor: getEnvDefault("BRAND_COLOR", "#6366f1"),
+
+ DevUserEmail: os.Getenv("DEV_USER_EMAIL"),
+ DevUserPassword: getEnvDefault("DEV_USER_PASSWORD", "DevPassword1"),
+ }
+
+ return cfg
+}
+
+func getEnvDefault(key, defaultVal string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return defaultVal
+}
diff --git a/services/persona-api/internal/domain/auth_code.go b/services/persona-api/internal/domain/auth_code.go
new file mode 100644
index 0000000..12cdf6f
--- /dev/null
+++ b/services/persona-api/internal/domain/auth_code.go
@@ -0,0 +1,32 @@
+package domain
+
+import "time"
+
+// AuthCodePurpose identifies what an auth code is used for.
+type AuthCodePurpose string
+
+const (
+ PurposeLoginOTP AuthCodePurpose = "login_otp"
+ PurposeMagicLink AuthCodePurpose = "magic_link"
+ PurposePasswordReset AuthCodePurpose = "password_reset"
+ PurposeEmailVerify AuthCodePurpose = "email_verify"
+)
+
+// AuthCode is a single-use, time-limited code for authentication flows.
+// Used by OTP login, magic links, password reset, and email verification.
+type AuthCode struct {
+ ID string
+ UserID *UserID // Nullable for magic link signup
+ Email string
+ Code string
+ Purpose AuthCodePurpose
+ ExpiresAt time.Time
+ UsedAt *time.Time
+ IPAddress string
+ CreatedAt time.Time
+}
+
+// IsValid returns true if the code has not been used and has not expired.
+func (c *AuthCode) IsValid() bool {
+ return c.UsedAt == nil && time.Now().Before(c.ExpiresAt)
+}
diff --git a/services/persona-api/internal/domain/errors.go b/services/persona-api/internal/domain/errors.go
new file mode 100644
index 0000000..a512fcc
--- /dev/null
+++ b/services/persona-api/internal/domain/errors.go
@@ -0,0 +1,36 @@
+// Package domain contains pure domain models with no external dependencies.
+// These types represent the core business concepts of the service.
+package domain
+
+import "errors"
+
+// Domain errors - these are business-level errors that should be translated
+// to appropriate HTTP status codes by the handler layer.
+var (
+ // ErrNotFound indicates a requested resource does not exist.
+ ErrNotFound = errors.New("not found")
+
+ // ErrExampleNotFound indicates the requested example does not exist.
+ ErrExampleNotFound = errors.New("example not found")
+
+ // ErrDuplicateExample indicates an example with the same name already exists.
+ ErrDuplicateExample = errors.New("example with this name already exists")
+
+ // ErrInvalidExampleName indicates the example name is invalid.
+ ErrInvalidExampleName = errors.New("invalid example name")
+
+ // Auth errors
+ ErrUserNotFound = errors.New("user not found")
+ ErrDuplicateEmail = errors.New("email already registered")
+ ErrInvalidCredentials = errors.New("invalid email or password")
+ ErrSessionNotFound = errors.New("session not found")
+ ErrSessionRevoked = errors.New("session has been revoked")
+ ErrInvalidAuthCode = errors.New("invalid or expired code")
+ ErrExpiredAuthCode = errors.New("code has expired")
+ ErrWeakPassword = errors.New("password does not meet requirements")
+ ErrUserSuspended = errors.New("account is suspended")
+ ErrRegistrationDisabled = errors.New("registration is disabled")
+ ErrNameTooLong = errors.New("name exceeds maximum length")
+ ErrEmailTooLong = errors.New("email exceeds maximum length")
+ ErrInvalidAvatarURL = errors.New("avatar URL must use http or https")
+)
diff --git a/services/persona-api/internal/domain/example.go b/services/persona-api/internal/domain/example.go
new file mode 100644
index 0000000..4ee48e9
--- /dev/null
+++ b/services/persona-api/internal/domain/example.go
@@ -0,0 +1,89 @@
+package domain
+
+import (
+ "time"
+ "unicode/utf8"
+)
+
+// ExampleID is a strongly-typed identifier for examples.
+type ExampleID string
+
+// String returns the string representation of the ID.
+func (id ExampleID) String() string {
+ return string(id)
+}
+
+// IsZero returns true if the ID is empty.
+func (id ExampleID) IsZero() bool {
+ return id == ""
+}
+
+// Example name constraints.
+const (
+ MinExampleNameLen = 1
+ MaxExampleNameLen = 100
+ MaxDescriptionLen = 500
+)
+
+// Example represents an example domain entity.
+// This is a pure domain model with no external dependencies.
+type Example struct {
+ ID ExampleID
+ Name string
+ Description string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// NewExample creates a new Example with validation.
+// Returns ErrInvalidExampleName if the name is invalid.
+func NewExample(id ExampleID, name, description string) (*Example, error) {
+ if err := validateExampleName(name); err != nil {
+ return nil, err
+ }
+ if err := validateDescription(description); err != nil {
+ return nil, err
+ }
+
+ now := time.Now().UTC()
+ return &Example{
+ ID: id,
+ Name: name,
+ Description: description,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }, nil
+}
+
+// Update modifies the example's mutable fields with validation.
+// Returns ErrInvalidExampleName if the name is invalid.
+func (e *Example) Update(name, description string) error {
+ if err := validateExampleName(name); err != nil {
+ return err
+ }
+ if err := validateDescription(description); err != nil {
+ return err
+ }
+
+ e.Name = name
+ e.Description = description
+ e.UpdatedAt = time.Now().UTC()
+ return nil
+}
+
+// validateExampleName validates an example name.
+func validateExampleName(name string) error {
+ length := utf8.RuneCountInString(name)
+ if length < MinExampleNameLen || length > MaxExampleNameLen {
+ return ErrInvalidExampleName
+ }
+ return nil
+}
+
+// validateDescription validates a description.
+func validateDescription(desc string) error {
+ if utf8.RuneCountInString(desc) > MaxDescriptionLen {
+ return ErrInvalidExampleName
+ }
+ return nil
+}
diff --git a/services/persona-api/internal/domain/media.go b/services/persona-api/internal/domain/media.go
new file mode 100644
index 0000000..22a4ee4
--- /dev/null
+++ b/services/persona-api/internal/domain/media.go
@@ -0,0 +1,27 @@
+package domain
+
+import "time"
+
+// MediaObjectID is a typed media object identifier with prefix "med_".
+type MediaObjectID string
+
+// MediaObject tracks a stored media file with ownership and metadata.
+// The actual file is stored in GCS (production) or MemoryStore (dev).
+// This record enables querying, soft deletes, and provenance tracking.
+type MediaObject struct {
+ ID MediaObjectID
+ UserID UserID
+ Path string // Storage path (e.g., "media/usr_123/uuid/photo.png")
+ Filename string // Original filename
+ ContentType string
+ Size int64
+ GenerationJobID string // Non-empty if created by AI generation
+ DeletedAt *time.Time
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// IsDeleted returns true if the media object has been soft-deleted.
+func (m *MediaObject) IsDeleted() bool {
+ return m.DeletedAt != nil
+}
diff --git a/services/persona-api/internal/domain/session.go b/services/persona-api/internal/domain/session.go
new file mode 100644
index 0000000..81e89ae
--- /dev/null
+++ b/services/persona-api/internal/domain/session.go
@@ -0,0 +1,25 @@
+package domain
+
+import "time"
+
+// SessionID is a typed session identifier with prefix "ses_".
+type SessionID string
+
+// Session tracks a user login with device and location information.
+// The session ID is embedded in the JWT token for revocation support.
+type Session struct {
+ ID SessionID
+ UserID UserID
+ IPAddress string
+ UserAgent string
+ DeviceLabel string
+ LastActiveAt time.Time
+ ExpiresAt time.Time
+ RevokedAt *time.Time
+ CreatedAt time.Time
+}
+
+// IsActive returns true if the session has not been revoked and has not expired.
+func (s *Session) IsActive() bool {
+ return s.RevokedAt == nil && time.Now().Before(s.ExpiresAt)
+}
diff --git a/services/persona-api/internal/domain/user.go b/services/persona-api/internal/domain/user.go
new file mode 100644
index 0000000..2b24d21
--- /dev/null
+++ b/services/persona-api/internal/domain/user.go
@@ -0,0 +1,52 @@
+package domain
+
+import "time"
+
+// UserID is a typed user identifier with prefix "usr_".
+type UserID string
+
+// UserStatus represents the account state.
+type UserStatus string
+
+const (
+ UserStatusActive UserStatus = "active"
+ UserStatusSuspended UserStatus = "suspended"
+ UserStatusDeactivated UserStatus = "deactivated"
+)
+
+// User is the full domain model for a registered user.
+// This is the database-backed identity, separate from auth.User which is the
+// lightweight JWT-derived identity carried in request context.
+type User struct {
+ ID UserID
+ Email string
+ EmailVerified bool
+ Name string
+ AvatarURL string
+ Status UserStatus
+ Roles []string
+ LastLoginAt *time.Time
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// Validation constants for user fields.
+const (
+ MaxNameLen = 100
+ MaxEmailLen = 254 // RFC 5321
+)
+
+// NewUser creates a new user with default values.
+func NewUser(id UserID, email, name string) *User {
+ now := time.Now()
+ return &User{
+ ID: id,
+ Email: email,
+ EmailVerified: false,
+ Name: name,
+ Status: UserStatusActive,
+ Roles: []string{"user"},
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+}
diff --git a/services/persona-api/internal/email/embed.go b/services/persona-api/internal/email/embed.go
new file mode 100644
index 0000000..a873619
--- /dev/null
+++ b/services/persona-api/internal/email/embed.go
@@ -0,0 +1,8 @@
+// Package email provides embedded email templates for persona-api transactional emails.
+// The TemplateFS is passed to pkg/email.NewRendererFromFS at startup.
+package email
+
+import "embed"
+
+//go:embed all:templates
+var TemplateFS embed.FS
diff --git a/services/persona-api/internal/email/renderer_test.go b/services/persona-api/internal/email/renderer_test.go
new file mode 100644
index 0000000..a732ad2
--- /dev/null
+++ b/services/persona-api/internal/email/renderer_test.go
@@ -0,0 +1,202 @@
+package email_test
+
+import (
+ "strings"
+ "testing"
+
+ emailpkg "git.threesix.ai/jordan/persona-community-2/pkg/email"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/email"
+)
+
+var testBrand = emailpkg.BrandConfig{
+ AppName: "Test App",
+ AppURL: "https://example.com",
+ SupportEmail: "support@example.com",
+ PrimaryColor: "#6366f1",
+}
+
+func newTestRenderer(t *testing.T) *emailpkg.Renderer {
+ t.Helper()
+ r, err := emailpkg.NewRendererFromFS(email.TemplateFS, "templates", testBrand)
+ if err != nil {
+ t.Fatalf("NewRendererFromFS: %v", err)
+ }
+ return r
+}
+
+func TestRendererLoads(t *testing.T) {
+ r := newTestRenderer(t)
+
+ purposes := r.Purposes()
+ want := []string{"email_verify", "login_otp", "magic_link", "password_reset", "welcome"}
+ if len(purposes) != len(want) {
+ t.Fatalf("expected %d purposes, got %d: %v", len(want), len(purposes), purposes)
+ }
+ for i, p := range want {
+ if purposes[i] != p {
+ t.Errorf("purpose[%d]: want %q, got %q", i, p, purposes[i])
+ }
+ }
+}
+
+func TestRenderLoginOTP(t *testing.T) {
+ r := newTestRenderer(t)
+ out, err := r.Render("login_otp", emailpkg.EmailContext{
+ Code: "482916",
+ ExpiresIn: 10,
+ Purpose: "sign in",
+ })
+ if err != nil {
+ t.Fatalf("Render login_otp: %v", err)
+ }
+ if out.Subject == "" {
+ t.Error("Subject is empty")
+ }
+ if !strings.Contains(out.Subject, "Test App") {
+ t.Errorf("Subject %q does not contain app name", out.Subject)
+ }
+ if !strings.Contains(out.HTML, "482916") {
+ t.Error("HTML does not contain OTP code")
+ }
+ if !strings.Contains(out.HTML, "code-box") {
+ t.Error("HTML does not contain code-box element")
+ }
+ if out.PlainText == "" {
+ t.Error("PlainText is empty")
+ }
+ if !strings.Contains(out.PlainText, "482916") {
+ t.Error("PlainText does not contain OTP code")
+ }
+ if out.Preheader == "" {
+ t.Error("Preheader is empty")
+ }
+}
+
+func TestRenderMagicLink(t *testing.T) {
+ r := newTestRenderer(t)
+ out, err := r.Render("magic_link", emailpkg.EmailContext{
+ ActionURL: "https://example.com/auth/verify?token=abc123",
+ ButtonText: "Sign In \u2192",
+ ExpiresIn: 15,
+ })
+ if err != nil {
+ t.Fatalf("Render magic_link: %v", err)
+ }
+ if !strings.Contains(out.HTML, "Sign In") {
+ t.Error("HTML does not contain button text")
+ }
+ if !strings.Contains(out.HTML, "auth/verify") {
+ t.Error("HTML does not contain action URL")
+ }
+ if out.PlainText == "" {
+ t.Error("PlainText is empty")
+ }
+}
+
+func TestRenderPasswordReset(t *testing.T) {
+ r := newTestRenderer(t)
+ out, err := r.Render("password_reset", emailpkg.EmailContext{
+ ActionURL: "https://example.com/auth/reset?token=xyz789",
+ ButtonText: "Reset Password \u2192",
+ ExpiresIn: 60,
+ })
+ if err != nil {
+ t.Fatalf("Render password_reset: %v", err)
+ }
+ if !strings.Contains(out.HTML, "Reset Password") {
+ t.Error("HTML does not contain button text")
+ }
+ if !strings.Contains(out.Subject, "Reset") {
+ t.Errorf("Subject %q does not mention reset", out.Subject)
+ }
+}
+
+func TestRenderVerifyEmail(t *testing.T) {
+ r := newTestRenderer(t)
+ out, err := r.Render("email_verify", emailpkg.EmailContext{
+ Code: "738201",
+ ExpiresIn: 30,
+ Purpose: "verify your email",
+ })
+ if err != nil {
+ t.Fatalf("Render email_verify: %v", err)
+ }
+ if !strings.Contains(out.HTML, "738201") {
+ t.Error("HTML does not contain verification code")
+ }
+ if !strings.Contains(out.HTML, "code-box") {
+ t.Error("HTML does not contain code-box element")
+ }
+}
+
+func TestRenderWelcome(t *testing.T) {
+ r := newTestRenderer(t)
+ out, err := r.Render("welcome", emailpkg.EmailContext{
+ ActionURL: "https://example.com/dashboard",
+ ButtonText: "Get Started \u2192",
+ Name: "Jordan",
+ })
+ if err != nil {
+ t.Fatalf("Render welcome: %v", err)
+ }
+ if !strings.Contains(out.HTML, "Jordan") {
+ t.Error("HTML does not contain user name")
+ }
+ if !strings.Contains(out.HTML, "Welcome") {
+ t.Error("HTML does not contain welcome heading")
+ }
+}
+
+func TestBrandColorInjection(t *testing.T) {
+ r := newTestRenderer(t)
+ out, err := r.Render("login_otp", emailpkg.EmailContext{
+ Code: "123456",
+ ExpiresIn: 10,
+ })
+ if err != nil {
+ t.Fatalf("Render: %v", err)
+ }
+ // Brand primary color should appear in the inlined styles.
+ if !strings.Contains(out.HTML, "#6366f1") {
+ t.Error("HTML does not contain brand color #6366f1")
+ }
+}
+
+func TestUnknownPurposeReturnsError(t *testing.T) {
+ r := newTestRenderer(t)
+ _, err := r.Render("nonexistent_type", emailpkg.EmailContext{})
+ if err == nil {
+ t.Error("expected error for unknown purpose, got nil")
+ }
+}
+
+func TestAllTemplatesHaveSubjectAndPreheader(t *testing.T) {
+ r := newTestRenderer(t)
+ contexts := map[string]emailpkg.EmailContext{
+ "login_otp": {Code: "111111", ExpiresIn: 10},
+ "magic_link": {ActionURL: "https://example.com/auth", ButtonText: "Sign In", ExpiresIn: 15},
+ "password_reset": {ActionURL: "https://example.com/reset", ButtonText: "Reset", ExpiresIn: 60},
+ "email_verify": {Code: "222222", ExpiresIn: 30},
+ "welcome": {ActionURL: "https://example.com", ButtonText: "Get Started", Name: "Alex"},
+ }
+ for _, purpose := range r.Purposes() {
+ ctx := contexts[purpose]
+ out, err := r.Render(purpose, ctx)
+ if err != nil {
+ t.Errorf("%s: render error: %v", purpose, err)
+ continue
+ }
+ if out.Subject == "" {
+ t.Errorf("%s: Subject is empty", purpose)
+ }
+ if out.Preheader == "" {
+ t.Errorf("%s: Preheader is empty", purpose)
+ }
+ if out.HTML == "" {
+ t.Errorf("%s: HTML is empty", purpose)
+ }
+ if out.PlainText == "" {
+ t.Errorf("%s: PlainText is empty", purpose)
+ }
+ }
+}
diff --git a/services/persona-api/internal/email/templates/_button.html b/services/persona-api/internal/email/templates/_button.html
new file mode 100644
index 0000000..bbfd435
--- /dev/null
+++ b/services/persona-api/internal/email/templates/_button.html
@@ -0,0 +1,4 @@
+{{define "button"}}
+{{end}}
diff --git a/services/persona-api/internal/email/templates/_code_box.html b/services/persona-api/internal/email/templates/_code_box.html
new file mode 100644
index 0000000..f0ecadc
--- /dev/null
+++ b/services/persona-api/internal/email/templates/_code_box.html
@@ -0,0 +1,4 @@
+{{define "code_box"}}
+ {{.Code}}
+
+{{end}}
diff --git a/services/persona-api/internal/email/templates/_footer.html b/services/persona-api/internal/email/templates/_footer.html
new file mode 100644
index 0000000..0c98740
--- /dev/null
+++ b/services/persona-api/internal/email/templates/_footer.html
@@ -0,0 +1,7 @@
+{{define "footer"}}
+{{end}}
diff --git a/services/persona-api/internal/email/templates/_header.html b/services/persona-api/internal/email/templates/_header.html
new file mode 100644
index 0000000..4e59b9e
--- /dev/null
+++ b/services/persona-api/internal/email/templates/_header.html
@@ -0,0 +1,8 @@
+{{define "header"}}
+{{end}}
diff --git a/services/persona-api/internal/email/templates/_layout.html b/services/persona-api/internal/email/templates/_layout.html
new file mode 100644
index 0000000..1db0a29
--- /dev/null
+++ b/services/persona-api/internal/email/templates/_layout.html
@@ -0,0 +1,83 @@
+{{define "layout"}}
+
+
+
+
+
+
+
+ {{.AppName}}
+
+
+
+
+
+
+
+ {{template "header" .}}
+
+ {{template "body" .}}
+
+ {{template "footer" .}}
+
+
+
+
+{{end}}
diff --git a/services/persona-api/internal/email/templates/email_verify/body.html b/services/persona-api/internal/email/templates/email_verify/body.html
new file mode 100644
index 0000000..47e68e0
--- /dev/null
+++ b/services/persona-api/internal/email/templates/email_verify/body.html
@@ -0,0 +1,8 @@
+{{define "body"}}
+Hi there,
+Enter this code to verify your email address for {{.AppName}} :
+
+{{template "code_box" .}}
+
+This code expires in {{.ExpiresIn}} minutes. If you didn't request this, you can safely ignore this email.
+{{end}}
diff --git a/services/persona-api/internal/email/templates/email_verify/meta.yaml b/services/persona-api/internal/email/templates/email_verify/meta.yaml
new file mode 100644
index 0000000..8978ac6
--- /dev/null
+++ b/services/persona-api/internal/email/templates/email_verify/meta.yaml
@@ -0,0 +1,4 @@
+purpose: email_verify
+category: transactional
+subject: "Verify your {{.AppName}} email address"
+preheader: "{{.Code}} is your email verification code — expires in {{.ExpiresIn}} minutes."
diff --git a/services/persona-api/internal/email/templates/login_otp/body.html b/services/persona-api/internal/email/templates/login_otp/body.html
new file mode 100644
index 0000000..5d4315d
--- /dev/null
+++ b/services/persona-api/internal/email/templates/login_otp/body.html
@@ -0,0 +1,8 @@
+{{define "body"}}
+Hi there,
+Here is your sign in code for {{.AppName}} :
+
+{{template "code_box" .}}
+
+This code expires in {{.ExpiresIn}} minutes. If you didn't request this, you can safely ignore this email — your account is secure.
+{{end}}
diff --git a/services/persona-api/internal/email/templates/login_otp/meta.yaml b/services/persona-api/internal/email/templates/login_otp/meta.yaml
new file mode 100644
index 0000000..1eac2d6
--- /dev/null
+++ b/services/persona-api/internal/email/templates/login_otp/meta.yaml
@@ -0,0 +1,4 @@
+purpose: login_otp
+category: transactional
+subject: "Your {{.AppName}} sign in code"
+preheader: "{{.Code}} is your sign in code — expires in {{.ExpiresIn}} minutes."
diff --git a/services/persona-api/internal/email/templates/magic_link/body.html b/services/persona-api/internal/email/templates/magic_link/body.html
new file mode 100644
index 0000000..9e7044a
--- /dev/null
+++ b/services/persona-api/internal/email/templates/magic_link/body.html
@@ -0,0 +1,13 @@
+{{define "body"}}
+Sign in to {{.AppName}}
+Click the button below to sign in. This link expires in {{.ExpiresIn}} minutes.
+
+{{template "button" .}}
+
+Or copy this link into your browser:
+{{.ActionURL}}
+
+
+
+If you didn't request this sign-in link, you can safely ignore this email — your account is secure.
+{{end}}
diff --git a/services/persona-api/internal/email/templates/magic_link/meta.yaml b/services/persona-api/internal/email/templates/magic_link/meta.yaml
new file mode 100644
index 0000000..08ebb3d
--- /dev/null
+++ b/services/persona-api/internal/email/templates/magic_link/meta.yaml
@@ -0,0 +1,4 @@
+purpose: magic_link
+category: transactional
+subject: "Sign in to {{.AppName}}"
+preheader: "Click to sign in to your {{.AppName}} account — link expires in {{.ExpiresIn}} minutes."
diff --git a/services/persona-api/internal/email/templates/password_reset/body.html b/services/persona-api/internal/email/templates/password_reset/body.html
new file mode 100644
index 0000000..e2bdcb6
--- /dev/null
+++ b/services/persona-api/internal/email/templates/password_reset/body.html
@@ -0,0 +1,13 @@
+{{define "body"}}
+Reset your password
+Click the button below to choose a new password. This link expires in {{.ExpiresIn}} minutes.
+
+{{template "button" .}}
+
+Or copy this link into your browser:
+{{.ActionURL}}
+
+
+
+If you didn't request a password reset, you can safely ignore this email — your password won't change.
+{{end}}
diff --git a/services/persona-api/internal/email/templates/password_reset/meta.yaml b/services/persona-api/internal/email/templates/password_reset/meta.yaml
new file mode 100644
index 0000000..67e8b7f
--- /dev/null
+++ b/services/persona-api/internal/email/templates/password_reset/meta.yaml
@@ -0,0 +1,4 @@
+purpose: password_reset
+category: transactional
+subject: "Reset your {{.AppName}} password"
+preheader: "You requested a password reset for {{.AppName}} — link expires in {{.ExpiresIn}} minutes."
diff --git a/services/persona-api/internal/email/templates/welcome/body.html b/services/persona-api/internal/email/templates/welcome/body.html
new file mode 100644
index 0000000..2eac89b
--- /dev/null
+++ b/services/persona-api/internal/email/templates/welcome/body.html
@@ -0,0 +1,10 @@
+{{define "body"}}
+Welcome to {{.AppName}}{{if .Name}}, {{.Name}}{{end}}!
+Your account is ready. Start exploring everything {{.AppName}} has to offer.
+
+{{template "button" .}}
+
+{{- if .SupportEmail}}
+Have questions? Reach us at {{.SupportEmail}} .
+{{- end}}
+{{end}}
diff --git a/services/persona-api/internal/email/templates/welcome/meta.yaml b/services/persona-api/internal/email/templates/welcome/meta.yaml
new file mode 100644
index 0000000..4f9bc92
--- /dev/null
+++ b/services/persona-api/internal/email/templates/welcome/meta.yaml
@@ -0,0 +1,4 @@
+purpose: welcome
+category: transactional
+subject: "Welcome to {{.AppName}}"
+preheader: "Your {{.AppName}} account is ready. Get started today."
diff --git a/services/persona-api/internal/port/album.go b/services/persona-api/internal/port/album.go
new file mode 100644
index 0000000..16e8c9a
--- /dev/null
+++ b/services/persona-api/internal/port/album.go
@@ -0,0 +1,34 @@
+package port
+
+import (
+ "context"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/album"
+)
+
+// AlbumRepository defines persistence operations for albums.
+// It extends album.AlbumUpdater so implementations satisfy both interfaces.
+type AlbumRepository interface {
+ album.AlbumUpdater
+
+ // Create persists a new album. Sets ID, CreatedAt, UpdatedAt.
+ Create(ctx context.Context, a *album.Album) error
+
+ // Get returns an album by ID. Returns ErrAlbumNotFound if not found.
+ Get(ctx context.Context, id album.AlbumID, userID string) (*album.Album, error)
+
+ // List returns all albums for a user, ordered by CreatedAt DESC.
+ List(ctx context.Context, userID string) ([]album.Album, error)
+
+ // Delete removes an album and all its shots. Does NOT delete stored images.
+ Delete(ctx context.Context, id album.AlbumID, userID string) error
+
+ // ResetShot clears a shot's ImageURL, JobID, Error, and sets Status to pending.
+ ResetShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int) error
+
+ // UpdateAnchorJobID sets the AnchorJobID when the anchor generation job is enqueued.
+ UpdateAnchorJobID(ctx context.Context, id album.AlbumID, userID, jobID string) error
+
+ // UpdateShotJobID sets the shot's JobID when a shot generation job is enqueued.
+ UpdateShotJobID(ctx context.Context, id album.AlbumID, userID string, shotIndex int, jobID string) error
+}
diff --git a/services/persona-api/internal/port/auth_code.go b/services/persona-api/internal/port/auth_code.go
new file mode 100644
index 0000000..f5e7f56
--- /dev/null
+++ b/services/persona-api/internal/port/auth_code.go
@@ -0,0 +1,24 @@
+package port
+
+import (
+ "context"
+
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+)
+
+// AuthCodeRepository defines the interface for auth code persistence.
+type AuthCodeRepository interface {
+ // Create persists a new auth code.
+ Create(ctx context.Context, code *domain.AuthCode) error
+
+ // FindValid returns an unused, non-expired code matching the criteria.
+ // Returns domain.ErrInvalidAuthCode if no valid code exists.
+ FindValid(ctx context.Context, email string, code string, purpose domain.AuthCodePurpose) (*domain.AuthCode, error)
+
+ // MarkUsed sets the used_at timestamp on a code, making it single-use.
+ MarkUsed(ctx context.Context, id string) error
+
+ // DeleteExpired removes codes that have passed their expiry time.
+ // Returns the number of codes deleted.
+ DeleteExpired(ctx context.Context) (int, error)
+}
diff --git a/services/persona-api/internal/port/email.go b/services/persona-api/internal/port/email.go
new file mode 100644
index 0000000..9b54741
--- /dev/null
+++ b/services/persona-api/internal/port/email.go
@@ -0,0 +1,11 @@
+package port
+
+import "context"
+
+// EmailSender sends emails for authentication flows (OTP, magic link, password reset, etc.).
+type EmailSender interface {
+ // SendAuthCode sends an authentication code to the given email.
+ // purpose identifies the flow (e.g. "login_otp", "magic_link", "password_reset", "email_verify").
+ // code is the token or OTP to include in the email.
+ SendAuthCode(ctx context.Context, email, code, purpose string) error
+}
diff --git a/services/persona-api/internal/port/example.go b/services/persona-api/internal/port/example.go
new file mode 100644
index 0000000..120ede6
--- /dev/null
+++ b/services/persona-api/internal/port/example.go
@@ -0,0 +1,37 @@
+// Package port defines interfaces (ports) for external dependencies.
+// These interfaces define the contracts between the application core and
+// infrastructure adapters, enabling testability and flexibility.
+package port
+
+import (
+ "context"
+
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+)
+
+// ExampleRepository defines the interface for example persistence operations.
+// Implementations may use databases, in-memory storage, or external services.
+type ExampleRepository interface {
+ // List returns all examples.
+ List(ctx context.Context) ([]domain.Example, error)
+
+ // Get returns an example by ID.
+ // Returns domain.ErrExampleNotFound if not found.
+ Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error)
+
+ // Create stores a new example.
+ // The example must have a valid ID set.
+ Create(ctx context.Context, example *domain.Example) error
+
+ // Update modifies an existing example.
+ // Returns domain.ErrExampleNotFound if not found.
+ Update(ctx context.Context, example *domain.Example) error
+
+ // Delete removes an example by ID.
+ // Returns domain.ErrExampleNotFound if not found.
+ Delete(ctx context.Context, id domain.ExampleID) error
+
+ // ExistsByName checks if an example with the given name exists.
+ // Used for duplicate detection.
+ ExistsByName(ctx context.Context, name string) (bool, error)
+}
diff --git a/services/persona-api/internal/port/media.go b/services/persona-api/internal/port/media.go
new file mode 100644
index 0000000..a8e578d
--- /dev/null
+++ b/services/persona-api/internal/port/media.go
@@ -0,0 +1,38 @@
+package port
+
+import (
+ "context"
+
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+)
+
+// MediaRepository defines the interface for media metadata persistence.
+type MediaRepository interface {
+ // Create persists a new media object record.
+ Create(ctx context.Context, obj *domain.MediaObject) error
+
+ // Get returns a media object by ID. Returns domain.ErrNotFound if not found or soft-deleted.
+ Get(ctx context.Context, id domain.MediaObjectID) (*domain.MediaObject, error)
+
+ // ListByUser returns non-deleted media objects for a user, ordered by created_at DESC.
+ ListByUser(ctx context.Context, userID domain.UserID, opts ListMediaOptions) ([]domain.MediaObject, int, error)
+
+ // SoftDelete marks a media object as deleted without removing it.
+ SoftDelete(ctx context.Context, id domain.MediaObjectID) error
+
+ // HardDelete permanently removes a media object record.
+ HardDelete(ctx context.Context, id domain.MediaObjectID) error
+
+ // GetByPath returns a media object by its storage path. Returns domain.ErrNotFound if not found.
+ GetByPath(ctx context.Context, path string) (*domain.MediaObject, error)
+}
+
+// ListMediaOptions controls filtering and pagination for media queries.
+type ListMediaOptions struct {
+ // ContentTypePrefix filters by MIME type prefix (e.g., "image/", "video/").
+ ContentTypePrefix string
+ // Limit is the maximum number of results (0 = default 50).
+ Limit int
+ // Offset is the pagination offset.
+ Offset int
+}
diff --git a/services/persona-api/internal/port/session.go b/services/persona-api/internal/port/session.go
new file mode 100644
index 0000000..e9dd778
--- /dev/null
+++ b/services/persona-api/internal/port/session.go
@@ -0,0 +1,33 @@
+package port
+
+import (
+ "context"
+
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+)
+
+// SessionRepository defines the interface for session persistence.
+type SessionRepository interface {
+ // Create persists a new session record.
+ Create(ctx context.Context, session *domain.Session) error
+
+ // Get returns a session by ID. Returns domain.ErrSessionNotFound if not found.
+ Get(ctx context.Context, id domain.SessionID) (*domain.Session, error)
+
+ // ListByUser returns all active (non-revoked) sessions for a user.
+ ListByUser(ctx context.Context, userID domain.UserID) ([]domain.Session, error)
+
+ // UpdateLastActive updates the last_active_at timestamp for a session.
+ UpdateLastActive(ctx context.Context, id domain.SessionID) error
+
+ // Revoke marks a session as revoked by setting revoked_at.
+ Revoke(ctx context.Context, id domain.SessionID) error
+
+ // RevokeAllForUser revokes all sessions for a user.
+ // If exceptID is non-nil, that session is kept active.
+ RevokeAllForUser(ctx context.Context, userID domain.UserID, exceptID *domain.SessionID) error
+
+ // DeleteExpired removes sessions that have passed their expiry time.
+ // Returns the number of sessions deleted.
+ DeleteExpired(ctx context.Context) (int, error)
+}
diff --git a/services/persona-api/internal/port/user.go b/services/persona-api/internal/port/user.go
new file mode 100644
index 0000000..b91578b
--- /dev/null
+++ b/services/persona-api/internal/port/user.go
@@ -0,0 +1,51 @@
+package port
+
+import (
+ "context"
+
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+)
+
+// UserRepository defines the interface for user persistence.
+type UserRepository interface {
+ // Create persists a new user.
+ Create(ctx context.Context, user *domain.User) error
+
+ // Get returns a user by ID. Returns domain.ErrUserNotFound if not found.
+ Get(ctx context.Context, id domain.UserID) (*domain.User, error)
+
+ // GetByEmail returns a user by email. Returns domain.ErrUserNotFound if not found.
+ GetByEmail(ctx context.Context, email string) (*domain.User, error)
+
+ // Update persists changes to an existing user.
+ Update(ctx context.Context, user *domain.User) error
+
+ // UpdateLastLogin sets the last_login_at timestamp.
+ UpdateLastLogin(ctx context.Context, id domain.UserID) error
+
+ // ExistsByEmail returns true if a user with the given email exists.
+ ExistsByEmail(ctx context.Context, email string) (bool, error)
+
+ // Password operations (separate from user CRUD because OAuth-only users have no password)
+
+ // SetPassword stores a bcrypt hash for a user. Creates or replaces existing.
+ SetPassword(ctx context.Context, userID domain.UserID, hash string) error
+
+ // GetPasswordHash returns the bcrypt hash for a user.
+ // Returns empty string and nil error if user has no password set.
+ GetPasswordHash(ctx context.Context, userID domain.UserID) (string, error)
+
+ // HasPassword returns true if the user has a password set.
+ HasPassword(ctx context.Context, userID domain.UserID) (bool, error)
+
+ // Role operations
+
+ // AddRole grants a role to a user. No-op if already granted.
+ AddRole(ctx context.Context, userID domain.UserID, role string) error
+
+ // RemoveRole revokes a role from a user. No-op if not granted.
+ RemoveRole(ctx context.Context, userID domain.UserID, role string) error
+
+ // GetRoles returns all roles for a user.
+ GetRoles(ctx context.Context, userID domain.UserID) ([]string, error)
+}
diff --git a/services/persona-api/internal/service/album.go b/services/persona-api/internal/service/album.go
new file mode 100644
index 0000000..b7d136c
--- /dev/null
+++ b/services/persona-api/internal/service/album.go
@@ -0,0 +1,186 @@
+package service
+
+import (
+ "context"
+ "fmt"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/album"
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/pkg/queue"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+)
+
+// AlbumService handles album creation, retrieval, and generation orchestration.
+// All generation is async: service enqueues jobs and returns immediately.
+// Results arrive via SSE on the user: channel.
+type AlbumService struct {
+ albums port.AlbumRepository
+ queue queue.Producer
+ logger *logging.Logger
+}
+
+// NewAlbumService creates a new AlbumService.
+func NewAlbumService(albums port.AlbumRepository, q queue.Producer, logger *logging.Logger) *AlbumService {
+ return &AlbumService{
+ albums: albums,
+ queue: q,
+ logger: logger.WithComponent("AlbumService"),
+ }
+}
+
+// Create creates a new album with the given shots and persists it.
+// Shots are provided as ShotTemplate slices (Label + Direction).
+func (s *AlbumService) Create(ctx context.Context, userID, name, subjectDesc string, shots []album.ShotTemplate) (*album.Album, error) {
+ if name == "" {
+ return nil, fmt.Errorf("album name is required")
+ }
+ if subjectDesc == "" {
+ return nil, fmt.Errorf("subject description is required")
+ }
+ if len(shots) == 0 {
+ return nil, fmt.Errorf("at least one shot is required")
+ }
+ if len(shots) > 20 {
+ return nil, fmt.Errorf("maximum 20 shots per album")
+ }
+
+ shotList := make([]album.Shot, len(shots))
+ for i, tmpl := range shots {
+ shotList[i] = album.Shot{
+ Index: i,
+ Label: tmpl.Label,
+ Direction: tmpl.Direction,
+ Status: album.ShotPending,
+ }
+ }
+
+ a := &album.Album{
+ ID: album.AlbumID("alb_" + generateID()),
+ UserID: userID,
+ Name: name,
+ SubjectDesc: subjectDesc,
+ Shots: shotList,
+ }
+
+ if err := s.albums.Create(ctx, a); err != nil {
+ return nil, fmt.Errorf("create album: %w", err)
+ }
+
+ s.logger.Info("album created", "album_id", string(a.ID), "user_id", userID, "shots", len(shotList))
+ return a, nil
+}
+
+// Get returns an album by ID, enforcing user ownership.
+func (s *AlbumService) Get(ctx context.Context, id album.AlbumID, userID string) (*album.Album, error) {
+ a, err := s.albums.Get(ctx, id, userID)
+ if err != nil {
+ return nil, fmt.Errorf("album not found: %w", err)
+ }
+ return a, nil
+}
+
+// List returns all albums for a user.
+func (s *AlbumService) List(ctx context.Context, userID string) ([]album.Album, error) {
+ return s.albums.List(ctx, userID)
+}
+
+// Delete removes an album. Does NOT delete stored images.
+func (s *AlbumService) Delete(ctx context.Context, id album.AlbumID, userID string) error {
+ return s.albums.Delete(ctx, id, userID)
+}
+
+// GenerateAnchor enqueues an anchor generation job for an album.
+// Returns the job ID. Result arrives via album_anchor_complete SSE event.
+func (s *AlbumService) GenerateAnchor(ctx context.Context, id album.AlbumID, userID string) (string, error) {
+ a, err := s.albums.Get(ctx, id, userID)
+ if err != nil {
+ return "", fmt.Errorf("album not found: %w", err)
+ }
+
+ jobID, err := s.queue.Enqueue(ctx, "generate_anchor", map[string]any{
+ "albumId": string(a.ID),
+ "userId": userID,
+ "subjectDesc": a.SubjectDesc,
+ })
+ if err != nil {
+ return "", fmt.Errorf("enqueue anchor job: %w", err)
+ }
+
+ if err := s.albums.UpdateAnchorJobID(ctx, id, userID, jobID); err != nil {
+ s.logger.Warn("failed to persist anchor job ID", "error", err, "album_id", string(id))
+ }
+
+ s.logger.Info("anchor generation enqueued", "album_id", string(id), "job_id", jobID)
+ return jobID, nil
+}
+
+// GenerateAllShots enqueues generation jobs for all pending shots.
+// Returns 422 if the album has no anchor yet (shots require an anchor reference).
+func (s *AlbumService) GenerateAllShots(ctx context.Context, id album.AlbumID, userID string) ([]string, error) {
+ a, err := s.albums.Get(ctx, id, userID)
+ if err != nil {
+ return nil, fmt.Errorf("album not found: %w", err)
+ }
+ if a.AnchorURL == "" {
+ return nil, album.ErrAnchorRequired
+ }
+
+ var jobIDs []string
+ for _, shot := range a.Shots {
+ if shot.Status != album.ShotPending && shot.Status != album.ShotFailed {
+ continue
+ }
+ jobID, err := s.enqueueShotJob(ctx, a, shot.Index)
+ if err != nil {
+ s.logger.Error("failed to enqueue shot", "error", err, "album_id", string(id), "shot_index", shot.Index)
+ continue
+ }
+ jobIDs = append(jobIDs, jobID)
+ }
+
+ s.logger.Info("shots enqueued", "album_id", string(id), "count", len(jobIDs))
+ return jobIDs, nil
+}
+
+// GenerateShot enqueues a generation job for a single shot (for regeneration).
+func (s *AlbumService) GenerateShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int) (string, error) {
+ a, err := s.albums.Get(ctx, id, userID)
+ if err != nil {
+ return "", fmt.Errorf("album not found: %w", err)
+ }
+ if a.AnchorURL == "" {
+ return "", fmt.Errorf("anchor must be generated before shots")
+ }
+ if shotIndex < 0 || shotIndex >= len(a.Shots) {
+ return "", fmt.Errorf("shot index out of range: %d", shotIndex)
+ }
+ return s.enqueueShotJob(ctx, a, shotIndex)
+}
+
+// ResetShot clears a shot back to pending so it can be regenerated.
+func (s *AlbumService) ResetShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int) error {
+ return s.albums.ResetShot(ctx, id, userID, shotIndex)
+}
+
+// enqueueShotJob is the internal helper that enqueues a single shot generation job.
+func (s *AlbumService) enqueueShotJob(ctx context.Context, a *album.Album, shotIndex int) (string, error) {
+ shot := a.Shots[shotIndex]
+ jobID, err := s.queue.Enqueue(ctx, "generate_shot", map[string]any{
+ "albumId": string(a.ID),
+ "userId": a.UserID,
+ "shotIndex": shotIndex,
+ "anchorUrl": a.AnchorURL,
+ "subjectDesc": a.SubjectDesc,
+ "direction": shot.Direction,
+ })
+ if err != nil {
+ return "", fmt.Errorf("enqueue shot job: %w", err)
+ }
+
+ if err := s.albums.UpdateShotJobID(ctx, a.ID, a.UserID, shotIndex, jobID); err != nil {
+ s.logger.Warn("failed to persist shot job ID", "error", err, "shot_index", shotIndex)
+ }
+
+ return jobID, nil
+}
+
diff --git a/services/persona-api/internal/service/auth.go b/services/persona-api/internal/service/auth.go
new file mode 100644
index 0000000..14c07e2
--- /dev/null
+++ b/services/persona-api/internal/service/auth.go
@@ -0,0 +1,644 @@
+package service
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "math/big"
+ "net/url"
+ "strings"
+ "time"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/auth"
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+)
+
+const (
+ // TokenLifetime is the access token duration (short-lived, requires refresh).
+ TokenLifetime = 15 * time.Minute
+ // SessionLifetime is how long a session stays valid before requiring re-login.
+ SessionLifetime = 30 * 24 * time.Hour // 30 days
+ // OTPExpiry is how long a one-time password is valid.
+ OTPExpiry = 10 * time.Minute
+ // MagicLinkExpiry is how long a magic link token is valid.
+ MagicLinkExpiry = 15 * time.Minute
+ // PasswordResetExpiry is how long a password reset token is valid.
+ PasswordResetExpiry = 1 * time.Hour
+ // EmailVerifyExpiry is how long an email verification code is valid.
+ EmailVerifyExpiry = 24 * time.Hour
+)
+
+// AuthService handles all authentication and identity flows.
+type AuthService struct {
+ users port.UserRepository
+ sessions port.SessionRepository
+ codes port.AuthCodeRepository
+ email port.EmailSender
+ jwtSecret []byte
+ issuer string
+ registrationEnabled bool
+ logger *logging.Logger
+}
+
+// NewAuthService creates a new auth service.
+func NewAuthService(
+ users port.UserRepository,
+ sessions port.SessionRepository,
+ codes port.AuthCodeRepository,
+ email port.EmailSender,
+ jwtSecret string,
+ registrationEnabled bool,
+ logger *logging.Logger,
+) *AuthService {
+ return &AuthService{
+ users: users,
+ sessions: sessions,
+ codes: codes,
+ email: email,
+ jwtSecret: []byte(jwtSecret),
+ issuer: "persona-community-2",
+ registrationEnabled: registrationEnabled,
+ logger: logger.WithService("AuthService"),
+ }
+}
+
+// LoginOutput is the result of a successful login or registration.
+type LoginOutput struct {
+ Token string
+ User *domain.User
+}
+
+// Register creates a new user account with email and password.
+func (s *AuthService) Register(ctx context.Context, email, password, name, ip, userAgent string) (*LoginOutput, error) {
+ if !s.registrationEnabled {
+ return nil, domain.ErrRegistrationDisabled
+ }
+
+ if err := auth.ValidatePasswordStrength(password); err != nil {
+ return nil, fmt.Errorf("%w: %w", domain.ErrWeakPassword, err)
+ }
+
+ name = strings.TrimSpace(name)
+ if len(name) > domain.MaxNameLen {
+ return nil, domain.ErrNameTooLong
+ }
+ if len(email) > domain.MaxEmailLen {
+ return nil, domain.ErrEmailTooLong
+ }
+
+ exists, err := s.users.ExistsByEmail(ctx, email)
+ if err != nil {
+ return nil, err
+ }
+ if exists {
+ return nil, domain.ErrDuplicateEmail
+ }
+
+ hash, err := auth.HashPassword(password)
+ if err != nil {
+ return nil, fmt.Errorf("hashing password: %w", err)
+ }
+
+ userID := domain.UserID("usr_" + generateID())
+ user := domain.NewUser(userID, email, name)
+
+ if err := s.users.Create(ctx, user); err != nil {
+ return nil, err
+ }
+ if err := s.users.SetPassword(ctx, userID, hash); err != nil {
+ return nil, err
+ }
+
+ s.logger.Info("user registered", "user_id", string(userID), "email", email)
+
+ return s.createSession(ctx, user, ip, userAgent)
+}
+
+// LoginWithPassword authenticates a user with email and password.
+func (s *AuthService) LoginWithPassword(ctx context.Context, email, password, ip, userAgent string) (*LoginOutput, error) {
+ user, err := s.users.GetByEmail(ctx, email)
+ if err != nil {
+ if errors.Is(err, domain.ErrUserNotFound) {
+ return nil, domain.ErrInvalidCredentials
+ }
+ return nil, err
+ }
+
+ if user.Status == domain.UserStatusSuspended {
+ return nil, domain.ErrUserSuspended
+ }
+
+ hash, err := s.users.GetPasswordHash(ctx, user.ID)
+ if err != nil {
+ return nil, err
+ }
+ if hash == "" || !auth.CheckPassword(password, hash) {
+ s.logger.Warn("invalid password attempt", "email", email)
+ return nil, domain.ErrInvalidCredentials
+ }
+
+ _ = s.users.UpdateLastLogin(ctx, user.ID)
+ s.logger.Info("user logged in", "user_id", string(user.ID), "email", email)
+
+ return s.createSession(ctx, user, ip, userAgent)
+}
+
+// RefreshToken issues a new access token if the session is still active.
+func (s *AuthService) RefreshToken(ctx context.Context, sessionID string, userID string) (*LoginOutput, error) {
+ sid := domain.SessionID(sessionID)
+ session, err := s.sessions.Get(ctx, sid)
+ if err != nil {
+ return nil, domain.ErrSessionNotFound
+ }
+ if !session.IsActive() {
+ return nil, domain.ErrSessionRevoked
+ }
+
+ user, err := s.users.Get(ctx, domain.UserID(userID))
+ if err != nil {
+ return nil, err
+ }
+ if user.Status == domain.UserStatusSuspended {
+ return nil, domain.ErrUserSuspended
+ }
+
+ _ = s.sessions.UpdateLastActive(ctx, sid)
+
+ token, err := s.generateToken(user, sessionID)
+ if err != nil {
+ return nil, err
+ }
+
+ return &LoginOutput{Token: token, User: user}, nil
+}
+
+// Logout revokes the current session.
+func (s *AuthService) Logout(ctx context.Context, sessionID string) error {
+ if sessionID == "" {
+ return nil
+ }
+ return s.sessions.Revoke(ctx, domain.SessionID(sessionID))
+}
+
+// LogoutAll revokes all sessions for a user, optionally keeping one.
+func (s *AuthService) LogoutAll(ctx context.Context, userID string, exceptSessionID *string) error {
+ var except *domain.SessionID
+ if exceptSessionID != nil {
+ sid := domain.SessionID(*exceptSessionID)
+ except = &sid
+ }
+ return s.sessions.RevokeAllForUser(ctx, domain.UserID(userID), except)
+}
+
+// CheckSession returns whether a session is active (not revoked, not expired).
+// Used as auth.SessionChecker for the SessionCheck middleware.
+func (s *AuthService) CheckSession(ctx context.Context, sessionID string) (bool, error) {
+ session, err := s.sessions.Get(ctx, domain.SessionID(sessionID))
+ if err != nil {
+ return false, nil
+ }
+ return session.IsActive(), nil
+}
+
+// ListSessions returns all active sessions for a user.
+func (s *AuthService) ListSessions(ctx context.Context, userID string) ([]domain.Session, error) {
+ return s.sessions.ListByUser(ctx, domain.UserID(userID))
+}
+
+// RevokeSession revokes a specific session for a user.
+func (s *AuthService) RevokeSession(ctx context.Context, userID, sessionID string) error {
+ session, err := s.sessions.Get(ctx, domain.SessionID(sessionID))
+ if err != nil {
+ return err
+ }
+ if session.UserID != domain.UserID(userID) {
+ return domain.ErrSessionNotFound
+ }
+ return s.sessions.Revoke(ctx, domain.SessionID(sessionID))
+}
+
+// GetCurrentUser returns the full user for the given ID.
+func (s *AuthService) GetCurrentUser(ctx context.Context, userID string) (*domain.User, error) {
+ return s.users.Get(ctx, domain.UserID(userID))
+}
+
+// UpdateProfile updates a user's name and avatar.
+func (s *AuthService) UpdateProfile(ctx context.Context, userID, name, avatarURL string) (*domain.User, error) {
+ user, err := s.users.Get(ctx, domain.UserID(userID))
+ if err != nil {
+ return nil, err
+ }
+
+ if name != "" {
+ name = strings.TrimSpace(name)
+ if len(name) > domain.MaxNameLen {
+ return nil, domain.ErrNameTooLong
+ }
+ user.Name = name
+ }
+ if avatarURL != "" {
+ if err := validateAvatarURL(avatarURL); err != nil {
+ return nil, err
+ }
+ user.AvatarURL = avatarURL
+ }
+
+ if err := s.users.Update(ctx, user); err != nil {
+ return nil, err
+ }
+ return user, nil
+}
+
+// ChangePassword changes a user's password after verifying the current one.
+func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error {
+ uid := domain.UserID(userID)
+
+ hash, err := s.users.GetPasswordHash(ctx, uid)
+ if err != nil {
+ return err
+ }
+ if hash == "" || !auth.CheckPassword(currentPassword, hash) {
+ return domain.ErrInvalidCredentials
+ }
+
+ if err := auth.ValidatePasswordStrength(newPassword); err != nil {
+ return fmt.Errorf("%w: %w", domain.ErrWeakPassword, err)
+ }
+
+ newHash, err := auth.HashPassword(newPassword)
+ if err != nil {
+ return fmt.Errorf("hashing password: %w", err)
+ }
+
+ return s.users.SetPassword(ctx, uid, newHash)
+}
+
+// SendOTP generates a one-time password for the given email.
+// If the email is not registered and registration is enabled, the code is still
+// sent — the account will be created when the code is verified. This supports a
+// unified register+login flow with a single OTP email.
+func (s *AuthService) SendOTP(ctx context.Context, email, ip string) error {
+ user, err := s.users.GetByEmail(ctx, email)
+ if err != nil {
+ if !errors.Is(err, domain.ErrUserNotFound) {
+ return err
+ }
+ // Unknown email: only proceed if registration is open.
+ if !s.registrationEnabled {
+ s.logger.Info("OTP requested for unknown email (registration disabled)", "email", email)
+ return nil
+ }
+ // Registration enabled — send code anyway. UserID will be nil until verify.
+ user = nil
+ }
+
+ code := generateOTP()
+ var uid *domain.UserID
+ if user != nil {
+ uid = &user.ID
+ }
+ authCode := &domain.AuthCode{
+ ID: "acd_" + generateID(),
+ UserID: uid,
+ Email: email,
+ Code: code,
+ Purpose: domain.PurposeLoginOTP,
+ ExpiresAt: time.Now().Add(OTPExpiry),
+ IPAddress: ip,
+ CreatedAt: time.Now(),
+ }
+
+ if err := s.codes.Create(ctx, authCode); err != nil {
+ return err
+ }
+
+ s.logger.Info("auth code created", "purpose", "login_otp", "email", email, "code_id", authCode.ID)
+ if err := s.email.SendAuthCode(ctx, email, code, string(domain.PurposeLoginOTP)); err != nil {
+ s.logger.Error("failed to send OTP email", "email", email, "error", err)
+ }
+ return nil
+}
+
+// VerifyOTP verifies a one-time password and returns a login token.
+// If the email has no account yet and registration is enabled, the account is
+// created automatically — OTP delivery proves email ownership.
+func (s *AuthService) VerifyOTP(ctx context.Context, email, code, ip, userAgent string) (*LoginOutput, error) {
+ authCode, err := s.codes.FindValid(ctx, email, code, domain.PurposeLoginOTP)
+ if err != nil {
+ return nil, domain.ErrInvalidAuthCode
+ }
+
+ if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil {
+ return nil, err
+ }
+
+ user, err := s.users.GetByEmail(ctx, email)
+ if err != nil {
+ if !errors.Is(err, domain.ErrUserNotFound) {
+ return nil, err
+ }
+ if !s.registrationEnabled {
+ return nil, domain.ErrRegistrationDisabled
+ }
+ // Auto-register: OTP delivery already proved email ownership.
+ user, err = s.autoRegisterViaOTP(ctx, email)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ _ = s.users.UpdateLastLogin(ctx, user.ID)
+ s.logger.Info("user logged in via OTP", "user_id", string(user.ID), "email", email)
+
+ return s.createSession(ctx, user, ip, userAgent)
+}
+
+// autoRegisterViaOTP creates a minimal user account for an email that just
+// verified an OTP. Email is considered verified because OTP delivery proved
+// ownership. The name defaults to the local part of the email address.
+func (s *AuthService) autoRegisterViaOTP(ctx context.Context, email string) (*domain.User, error) {
+ name := email
+ if at := strings.IndexByte(email, '@'); at > 0 {
+ name = email[:at]
+ }
+ userID := domain.UserID("usr_" + generateID())
+ user := domain.NewUser(userID, email, name)
+ user.EmailVerified = true // OTP delivery proves ownership
+
+ if err := s.users.Create(ctx, user); err != nil {
+ return nil, err
+ }
+ s.logger.Info("user auto-registered via OTP", "user_id", string(userID), "email", email)
+ return user, nil
+}
+
+// SendMagicLink generates and logs a magic link token.
+func (s *AuthService) SendMagicLink(ctx context.Context, email, ip string) error {
+ // Magic links can work for existing users.
+ // Don't reveal whether email exists — but propagate infrastructure errors.
+ user, err := s.users.GetByEmail(ctx, email)
+ if err != nil && !errors.Is(err, domain.ErrUserNotFound) {
+ return err
+ }
+
+ token := generateHexToken()
+ var uid *domain.UserID
+ if user != nil {
+ uid = &user.ID
+ }
+
+ authCode := &domain.AuthCode{
+ ID: "acd_" + generateID(),
+ UserID: uid,
+ Email: email,
+ Code: token,
+ Purpose: domain.PurposeMagicLink,
+ ExpiresAt: time.Now().Add(MagicLinkExpiry),
+ IPAddress: ip,
+ CreatedAt: time.Now(),
+ }
+
+ if err := s.codes.Create(ctx, authCode); err != nil {
+ return err
+ }
+
+ s.logger.Info("auth code created", "purpose", "magic_link", "email", email, "code_id", authCode.ID)
+ if err := s.email.SendAuthCode(ctx, email, token, string(domain.PurposeMagicLink)); err != nil {
+ s.logger.Error("failed to send magic link email", "email", email, "error", err)
+ }
+ return nil
+}
+
+// VerifyMagicLink verifies a magic link token and returns a login token.
+func (s *AuthService) VerifyMagicLink(ctx context.Context, email, token, ip, userAgent string) (*LoginOutput, error) {
+ authCode, err := s.codes.FindValid(ctx, email, token, domain.PurposeMagicLink)
+ if err != nil {
+ return nil, domain.ErrInvalidAuthCode
+ }
+
+ if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil {
+ return nil, err
+ }
+
+ user, err := s.users.GetByEmail(ctx, email)
+ if err != nil {
+ return nil, err
+ }
+
+ _ = s.users.UpdateLastLogin(ctx, user.ID)
+ s.logger.Info("user logged in via magic link", "user_id", string(user.ID), "email", email)
+
+ return s.createSession(ctx, user, ip, userAgent)
+}
+
+// ForgotPassword generates a password reset token.
+func (s *AuthService) ForgotPassword(ctx context.Context, email, ip string) error {
+ user, err := s.users.GetByEmail(ctx, email)
+ if err != nil {
+ if errors.Is(err, domain.ErrUserNotFound) {
+ // Don't reveal whether email exists
+ s.logger.Info("password reset requested for unknown email", "email", email)
+ return nil
+ }
+ return err
+ }
+
+ token := generateHexToken()
+ uid := user.ID
+ authCode := &domain.AuthCode{
+ ID: "acd_" + generateID(),
+ UserID: &uid,
+ Email: email,
+ Code: token,
+ Purpose: domain.PurposePasswordReset,
+ ExpiresAt: time.Now().Add(PasswordResetExpiry),
+ IPAddress: ip,
+ CreatedAt: time.Now(),
+ }
+
+ if err := s.codes.Create(ctx, authCode); err != nil {
+ return err
+ }
+
+ s.logger.Info("auth code created", "purpose", "password_reset", "email", email, "code_id", authCode.ID)
+ if err := s.email.SendAuthCode(ctx, email, token, string(domain.PurposePasswordReset)); err != nil {
+ s.logger.Error("failed to send password reset email", "email", email, "error", err)
+ }
+ return nil
+}
+
+// ResetPassword sets a new password using a reset token and revokes all sessions.
+func (s *AuthService) ResetPassword(ctx context.Context, email, token, newPassword string) error {
+ authCode, err := s.codes.FindValid(ctx, email, token, domain.PurposePasswordReset)
+ if err != nil {
+ return domain.ErrInvalidAuthCode
+ }
+
+ if err := auth.ValidatePasswordStrength(newPassword); err != nil {
+ return fmt.Errorf("%w: %w", domain.ErrWeakPassword, err)
+ }
+
+ user, err := s.users.GetByEmail(ctx, email)
+ if err != nil {
+ return err
+ }
+
+ hash, err := auth.HashPassword(newPassword)
+ if err != nil {
+ return fmt.Errorf("hashing password: %w", err)
+ }
+
+ if err := s.users.SetPassword(ctx, user.ID, hash); err != nil {
+ return err
+ }
+ if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil {
+ return err
+ }
+
+ // Revoke all sessions — user must re-login with new password.
+ _ = s.sessions.RevokeAllForUser(ctx, user.ID, nil)
+ s.logger.Info("password reset completed", "user_id", string(user.ID), "email", email)
+
+ return nil
+}
+
+// SendVerifyEmail generates an email verification code.
+func (s *AuthService) SendVerifyEmail(ctx context.Context, userID string) error {
+ user, err := s.users.Get(ctx, domain.UserID(userID))
+ if err != nil {
+ return err
+ }
+ if user.EmailVerified {
+ return nil
+ }
+
+ code := generateOTP()
+ uid := user.ID
+ authCode := &domain.AuthCode{
+ ID: "acd_" + generateID(),
+ UserID: &uid,
+ Email: user.Email,
+ Code: code,
+ Purpose: domain.PurposeEmailVerify,
+ ExpiresAt: time.Now().Add(EmailVerifyExpiry),
+ CreatedAt: time.Now(),
+ }
+
+ if err := s.codes.Create(ctx, authCode); err != nil {
+ return err
+ }
+
+ s.logger.Info("auth code created", "purpose", "email_verify", "email", user.Email, "code_id", authCode.ID)
+ if err := s.email.SendAuthCode(ctx, user.Email, code, string(domain.PurposeEmailVerify)); err != nil {
+ s.logger.Error("failed to send email verification", "email", user.Email, "error", err)
+ }
+ return nil
+}
+
+// VerifyEmail marks the user's email as verified.
+func (s *AuthService) VerifyEmail(ctx context.Context, userID, code string) error {
+ user, err := s.users.Get(ctx, domain.UserID(userID))
+ if err != nil {
+ return err
+ }
+
+ authCode, err := s.codes.FindValid(ctx, user.Email, code, domain.PurposeEmailVerify)
+ if err != nil {
+ return domain.ErrInvalidAuthCode
+ }
+
+ if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil {
+ return err
+ }
+
+ user.EmailVerified = true
+ if err := s.users.Update(ctx, user); err != nil {
+ return err
+ }
+
+ s.logger.Info("email verified", "user_id", userID, "email", user.Email)
+ return nil
+}
+
+// createSession creates a session record and generates a JWT.
+func (s *AuthService) createSession(ctx context.Context, user *domain.User, ip, userAgent string) (*LoginOutput, error) {
+ sessionID := "ses_" + generateID()
+ now := time.Now()
+
+ session := &domain.Session{
+ ID: domain.SessionID(sessionID),
+ UserID: user.ID,
+ IPAddress: ip,
+ UserAgent: userAgent,
+ DeviceLabel: auth.ParseDeviceLabel(userAgent),
+ LastActiveAt: now,
+ ExpiresAt: now.Add(SessionLifetime),
+ CreatedAt: now,
+ }
+
+ if err := s.sessions.Create(ctx, session); err != nil {
+ return nil, err
+ }
+
+ token, err := s.generateToken(user, sessionID)
+ if err != nil {
+ return nil, err
+ }
+
+ return &LoginOutput{Token: token, User: user}, nil
+}
+
+// generateToken creates a JWT for the user with the given session ID.
+func (s *AuthService) generateToken(user *domain.User, sessionID string) (string, error) {
+ authUser := &auth.User{
+ ID: string(user.ID),
+ Email: user.Email,
+ Roles: user.Roles,
+ }
+ return auth.GenerateTokenWithSession(
+ s.jwtSecret, authUser, TokenLifetime, s.issuer, s.issuer, sessionID,
+ )
+}
+
+// generateID returns a random hex string suitable for entity IDs.
+func generateID() string {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ panic("crypto/rand failed: " + err.Error())
+ }
+ return hex.EncodeToString(b)
+}
+
+// generateOTP returns a 6-digit numeric one-time password.
+func generateOTP() string {
+ n, err := rand.Int(rand.Reader, big.NewInt(1000000))
+ if err != nil {
+ panic("crypto/rand failed: " + err.Error())
+ }
+ return fmt.Sprintf("%06d", n.Int64())
+}
+
+// validateAvatarURL checks that the URL uses http or https.
+func validateAvatarURL(rawURL string) error {
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ return domain.ErrInvalidAvatarURL
+ }
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return domain.ErrInvalidAvatarURL
+ }
+ return nil
+}
+
+// generateHexToken returns a 32-character hex token for magic links and resets.
+func generateHexToken() string {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ panic("crypto/rand failed: " + err.Error())
+ }
+ return hex.EncodeToString(b)
+}
diff --git a/services/persona-api/internal/service/example.go b/services/persona-api/internal/service/example.go
new file mode 100644
index 0000000..3a2e3bb
--- /dev/null
+++ b/services/persona-api/internal/service/example.go
@@ -0,0 +1,137 @@
+// Package service provides business logic / use cases for the application.
+// Services orchestrate domain operations using port interfaces.
+package service
+
+import (
+ "context"
+ "errors"
+
+ "github.com/google/uuid"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+)
+
+// ExampleService handles example-related business logic.
+type ExampleService struct {
+ repo port.ExampleRepository
+ logger *logging.Logger
+}
+
+// NewExampleService creates a new example service.
+func NewExampleService(repo port.ExampleRepository, logger *logging.Logger) *ExampleService {
+ return &ExampleService{
+ repo: repo,
+ logger: logger.WithService("ExampleService"),
+ }
+}
+
+// List returns all examples.
+func (s *ExampleService) List(ctx context.Context) ([]domain.Example, error) {
+ return s.repo.List(ctx)
+}
+
+// Get returns an example by ID.
+// Returns domain.ErrExampleNotFound if not found.
+func (s *ExampleService) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
+ return s.repo.Get(ctx, id)
+}
+
+// CreateInput contains the data needed to create an example.
+type CreateInput struct {
+ Name string
+ Description string
+}
+
+// Create creates a new example with duplicate detection.
+// Returns domain.ErrDuplicateExample if name already exists.
+// Returns domain.ErrInvalidExampleName if name is invalid.
+func (s *ExampleService) Create(ctx context.Context, input CreateInput) (*domain.Example, error) {
+ // Check for duplicates
+ exists, err := s.repo.ExistsByName(ctx, input.Name)
+ if err != nil {
+ return nil, err
+ }
+ if exists {
+ return nil, domain.ErrDuplicateExample
+ }
+
+ // Generate new ID
+ id := domain.ExampleID(uuid.New().String())
+
+ // Create domain entity (validates name)
+ example, err := domain.NewExample(id, input.Name, input.Description)
+ if err != nil {
+ return nil, err
+ }
+
+ // Persist
+ if err := s.repo.Create(ctx, example); err != nil {
+ return nil, err
+ }
+
+ s.logger.Info("example created", "id", id, "name", input.Name)
+ return example, nil
+}
+
+// UpdateInput contains the data needed to update an example.
+type UpdateInput struct {
+ Name string
+ Description string
+}
+
+// Update modifies an existing example.
+// Returns domain.ErrExampleNotFound if not found.
+// Returns domain.ErrDuplicateExample if new name conflicts with another example.
+// Returns domain.ErrInvalidExampleName if name is invalid.
+func (s *ExampleService) Update(ctx context.Context, id domain.ExampleID, input UpdateInput) (*domain.Example, error) {
+ // Fetch existing
+ example, err := s.repo.Get(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+
+ // Check for name conflicts (only if name changed)
+ if example.Name != input.Name {
+ exists, err := s.repo.ExistsByName(ctx, input.Name)
+ if err != nil {
+ return nil, err
+ }
+ if exists {
+ return nil, domain.ErrDuplicateExample
+ }
+ }
+
+ // Update domain entity (validates name)
+ if err := example.Update(input.Name, input.Description); err != nil {
+ return nil, err
+ }
+
+ // Persist
+ if err := s.repo.Update(ctx, example); err != nil {
+ return nil, err
+ }
+
+ s.logger.Info("example updated", "id", id, "name", input.Name)
+ return example, nil
+}
+
+// Delete removes an example by ID.
+// Returns domain.ErrExampleNotFound if not found.
+func (s *ExampleService) Delete(ctx context.Context, id domain.ExampleID) error {
+ // Verify exists before delete
+ if _, err := s.repo.Get(ctx, id); err != nil {
+ if errors.Is(err, domain.ErrExampleNotFound) {
+ return domain.ErrExampleNotFound
+ }
+ return err
+ }
+
+ if err := s.repo.Delete(ctx, id); err != nil {
+ return err
+ }
+
+ s.logger.Info("example deleted", "id", id)
+ return nil
+}
diff --git a/services/persona-api/internal/service/example_test.go b/services/persona-api/internal/service/example_test.go
new file mode 100644
index 0000000..117f869
--- /dev/null
+++ b/services/persona-api/internal/service/example_test.go
@@ -0,0 +1,282 @@
+package service
+
+import (
+ "context"
+ "sync"
+ "testing"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
+ "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
+)
+
+// mockExampleRepository implements port.ExampleRepository for testing.
+type mockExampleRepository struct {
+ mu sync.RWMutex
+ examples map[domain.ExampleID]*domain.Example
+}
+
+var _ port.ExampleRepository = (*mockExampleRepository)(nil)
+
+func newMockExampleRepository() *mockExampleRepository {
+ return &mockExampleRepository{
+ examples: make(map[domain.ExampleID]*domain.Example),
+ }
+}
+
+func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ result := make([]domain.Example, 0, len(m.examples))
+ for _, e := range m.examples {
+ result = append(result, *e)
+ }
+ return result, nil
+}
+
+func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ e, ok := m.examples[id]
+ if !ok {
+ return nil, domain.ErrExampleNotFound
+ }
+ // Return a copy to avoid mutation
+ copy := *e
+ return ©, nil
+}
+
+func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ // Store a copy
+ copy := *example
+ m.examples[example.ID] = ©
+ return nil
+}
+
+func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if _, ok := m.examples[example.ID]; !ok {
+ return domain.ErrExampleNotFound
+ }
+ // Store a copy
+ copy := *example
+ m.examples[example.ID] = ©
+ return nil
+}
+
+func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if _, ok := m.examples[id]; !ok {
+ return domain.ErrExampleNotFound
+ }
+ delete(m.examples, id)
+ return nil
+}
+
+func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ for _, e := range m.examples {
+ if e.Name == name {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func TestExampleService_Create(t *testing.T) {
+ repo := newMockExampleRepository()
+ svc := NewExampleService(repo, logging.Nop())
+
+ t.Run("creates example successfully", func(t *testing.T) {
+ example, err := svc.Create(context.Background(), CreateInput{
+ Name: "Test Example",
+ Description: "A test description",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if example.Name != "Test Example" {
+ t.Errorf("expected name 'Test Example', got '%s'", example.Name)
+ }
+ if example.ID.IsZero() {
+ t.Error("expected non-empty ID")
+ }
+ })
+
+ t.Run("rejects duplicate name", func(t *testing.T) {
+ _, err := svc.Create(context.Background(), CreateInput{
+ Name: "Test Example",
+ Description: "Another description",
+ })
+ if err != domain.ErrDuplicateExample {
+ t.Errorf("expected ErrDuplicateExample, got %v", err)
+ }
+ })
+
+ t.Run("rejects empty name", func(t *testing.T) {
+ _, err := svc.Create(context.Background(), CreateInput{
+ Name: "",
+ Description: "Description",
+ })
+ if err != domain.ErrInvalidExampleName {
+ t.Errorf("expected ErrInvalidExampleName, got %v", err)
+ }
+ })
+}
+
+func TestExampleService_Get(t *testing.T) {
+ repo := newMockExampleRepository()
+ svc := NewExampleService(repo, logging.Nop())
+
+ // Create an example first
+ created, _ := svc.Create(context.Background(), CreateInput{
+ Name: "Get Test",
+ Description: "Description",
+ })
+
+ t.Run("returns existing example", func(t *testing.T) {
+ example, err := svc.Get(context.Background(), created.ID)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if example.Name != "Get Test" {
+ t.Errorf("expected name 'Get Test', got '%s'", example.Name)
+ }
+ })
+
+ t.Run("returns not found for missing example", func(t *testing.T) {
+ _, err := svc.Get(context.Background(), "nonexistent-id")
+ if err != domain.ErrExampleNotFound {
+ t.Errorf("expected ErrExampleNotFound, got %v", err)
+ }
+ })
+}
+
+func TestExampleService_Update(t *testing.T) {
+ repo := newMockExampleRepository()
+ svc := NewExampleService(repo, logging.Nop())
+
+ // Create examples
+ example1, _ := svc.Create(context.Background(), CreateInput{
+ Name: "Update Test 1",
+ Description: "Original",
+ })
+ _, _ = svc.Create(context.Background(), CreateInput{
+ Name: "Update Test 2",
+ Description: "Other",
+ })
+
+ t.Run("updates example successfully", func(t *testing.T) {
+ updated, err := svc.Update(context.Background(), example1.ID, UpdateInput{
+ Name: "Updated Name",
+ Description: "Updated description",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if updated.Name != "Updated Name" {
+ t.Errorf("expected name 'Updated Name', got '%s'", updated.Name)
+ }
+ })
+
+ t.Run("allows same name on same example", func(t *testing.T) {
+ _, err := svc.Update(context.Background(), example1.ID, UpdateInput{
+ Name: "Updated Name",
+ Description: "Same name",
+ })
+ if err != nil {
+ t.Errorf("unexpected error updating with same name: %v", err)
+ }
+ })
+
+ t.Run("rejects name conflict", func(t *testing.T) {
+ _, err := svc.Update(context.Background(), example1.ID, UpdateInput{
+ Name: "Update Test 2",
+ Description: "Conflict",
+ })
+ if err != domain.ErrDuplicateExample {
+ t.Errorf("expected ErrDuplicateExample, got %v", err)
+ }
+ })
+
+ t.Run("returns not found for missing example", func(t *testing.T) {
+ _, err := svc.Update(context.Background(), "nonexistent-id", UpdateInput{
+ Name: "Anything",
+ Description: "",
+ })
+ if err != domain.ErrExampleNotFound {
+ t.Errorf("expected ErrExampleNotFound, got %v", err)
+ }
+ })
+}
+
+func TestExampleService_Delete(t *testing.T) {
+ repo := newMockExampleRepository()
+ svc := NewExampleService(repo, logging.Nop())
+
+ // Create an example first
+ created, _ := svc.Create(context.Background(), CreateInput{
+ Name: "Delete Test",
+ Description: "To be deleted",
+ })
+
+ t.Run("deletes example successfully", func(t *testing.T) {
+ err := svc.Delete(context.Background(), created.ID)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Verify deleted
+ _, err = svc.Get(context.Background(), created.ID)
+ if err != domain.ErrExampleNotFound {
+ t.Errorf("expected ErrExampleNotFound after delete, got %v", err)
+ }
+ })
+
+ t.Run("returns not found for missing example", func(t *testing.T) {
+ err := svc.Delete(context.Background(), "nonexistent-id")
+ if err != domain.ErrExampleNotFound {
+ t.Errorf("expected ErrExampleNotFound, got %v", err)
+ }
+ })
+}
+
+func TestExampleService_List(t *testing.T) {
+ repo := newMockExampleRepository()
+ svc := NewExampleService(repo, logging.Nop())
+
+ t.Run("returns empty list initially", func(t *testing.T) {
+ examples, err := svc.List(context.Background())
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(examples) != 0 {
+ t.Errorf("expected 0 examples, got %d", len(examples))
+ }
+ })
+
+ // Create some examples
+ _, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 1", Description: ""})
+ _, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 2", Description: ""})
+
+ t.Run("returns all examples", func(t *testing.T) {
+ examples, err := svc.List(context.Background())
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(examples) != 2 {
+ t.Errorf("expected 2 examples, got %d", len(examples))
+ }
+ })
+}
diff --git a/workers/media-worker/.env.example b/workers/media-worker/.env.example
new file mode 100644
index 0000000..3debabe
--- /dev/null
+++ b/workers/media-worker/.env.example
@@ -0,0 +1,25 @@
+# media-worker Worker Configuration
+
+# App
+APP_NAME=media-worker
+APP_ENVIRONMENT=development
+APP_DEBUG=true
+
+# Logging
+LOG_LEVEL=debug
+LOG_FORMAT=text
+
+# Database (required for job queue)
+# Local dev: PostgreSQL via docker-compose. Production: CockroachDB (platform-provisioned).
+# The postgres:// scheme works for both — CockroachDB is wire-compatible.
+DATABASE_URL=postgres://dev:dev@localhost:5432/persona-community-2?sslmode=disable
+
+# Worker
+WORKER_POLL_INTERVAL=10s
+WORKER_BATCH_SIZE=10
+WORKER_MAX_RETRIES=3
+WORKER_STALE_JOB_TIMEOUT=5m
+WORKER_JOB_TIMEOUT=5m
+
+# Redis (optional, for cache)
+# REDIS_URL=redis://localhost:6379/0
diff --git a/workers/media-worker/Dockerfile b/workers/media-worker/Dockerfile
new file mode 100644
index 0000000..bcc8c32
--- /dev/null
+++ b/workers/media-worker/Dockerfile
@@ -0,0 +1,33 @@
+# Build stage
+FROM golang:1.25-alpine AS builder
+
+RUN apk add --no-cache git
+
+# Configure Go private modules
+# Disable workspace mode - each component builds independently with replace directives
+ENV GOPRIVATE=git.threesix.ai/*
+ENV GOWORK=off
+
+WORKDIR /app
+
+# Copy shared pkg and this worker only
+COPY pkg/ ./pkg/
+COPY workers/media-worker/ ./workers/media-worker/
+
+# Download dependencies (populates go.sum if empty)
+RUN cd pkg && go mod download
+RUN cd workers/media-worker && go mod download
+
+# Build from the worker directory (uses replace directive for ../pkg)
+RUN cd workers/media-worker && CGO_ENABLED=0 go build -o /media-worker ./cmd/worker
+
+# Production stage
+FROM alpine:3.19
+
+RUN apk add --no-cache ca-certificates tzdata
+
+WORKDIR /
+
+COPY --from=builder /media-worker /media-worker
+
+ENTRYPOINT ["/media-worker"]
diff --git a/workers/media-worker/Makefile b/workers/media-worker/Makefile
new file mode 100644
index 0000000..a9d7d6a
--- /dev/null
+++ b/workers/media-worker/Makefile
@@ -0,0 +1,38 @@
+.PHONY: build run dev test lint fmt docker-build clean
+
+WORKER := media-worker
+BINARY := bin/$(WORKER)
+GO_MODULE := git.threesix.ai/jordan/persona-community-2
+
+# Build the worker binary
+build:
+ go build -o $(BINARY) ./cmd/worker
+
+# Run the worker locally
+run:
+ go run ./cmd/worker
+
+# Run the worker in development mode (alias for run)
+dev:
+ go run ./cmd/worker
+
+# Run tests
+test:
+ go test -v ./...
+
+# Run linter
+lint:
+ golangci-lint run ./...
+
+# Format code
+fmt:
+ gofmt -w .
+ goimports -w -local $(GO_MODULE) .
+
+# Build Docker image (run from monorepo root)
+docker-build:
+ docker build -t $(WORKER):latest -f Dockerfile ../..
+
+# Clean build artifacts
+clean:
+ rm -rf bin/
diff --git a/workers/media-worker/cmd/worker/main.go b/workers/media-worker/cmd/worker/main.go
new file mode 100644
index 0000000..ae158e1
--- /dev/null
+++ b/workers/media-worker/cmd/worker/main.go
@@ -0,0 +1,269 @@
+// Package main is the entry point for the media-worker worker.
+package main
+
+import (
+ "context"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/redis/go-redis/v9"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/database"
+ "git.threesix.ai/jordan/persona-community-2/pkg/gemini"
+ "git.threesix.ai/jordan/persona-community-2/pkg/laozhang"
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/pkg/mediagen"
+ mediagenAdapters "git.threesix.ai/jordan/persona-community-2/pkg/mediagen/adapters"
+ "git.threesix.ai/jordan/persona-community-2/pkg/personagen"
+ "git.threesix.ai/jordan/persona-community-2/pkg/queue"
+ "git.threesix.ai/jordan/persona-community-2/pkg/realtime"
+ "git.threesix.ai/jordan/persona-community-2/pkg/storage"
+ "git.threesix.ai/jordan/persona-community-2/pkg/textgen"
+ textgenAdapters "git.threesix.ai/jordan/persona-community-2/pkg/textgen/adapters"
+ "git.threesix.ai/jordan/persona-community-2/workers/media-worker/internal/config"
+ "git.threesix.ai/jordan/persona-community-2/workers/media-worker/internal/handlers"
+)
+
+func main() {
+ // Initialize logger first (with defaults) so we can log config errors
+ logger := logging.New(logging.Config{
+ Level: logging.LevelInfo,
+ Format: logging.FormatJSON,
+ }).WithService("media-worker")
+
+ // Initialize configuration
+ cfg, err := config.Load()
+ if err != nil {
+ logger.Error("failed to load config", "error", err)
+ os.Exit(1)
+ }
+
+ // Reconfigure logger with loaded config
+ logger = logging.New(logging.Config{
+ Level: logging.ParseLevel(cfg.Logging.Level),
+ Format: logging.ParseFormat(cfg.Logging.Format),
+ Environment: cfg.AppConfig.Environment,
+ AddSource: cfg.AppConfig.IsDevelopment(),
+ }).WithService("media-worker")
+
+ logger.Info("starting media-worker worker")
+
+ // Setup graceful shutdown
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ // Connect to database
+ pool, err := database.Connect(ctx, cfg.Database.URL, database.Options{
+ MaxOpenConns: cfg.Database.MaxOpenConns,
+ MaxIdleConns: cfg.Database.MaxIdleConns,
+ ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
+ })
+ if err != nil {
+ logger.Error("failed to connect to database", "error", err)
+ os.Exit(1)
+ }
+ defer pool.Close()
+ logger.Info("connected to database", "url", pool.URL)
+
+ // Run queue migrations (idempotent — safe for both service and worker)
+ if err := queue.RunMigrations(ctx, pool); err != nil {
+ logger.Error("failed to run queue migrations", "error", err)
+ os.Exit(1)
+ }
+ logger.Info("queue migrations complete")
+
+ // Initialize queue
+ jobQueue := queue.NewQueue(pool.DB, logger)
+
+ // Initialize Redis for SSE event publishing
+ if cfg.RedisURL == "" {
+ logger.Error("REDIS_URL is required for worker to publish SSE events")
+ os.Exit(1)
+ }
+ redisOpts, err := redis.ParseURL(cfg.RedisURL)
+ if err != nil {
+ logger.Error("failed to parse REDIS_URL", "error", err)
+ os.Exit(1)
+ }
+ redisClient := redis.NewClient(redisOpts)
+ if err := redisClient.Ping(ctx).Err(); err != nil {
+ logger.Error("failed to connect to Redis", "error", err)
+ os.Exit(1)
+ }
+ logger.Info("connected to Redis")
+
+ ssePub := realtime.NewSSEPublisher(redisClient, logger.Logger)
+
+ // Initialize AI providers
+ // LaoZhang client (primary provider — pay-per-use, OpenAI-compatible)
+ var laozhangClient *laozhang.Client
+ if apiKey := os.Getenv("LAOZHANG_API_KEY"); apiKey != "" {
+ laozhangClient, err = laozhang.NewClient(laozhang.Config{
+ APIKey: apiKey,
+ VideoTimeout: 5 * time.Minute,
+ Logger: logger.Logger,
+ })
+ if err != nil {
+ logger.Warn("failed to create LaoZhang client", "error", err)
+ } else {
+ logger.Info("LaoZhang client initialized")
+ }
+ }
+
+ // Gemini client for media generation
+ var geminiClient *gemini.Client
+ if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
+ geminiClient, err = gemini.NewClient(ctx, gemini.Config{
+ APIKey: apiKey,
+ Logger: logger.Logger,
+ })
+ if err != nil {
+ logger.Warn("failed to create Gemini client", "error", err)
+ } else {
+ logger.Info("Gemini client initialized")
+ }
+ }
+
+ // Create mediagen manager (image + video)
+ var mediagenManager *mediagen.Manager
+ {
+ var laozhangMediaProvider *mediagenAdapters.LaoZhangProvider
+ var geminiMediaProvider *mediagenAdapters.GeminiProvider
+ if laozhangClient != nil {
+ laozhangMediaProvider = mediagenAdapters.NewLaoZhangProvider(laozhangClient)
+ }
+ if geminiClient != nil {
+ geminiMediaProvider = mediagenAdapters.NewGeminiProvider(geminiClient)
+ }
+
+ if geminiMediaProvider != nil || laozhangMediaProvider != nil {
+ mgCfg := mediagen.ProductionConfig(mediagen.ProviderSet{
+ LaoZhang: laozhangMediaProvider,
+ Gemini: geminiMediaProvider,
+ }, mediagen.WithLogger(logger.Logger))
+ if laozhangMediaProvider != nil {
+ mgCfg.VideoProviders = append(mgCfg.VideoProviders, laozhangMediaProvider)
+ }
+ if geminiMediaProvider != nil {
+ mgCfg.VideoProviders = append(mgCfg.VideoProviders, geminiMediaProvider)
+ }
+ mediagenManager, err = mediagen.NewManager(mgCfg)
+ if err != nil {
+ logger.Warn("failed to create mediagen manager", "error", err)
+ } else {
+ logger.Info("mediagen manager initialized (image + video)")
+ }
+ }
+ }
+
+ // Create textgen manager (text + streaming)
+ var textgenManager *textgen.Manager
+ {
+ var textProviders []textgen.TextGenerator
+ if laozhangClient != nil {
+ textProviders = append(textProviders, textgenAdapters.NewLaoZhangTextProvider(laozhangClient, ""))
+ }
+ if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
+ geminiTextProvider, err := textgenAdapters.NewGeminiTextProvider(ctx, textgenAdapters.GeminiTextConfig{
+ APIKey: apiKey,
+ })
+ if err != nil {
+ logger.Warn("failed to create gemini text provider", "error", err)
+ } else {
+ textProviders = append(textProviders, geminiTextProvider)
+ }
+ }
+ if len(textProviders) > 0 {
+ textgenCfg := textgen.ProductionConfig(textgen.ProviderSet{}, textgen.WithLogger(logger.Logger))
+ textgenCfg.Providers = textProviders
+ textgenManager, err = textgen.NewManager(textgenCfg)
+ if err != nil {
+ logger.Warn("failed to create textgen manager", "error", err)
+ } else {
+ logger.Info("textgen manager initialized")
+ }
+ }
+ }
+
+ // Initialize and start handler
+ handler := handlers.New(logger, jobQueue, handlers.Config{
+ PollInterval: cfg.Worker.PollInterval,
+ StaleJobTimeout: cfg.Worker.StaleJobTimeout,
+ JobTimeout: cfg.Worker.JobTimeout,
+ })
+
+ // Initialize storage backend for persisting generated media.
+ // GCS_BUCKET is injected by the platform; if absent, store is nil (media not persisted).
+ var mediaStore storage.Store
+ if bucket := os.Getenv("GCS_BUCKET"); bucket != "" {
+ gcsStore, err := storage.NewGCSStore(ctx, bucket, os.Getenv("GCS_SERVICE_ACCOUNT_JSON"), logger.Logger)
+ if err != nil {
+ logger.Warn("failed to create GCS store, generated media will not be persisted", "error", err)
+ } else {
+ defer func() { _ = gcsStore.Close() }()
+ mediaStore = gcsStore
+ logger.Info("storage initialized (GCS)", "bucket", bucket)
+ }
+ }
+
+ // Register job handlers
+ if mediagenManager != nil {
+ handler.RegisterHandler("generate_image", handlers.ImageHandler(mediagenManager, mediaStore, ssePub, logger))
+ handler.RegisterHandler("generate_video", handlers.VideoHandler(mediagenManager, mediaStore, ssePub, logger))
+ }
+ if textgenManager != nil {
+ handler.RegisterHandler("generate_text", handlers.TextHandler(textgenManager, ssePub, logger))
+ handler.RegisterHandler("ai_chat_response", handlers.ChatResponseHandler(textgenManager, ssePub, logger))
+ }
+ // Persona generation requires both textgen (5-stage LLM pipeline) and mediagen (20 images + 4 videos).
+ if textgenManager != nil && mediagenManager != nil {
+ handler.RegisterHandler("persona_generate", personagen.QueueHandler(textgenManager, mediagenManager, mediaStore, ssePub, logger.Logger))
+ }
+
+ // Setup signal handling
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
+
+ // Start worker in goroutine
+ go handler.Run(ctx)
+
+ // Start stale job recovery in goroutine
+ go runStaleJobRecovery(ctx, jobQueue, cfg.Worker.StaleJobTimeout, logger)
+
+ // Wait for shutdown signal
+ sig := <-sigCh
+ logger.Info("received shutdown signal", "signal", sig.String())
+
+ // Trigger graceful shutdown with grace period
+ logger.Info("initiating graceful shutdown")
+ cancel()
+
+ // Give in-flight jobs time to complete (grace period)
+ const shutdownGracePeriod = 5 * time.Second
+ time.Sleep(shutdownGracePeriod)
+
+ logger.Info("media-worker worker stopped")
+}
+
+// runStaleJobRecovery periodically requeues jobs that have been running too long.
+func runStaleJobRecovery(ctx context.Context, q *queue.DBQueue, timeout time.Duration, logger *logging.Logger) {
+ const staleCheckInterval = time.Minute
+ ticker := time.NewTicker(staleCheckInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ count, err := q.RequeueStale(ctx, timeout)
+ if err != nil {
+ logger.Error("failed to requeue stale jobs", "error", err)
+ } else if count > 0 {
+ logger.Info("requeued stale jobs", "count", count)
+ }
+ }
+ }
+}
diff --git a/workers/media-worker/cmd/worker/migrations/001_create_jobs.sql b/workers/media-worker/cmd/worker/migrations/001_create_jobs.sql
new file mode 100644
index 0000000..5af8ef9
--- /dev/null
+++ b/workers/media-worker/cmd/worker/migrations/001_create_jobs.sql
@@ -0,0 +1,32 @@
+-- Jobs queue table for async job processing.
+-- Used by pkg/queue for producer/consumer patterns.
+CREATE TABLE IF NOT EXISTS jobs (
+ id UUID PRIMARY KEY,
+ job_type VARCHAR(255) NOT NULL,
+ payload JSONB NOT NULL DEFAULT '{}',
+ status VARCHAR(50) NOT NULL DEFAULT 'pending',
+ priority INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ started_at TIMESTAMPTZ,
+ completed_at TIMESTAMPTZ,
+ retry_count INT NOT NULL DEFAULT 0,
+ max_retries INT NOT NULL DEFAULT 3,
+ error TEXT,
+ worker_id VARCHAR(255)
+);
+
+-- Index for efficient dequeue: pending jobs ordered by priority (desc) and age (asc).
+-- Partial index only includes pending jobs for efficiency.
+CREATE INDEX IF NOT EXISTS idx_jobs_dequeue ON jobs (priority DESC, created_at ASC)
+ WHERE status = 'pending';
+
+-- Index for finding stale running jobs that need requeue.
+-- Used by RequeueStale to recover from crashed workers.
+CREATE INDEX IF NOT EXISTS idx_jobs_stale ON jobs (started_at)
+ WHERE status = 'running';
+
+-- Index for listing/filtering jobs by type.
+CREATE INDEX IF NOT EXISTS idx_jobs_type ON jobs (job_type, created_at DESC);
+
+-- Index for listing jobs by status (useful for monitoring dashboards).
+CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status, created_at DESC);
diff --git a/workers/media-worker/component.yaml b/workers/media-worker/component.yaml
new file mode 100644
index 0000000..0c326be
--- /dev/null
+++ b/workers/media-worker/component.yaml
@@ -0,0 +1,8 @@
+name: media-worker
+type: worker
+path: workers/media-worker
+dependencies: []
+# Add dependencies as needed:
+# - postgres
+# - redis
+# - rabbitmq
diff --git a/workers/media-worker/go.mod b/workers/media-worker/go.mod
new file mode 100644
index 0000000..23cd12d
--- /dev/null
+++ b/workers/media-worker/go.mod
@@ -0,0 +1,11 @@
+module git.threesix.ai/jordan/persona-community-2/workers/media-worker
+
+go 1.25
+
+require (
+ git.threesix.ai/jordan/persona-community-2/pkg v0.0.0
+ github.com/google/uuid v1.6.0
+)
+
+// Use local workspace modules (for Docker builds without go.work)
+replace git.threesix.ai/jordan/persona-community-2/pkg => ../../pkg
diff --git a/workers/media-worker/go.sum b/workers/media-worker/go.sum
new file mode 100644
index 0000000..e69de29
diff --git a/workers/media-worker/internal/config/config.go b/workers/media-worker/internal/config/config.go
new file mode 100644
index 0000000..29d4698
--- /dev/null
+++ b/workers/media-worker/internal/config/config.go
@@ -0,0 +1,71 @@
+// Package config provides worker-specific configuration.
+package config
+
+import (
+ "os"
+ "time"
+
+ "github.com/spf13/viper"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/config"
+)
+
+// Config holds media-worker worker configuration.
+type Config struct {
+ config.AppConfig
+ Database config.DatabaseConfig
+ Logging config.LoggingConfig
+ Worker WorkerConfig
+
+ // Redis for publishing SSE events to the service
+ RedisURL string
+}
+
+// WorkerConfig holds worker-specific settings.
+type WorkerConfig struct {
+ // PollInterval is how often to check for new jobs when queue is empty.
+ PollInterval time.Duration
+
+ // BatchSize is the max number of jobs to process per poll (for batch workers).
+ BatchSize int
+
+ // MaxRetries is the default maximum retry attempts for failed jobs.
+ MaxRetries int
+
+ // StaleJobTimeout is how long a job can run before being considered stale.
+ // Jobs running longer than this without heartbeat will be requeued.
+ StaleJobTimeout time.Duration
+
+ // JobTimeout is the maximum time a single job handler can run.
+ JobTimeout time.Duration
+}
+
+// Load reads configuration from environment variables.
+func Load() (*Config, error) {
+ if err := config.Init(config.Options{
+ AppName: "media-worker",
+ SetDefaults: func() {
+ viper.SetDefault("WORKER_POLL_INTERVAL", "10s")
+ viper.SetDefault("WORKER_BATCH_SIZE", 10)
+ viper.SetDefault("WORKER_MAX_RETRIES", 3)
+ viper.SetDefault("WORKER_STALE_JOB_TIMEOUT", "5m")
+ viper.SetDefault("WORKER_JOB_TIMEOUT", "5m")
+ },
+ }); err != nil {
+ return nil, err
+ }
+
+ return &Config{
+ AppConfig: config.ReadAppConfig(),
+ Database: config.ReadDatabaseConfig(),
+ Logging: config.ReadLoggingConfig(),
+ Worker: WorkerConfig{
+ PollInterval: viper.GetDuration("WORKER_POLL_INTERVAL"),
+ BatchSize: viper.GetInt("WORKER_BATCH_SIZE"),
+ MaxRetries: viper.GetInt("WORKER_MAX_RETRIES"),
+ StaleJobTimeout: viper.GetDuration("WORKER_STALE_JOB_TIMEOUT"),
+ JobTimeout: viper.GetDuration("WORKER_JOB_TIMEOUT"),
+ },
+ RedisURL: os.Getenv("REDIS_URL"),
+ }, nil
+}
diff --git a/workers/media-worker/internal/handlers/generate.go b/workers/media-worker/internal/handlers/generate.go
new file mode 100644
index 0000000..a4263b6
--- /dev/null
+++ b/workers/media-worker/internal/handlers/generate.go
@@ -0,0 +1,33 @@
+// Package handlers re-exports generation job handlers from the shared package.
+// The worker registers these handlers to process queue jobs.
+package handlers
+
+import (
+ "git.threesix.ai/jordan/persona-community-2/pkg/generation"
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/pkg/mediagen"
+ "git.threesix.ai/jordan/persona-community-2/pkg/queue"
+ "git.threesix.ai/jordan/persona-community-2/pkg/realtime"
+ "git.threesix.ai/jordan/persona-community-2/pkg/storage"
+ "git.threesix.ai/jordan/persona-community-2/pkg/textgen"
+)
+
+// ImageHandler returns a queue.Handler that processes image generation jobs.
+func ImageHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler {
+ return generation.ImageHandler(mg, store, pub, logger)
+}
+
+// VideoHandler returns a queue.Handler that processes video generation jobs.
+func VideoHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler {
+ return generation.VideoHandler(mg, store, pub, logger)
+}
+
+// TextHandler returns a queue.Handler that processes text generation jobs with streaming.
+func TextHandler(tg *textgen.Manager, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler {
+ return generation.TextHandler(tg, pub, logger)
+}
+
+// ChatResponseHandler returns a queue.Handler that generates AI chat responses.
+func ChatResponseHandler(tg *textgen.Manager, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler {
+ return generation.ChatResponseHandler(tg, pub, logger)
+}
diff --git a/workers/media-worker/internal/handlers/handler.go b/workers/media-worker/internal/handlers/handler.go
new file mode 100644
index 0000000..7d34012
--- /dev/null
+++ b/workers/media-worker/internal/handlers/handler.go
@@ -0,0 +1,147 @@
+// Package handlers provides the worker's job processing logic.
+package handlers
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+
+ "git.threesix.ai/jordan/persona-community-2/pkg/logging"
+ "git.threesix.ai/jordan/persona-community-2/pkg/queue"
+)
+
+// Config holds handler configuration.
+type Config struct {
+ // PollInterval is how often to check for new jobs when queue is empty.
+ PollInterval time.Duration
+
+ // StaleJobTimeout is how long a job can run before being considered stale.
+ StaleJobTimeout time.Duration
+
+ // JobTimeout is the maximum time a job handler can run.
+ JobTimeout time.Duration
+}
+
+// Handler processes background jobs from the queue.
+type Handler struct {
+ logger *logging.Logger
+ queue queue.Consumer
+ handlers map[string]queue.Handler
+ config Config
+ workerID string
+ mu sync.RWMutex
+}
+
+// New creates a new Handler.
+func New(logger *logging.Logger, q queue.Consumer, cfg Config) *Handler {
+ // Apply defaults
+ if cfg.PollInterval == 0 {
+ cfg.PollInterval = 10 * time.Second
+ }
+ if cfg.StaleJobTimeout == 0 {
+ cfg.StaleJobTimeout = 5 * time.Minute
+ }
+ if cfg.JobTimeout == 0 {
+ cfg.JobTimeout = 5 * time.Minute
+ }
+
+ return &Handler{
+ logger: logger.WithComponent("handler"),
+ queue: q,
+ handlers: make(map[string]queue.Handler),
+ config: cfg,
+ workerID: uuid.New().String(),
+ }
+}
+
+// RegisterHandler registers a handler for a specific job type.
+// Call this before Run() to set up job processing.
+func (h *Handler) RegisterHandler(jobType string, handler queue.Handler) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.handlers[jobType] = handler
+ h.logger.Info("registered job handler", "type", jobType)
+}
+
+// Run starts the worker loop and processes jobs until context is cancelled.
+func (h *Handler) Run(ctx context.Context) {
+ h.logger.Info("worker loop started", "worker_id", h.workerID)
+
+ for {
+ select {
+ case <-ctx.Done():
+ h.logger.Info("worker loop stopping", "worker_id", h.workerID)
+ return
+ default:
+ if err := h.processNextJob(ctx); err != nil {
+ if errors.Is(err, queue.ErrNoJob) {
+ // Queue is empty, wait before polling again
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(h.config.PollInterval):
+ continue
+ }
+ }
+ // Log error and continue
+ h.logger.Error("error processing job", "error", err)
+ time.Sleep(time.Second) // Brief pause on error
+ }
+ }
+ }
+}
+
+// processNextJob dequeues and processes a single job.
+func (h *Handler) processNextJob(ctx context.Context) error {
+ job, err := h.queue.Dequeue(ctx, h.workerID)
+ if err != nil {
+ return err
+ }
+
+ // Get handler for job type
+ h.mu.RLock()
+ handler, ok := h.handlers[job.Type]
+ h.mu.RUnlock()
+
+ if !ok {
+ h.logger.Error("no handler for job type", "job_id", job.ID, "type", job.Type)
+ return h.queue.Fail(ctx, job.ID, fmt.Sprintf("unknown job type: %s", job.Type))
+ }
+
+ // Apply middleware and process (TimeoutMiddleware handles the deadline)
+ wrappedHandler := queue.Chain(
+ queue.RecoveryMiddleware(h.logger),
+ queue.LoggingMiddleware(h.logger),
+ queue.TimeoutMiddleware(h.config.JobTimeout),
+ )(handler)
+
+ // Use parent context - TimeoutMiddleware applies the job timeout
+ jobCtx := ctx
+ _ = jobCtx // jobCtx used below
+
+ if err := wrappedHandler(jobCtx, job); err != nil {
+ // Truncate error message to prevent log bloat and potential data leakage
+ errMsg := truncateErrorMessage(err.Error(), 1000)
+ h.logger.Debug("job handler failed", "job_id", job.ID, "error", errMsg)
+ return h.queue.Fail(ctx, job.ID, errMsg)
+ }
+
+ return h.queue.Ack(ctx, job.ID)
+}
+
+// WorkerID returns this handler's unique worker identifier.
+func (h *Handler) WorkerID() string {
+ return h.workerID
+}
+
+// truncateErrorMessage limits error message length to prevent log bloat.
+func truncateErrorMessage(msg string, maxLen int) string {
+ if len(msg) <= maxLen {
+ return msg
+ }
+ return msg[:maxLen-3] + "..."
+}