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 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 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 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
321pub 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
531pub 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
743pub 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
779pub 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
869pub 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
916pub 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 #[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 #[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:")); assert!(output.contains("name: 'test-instance'"));
1125 }
1126
1127 #[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 #[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(); 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(); 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")); }
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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}