@digilogiclabs/create-saas-app 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +975 -891
  3. package/bin/index.js +2 -0
  4. package/dist/.tsbuildinfo +1 -1
  5. package/dist/cli/commands/create.d.ts +2 -2
  6. package/dist/cli/commands/create.d.ts.map +1 -1
  7. package/dist/cli/commands/create.js.map +1 -1
  8. package/dist/cli/prompts/project-setup.d.ts.map +1 -1
  9. package/dist/cli/prompts/project-setup.js +13 -5
  10. package/dist/cli/prompts/project-setup.js.map +1 -1
  11. package/dist/generators/template-generator.d.ts +11 -0
  12. package/dist/generators/template-generator.d.ts.map +1 -1
  13. package/dist/generators/template-generator.js +360 -16
  14. package/dist/generators/template-generator.js.map +1 -1
  15. package/dist/templates/shared/admin/web/app/admin/layout.tsx +34 -0
  16. package/dist/templates/shared/admin/web/components/admin-nav.tsx +48 -0
  17. package/dist/templates/shared/audit/web/lib/audit.ts +24 -0
  18. package/dist/templates/shared/auth/keycloak/web/app/api/auth/federated-logout/route.ts +173 -0
  19. package/dist/templates/shared/auth/keycloak/web/auth.config.ts +84 -0
  20. package/dist/templates/shared/auth/keycloak/web/auth.ts +26 -0
  21. package/dist/templates/shared/beta/web/app/api/beta-settings/route.ts +25 -0
  22. package/dist/templates/shared/beta/web/app/api/validate-beta-code/route.ts +67 -0
  23. package/dist/templates/shared/beta/web/lib/beta/settings.ts +31 -0
  24. package/dist/templates/shared/cache/web/lib/cache.ts +44 -0
  25. package/dist/templates/shared/config/web/lib/config.ts +112 -0
  26. package/dist/templates/shared/config/web/next.config.mjs +62 -0
  27. package/dist/templates/shared/contact/web/app/api/contact/route.ts +113 -0
  28. package/dist/templates/shared/contact/web/app/contact/page.tsx +195 -0
  29. package/dist/templates/shared/cookie-consent/web/components/cookie-consent.tsx +54 -0
  30. package/dist/templates/shared/database/postgresql/web/drizzle.config.ts +16 -0
  31. package/dist/templates/shared/database/postgresql/web/lib/db/drizzle.ts +39 -0
  32. package/dist/templates/shared/database/postgresql/web/lib/db/schema.ts +33 -0
  33. package/dist/templates/shared/database/supabase/web/lib/supabase/client.ts +12 -0
  34. package/dist/templates/shared/database/supabase/web/lib/supabase/server.ts +31 -0
  35. package/dist/templates/shared/database/supabase/web/lib/supabase/service.ts +15 -0
  36. package/dist/templates/shared/email/web/lib/email/branding.ts +18 -0
  37. package/dist/templates/shared/email/web/lib/email/client.ts +96 -0
  38. package/dist/templates/shared/error-pages/web/app/error.tsx +70 -0
  39. package/dist/templates/shared/error-pages/web/app/global-error.tsx +102 -0
  40. package/dist/templates/shared/error-pages/web/app/not-found.tsx +39 -0
  41. package/dist/templates/shared/health/web/app/api/health/route.ts +68 -0
  42. package/dist/templates/shared/legal/web/app/(legal)/privacy/page.tsx +205 -0
  43. package/dist/templates/shared/legal/web/app/(legal)/terms/page.tsx +154 -0
  44. package/dist/templates/shared/legal/web/lib/legal-config.ts +50 -0
  45. package/dist/templates/shared/loading/web/app/loading.tsx +5 -0
  46. package/dist/templates/shared/loading/web/components/skeleton.tsx +95 -0
  47. package/dist/templates/shared/middleware/web/middleware.ts +68 -0
  48. package/dist/templates/shared/observability/web/lib/observability.ts +135 -0
  49. package/dist/templates/shared/payments/web/app/api/webhooks/stripe/route.ts +109 -0
  50. package/dist/templates/shared/platform/web/lib/platform.ts +37 -0
  51. package/dist/templates/shared/redis/web/lib/rate-limit-store.ts +18 -0
  52. package/dist/templates/shared/redis/web/lib/redis.ts +48 -0
  53. package/dist/templates/shared/security/web/lib/api-security.ts +318 -0
  54. package/dist/templates/shared/seo/web/app/api/og/route.tsx +97 -0
  55. package/dist/templates/shared/seo/web/app/robots.ts +53 -0
  56. package/dist/templates/shared/seo/web/app/sitemap.ts +53 -0
  57. package/dist/templates/shared/utils/web/lib/api-response.ts +71 -0
  58. package/dist/templates/shared/utils/web/lib/utils.ts +85 -0
  59. package/package.json +5 -4
  60. package/src/templates/shared/admin/web/app/admin/layout.tsx +34 -0
  61. package/src/templates/shared/admin/web/components/admin-nav.tsx +48 -0
  62. package/src/templates/shared/audit/web/lib/audit.ts +24 -0
  63. package/src/templates/shared/auth/keycloak/web/app/api/auth/federated-logout/route.ts +173 -0
  64. package/src/templates/shared/auth/keycloak/web/auth.config.ts +84 -0
  65. package/src/templates/shared/auth/keycloak/web/auth.ts +26 -0
  66. package/src/templates/shared/beta/web/app/api/beta-settings/route.ts +25 -0
  67. package/src/templates/shared/beta/web/app/api/validate-beta-code/route.ts +67 -0
  68. package/src/templates/shared/beta/web/lib/beta/settings.ts +31 -0
  69. package/src/templates/shared/cache/web/lib/cache.ts +44 -0
  70. package/src/templates/shared/config/web/lib/config.ts +112 -0
  71. package/src/templates/shared/config/web/next.config.mjs +62 -0
  72. package/src/templates/shared/contact/web/app/api/contact/route.ts +113 -0
  73. package/src/templates/shared/contact/web/app/contact/page.tsx +195 -0
  74. package/src/templates/shared/cookie-consent/web/components/cookie-consent.tsx +54 -0
  75. package/src/templates/shared/database/postgresql/web/drizzle.config.ts +16 -0
  76. package/src/templates/shared/database/postgresql/web/lib/db/drizzle.ts +39 -0
  77. package/src/templates/shared/database/postgresql/web/lib/db/schema.ts +33 -0
  78. package/src/templates/shared/database/supabase/web/lib/supabase/client.ts +12 -0
  79. package/src/templates/shared/database/supabase/web/lib/supabase/server.ts +31 -0
  80. package/src/templates/shared/database/supabase/web/lib/supabase/service.ts +15 -0
  81. package/src/templates/shared/email/web/lib/email/branding.ts +18 -0
  82. package/src/templates/shared/email/web/lib/email/client.ts +96 -0
  83. package/src/templates/shared/error-pages/web/app/error.tsx +70 -0
  84. package/src/templates/shared/error-pages/web/app/global-error.tsx +102 -0
  85. package/src/templates/shared/error-pages/web/app/not-found.tsx +39 -0
  86. package/src/templates/shared/health/web/app/api/health/route.ts +68 -0
  87. package/src/templates/shared/legal/web/app/(legal)/privacy/page.tsx +205 -0
  88. package/src/templates/shared/legal/web/app/(legal)/terms/page.tsx +154 -0
  89. package/src/templates/shared/legal/web/lib/legal-config.ts +50 -0
  90. package/src/templates/shared/loading/web/app/loading.tsx +5 -0
  91. package/src/templates/shared/loading/web/components/skeleton.tsx +95 -0
  92. package/src/templates/shared/middleware/web/middleware.ts +68 -0
  93. package/src/templates/shared/observability/web/lib/observability.ts +135 -0
  94. package/src/templates/shared/payments/web/app/api/webhooks/stripe/route.ts +109 -0
  95. package/src/templates/shared/platform/web/lib/platform.ts +37 -0
  96. package/src/templates/shared/redis/web/lib/rate-limit-store.ts +18 -0
  97. package/src/templates/shared/redis/web/lib/redis.ts +48 -0
  98. package/src/templates/shared/security/web/lib/api-security.ts +318 -0
  99. package/src/templates/shared/seo/web/app/api/og/route.tsx +97 -0
  100. package/src/templates/shared/seo/web/app/robots.ts +53 -0
  101. package/src/templates/shared/seo/web/app/sitemap.ts +53 -0
  102. package/src/templates/shared/utils/web/lib/api-response.ts +71 -0
  103. package/src/templates/shared/utils/web/lib/utils.ts +85 -0
@@ -0,0 +1,154 @@
1
+ import { Metadata } from 'next';
2
+ import { LEGAL_CONFIG } from '@/lib/legal-config';
3
+
4
+ export const metadata: Metadata = {
5
+ title: `Terms of Service | ${LEGAL_CONFIG.appName}`,
6
+ description: `Terms of Service for ${LEGAL_CONFIG.appName}. Read our terms and conditions.`,
7
+ alternates: {
8
+ canonical: `${LEGAL_CONFIG.appUrl}/terms`,
9
+ },
10
+ };
11
+
12
+ export default function TermsPage() {
13
+ const {
14
+ appName,
15
+ companyName,
16
+ appUrl,
17
+ supportEmail,
18
+ effectiveDate,
19
+ minimumAge,
20
+ jurisdiction,
21
+ hasPayments,
22
+ platformFeePercent,
23
+ } = LEGAL_CONFIG;
24
+
25
+ return (
26
+ <main className="min-h-screen bg-white dark:bg-gray-950">
27
+ <div className="mx-auto max-w-3xl px-4 py-16 sm:px-6 lg:px-8">
28
+ <h1 className="mb-2 text-3xl font-bold tracking-tight text-gray-900 dark:text-white">
29
+ Terms of Service
30
+ </h1>
31
+ <p className="mb-8 text-sm text-gray-500 dark:text-gray-400">
32
+ Effective: {effectiveDate}
33
+ </p>
34
+
35
+ <div className="prose prose-gray dark:prose-invert max-w-none">
36
+ <section>
37
+ <h2>1. Acceptance of Terms</h2>
38
+ <p>
39
+ By accessing or using {appName} ({appUrl}), you agree to be bound
40
+ by these Terms of Service. If you do not agree, do not use the
41
+ service.
42
+ </p>
43
+ </section>
44
+
45
+ {minimumAge > 0 && (
46
+ <section>
47
+ <h2>2. Age Requirement</h2>
48
+ <p>
49
+ You must be at least {minimumAge} years old to use {appName}. By
50
+ using this service, you represent that you meet this age
51
+ requirement.
52
+ </p>
53
+ </section>
54
+ )}
55
+
56
+ <section>
57
+ <h2>{minimumAge > 0 ? '3' : '2'}. User Accounts</h2>
58
+ <p>
59
+ You are responsible for maintaining the confidentiality of your
60
+ account credentials and for all activities under your account.
61
+ Notify us immediately of any unauthorized use.
62
+ </p>
63
+ </section>
64
+
65
+ <section>
66
+ <h2>{minimumAge > 0 ? '4' : '3'}. Acceptable Use</h2>
67
+ <p>You agree not to:</p>
68
+ <ul>
69
+ <li>Violate any applicable laws or regulations</li>
70
+ <li>
71
+ Upload harmful, offensive, or infringing content
72
+ </li>
73
+ <li>Attempt to access other users&apos; accounts</li>
74
+ <li>
75
+ Use automated tools to scrape or interfere with the service
76
+ </li>
77
+ <li>Impersonate any person or entity</li>
78
+ </ul>
79
+ </section>
80
+
81
+ {hasPayments && (
82
+ <section>
83
+ <h2>{minimumAge > 0 ? '5' : '4'}. Payments &amp; Fees</h2>
84
+ <p>
85
+ Payments are processed securely through Stripe. All fees are
86
+ listed at the time of purchase.
87
+ {Number(platformFeePercent) > 0 &&
88
+ ` ${appName} charges a ${platformFeePercent}% platform fee on applicable transactions.`}
89
+ </p>
90
+ <p>
91
+ Refund policies are described at the point of sale. You are
92
+ responsible for any applicable taxes on your purchases.
93
+ </p>
94
+ </section>
95
+ )}
96
+
97
+ <section>
98
+ <h2>Intellectual Property</h2>
99
+ <p>
100
+ {appName} and its original content, features, and functionality
101
+ are owned by {companyName}. You retain ownership of content you
102
+ submit but grant us a license to display it as part of the
103
+ service.
104
+ </p>
105
+ </section>
106
+
107
+ <section>
108
+ <h2>Termination</h2>
109
+ <p>
110
+ We may suspend or terminate your account for violations of these
111
+ terms. You may delete your account at any time. Upon termination,
112
+ your right to use the service ceases immediately.
113
+ </p>
114
+ </section>
115
+
116
+ <section>
117
+ <h2>Limitation of Liability</h2>
118
+ <p>
119
+ {appName} is provided &quot;as is&quot; without warranties of any
120
+ kind. {companyName} shall not be liable for any indirect,
121
+ incidental, or consequential damages arising from your use of the
122
+ service.
123
+ </p>
124
+ </section>
125
+
126
+ <section>
127
+ <h2>Governing Law</h2>
128
+ <p>
129
+ These terms are governed by the laws of {jurisdiction}. Any
130
+ disputes shall be resolved in the courts of {jurisdiction}.
131
+ </p>
132
+ </section>
133
+
134
+ <section>
135
+ <h2>Changes to Terms</h2>
136
+ <p>
137
+ We may update these terms at any time. Continued use after changes
138
+ constitutes acceptance. We will notify registered users of
139
+ material changes via email.
140
+ </p>
141
+ </section>
142
+
143
+ <section>
144
+ <h2>Contact</h2>
145
+ <p>
146
+ Questions about these terms? Contact us at{' '}
147
+ <a href={`mailto:${supportEmail}`}>{supportEmail}</a>.
148
+ </p>
149
+ </section>
150
+ </div>
151
+ </div>
152
+ </main>
153
+ );
154
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Legal page configuration.
3
+ *
4
+ * Update these values to match your app's legal details.
5
+ * Used by the Terms of Service and Privacy Policy pages.
6
+ */
7
+ export const LEGAL_CONFIG = {
8
+ /** App display name */
9
+ appName: 'My App',
10
+
11
+ /** Company or entity name */
12
+ companyName: 'My Company',
13
+
14
+ /** Production URL (no trailing slash) */
15
+ appUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
16
+
17
+ /** Support email address */
18
+ supportEmail: 'support@example.com',
19
+
20
+ /** Privacy-specific contact email */
21
+ privacyEmail: 'privacy@example.com',
22
+
23
+ /** Date the terms/privacy were last updated */
24
+ effectiveDate: 'January 1, 2026',
25
+
26
+ /** Minimum age requirement (0 to disable) */
27
+ minimumAge: 18,
28
+
29
+ /** Jurisdiction / governing law */
30
+ jurisdiction: 'the United States',
31
+
32
+ /** Whether the app processes payments */
33
+ hasPayments: true,
34
+
35
+ /** Platform fee percentage (if applicable) */
36
+ platformFeePercent: '0',
37
+
38
+ /** Whether the app uses cookies beyond essentials */
39
+ usesAnalyticsCookies: true,
40
+
41
+ /** Third-party services used (listed in privacy policy) */
42
+ thirdPartyServices: [
43
+ 'Stripe (payment processing)',
44
+ 'Cloudflare (CDN and security)',
45
+ 'Resend (transactional email)',
46
+ ],
47
+
48
+ /** Data retention period */
49
+ dataRetentionPeriod: '3 years after account deletion',
50
+ } as const;
@@ -0,0 +1,5 @@
1
+ import { LoadingSpinner } from '@/components/skeleton';
2
+
3
+ export default function Loading() {
4
+ return <LoadingSpinner />;
5
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Reusable skeleton loading components.
3
+ *
4
+ * Use these in loading.tsx files for consistent loading states.
5
+ * All components use Tailwind's animate-pulse for the shimmer effect.
6
+ */
7
+
8
+ /** Base skeleton block with customizable dimensions */
9
+ export function Skeleton({
10
+ className = '',
11
+ }: {
12
+ className?: string;
13
+ }) {
14
+ return (
15
+ <div
16
+ className={`animate-pulse rounded bg-gray-200 dark:bg-gray-800 ${className}`}
17
+ />
18
+ );
19
+ }
20
+
21
+ /** Skeleton for a card with title, description, and action area */
22
+ export function CardSkeleton() {
23
+ return (
24
+ <div className="rounded-lg border border-gray-200 p-6 dark:border-gray-800">
25
+ <Skeleton className="mb-4 h-4 w-3/4" />
26
+ <Skeleton className="mb-2 h-3 w-full" />
27
+ <Skeleton className="mb-4 h-3 w-5/6" />
28
+ <Skeleton className="h-8 w-24" />
29
+ </div>
30
+ );
31
+ }
32
+
33
+ /** Skeleton for a grid of cards */
34
+ export function CardGridSkeleton({ count = 6 }: { count?: number }) {
35
+ return (
36
+ <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
37
+ {Array.from({ length: count }, (_, i) => (
38
+ <CardSkeleton key={i} />
39
+ ))}
40
+ </div>
41
+ );
42
+ }
43
+
44
+ /** Skeleton for a table with rows */
45
+ export function TableSkeleton({ rows = 5 }: { rows?: number }) {
46
+ return (
47
+ <div className="space-y-3">
48
+ {/* Header */}
49
+ <div className="flex gap-4 border-b border-gray-200 pb-3 dark:border-gray-800">
50
+ <Skeleton className="h-4 w-1/4" />
51
+ <Skeleton className="h-4 w-1/3" />
52
+ <Skeleton className="h-4 w-1/4" />
53
+ <Skeleton className="h-4 w-1/6" />
54
+ </div>
55
+ {/* Rows */}
56
+ {Array.from({ length: rows }, (_, i) => (
57
+ <div key={i} className="flex gap-4 py-2">
58
+ <Skeleton className="h-4 w-1/4" />
59
+ <Skeleton className="h-4 w-1/3" />
60
+ <Skeleton className="h-4 w-1/4" />
61
+ <Skeleton className="h-4 w-1/6" />
62
+ </div>
63
+ ))}
64
+ </div>
65
+ );
66
+ }
67
+
68
+ /** Skeleton for a full page with hero and content */
69
+ export function PageSkeleton() {
70
+ return (
71
+ <div className="mx-auto max-w-4xl px-4 py-8">
72
+ {/* Hero */}
73
+ <Skeleton className="mb-4 h-8 w-2/3" />
74
+ <Skeleton className="mb-8 h-4 w-1/2" />
75
+ {/* Content blocks */}
76
+ <div className="space-y-4">
77
+ <Skeleton className="h-4 w-full" />
78
+ <Skeleton className="h-4 w-5/6" />
79
+ <Skeleton className="h-4 w-4/5" />
80
+ <Skeleton className="h-32 w-full" />
81
+ <Skeleton className="h-4 w-full" />
82
+ <Skeleton className="h-4 w-3/4" />
83
+ </div>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ /** Default loading spinner for loading.tsx files */
89
+ export function LoadingSpinner() {
90
+ return (
91
+ <div className="flex min-h-[50vh] items-center justify-center">
92
+ <div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-blue-600 dark:border-gray-700 dark:border-t-blue-400" />
93
+ </div>
94
+ );
95
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Next.js Middleware (Edge Runtime)
3
+ *
4
+ * IMPORTANT: This file runs in the Edge runtime. Do NOT import:
5
+ * - Server-only modules (ioredis, pg, nodemailer)
6
+ * - The main platform-core barrel (pulls in node:stream)
7
+ * - auth.ts (server-only) — use auth.config.ts instead
8
+ *
9
+ * Use @digilogiclabs/platform-core/auth subpath for Edge-safe imports.
10
+ */
11
+ import NextAuth from 'next-auth';
12
+ import { authConfig } from './auth.config';
13
+
14
+ const { auth } = NextAuth(authConfig);
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Route definitions
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** Routes that require authentication */
21
+ const protectedRoutes = ['/dashboard', '/settings', '/account'];
22
+
23
+ /** Routes that are always public */
24
+ const publicRoutes = ['/', '/auth/signin', '/auth/signup', '/api/health', '/api/auth'];
25
+
26
+ /** Admin-only routes */
27
+ const adminRoutes = ['/admin'];
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Middleware handler
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export default auth((req) => {
34
+ const { pathname } = req.nextUrl;
35
+ const session = req.auth;
36
+
37
+ // Allow public routes and API routes
38
+ if (publicRoutes.some((r) => pathname.startsWith(r))) {
39
+ return;
40
+ }
41
+
42
+ // Allow static files and Next.js internals
43
+ if (pathname.startsWith('/_next') || pathname.startsWith('/favicon') || pathname.includes('.')) {
44
+ return;
45
+ }
46
+
47
+ // Check authentication for protected routes
48
+ const isProtected = protectedRoutes.some((r) => pathname.startsWith(r));
49
+ const isAdmin = adminRoutes.some((r) => pathname.startsWith(r));
50
+
51
+ if ((isProtected || isAdmin) && !session) {
52
+ const signInUrl = new URL('/auth/signin', req.url);
53
+ signInUrl.searchParams.set('callbackUrl', pathname);
54
+ return Response.redirect(signInUrl);
55
+ }
56
+
57
+ // Check admin role
58
+ if (isAdmin && session) {
59
+ const roles = (session.user as { roles?: string[] })?.roles || [];
60
+ if (!roles.includes('admin')) {
61
+ return Response.redirect(new URL('/dashboard', req.url));
62
+ }
63
+ }
64
+ });
65
+
66
+ export const config = {
67
+ matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)'],
68
+ };
@@ -0,0 +1,135 @@
1
+ import { getClientIp } from '@digilogiclabs/platform-core/auth';
2
+
3
+ /**
4
+ * Observability helpers — audit logging, error reporting, metrics.
5
+ *
6
+ * All functions are fire-and-forget (no await needed) and fail silently.
7
+ * Uses platform-core adapters when available, falls back to console.
8
+ *
9
+ * Customize the SERVICE_NAME and add app-specific audit actions.
10
+ */
11
+
12
+ const SERVICE_NAME = 'my-app';
13
+
14
+ // ─── Lazy Singletons ────────────────────────────────────────
15
+
16
+ let auditLogPromise: Promise<AuditLogger> | null = null;
17
+
18
+ interface AuditLogger {
19
+ log(event: AuditEvent): Promise<void>;
20
+ }
21
+
22
+ interface AuditEvent {
23
+ action: string;
24
+ actor: { id: string; type: string; email?: string };
25
+ resource?: { type: string; id?: string };
26
+ outcome: 'success' | 'failure' | 'blocked';
27
+ metadata?: Record<string, unknown>;
28
+ category?: string;
29
+ ip?: string;
30
+ userAgent?: string;
31
+ }
32
+
33
+ async function getAuditLog(): Promise<AuditLogger> {
34
+ if (!auditLogPromise) {
35
+ auditLogPromise = (async () => {
36
+ try {
37
+ const { getPlatform } = await import('@/lib/platform');
38
+ const platform = await getPlatform();
39
+ const { DatabaseAuditLog } = await import('@digilogiclabs/platform-core');
40
+ return new DatabaseAuditLog(platform.db, { serviceName: SERVICE_NAME });
41
+ } catch {
42
+ return {
43
+ log: async (event: AuditEvent) => {
44
+ console.log(`[Audit] ${event.action}`, JSON.stringify(event));
45
+ },
46
+ };
47
+ }
48
+ })();
49
+ }
50
+ return auditLogPromise;
51
+ }
52
+
53
+ // ─── Public Helpers ──────────────────────────────────────────
54
+
55
+ /** Get user agent string from request */
56
+ export function getUserAgent(request: Request): string {
57
+ return request.headers.get('user-agent') || 'unknown';
58
+ }
59
+
60
+ // Re-export getClientIp from platform-core
61
+ export { getClientIp } from '@digilogiclabs/platform-core/auth';
62
+
63
+ /** Log an audit event (fire-and-forget) */
64
+ export function auditAction(params: {
65
+ action: string;
66
+ actorId: string;
67
+ actorEmail?: string;
68
+ resourceType?: string;
69
+ resourceId?: string;
70
+ outcome?: 'success' | 'failure' | 'blocked';
71
+ metadata?: Record<string, unknown>;
72
+ request?: Request;
73
+ }): void {
74
+ const category = inferCategory(params.action);
75
+ getAuditLog()
76
+ .then((log) =>
77
+ log.log({
78
+ action: params.action,
79
+ actor: {
80
+ id: params.actorId,
81
+ type: params.actorId === 'system' ? 'system' : 'user',
82
+ email: params.actorEmail,
83
+ },
84
+ resource: params.resourceType
85
+ ? { type: params.resourceType, id: params.resourceId }
86
+ : undefined,
87
+ outcome: params.outcome || 'success',
88
+ metadata: params.metadata,
89
+ category,
90
+ ip: params.request ? getClientIp(params.request) : undefined,
91
+ userAgent: params.request ? getUserAgent(params.request) : undefined,
92
+ })
93
+ )
94
+ .catch(() => {});
95
+ }
96
+
97
+ /** Log an admin action */
98
+ export function auditAdminAction(
99
+ params: Omit<Parameters<typeof auditAction>[0], 'action'> & { action: string }
100
+ ): void {
101
+ auditAction({ ...params, action: `admin.${params.action}` });
102
+ }
103
+
104
+ /** Log a cron job action */
105
+ export function auditCronAction(
106
+ params: Omit<Parameters<typeof auditAction>[0], 'actorId'> & { action: string }
107
+ ): void {
108
+ auditAction({ ...params, actorId: 'system', action: `cron.${params.action}` });
109
+ }
110
+
111
+ /** Capture an error (fire-and-forget) */
112
+ export function captureError(error: unknown, context?: Record<string, unknown>): void {
113
+ try {
114
+ const message = error instanceof Error ? error.message : String(error);
115
+ const stack = error instanceof Error ? error.stack : undefined;
116
+ console.error(`[${SERVICE_NAME}] Error:`, message, context || '');
117
+ if (stack && process.env.NODE_ENV === 'development') {
118
+ console.error(stack);
119
+ }
120
+ } catch {
121
+ // Silent fail — observability must never crash the app
122
+ }
123
+ }
124
+
125
+ // ─── Helpers ─────────────────────────────────────────────────
126
+
127
+ function inferCategory(action: string): string {
128
+ if (action.startsWith('admin.')) return 'admin';
129
+ if (action.startsWith('payment.')) return 'billing';
130
+ if (action.startsWith('account.')) return 'data_mutation';
131
+ if (action.startsWith('auth.')) return 'authentication';
132
+ if (action.startsWith('cron.')) return 'system';
133
+ if (action.startsWith('security.')) return 'security';
134
+ return 'general';
135
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Stripe Webhook Handler
3
+ *
4
+ * Handles Stripe webhook events for subscription lifecycle management.
5
+ * Uses HMAC signature verification and Redis-based idempotency.
6
+ *
7
+ * Required env vars:
8
+ * STRIPE_SECRET_KEY - Stripe API key
9
+ * STRIPE_WEBHOOK_SECRET - Webhook signing secret (whsec_...)
10
+ */
11
+ import { NextRequest, NextResponse } from 'next/server';
12
+ import Stripe from 'stripe';
13
+ import { getRedisClient } from '@/lib/redis';
14
+ import { enforceRateLimit, AppRateLimits } from '@/lib/api-security';
15
+
16
+ export const dynamic = 'force-dynamic';
17
+
18
+ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
19
+ apiVersion: '2024-12-18.acacia',
20
+ });
21
+
22
+ /**
23
+ * Check if this webhook event has already been processed (idempotency).
24
+ * Uses Redis SET NX + EX pattern — atomic check-and-set with 24h TTL.
25
+ * Returns true if already processed, false if new.
26
+ */
27
+ async function isEventProcessed(eventId: string): Promise<boolean> {
28
+ const redis = getRedisClient();
29
+ if (!redis) return false; // No Redis — allow processing (best-effort)
30
+ try {
31
+ const result = await redis.set(`webhook:${eventId}`, '1', 'EX', 86400, 'NX');
32
+ return result === null; // null = key already existed = already processed
33
+ } catch {
34
+ return false; // Redis error — allow processing
35
+ }
36
+ }
37
+
38
+ export async function POST(request: NextRequest) {
39
+ // Rate limit webhook calls (generous — Stripe retries on failure)
40
+ const rateLimited = await enforceRateLimit(request, 'stripe-webhook', AppRateLimits.webhook);
41
+ if (rateLimited) return rateLimited;
42
+
43
+ const body = await request.text();
44
+ const signature = request.headers.get('stripe-signature');
45
+
46
+ if (!signature) {
47
+ return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
48
+ }
49
+
50
+ let event: Stripe.Event;
51
+ try {
52
+ event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
53
+ } catch (err) {
54
+ console.error('[Stripe Webhook] Signature verification failed:', err);
55
+ return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
56
+ }
57
+
58
+ // Idempotency check — prevent duplicate processing
59
+ if (await isEventProcessed(event.id)) {
60
+ return NextResponse.json({ received: true, duplicate: true });
61
+ }
62
+
63
+ try {
64
+ switch (event.type) {
65
+ case 'checkout.session.completed': {
66
+ const session = event.data.object as Stripe.Checkout.Session;
67
+ console.log('[Stripe Webhook] Checkout completed:', session.id);
68
+ // TODO: Activate subscription, provision access, send welcome email
69
+ break;
70
+ }
71
+
72
+ case 'invoice.paid': {
73
+ const invoice = event.data.object as Stripe.Invoice;
74
+ console.log('[Stripe Webhook] Invoice paid:', invoice.id);
75
+ // TODO: Record payment, extend subscription
76
+ break;
77
+ }
78
+
79
+ case 'invoice.payment_failed': {
80
+ const invoice = event.data.object as Stripe.Invoice;
81
+ console.log('[Stripe Webhook] Payment failed:', invoice.id);
82
+ // TODO: Notify user, start grace period
83
+ break;
84
+ }
85
+
86
+ case 'customer.subscription.updated': {
87
+ const subscription = event.data.object as Stripe.Subscription;
88
+ console.log('[Stripe Webhook] Subscription updated:', subscription.id);
89
+ // TODO: Handle plan changes, cancellation scheduling
90
+ break;
91
+ }
92
+
93
+ case 'customer.subscription.deleted': {
94
+ const subscription = event.data.object as Stripe.Subscription;
95
+ console.log('[Stripe Webhook] Subscription deleted:', subscription.id);
96
+ // TODO: Revoke access, send cancellation email
97
+ break;
98
+ }
99
+
100
+ default:
101
+ console.log(`[Stripe Webhook] Unhandled event type: ${event.type}`);
102
+ }
103
+
104
+ return NextResponse.json({ received: true });
105
+ } catch (error) {
106
+ console.error('[Stripe Webhook] Handler error:', error);
107
+ return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 });
108
+ }
109
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Platform-Core Singleton
3
+ *
4
+ * Lazy-initialized platform instance with adapter auto-selection from env vars.
5
+ * Uses a promise-based lock to prevent race conditions during initialization.
6
+ *
7
+ * Usage:
8
+ * import { getPlatform } from '@/lib/platform';
9
+ * const platform = await getPlatform();
10
+ * await platform.cache.set('key', value, 3600);
11
+ */
12
+ import 'server-only';
13
+ import { createPlatformAsync, type IPlatform } from '@digilogiclabs/platform-core';
14
+
15
+ let _platform: IPlatform | null = null;
16
+ let _initPromise: Promise<IPlatform> | null = null;
17
+
18
+ /**
19
+ * Get or initialize the platform singleton.
20
+ * Adapters are selected automatically based on environment variables:
21
+ * - DATABASE_URL → PostgreSQL adapter
22
+ * - REDIS_URL → Redis cache adapter
23
+ * - RESEND_API_KEY → Resend email adapter
24
+ * Falls back to memory adapters when env vars are not set.
25
+ */
26
+ export async function getPlatform(): Promise<IPlatform> {
27
+ if (_platform) return _platform;
28
+
29
+ if (!_initPromise) {
30
+ _initPromise = createPlatformAsync().then((p) => {
31
+ _platform = p;
32
+ return p;
33
+ });
34
+ }
35
+
36
+ return _initPromise;
37
+ }
@@ -0,0 +1,18 @@
1
+ import 'server-only';
2
+ import { createRedisRateLimitStore } from '@digilogiclabs/platform-core/auth';
3
+ import type { RateLimitStore } from '@digilogiclabs/platform-core/auth';
4
+ import { getRedisClient } from './redis';
5
+
6
+ let _store: RateLimitStore | null = null;
7
+
8
+ /**
9
+ * Returns a Redis-backed rate limit store, or undefined to fall back to in-memory.
10
+ * The store is created lazily on first call and reused thereafter.
11
+ */
12
+ export function getRateLimitStore(): RateLimitStore | undefined {
13
+ if (_store) return _store;
14
+ const redis = getRedisClient();
15
+ if (!redis) return undefined; // falls back to in-memory in enforceRateLimit
16
+ _store = createRedisRateLimitStore(redis, { keyPrefix: 'rl:' });
17
+ return _store;
18
+ }