Skip to main content

create_commonpub/
template.rs

1use crate::prompts::InstanceConfig;
2
3pub fn render_env(config: &InstanceConfig) -> String {
4    let mut env = format!(
5        r#"# CommonPub Instance: {name}
6
7# Database
8DATABASE_URL={database_url}
9
10# Redis
11REDIS_URL={redis_url}
12
13# Auth
14AUTH_SECRET=change-me-in-production-min-32-chars
15NUXT_AUTH_ORIGIN=http://{domain}
16
17# Instance
18INSTANCE_DOMAIN={domain}
19INSTANCE_NAME={name}
20INSTANCE_DESCRIPTION={description}
21
22# Feature Flags
23FEATURE_CONTENT={content}
24FEATURE_SOCIAL={social}
25FEATURE_HUBS={hubs}
26FEATURE_DOCS={docs}
27FEATURE_VIDEO={video}
28FEATURE_CONTESTS={contests}
29FEATURE_LEARNING={learning}
30FEATURE_EXPLAINERS={explainers}
31FEATURE_FEDERATION={federation}
32FEATURE_ADMIN={admin}
33
34# Search
35MEILI_URL=http://localhost:7700
36MEILI_MASTER_KEY=commonpub_dev_key
37
38# Email — "console" (dev), "smtp" (nodemailer), or "resend" (Resend API)
39EMAIL_ADAPTER=console
40# SMTP_HOST=smtp.example.com
41# SMTP_PORT=587
42# SMTP_USER=
43# SMTP_PASS=
44# SMTP_FROM=noreply@{domain}
45# RESEND_API_KEY=re_...
46# RESEND_FROM=noreply@{domain}
47"#,
48        name = config.name,
49        domain = config.domain,
50        description = config.description,
51        database_url = config.database_url,
52        redis_url = config.redis_url,
53        content = config.feature_content,
54        social = config.feature_social,
55        hubs = config.feature_hubs,
56        docs = config.feature_docs,
57        video = config.feature_video,
58        contests = config.feature_contests,
59        learning = config.feature_learning,
60        explainers = config.feature_explainers,
61        federation = config.feature_federation,
62        admin = config.feature_admin,
63    );
64
65    // OAuth placeholders only if enabled
66    if config.auth_github {
67        env.push_str(
68            "\n# GitHub OAuth\nGITHUB_CLIENT_ID=\nGITHUB_CLIENT_SECRET=\n",
69        );
70    }
71    if config.auth_google {
72        env.push_str(
73            "\n# Google OAuth\nGOOGLE_CLIENT_ID=\nGOOGLE_CLIENT_SECRET=\n",
74        );
75    }
76
77    // Storage (optional, local by default)
78    env.push_str(
79        r#"
80# Storage — set S3_BUCKET to enable S3/DO Spaces, otherwise local ./uploads
81# S3_BUCKET=
82# S3_REGION=us-east-1
83# S3_ENDPOINT=
84# S3_ACCESS_KEY=
85# S3_SECRET_KEY=
86# S3_PUBLIC_URL=
87"#,
88    );
89
90    env
91}
92
93pub fn render_config(config: &InstanceConfig) -> String {
94    let content_types_str = if !config.content_types.is_empty() {
95        let types: Vec<String> = config.content_types.iter().map(|t| format!("'{}'", t)).collect();
96        format!("\n    contentTypes: [{}],", types.join(", "))
97    } else {
98        String::new()
99    };
100
101    let contest_creation_str = if config.feature_contests && config.contest_creation != "admin" {
102        format!("\n    contestCreation: '{}',", config.contest_creation)
103    } else {
104        // admin is the default, only include if non-default or if contests enabled
105        if config.feature_contests {
106            format!("\n    contestCreation: '{}',", config.contest_creation)
107        } else {
108            String::new()
109        }
110    };
111
112    format!(
113        r#"import {{ defineCommonPubConfig }} from '@commonpub/config';
114
115export default defineCommonPubConfig({{
116  instance: {{
117    name: '{name}',
118    domain: '{domain}',
119    description: '{description}',{content_types_str}{contest_creation_str}
120  }},
121  features: {{
122    content: {content},
123    social: {social},
124    hubs: {hubs},
125    docs: {docs},
126    video: {video},
127    contests: {contests},
128    learning: {learning},
129    explainers: {explainers},
130    federation: {federation},
131    admin: {admin},
132  }},
133  auth: {{
134    emailPassword: {email_password},
135    magicLink: {magic_link},
136    passkeys: {passkeys},
137  }},
138}});
139"#,
140        name = config.name,
141        domain = config.domain,
142        description = config.description,
143        content = config.feature_content,
144        social = config.feature_social,
145        hubs = config.feature_hubs,
146        docs = config.feature_docs,
147        video = config.feature_video,
148        contests = config.feature_contests,
149        learning = config.feature_learning,
150        explainers = config.feature_explainers,
151        federation = config.feature_federation,
152        admin = config.feature_admin,
153        email_password = config.auth_email_password,
154        magic_link = config.auth_magic_link,
155        passkeys = config.auth_passkeys,
156    )
157}
158
159pub fn render_nuxt_config(config: &InstanceConfig) -> String {
160    let theme_css = if config.theme != "base" {
161        format!(
162            "\n    '@commonpub/ui/theme/{}.css',",
163            config.theme
164        )
165    } else {
166        String::new()
167    };
168
169    format!(
170        r#"export default defineNuxtConfig({{
171  compatibilityDate: '2024-11-01',
172  devtools: {{ enabled: true }},
173  css: [
174    '@commonpub/ui/theme/base.css',
175    '@commonpub/ui/theme/dark.css',
176    '@commonpub/ui/theme/components.css',
177    '@commonpub/ui/theme/prose.css',
178    '@commonpub/ui/theme/layouts.css',
179    '@commonpub/ui/theme/forms.css',{theme_css}
180  ],
181  modules: [],
182  runtimeConfig: {{
183    databaseUrl: '',
184    authSecret: 'dev-secret-change-me',
185    emailAdapter: 'console',
186    smtpHost: '',
187    smtpPort: '587',
188    smtpUser: '',
189    smtpPass: '',
190    smtpFrom: '',
191    resendApiKey: '',
192    resendFrom: '',
193    s3Bucket: '',
194    s3Region: 'us-east-1',
195    s3Endpoint: '',
196    s3AccessKey: '',
197    s3SecretKey: '',
198    s3PublicUrl: '',
199    uploadDir: './uploads',
200    public: {{
201      siteUrl: 'http://{domain}',
202      domain: '{domain}',
203      siteName: '{name}',
204      siteDescription: '{description}',
205    }},
206  }},
207  nitro: {{
208    preset: 'node-server',
209    publicAssets: [
210      {{
211        dir: '../uploads',
212        baseURL: '/uploads',
213        maxAge: 60 * 60 * 24,
214      }},
215    ],
216  }},
217  vite: {{
218    server: {{
219      fs: {{
220        allow: ['..'],
221      }},
222    }},
223  }},
224}});
225"#,
226        domain = config.domain,
227        name = config.name,
228        description = config.description,
229    )
230}
231
232pub fn render_package_json(config: &InstanceConfig) -> String {
233    let mut deps = vec![
234        r#"    "@commonpub/config": "^0.2.0""#.to_string(),
235        r#"    "@commonpub/schema": "^0.2.0""#.to_string(),
236        r#"    "@commonpub/auth": "^0.2.0""#.to_string(),
237        r#"    "@commonpub/ui": "^0.2.0""#.to_string(),
238        r#"    "@commonpub/server": "^0.2.0""#.to_string(),
239        r#"    "@commonpub/infra": "^0.2.0""#.to_string(),
240    ];
241
242    if config.feature_content {
243        deps.push(r#"    "@commonpub/editor": "^0.2.0""#.to_string());
244    }
245    if config.feature_docs {
246        deps.push(r#"    "@commonpub/docs": "^0.2.0""#.to_string());
247    }
248    if config.feature_learning {
249        deps.push(r#"    "@commonpub/learning": "^0.2.0""#.to_string());
250    }
251    if config.feature_explainers {
252        deps.push(r#"    "@commonpub/explainer": "^0.2.0""#.to_string());
253    }
254    if config.feature_federation {
255        deps.push(r#"    "@commonpub/protocol": "^0.2.0""#.to_string());
256    }
257
258    let deps_str = deps.join(",\n");
259
260    format!(
261        r#"{{
262  "name": "{name}",
263  "private": true,
264  "type": "module",
265  "scripts": {{
266    "dev": "nuxt dev",
267    "build": "nuxt build",
268    "preview": "nuxt preview",
269    "postinstall": "nuxt prepare",
270    "db:push": "drizzle-kit push",
271    "db:studio": "drizzle-kit studio"
272  }},
273  "dependencies": {{
274{deps},
275    "nuxt": "^3.16.0",
276    "vue": "^3.4.0",
277    "drizzle-orm": "^0.45.0",
278    "better-auth": "^1.2.0",
279    "pg": "^8.13.0",
280    "zod": "^3.24.0"
281  }},
282  "devDependencies": {{
283    "drizzle-kit": "^0.30.0",
284    "typescript": "^5.7.0"
285  }}
286}}
287"#,
288        name = config.name,
289        deps = deps_str,
290    )
291}
292
293pub fn render_tsconfig() -> String {
294    r#"{
295  "extends": "./.nuxt/tsconfig.json"
296}
297"#
298    .to_string()
299}
300
301pub fn render_app_vue(config: &InstanceConfig) -> String {
302    format!(
303        r##"<template>
304  <a href="#main-content" class="cpub-skip-link">Skip to main content</a>
305  <NuxtLoadingIndicator color="#5b9cf6" />
306  <NuxtLayout>
307    <NuxtPage />
308  </NuxtLayout>
309</template>
310
311<script setup lang="ts">
312useHead({{
313  titleTemplate: (title) => title ? `${{title}} — {name}` : '{name}',
314}});
315</script>
316"##,
317        name = config.name,
318    )
319}
320
321// ── Server utils ──────────────────────────────────────────
322
323pub fn render_server_config() -> String {
324    r#"// Singleton CommonPub config for Nitro server
325import { defineCommonPubConfig, type CommonPubConfig } from '@commonpub/config';
326
327let cachedConfig: CommonPubConfig | null = null;
328
329export function useConfig(): CommonPubConfig {
330  if (cachedConfig) return cachedConfig;
331
332  const runtimeConfig = useRuntimeConfig();
333
334  const { config } = defineCommonPubConfig({
335    instance: {
336      domain: (runtimeConfig.public.domain as string) || 'localhost:3000',
337      name: (runtimeConfig.public.siteName as string) || 'CommonPub',
338      description: (runtimeConfig.public.siteDescription as string) || 'A CommonPub instance',
339    },
340  });
341
342  cachedConfig = config;
343  return config;
344}
345"#
346    .to_string()
347}
348
349pub fn render_server_db() -> String {
350    r#"// Singleton Drizzle DB instance for Nitro server
351import { drizzle } from 'drizzle-orm/node-postgres';
352// @ts-expect-error no types for pg
353import pg from 'pg';
354import * as schema from '@commonpub/schema';
355import type { DB } from '@commonpub/server';
356
357let db: DB | null = null;
358
359export function useDB(): DB {
360  if (db) return db;
361
362  const config = useRuntimeConfig();
363  const databaseUrl = config.databaseUrl as string;
364
365  if (!databaseUrl) {
366    throw new Error('DATABASE_URL is not configured. Set NUXT_DATABASE_URL environment variable.');
367  }
368
369  // Guard against default auth secret in production
370  if (process.env.NODE_ENV === 'production' && config.authSecret === 'dev-secret-change-me') {
371    throw new Error('NUXT_AUTH_SECRET must be set in production. Do not use the default dev secret.');
372  }
373
374  const pool = new pg.Pool({
375    connectionString: databaseUrl,
376    max: 20,
377    idleTimeoutMillis: 30_000,
378    connectionTimeoutMillis: 5_000,
379  });
380  db = drizzle(pool, { schema });
381
382  return db;
383}
384"#
385    .to_string()
386}
387
388pub fn render_server_auth() -> String {
389    r#"// Auth helper — extracts authenticated user from event context
390import type { H3Event } from 'h3';
391
392export interface AuthUser {
393  id: string;
394  username: string;
395  role: string;
396}
397
398export function requireAuth(event: H3Event): AuthUser {
399  const auth = event.context.auth;
400  if (!auth?.user) {
401    const cookie = getRequestHeader(event, 'cookie') || '';
402    const hasSessionCookie = cookie.includes('better-auth.session_token');
403    throw createError({
404      statusCode: 401,
405      statusMessage: hasSessionCookie
406        ? 'Session expired or invalid. Please log in again.'
407        : 'Not logged in. Please log in to continue.',
408    });
409  }
410  return auth.user as AuthUser;
411}
412
413export function requireAdmin(event: H3Event): AuthUser {
414  const user = requireAuth(event);
415  if (user.role !== 'admin') {
416    throw createError({ statusCode: 403, statusMessage: 'Admin access required' });
417  }
418  return user;
419}
420
421export function getOptionalUser(event: H3Event): AuthUser | null {
422  const auth = event.context.auth;
423  return (auth?.user as AuthUser) ?? null;
424}
425"#
426    .to_string()
427}
428
429pub fn render_server_validate() -> String {
430    r#"// API route validation helpers
431import type { H3Event } from 'h3';
432import type { ZodType } from 'zod';
433
434const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
435const SLUG_REGEX = /^[a-z0-9][a-z0-9-]*$/;
436
437type ParamType = 'uuid' | 'slug' | 'string';
438
439/** Parse and validate request body against a Zod schema. Throws 400 on failure. */
440export async function parseBody<T>(event: H3Event, schema: ZodType<T>): Promise<T> {
441  const body = await readBody(event);
442  const parsed = schema.safeParse(body);
443  if (!parsed.success) {
444    throw createError({
445      statusCode: 400,
446      statusMessage: 'Validation failed',
447      data: { errors: parsed.error.flatten().fieldErrors },
448    });
449  }
450  return parsed.data;
451}
452
453/** Parse and validate query string against a Zod schema. Throws 400 on failure. */
454export function parseQueryParams<T>(event: H3Event, schema: ZodType<T>): T {
455  const query = getQuery(event);
456  const parsed = schema.safeParse(query);
457  if (!parsed.success) {
458    throw createError({
459      statusCode: 400,
460      statusMessage: 'Invalid query parameters',
461      data: { errors: parsed.error.flatten().fieldErrors },
462    });
463  }
464  return parsed.data;
465}
466
467/**
468 * Extract and validate route parameters.
469 *
470 * @example
471 * const { id } = parseParams(event, { id: 'uuid' });
472 * const { slug } = parseParams(event, { slug: 'slug' });
473 */
474export function parseParams<T extends Record<string, ParamType>>(
475  event: H3Event,
476  spec: T,
477): { [K in keyof T]: string } {
478  const result = {} as { [K in keyof T]: string };
479
480  for (const [name, type] of Object.entries(spec)) {
481    const value = getRouterParam(event, name);
482    if (!value) {
483      throw createError({ statusCode: 400, statusMessage: `Missing parameter: ${name}` });
484    }
485
486    if (type === 'uuid' && !UUID_REGEX.test(value)) {
487      throw createError({ statusCode: 400, statusMessage: `Invalid ${name} format` });
488    }
489    if (type === 'slug' && !SLUG_REGEX.test(value)) {
490      throw createError({ statusCode: 400, statusMessage: `Invalid ${name} format` });
491    }
492
493    (result as Record<string, string>)[name] = value;
494  }
495
496  return result;
497}
498"#
499    .to_string()
500}
501
502pub fn render_server_errors() -> String {
503    r#"// Consistent error helpers for Nitro API routes
504
505export function validationError(errors: Record<string, string[]>): never {
506  throw createError({
507    statusCode: 400,
508    statusMessage: 'Validation failed',
509    data: { errors },
510  });
511}
512
513export function notFound(entity: string): never {
514  throw createError({
515    statusCode: 404,
516    statusMessage: `${entity} not found`,
517  });
518}
519
520export function forbidden(message = 'Permission denied'): never {
521  throw createError({ statusCode: 403, statusMessage: message });
522}
523
524export function badRequest(message: string): never {
525  throw createError({ statusCode: 400, statusMessage: message });
526}
527"#
528    .to_string()
529}
530
531// ── Server middleware ─────────────────────────────────────
532
533pub fn render_middleware_auth() -> String {
534    r#"// Nitro middleware — Better Auth integration with configurable email
535import { createAuthMiddleware, type AuthLocals } from '@commonpub/auth';
536import { createAuth } from '@commonpub/auth';
537import { ConsoleEmailAdapter, SmtpEmailAdapter, ResendEmailAdapter, emailTemplates } from '@commonpub/server';
538import type { EmailAdapter } from '@commonpub/server';
539
540let authMiddleware: ReturnType<typeof createAuthMiddleware> | null = null;
541
542function createEmailAdapter(): EmailAdapter {
543  const runtimeConfig = useRuntimeConfig();
544  const adapter = (runtimeConfig.emailAdapter as string) || 'console';
545
546  if (adapter === 'smtp') {
547    const host = runtimeConfig.smtpHost as string;
548    const port = parseInt(runtimeConfig.smtpPort as string, 10) || 587;
549    const user = runtimeConfig.smtpUser as string;
550    const pass = runtimeConfig.smtpPass as string;
551    const from = runtimeConfig.smtpFrom as string;
552
553    if (!host || !user || !pass || !from) {
554      console.warn('[email] SMTP configured but missing credentials — falling back to console');
555      return new ConsoleEmailAdapter();
556    }
557
558    return new SmtpEmailAdapter({ host, port, user, pass, from });
559  }
560
561  if (adapter === 'resend') {
562    const apiKey = runtimeConfig.resendApiKey as string;
563    const from = runtimeConfig.resendFrom as string;
564
565    if (!apiKey || !from) {
566      console.warn('[email] Resend configured but missing API key or from address — falling back to console');
567      return new ConsoleEmailAdapter();
568    }
569
570    return new ResendEmailAdapter({ apiKey, from });
571  }
572
573  return new ConsoleEmailAdapter();
574}
575
576function getAuthMiddleware(): ReturnType<typeof createAuthMiddleware> {
577  if (authMiddleware) return authMiddleware;
578
579  const config = useConfig();
580  const db = useDB();
581  const runtimeConfig = useRuntimeConfig();
582  const siteUrl = (runtimeConfig.public?.siteUrl as string) || `https://${config.instance.domain}`;
583  const siteName = config.instance.name || 'CommonPub';
584
585  const emailAdapter = createEmailAdapter();
586
587  const auth = createAuth({
588    config,
589    db: db as unknown as Parameters<typeof createAuth>[0]['db'],
590    secret: (() => {
591      const s = runtimeConfig.authSecret as string;
592      if (!s && process.env.NODE_ENV === 'production') {
593        throw new Error('AUTH_SECRET must be set in production');
594      }
595      return s || 'dev-secret-change-me';
596    })(),
597    baseURL: siteUrl,
598    emailSender: {
599      async sendResetPasswordEmail(email: string, url: string, _token: string): Promise<void> {
600        const template = emailTemplates.passwordReset(siteName, url);
601        await emailAdapter.send({ ...template, to: email });
602      },
603      async sendVerificationEmail(email: string, url: string, _token: string): Promise<void> {
604        const template = emailTemplates.verification(siteName, url);
605        await emailAdapter.send({ ...template, to: email });
606      },
607    },
608  });
609
610  authMiddleware = createAuthMiddleware({ auth });
611  return authMiddleware;
612}
613
614declare module 'h3' {
615  interface H3EventContext {
616    auth: AuthLocals;
617  }
618}
619
620export default defineEventHandler(async (event) => {
621  const pathname = getRequestURL(event).pathname;
622
623  // Skip auth for non-API routes and static assets
624  if (!pathname.startsWith('/api') && !pathname.startsWith('/_nuxt')) {
625    // Still resolve session for SSR pages
626    try {
627      const middleware = getAuthMiddleware();
628      const headers = getRequestHeaders(event);
629      const webHeaders = new Headers(headers as Record<string, string>);
630      event.context.auth = await middleware.resolveSession(webHeaders);
631    } catch {
632      event.context.auth = { user: null, session: null };
633    }
634    return;
635  }
636
637  let middleware: ReturnType<typeof getAuthMiddleware>;
638  try {
639    middleware = getAuthMiddleware();
640  } catch (err: unknown) {
641    // DB not connected — fail with a clear message
642    if (pathname.startsWith('/api/auth') || pathname.startsWith('/api/')) {
643      throw createError({
644        statusCode: 503,
645        statusMessage: 'Database unavailable. Check that PostgreSQL is running.',
646      });
647    }
648    event.context.auth = { user: null, session: null };
649    return;
650  }
651
652  // Handle auth API routes
653  if (pathname.startsWith('/api/auth')) {
654    try {
655      const response = await middleware.handleAuthRoute(
656        toWebRequest(event),
657        pathname,
658      );
659      if (response) {
660        return sendWebResponse(event, response);
661      }
662    } catch (err: unknown) {
663      console.error('[auth] Route handler error:', err instanceof Error ? err.message : err);
664      throw createError({
665        statusCode: 500,
666        statusMessage: 'Authentication service error',
667      });
668    }
669  }
670
671  // Resolve session for API requests
672  try {
673    const headers = getRequestHeaders(event);
674    const webHeaders = new Headers(headers as Record<string, string>);
675    event.context.auth = await middleware.resolveSession(webHeaders);
676  } catch (err: unknown) {
677    if (pathname.startsWith('/api/')) {
678      console.error('[auth] Session resolution failed:', err instanceof Error ? err.message : err);
679    }
680    event.context.auth = { user: null, session: null };
681  }
682});
683"#
684    .to_string()
685}
686
687pub fn render_middleware_security() -> String {
688    r#"// Security middleware — rate limiting + security headers + CSP
689import { RateLimitStore, checkRateLimit, shouldSkipRateLimit, getSecurityHeaders, buildCspHeader, buildCspDirectives } from '@commonpub/server';
690
691const store = new RateLimitStore();
692const isDev = process.env.NODE_ENV !== 'production';
693
694export default defineEventHandler((event) => {
695  const url = getRequestURL(event);
696  const pathname = url.pathname;
697
698  // Skip rate limiting for static assets
699  if (shouldSkipRateLimit(pathname)) return;
700
701  // Skip rate limiting in development — SSR + HMR + prefetch burns through limits
702  if (!isDev) {
703    const ip = getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
704      || getRequestHeader(event, 'x-real-ip')
705      || 'unknown';
706
707    const userId = event.context.auth?.user?.id as string | undefined;
708    const { result, headers: rlHeaders } = checkRateLimit(store, ip, pathname, userId);
709
710    for (const [key, value] of Object.entries(rlHeaders)) {
711      setResponseHeader(event, key, value);
712    }
713
714    if (!result.allowed) {
715      throw createError({
716        statusCode: 429,
717        statusMessage: 'Too Many Requests',
718      });
719    }
720  }
721
722  // Security headers
723  const headers = getSecurityHeaders(isDev);
724  for (const [key, value] of Object.entries(headers)) {
725    setResponseHeader(event, key, value);
726  }
727
728  // Content Security Policy — skip for API responses (JSON doesn't need CSP)
729  if (!pathname.startsWith('/api/')) {
730    const cspDirectives = buildCspDirectives();
731    if (isDev) {
732      cspDirectives['script-src'] = "'self' 'unsafe-inline' 'unsafe-eval'";
733      cspDirectives['style-src'] = "'self' 'unsafe-inline' https://cdnjs.cloudflare.com";
734      cspDirectives['connect-src'] = "'self' ws: wss:";
735    }
736    setResponseHeader(event, 'Content-Security-Policy', buildCspHeader(cspDirectives));
737  }
738});
739"#
740    .to_string()
741}
742
743// ── Plugins ───────────────────────────────────────────────
744
745pub fn render_plugin_auth() -> String {
746    r#"// Auth plugin — fetches session on app init
747import type { ClientAuthUser, ClientAuthSession } from '~/composables/useAuth';
748
749export default defineNuxtPlugin(async () => {
750  const user = useState<ClientAuthUser | null>('auth-user', () => null);
751  const session = useState<ClientAuthSession | null>('auth-session', () => null);
752
753  if (import.meta.server) {
754    const event = useRequestEvent();
755    const authCtx = (event?.context as any)?.auth as { user?: ClientAuthUser; session?: ClientAuthSession } | undefined;
756    if (authCtx) {
757      user.value = (authCtx.user as ClientAuthUser) ?? null;
758      session.value = (authCtx.session as ClientAuthSession) ?? null;
759    }
760    return;
761  }
762
763  // On client, fetch session from the auth API
764  try {
765    const data = await $fetch<{ user: ClientAuthUser | null; session: ClientAuthSession | null }>('/api/auth/get-session', {
766      credentials: 'include',
767    });
768    user.value = data?.user ?? null;
769    session.value = data?.session ?? null;
770  } catch {
771    user.value = null;
772    session.value = null;
773  }
774});
775"#
776    .to_string()
777}
778
779// ── Composables ───────────────────────────────────────────
780
781pub fn render_composable_auth() -> String {
782    r#"// Auth composable — reactive auth state + methods
783
784/** Client-side auth user shape, matching what Better Auth returns */
785export interface ClientAuthUser {
786  id: string;
787  name: string | null;
788  username: string;
789  email: string;
790  role: string;
791  image: string | null;
792  emailVerified: boolean;
793  createdAt: string;
794  updatedAt: string;
795}
796
797export interface ClientAuthSession {
798  id: string;
799  userId: string;
800  token: string;
801  expiresAt: string;
802}
803
804export function useAuth() {
805  const user = useState<ClientAuthUser | null>('auth-user', () => null);
806  const session = useState<ClientAuthSession | null>('auth-session', () => null);
807
808  const isAuthenticated = computed(() => !!user.value);
809  const isAdmin = computed(() => user.value?.role === 'admin');
810
811  async function signIn(email: string, password: string): Promise<void> {
812    const data = await $fetch<{ user: ClientAuthUser | null; session: ClientAuthSession | null }>('/api/auth/sign-in/email', {
813      method: 'POST',
814      body: { email, password },
815      credentials: 'include',
816    });
817    user.value = data?.user ?? null;
818    session.value = data?.session ?? null;
819  }
820
821  async function signUp(email: string, password: string, username: string): Promise<void> {
822    const data = await $fetch<{ user: ClientAuthUser | null; session: ClientAuthSession | null }>('/api/auth/sign-up/email', {
823      method: 'POST',
824      body: { email, password, name: username },
825      credentials: 'include',
826    });
827    user.value = data?.user ?? null;
828    session.value = data?.session ?? null;
829  }
830
831  async function signOut(): Promise<void> {
832    await $fetch('/api/auth/sign-out', { method: 'POST', credentials: 'include' });
833    user.value = null;
834    session.value = null;
835    await navigateTo('/');
836  }
837
838  /** Refresh the session from the server. */
839  async function refreshSession(): Promise<void> {
840    if (import.meta.server) return;
841    try {
842      const data = await $fetch<{ user: ClientAuthUser | null; session: ClientAuthSession | null }>(
843        '/api/auth/get-session',
844        { credentials: 'include' },
845      );
846      user.value = data?.user ?? null;
847      session.value = data?.session ?? null;
848    } catch {
849      user.value = null;
850      session.value = null;
851    }
852  }
853
854  return {
855    user: readonly(user),
856    session: readonly(session),
857    isAuthenticated,
858    isAdmin,
859    signIn,
860    signUp,
861    signOut,
862    refreshSession,
863  };
864}
865"#
866    .to_string()
867}
868
869// ── Pages & layouts ───────────────────────────────────────
870
871pub fn render_default_layout(config: &InstanceConfig) -> String {
872    format!(
873        r#"<template>
874  <div class="cpub-layout">
875    <header class="cpub-header">
876      <nav class="cpub-nav">
877        <NuxtLink to="/" class="cpub-nav-brand">{name}</NuxtLink>
878        <div class="cpub-nav-links">
879          <NuxtLink to="/">Home</NuxtLink>
880        </div>
881      </nav>
882    </header>
883    <main id="main-content" class="cpub-main">
884      <slot />
885    </main>
886    <footer class="cpub-footer">
887      <p>Powered by <a href="https://commonpub.dev">CommonPub</a></p>
888    </footer>
889  </div>
890</template>
891"#,
892        name = config.name,
893    )
894}
895
896pub fn render_index_page(config: &InstanceConfig) -> String {
897    format!(
898        r#"<template>
899  <div class="cpub-page-index">
900    <h1>{name}</h1>
901    <p>{description}</p>
902  </div>
903</template>
904
905<script setup lang="ts">
906useHead({{
907  title: 'Home',
908}});
909</script>
910"#,
911        name = config.name,
912        description = config.description,
913    )
914}
915
916// ── Infra files ───────────────────────────────────────────
917
918pub fn render_gitignore() -> String {
919    r#"# Dependencies
920node_modules/
921
922# Build
923.nuxt/
924.output/
925dist/
926.turbo/
927
928# Environment
929.env
930.env.local
931
932# IDE
933.vscode/
934.idea/
935*.swp
936*.swo
937
938# OS
939.DS_Store
940Thumbs.db
941
942# Uploads (dev)
943uploads/*
944!uploads/.gitkeep
945"#
946    .to_string()
947}
948
949pub fn render_docker_compose(_config: &InstanceConfig) -> String {
950    r#"services:
951  postgres:
952    image: postgres:16-alpine
953    restart: unless-stopped
954    ports:
955      - '5432:5432'
956    environment:
957      POSTGRES_USER: commonpub
958      POSTGRES_PASSWORD: commonpub_dev
959      POSTGRES_DB: commonpub
960    volumes:
961      - postgres_data:/var/lib/postgresql/data
962    healthcheck:
963      test: ['CMD-SHELL', 'pg_isready -U commonpub']
964      interval: 5s
965      timeout: 5s
966      retries: 5
967
968  redis:
969    image: redis:7-alpine
970    restart: unless-stopped
971    ports:
972      - '6379:6379'
973    volumes:
974      - redis_data:/data
975    healthcheck:
976      test: ['CMD', 'redis-cli', 'ping']
977      interval: 5s
978      timeout: 5s
979      retries: 5
980
981  meilisearch:
982    image: getmeili/meilisearch:v1.12
983    restart: unless-stopped
984    ports:
985      - '7700:7700'
986    environment:
987      MEILI_ENV: development
988      MEILI_MASTER_KEY: commonpub_dev_key
989    volumes:
990      - meili_data:/meili_data
991
992volumes:
993  postgres_data:
994  redis_data:
995  meili_data:
996"#
997    .to_string()
998}
999
1000#[cfg(test)]
1001mod tests {
1002    use super::*;
1003    use crate::prompts::InstanceConfig;
1004
1005    fn test_config() -> InstanceConfig {
1006        InstanceConfig::with_defaults("test-instance")
1007    }
1008
1009    // ── .env ──────────────────────────────────────────────
1010
1011    #[test]
1012    fn env_contains_database_url() {
1013        let env = render_env(&test_config());
1014        assert!(env.contains("DATABASE_URL="));
1015        assert!(env.contains("postgresql://"));
1016    }
1017
1018    #[test]
1019    fn env_contains_all_feature_flags() {
1020        let env = render_env(&test_config());
1021        assert!(env.contains("FEATURE_CONTENT=true"));
1022        assert!(env.contains("FEATURE_SOCIAL=true"));
1023        assert!(env.contains("FEATURE_HUBS=true"));
1024        assert!(env.contains("FEATURE_DOCS=true"));
1025        assert!(env.contains("FEATURE_VIDEO=true"));
1026        assert!(env.contains("FEATURE_CONTESTS=false"));
1027        assert!(env.contains("FEATURE_LEARNING=true"));
1028        assert!(env.contains("FEATURE_EXPLAINERS=true"));
1029        assert!(env.contains("FEATURE_FEDERATION=false"));
1030        assert!(env.contains("FEATURE_ADMIN=false"));
1031    }
1032
1033    #[test]
1034    fn env_contains_instance_identity() {
1035        let config = test_config();
1036        let env = render_env(&config);
1037        assert!(env.contains("INSTANCE_NAME=test-instance"));
1038        assert!(env.contains("INSTANCE_DOMAIN=test-instance.localhost"));
1039    }
1040
1041    #[test]
1042    fn env_contains_email_config() {
1043        let env = render_env(&test_config());
1044        assert!(env.contains("EMAIL_ADAPTER=console"));
1045        assert!(env.contains("SMTP_HOST"));
1046        assert!(env.contains("SMTP_PORT"));
1047        assert!(env.contains("SMTP_FROM"));
1048        assert!(env.contains("RESEND_API_KEY"));
1049        assert!(env.contains("RESEND_FROM"));
1050    }
1051
1052    #[test]
1053    fn env_includes_github_oauth_when_enabled() {
1054        let mut config = test_config();
1055        config.auth_github = true;
1056        let env = render_env(&config);
1057        assert!(env.contains("GITHUB_CLIENT_ID="));
1058    }
1059
1060    #[test]
1061    fn env_excludes_github_oauth_when_disabled() {
1062        let config = test_config();
1063        let env = render_env(&config);
1064        assert!(!env.contains("GITHUB_CLIENT_ID"));
1065    }
1066
1067    // ── commonpub.config.ts ───────────────────────────────
1068
1069    #[test]
1070    fn config_is_valid_typescript_structure() {
1071        let config = render_config(&test_config());
1072        assert!(config.contains("import { defineCommonPubConfig }"));
1073        assert!(config.contains("export default defineCommonPubConfig"));
1074    }
1075
1076    #[test]
1077    fn config_contains_all_feature_flags() {
1078        let config = render_config(&test_config());
1079        assert!(config.contains("content: true"));
1080        assert!(config.contains("social: true"));
1081        assert!(config.contains("hubs: true"));
1082        assert!(config.contains("federation: false"));
1083        assert!(config.contains("contests: false"));
1084    }
1085
1086    #[test]
1087    fn config_contains_auth_settings() {
1088        let config = render_config(&test_config());
1089        assert!(config.contains("emailPassword: true"));
1090        assert!(config.contains("magicLink: false"));
1091        assert!(config.contains("passkeys: false"));
1092    }
1093
1094    #[test]
1095    fn config_includes_contest_creation_when_contests_enabled() {
1096        let mut config = test_config();
1097        config.feature_contests = true;
1098        config.contest_creation = "staff".to_string();
1099        let output = render_config(&config);
1100        assert!(output.contains("contestCreation: 'staff'"));
1101    }
1102
1103    #[test]
1104    fn config_includes_content_types() {
1105        let config = test_config();
1106        let output = render_config(&config);
1107        assert!(output.contains("contentTypes: ['project', 'article', 'blog', 'explainer']"));
1108    }
1109
1110    #[test]
1111    fn config_omits_content_types_when_empty() {
1112        let mut config = test_config();
1113        config.content_types = vec![];
1114        let output = render_config(&config);
1115        assert!(!output.contains("contentTypes"));
1116    }
1117
1118    #[test]
1119    fn config_uses_selected_theme() {
1120        let mut config = test_config();
1121        config.theme = "deepwood".to_string();
1122        let output = render_config(&config);
1123        assert!(!output.contains("theme:")); // theme is in nuxt.config now
1124        assert!(output.contains("name: 'test-instance'"));
1125    }
1126
1127    // ── nuxt.config.ts ───────────────────────────────────
1128
1129    #[test]
1130    fn nuxt_config_has_css_and_runtime() {
1131        let config = render_nuxt_config(&test_config());
1132        assert!(config.contains("@commonpub/ui/theme/base.css"));
1133        assert!(config.contains("nitro:"));
1134        assert!(config.contains("runtimeConfig:"));
1135        assert!(config.contains("test-instance.localhost"));
1136    }
1137
1138    #[test]
1139    fn nuxt_config_has_email_runtime_config() {
1140        let config = render_nuxt_config(&test_config());
1141        assert!(config.contains("emailAdapter:"));
1142        assert!(config.contains("smtpHost:"));
1143        assert!(config.contains("resendApiKey:"));
1144        assert!(config.contains("resendFrom:"));
1145    }
1146
1147    #[test]
1148    fn nuxt_config_has_vite_fs_allow() {
1149        let config = render_nuxt_config(&test_config());
1150        assert!(config.contains("fs:"));
1151        assert!(config.contains("allow:"));
1152    }
1153
1154    #[test]
1155    fn nuxt_config_includes_theme_css_when_non_base() {
1156        let mut config = test_config();
1157        config.theme = "deepwood".to_string();
1158        let output = render_nuxt_config(&config);
1159        assert!(output.contains("deepwood.css"));
1160    }
1161
1162    // ── package.json ──────────────────────────────────────
1163
1164    #[test]
1165    fn package_json_is_nuxt() {
1166        let json = render_package_json(&test_config());
1167        assert!(json.contains("\"name\": \"test-instance\""));
1168        assert!(json.contains("nuxt dev"));
1169        assert!(json.contains("nuxt build"));
1170        assert!(json.contains("\"nuxt\":"));
1171        assert!(json.contains("\"vue\":"));
1172    }
1173
1174    #[test]
1175    fn package_json_has_core_commonpub_deps() {
1176        let json = render_package_json(&test_config());
1177        assert!(json.contains("@commonpub/config"));
1178        assert!(json.contains("@commonpub/schema"));
1179        assert!(json.contains("@commonpub/auth"));
1180        assert!(json.contains("@commonpub/ui"));
1181        assert!(json.contains("@commonpub/server"));
1182        assert!(json.contains("@commonpub/infra"));
1183    }
1184
1185    #[test]
1186    fn package_json_has_pg_and_zod() {
1187        let json = render_package_json(&test_config());
1188        assert!(json.contains("\"pg\":"));
1189        assert!(json.contains("\"zod\":"));
1190    }
1191
1192    #[test]
1193    fn package_json_includes_editor_when_content_enabled() {
1194        let config = test_config(); // content enabled by default
1195        let json = render_package_json(&config);
1196        assert!(json.contains("@commonpub/editor"));
1197    }
1198
1199    #[test]
1200    fn package_json_excludes_editor_when_content_disabled() {
1201        let mut config = test_config();
1202        config.feature_content = false;
1203        let json = render_package_json(&config);
1204        assert!(!json.contains("@commonpub/editor"));
1205    }
1206
1207    #[test]
1208    fn package_json_includes_optional_deps_when_enabled() {
1209        let config = test_config(); // docs + learning + explainers enabled
1210        let json = render_package_json(&config);
1211        assert!(json.contains("@commonpub/docs"));
1212        assert!(json.contains("@commonpub/learning"));
1213        assert!(json.contains("@commonpub/explainer"));
1214        assert!(!json.contains("@commonpub/protocol")); // federation off
1215    }
1216
1217    #[test]
1218    fn package_json_includes_protocol_when_federation_enabled() {
1219        let mut config = test_config();
1220        config.feature_federation = true;
1221        let json = render_package_json(&config);
1222        assert!(json.contains("@commonpub/protocol"));
1223    }
1224
1225    #[test]
1226    fn package_json_excludes_optional_deps_when_disabled() {
1227        let mut config = test_config();
1228        config.feature_docs = false;
1229        config.feature_learning = false;
1230        config.feature_explainers = false;
1231        let json = render_package_json(&config);
1232        assert!(!json.contains("@commonpub/docs"));
1233        assert!(!json.contains("@commonpub/learning"));
1234        assert!(!json.contains("@commonpub/explainer"));
1235    }
1236
1237    // ── app.vue ───────────────────────────────────────────
1238
1239    #[test]
1240    fn app_vue_has_skip_link_and_layout() {
1241        let vue = render_app_vue(&test_config());
1242        assert!(vue.contains("cpub-skip-link"));
1243        assert!(vue.contains("NuxtLayout"));
1244        assert!(vue.contains("NuxtPage"));
1245        assert!(vue.contains("test-instance"));
1246    }
1247
1248    // ── Server utils ──────────────────────────────────────
1249
1250    #[test]
1251    fn server_config_uses_define_commonpub_config() {
1252        let sc = render_server_config();
1253        assert!(sc.contains("defineCommonPubConfig"));
1254        assert!(sc.contains("useConfig"));
1255        assert!(sc.contains("cachedConfig"));
1256    }
1257
1258    #[test]
1259    fn server_db_has_pool_and_singleton() {
1260        let db = render_server_db();
1261        assert!(db.contains("useDB"));
1262        assert!(db.contains("pg.Pool"));
1263        assert!(db.contains("drizzle(pool"));
1264        assert!(db.contains("@commonpub/schema"));
1265        assert!(db.contains("production"));
1266    }
1267
1268    #[test]
1269    fn server_auth_has_require_and_optional() {
1270        let auth = render_server_auth();
1271        assert!(auth.contains("requireAuth"));
1272        assert!(auth.contains("requireAdmin"));
1273        assert!(auth.contains("getOptionalUser"));
1274        assert!(auth.contains("AuthUser"));
1275    }
1276
1277    #[test]
1278    fn server_validate_has_parse_helpers() {
1279        let validate = render_server_validate();
1280        assert!(validate.contains("parseBody"));
1281        assert!(validate.contains("parseQueryParams"));
1282        assert!(validate.contains("parseParams"));
1283        assert!(validate.contains("ZodType"));
1284    }
1285
1286    #[test]
1287    fn server_errors_has_helpers() {
1288        let errors = render_server_errors();
1289        assert!(errors.contains("validationError"));
1290        assert!(errors.contains("notFound"));
1291        assert!(errors.contains("forbidden"));
1292        assert!(errors.contains("badRequest"));
1293    }
1294
1295    // ── Middleware ─────────────────────────────────────────
1296
1297    #[test]
1298    fn middleware_auth_has_email_adapter_switch() {
1299        let auth = render_middleware_auth();
1300        assert!(auth.contains("createEmailAdapter"));
1301        assert!(auth.contains("SmtpEmailAdapter"));
1302        assert!(auth.contains("ResendEmailAdapter"));
1303        assert!(auth.contains("ConsoleEmailAdapter"));
1304        assert!(auth.contains("emailAdapter"));
1305        assert!(auth.contains("emailTemplates"));
1306        assert!(auth.contains("createAuth"));
1307    }
1308
1309    #[test]
1310    fn middleware_auth_handles_session_resolution() {
1311        let auth = render_middleware_auth();
1312        assert!(auth.contains("resolveSession"));
1313        assert!(auth.contains("handleAuthRoute"));
1314        assert!(auth.contains("/api/auth"));
1315    }
1316
1317    #[test]
1318    fn middleware_security_has_rate_limiting_and_csp() {
1319        let sec = render_middleware_security();
1320        assert!(sec.contains("RateLimitStore"));
1321        assert!(sec.contains("checkRateLimit"));
1322        assert!(sec.contains("getSecurityHeaders"));
1323        assert!(sec.contains("Content-Security-Policy"));
1324    }
1325
1326    // ── Plugin ────────────────────────────────────────────
1327
1328    #[test]
1329    fn plugin_auth_bridges_ssr_to_client() {
1330        let plugin = render_plugin_auth();
1331        assert!(plugin.contains("defineNuxtPlugin"));
1332        assert!(plugin.contains("import.meta.server"));
1333        assert!(plugin.contains("auth-user"));
1334        assert!(plugin.contains("/api/auth/get-session"));
1335    }
1336
1337    // ── Composable ────────────────────────────────────────
1338
1339    #[test]
1340    fn composable_auth_has_full_api() {
1341        let auth = render_composable_auth();
1342        assert!(auth.contains("useAuth"));
1343        assert!(auth.contains("signIn"));
1344        assert!(auth.contains("signUp"));
1345        assert!(auth.contains("signOut"));
1346        assert!(auth.contains("refreshSession"));
1347        assert!(auth.contains("isAuthenticated"));
1348        assert!(auth.contains("isAdmin"));
1349        assert!(auth.contains("ClientAuthUser"));
1350    }
1351
1352    // ── Docker ────────────────────────────────────────────
1353
1354    #[test]
1355    fn docker_compose_has_all_services() {
1356        let compose = render_docker_compose(&test_config());
1357        assert!(compose.contains("postgres:"));
1358        assert!(compose.contains("redis:"));
1359        assert!(compose.contains("meilisearch:"));
1360    }
1361
1362    #[test]
1363    fn docker_compose_has_health_checks() {
1364        let compose = render_docker_compose(&test_config());
1365        assert!(compose.contains("healthcheck:"));
1366        assert!(compose.contains("pg_isready"));
1367        assert!(compose.contains("redis-cli"));
1368    }
1369
1370    // ── Defaults ──────────────────────────────────────────
1371
1372    #[test]
1373    fn default_config_values_correct() {
1374        let config = InstanceConfig::with_defaults("my-app");
1375        assert_eq!(config.name, "my-app");
1376        assert_eq!(config.domain, "my-app.localhost");
1377        assert_eq!(config.theme, "base");
1378        assert!(config.feature_content);
1379        assert!(config.feature_social);
1380        assert!(config.feature_hubs);
1381        assert!(config.feature_docs);
1382        assert!(config.feature_video);
1383        assert!(!config.feature_contests);
1384        assert!(config.feature_learning);
1385        assert!(config.feature_explainers);
1386        assert!(!config.feature_federation);
1387        assert!(!config.feature_admin);
1388        assert!(config.auth_email_password);
1389        assert!(!config.auth_magic_link);
1390        assert!(!config.auth_passkeys);
1391        assert!(!config.auth_github);
1392        assert!(!config.auth_google);
1393        assert!(config.use_docker);
1394        assert_eq!(config.contest_creation, "admin");
1395        assert_eq!(config.content_types.len(), 4);
1396    }
1397
1398    #[test]
1399    fn gitignore_has_nuxt_entries() {
1400        let gi = render_gitignore();
1401        assert!(gi.contains(".nuxt/"));
1402        assert!(gi.contains(".output/"));
1403        assert!(gi.contains("node_modules/"));
1404        assert!(gi.contains(".env"));
1405        assert!(gi.contains(".turbo/"));
1406    }
1407
1408    #[test]
1409    fn default_layout_has_accessibility() {
1410        let layout = render_default_layout(&test_config());
1411        assert!(layout.contains("cpub-layout"));
1412        assert!(layout.contains("main-content"));
1413        assert!(layout.contains("commonpub.dev"));
1414    }
1415
1416    #[test]
1417    fn index_page_has_instance_info() {
1418        let page = render_index_page(&test_config());
1419        assert!(page.contains("test-instance"));
1420        assert!(page.contains("useHead"));
1421    }
1422}