@digilogiclabs/create-saas-app 2.1.0 → 2.2.0
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.
- package/CHANGELOG.md +7 -0
- package/README.md +975 -891
- package/dist/.tsbuildinfo +1 -1
- package/dist/generators/template-generator.d.ts +11 -0
- package/dist/generators/template-generator.d.ts.map +1 -1
- package/dist/generators/template-generator.js +360 -16
- package/dist/generators/template-generator.js.map +1 -1
- package/dist/index.js +1837 -0
- package/dist/index.js.map +1 -0
- package/dist/templates/shared/admin/web/app/admin/layout.tsx +34 -0
- package/dist/templates/shared/admin/web/components/admin-nav.tsx +48 -0
- package/dist/templates/shared/audit/web/lib/audit.ts +24 -0
- package/dist/templates/shared/auth/keycloak/web/app/api/auth/federated-logout/route.ts +173 -0
- package/dist/templates/shared/auth/keycloak/web/auth.config.ts +84 -0
- package/dist/templates/shared/auth/keycloak/web/auth.ts +26 -0
- package/dist/templates/shared/beta/web/app/api/beta-settings/route.ts +25 -0
- package/dist/templates/shared/beta/web/app/api/validate-beta-code/route.ts +67 -0
- package/dist/templates/shared/beta/web/lib/beta/settings.ts +31 -0
- package/dist/templates/shared/cache/web/lib/cache.ts +44 -0
- package/dist/templates/shared/config/web/lib/config.ts +112 -0
- package/dist/templates/shared/config/web/next.config.mjs +62 -0
- package/dist/templates/shared/contact/web/app/api/contact/route.ts +113 -0
- package/dist/templates/shared/contact/web/app/contact/page.tsx +195 -0
- package/dist/templates/shared/cookie-consent/web/components/cookie-consent.tsx +54 -0
- package/dist/templates/shared/database/postgresql/web/drizzle.config.ts +16 -0
- package/dist/templates/shared/database/postgresql/web/lib/db/drizzle.ts +39 -0
- package/dist/templates/shared/database/postgresql/web/lib/db/schema.ts +33 -0
- package/dist/templates/shared/database/supabase/web/lib/supabase/client.ts +12 -0
- package/dist/templates/shared/database/supabase/web/lib/supabase/server.ts +31 -0
- package/dist/templates/shared/database/supabase/web/lib/supabase/service.ts +15 -0
- package/dist/templates/shared/email/web/lib/email/branding.ts +18 -0
- package/dist/templates/shared/email/web/lib/email/client.ts +96 -0
- package/dist/templates/shared/error-pages/web/app/error.tsx +70 -0
- package/dist/templates/shared/error-pages/web/app/global-error.tsx +102 -0
- package/dist/templates/shared/error-pages/web/app/not-found.tsx +39 -0
- package/dist/templates/shared/health/web/app/api/health/route.ts +68 -0
- package/dist/templates/shared/legal/web/app/(legal)/privacy/page.tsx +205 -0
- package/dist/templates/shared/legal/web/app/(legal)/terms/page.tsx +154 -0
- package/dist/templates/shared/legal/web/lib/legal-config.ts +50 -0
- package/dist/templates/shared/loading/web/app/loading.tsx +5 -0
- package/dist/templates/shared/loading/web/components/skeleton.tsx +95 -0
- package/dist/templates/shared/middleware/web/middleware.ts +68 -0
- package/dist/templates/shared/observability/web/lib/observability.ts +135 -0
- package/dist/templates/shared/payments/web/app/api/webhooks/stripe/route.ts +109 -0
- package/dist/templates/shared/platform/web/lib/platform.ts +37 -0
- package/dist/templates/shared/redis/web/lib/rate-limit-store.ts +18 -0
- package/dist/templates/shared/redis/web/lib/redis.ts +48 -0
- package/dist/templates/shared/security/web/lib/api-security.ts +318 -0
- package/dist/templates/shared/seo/web/app/api/og/route.tsx +97 -0
- package/dist/templates/shared/seo/web/app/robots.ts +53 -0
- package/dist/templates/shared/seo/web/app/sitemap.ts +53 -0
- package/dist/templates/shared/utils/web/lib/api-response.ts +71 -0
- package/dist/templates/shared/utils/web/lib/utils.ts +85 -0
- package/package.json +5 -4
- package/src/templates/shared/admin/web/app/admin/layout.tsx +34 -0
- package/src/templates/shared/admin/web/components/admin-nav.tsx +48 -0
- package/src/templates/shared/audit/web/lib/audit.ts +24 -0
- package/src/templates/shared/auth/keycloak/web/app/api/auth/federated-logout/route.ts +173 -0
- package/src/templates/shared/auth/keycloak/web/auth.config.ts +84 -0
- package/src/templates/shared/auth/keycloak/web/auth.ts +26 -0
- package/src/templates/shared/beta/web/app/api/beta-settings/route.ts +25 -0
- package/src/templates/shared/beta/web/app/api/validate-beta-code/route.ts +67 -0
- package/src/templates/shared/beta/web/lib/beta/settings.ts +31 -0
- package/src/templates/shared/cache/web/lib/cache.ts +44 -0
- package/src/templates/shared/config/web/lib/config.ts +112 -0
- package/src/templates/shared/config/web/next.config.mjs +62 -0
- package/src/templates/shared/contact/web/app/api/contact/route.ts +113 -0
- package/src/templates/shared/contact/web/app/contact/page.tsx +195 -0
- package/src/templates/shared/cookie-consent/web/components/cookie-consent.tsx +54 -0
- package/src/templates/shared/database/postgresql/web/drizzle.config.ts +16 -0
- package/src/templates/shared/database/postgresql/web/lib/db/drizzle.ts +39 -0
- package/src/templates/shared/database/postgresql/web/lib/db/schema.ts +33 -0
- package/src/templates/shared/database/supabase/web/lib/supabase/client.ts +12 -0
- package/src/templates/shared/database/supabase/web/lib/supabase/server.ts +31 -0
- package/src/templates/shared/database/supabase/web/lib/supabase/service.ts +15 -0
- package/src/templates/shared/email/web/lib/email/branding.ts +18 -0
- package/src/templates/shared/email/web/lib/email/client.ts +96 -0
- package/src/templates/shared/error-pages/web/app/error.tsx +70 -0
- package/src/templates/shared/error-pages/web/app/global-error.tsx +102 -0
- package/src/templates/shared/error-pages/web/app/not-found.tsx +39 -0
- package/src/templates/shared/health/web/app/api/health/route.ts +68 -0
- package/src/templates/shared/legal/web/app/(legal)/privacy/page.tsx +205 -0
- package/src/templates/shared/legal/web/app/(legal)/terms/page.tsx +154 -0
- package/src/templates/shared/legal/web/lib/legal-config.ts +50 -0
- package/src/templates/shared/loading/web/app/loading.tsx +5 -0
- package/src/templates/shared/loading/web/components/skeleton.tsx +95 -0
- package/src/templates/shared/middleware/web/middleware.ts +68 -0
- package/src/templates/shared/observability/web/lib/observability.ts +135 -0
- package/src/templates/shared/payments/web/app/api/webhooks/stripe/route.ts +109 -0
- package/src/templates/shared/platform/web/lib/platform.ts +37 -0
- package/src/templates/shared/redis/web/lib/rate-limit-store.ts +18 -0
- package/src/templates/shared/redis/web/lib/redis.ts +48 -0
- package/src/templates/shared/security/web/lib/api-security.ts +318 -0
- package/src/templates/shared/seo/web/app/api/og/route.tsx +97 -0
- package/src/templates/shared/seo/web/app/robots.ts +53 -0
- package/src/templates/shared/seo/web/app/sitemap.ts +53 -0
- package/src/templates/shared/utils/web/lib/api-response.ts +71 -0
- package/src/templates/shared/utils/web/lib/utils.ts +85 -0
- package/dist/cli/commands/add.d.ts +0 -6
- package/dist/cli/commands/add.d.ts.map +0 -1
- package/dist/cli/commands/add.js +0 -39
- package/dist/cli/commands/add.js.map +0 -1
- package/dist/cli/commands/create.d.ts +0 -45
- package/dist/cli/commands/create.d.ts.map +0 -1
- package/dist/cli/commands/create.js +0 -175
- package/dist/cli/commands/create.js.map +0 -1
- package/dist/cli/commands/index.d.ts +0 -4
- package/dist/cli/commands/index.d.ts.map +0 -1
- package/dist/cli/commands/index.js +0 -20
- package/dist/cli/commands/index.js.map +0 -1
- package/dist/cli/commands/update.d.ts +0 -6
- package/dist/cli/commands/update.d.ts.map +0 -1
- package/dist/cli/commands/update.js +0 -68
- package/dist/cli/commands/update.js.map +0 -1
- package/dist/cli/index.d.ts +0 -4
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/index.js +0 -61
- package/dist/cli/index.js.map +0 -1
- package/dist/cli/prompts/index.d.ts +0 -2
- package/dist/cli/prompts/index.d.ts.map +0 -1
- package/dist/cli/prompts/index.js +0 -18
- package/dist/cli/prompts/index.js.map +0 -1
- package/dist/cli/prompts/project-setup.d.ts +0 -5
- package/dist/cli/prompts/project-setup.d.ts.map +0 -1
- package/dist/cli/prompts/project-setup.js +0 -351
- package/dist/cli/prompts/project-setup.js.map +0 -1
- package/dist/cli/utils/git.d.ts +0 -9
- package/dist/cli/utils/git.d.ts.map +0 -1
- package/dist/cli/utils/git.js +0 -77
- package/dist/cli/utils/git.js.map +0 -1
- package/dist/cli/utils/index.d.ts +0 -5
- package/dist/cli/utils/index.d.ts.map +0 -1
- package/dist/cli/utils/index.js +0 -21
- package/dist/cli/utils/index.js.map +0 -1
- package/dist/cli/utils/logger.d.ts +0 -16
- package/dist/cli/utils/logger.d.ts.map +0 -1
- package/dist/cli/utils/logger.js +0 -55
- package/dist/cli/utils/logger.js.map +0 -1
- package/dist/cli/utils/package-manager.d.ts +0 -8
- package/dist/cli/utils/package-manager.d.ts.map +0 -1
- package/dist/cli/utils/package-manager.js +0 -92
- package/dist/cli/utils/package-manager.js.map +0 -1
- package/dist/cli/utils/spinner.d.ts +0 -7
- package/dist/cli/utils/spinner.d.ts.map +0 -1
- package/dist/cli/utils/spinner.js +0 -48
- package/dist/cli/utils/spinner.js.map +0 -1
- package/dist/cli/validators/dependencies.d.ts +0 -15
- package/dist/cli/validators/dependencies.d.ts.map +0 -1
- package/dist/cli/validators/dependencies.js +0 -108
- package/dist/cli/validators/dependencies.js.map +0 -1
- package/dist/cli/validators/index.d.ts +0 -3
- package/dist/cli/validators/index.d.ts.map +0 -1
- package/dist/cli/validators/index.js +0 -19
- package/dist/cli/validators/index.js.map +0 -1
- package/dist/cli/validators/project-name.d.ts +0 -5
- package/dist/cli/validators/project-name.d.ts.map +0 -1
- package/dist/cli/validators/project-name.js +0 -151
- package/dist/cli/validators/project-name.js.map +0 -1
- package/dist/generators/file-processor.d.ts +0 -28
- package/dist/generators/file-processor.d.ts.map +0 -1
- package/dist/generators/file-processor.js +0 -224
- package/dist/generators/file-processor.js.map +0 -1
- package/dist/generators/index.d.ts +0 -4
- package/dist/generators/index.d.ts.map +0 -1
- package/dist/generators/index.js +0 -20
- package/dist/generators/index.js.map +0 -1
- package/dist/generators/package-installer.d.ts +0 -29
- package/dist/generators/package-installer.d.ts.map +0 -1
- package/dist/generators/package-installer.js +0 -177
- package/dist/generators/package-installer.js.map +0 -1
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared API security utilities.
|
|
3
|
+
*
|
|
4
|
+
* Built on platform-core/auth building blocks — no local reimplementations.
|
|
5
|
+
* Uses constantTimeEqual, classifyError, rate limiting, and audit from the package.
|
|
6
|
+
*
|
|
7
|
+
* Two usage patterns:
|
|
8
|
+
* 1. Wrappers: withPublicApi / withAuthenticatedApi / withAdminApi (recommended)
|
|
9
|
+
* 2. Primitives: enforceRateLimit, isAdminRequest, errorResponse (manual composition)
|
|
10
|
+
*/
|
|
11
|
+
import 'server-only';
|
|
12
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
13
|
+
import { randomUUID } from 'crypto';
|
|
14
|
+
import {
|
|
15
|
+
// Security primitives
|
|
16
|
+
constantTimeEqual,
|
|
17
|
+
// Rate limiting
|
|
18
|
+
CommonRateLimits,
|
|
19
|
+
type RateLimitRule,
|
|
20
|
+
type RateLimitOptions,
|
|
21
|
+
// Next.js API helpers
|
|
22
|
+
enforceRateLimit as _enforceRateLimit,
|
|
23
|
+
errorResponse,
|
|
24
|
+
zodErrorResponse,
|
|
25
|
+
classifyError,
|
|
26
|
+
} from '@digilogiclabs/platform-core/auth';
|
|
27
|
+
|
|
28
|
+
// Trigger env validation on first import (fail-fast in production)
|
|
29
|
+
import { config } from '@/lib/config';
|
|
30
|
+
|
|
31
|
+
// Re-export for convenience in routes
|
|
32
|
+
export { escapeHtml } from '@digilogiclabs/platform-core/auth';
|
|
33
|
+
export { errorResponse, zodErrorResponse };
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Request ID / Correlation ID
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/** Generate or extract a request ID for correlation. */
|
|
40
|
+
export function getRequestId(request: NextRequest): string {
|
|
41
|
+
return request.headers.get('x-request-id') || randomUUID();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Rate-limit a request using Redis-backed store (if REDIS_URL is set)
|
|
46
|
+
* or in-memory fallback. Wraps platform-core's enforceRateLimit to
|
|
47
|
+
* automatically inject the store.
|
|
48
|
+
*/
|
|
49
|
+
export async function enforceRateLimit(
|
|
50
|
+
request: { headers: { get(name: string): string | null } },
|
|
51
|
+
operation: string,
|
|
52
|
+
rule: RateLimitRule,
|
|
53
|
+
options?: {
|
|
54
|
+
identifier?: string;
|
|
55
|
+
userId?: string;
|
|
56
|
+
rateLimitOptions?: RateLimitOptions;
|
|
57
|
+
}
|
|
58
|
+
): Promise<Response | null> {
|
|
59
|
+
const { getRateLimitStore } = await import('@/lib/rate-limit-store');
|
|
60
|
+
const store = getRateLimitStore();
|
|
61
|
+
return _enforceRateLimit(request, operation, rule, {
|
|
62
|
+
...options,
|
|
63
|
+
rateLimitOptions: { ...options?.rateLimitOptions, ...(store ? { store } : {}) },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Admin / Cron auth helpers
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/** Extract bearer token from Authorization header. */
|
|
72
|
+
function extractBearerToken(request: NextRequest): string | null {
|
|
73
|
+
const header = request.headers.get('authorization');
|
|
74
|
+
if (!header?.startsWith('Bearer ')) return null;
|
|
75
|
+
return header.slice(7);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Check if request has a valid admin bearer token (ADMIN_SECRET). Timing-safe. */
|
|
79
|
+
export function isAdminRequest(request: NextRequest): boolean {
|
|
80
|
+
const secret = config.adminSecret;
|
|
81
|
+
if (!secret) return false;
|
|
82
|
+
const token = extractBearerToken(request);
|
|
83
|
+
if (!token) return false;
|
|
84
|
+
return constantTimeEqual(token, secret);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Check if request has a valid cron bearer token (CRON_SECRET). Timing-safe. */
|
|
88
|
+
export function isCronRequest(request: NextRequest): boolean {
|
|
89
|
+
const secret = config.cronSecret;
|
|
90
|
+
if (!secret) return false;
|
|
91
|
+
const token = extractBearerToken(request);
|
|
92
|
+
if (!token) return false;
|
|
93
|
+
return constantTimeEqual(token, secret);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Rate limiting presets — tune for your app's traffic patterns
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/** App-specific rate limit presets. Extend or override as needed. */
|
|
101
|
+
export const AppRateLimits = {
|
|
102
|
+
/** Public read endpoints */
|
|
103
|
+
publicRead: { limit: 60, windowSeconds: 60 } satisfies RateLimitRule,
|
|
104
|
+
/** Authenticated mutations */
|
|
105
|
+
authMutation: { limit: 30, windowSeconds: 60 } satisfies RateLimitRule,
|
|
106
|
+
/** Admin endpoints */
|
|
107
|
+
admin: CommonRateLimits.adminAction,
|
|
108
|
+
/** Beta code validation */
|
|
109
|
+
betaValidation: CommonRateLimits.betaValidation,
|
|
110
|
+
/** Webhook endpoints (generous — Stripe retries) */
|
|
111
|
+
webhook: { limit: 100, windowSeconds: 60 } satisfies RateLimitRule,
|
|
112
|
+
} as const;
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// API Wrapper Types
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
interface ApiWrapperConfig {
|
|
119
|
+
/** Rate limit rule (defaults to preset based on wrapper type) */
|
|
120
|
+
rateLimit?: RateLimitRule;
|
|
121
|
+
/** Operation name for rate limiting and logging */
|
|
122
|
+
operation?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface AuthenticatedApiContext {
|
|
126
|
+
/** The authenticated session */
|
|
127
|
+
session: { user: { id: string; email: string; roles?: string[] } };
|
|
128
|
+
/** Request correlation ID */
|
|
129
|
+
requestId: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface PublicApiContext {
|
|
133
|
+
/** Request correlation ID */
|
|
134
|
+
requestId: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type PublicApiHandler = (request: NextRequest, context: PublicApiContext) => Promise<NextResponse>;
|
|
138
|
+
|
|
139
|
+
type AuthenticatedApiHandler = (
|
|
140
|
+
request: NextRequest,
|
|
141
|
+
context: AuthenticatedApiContext
|
|
142
|
+
) => Promise<NextResponse>;
|
|
143
|
+
|
|
144
|
+
type AdminApiHandler = (request: NextRequest, context: PublicApiContext) => Promise<NextResponse>;
|
|
145
|
+
|
|
146
|
+
type CronApiHandler = (request: NextRequest, context: PublicApiContext) => Promise<NextResponse>;
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// API Wrappers — compose auth, rate limiting, error handling automatically
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Wrap a public API route with rate limiting and error handling.
|
|
154
|
+
* No authentication required.
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* export const GET = withPublicApi({ operation: 'list-items' }, async (req, ctx) => {
|
|
158
|
+
* return NextResponse.json({ items: [] });
|
|
159
|
+
* });
|
|
160
|
+
*/
|
|
161
|
+
export function withPublicApi(
|
|
162
|
+
handlerConfig: ApiWrapperConfig,
|
|
163
|
+
handler: PublicApiHandler
|
|
164
|
+
): (request: NextRequest) => Promise<NextResponse> {
|
|
165
|
+
return async (request: NextRequest) => {
|
|
166
|
+
const requestId = getRequestId(request);
|
|
167
|
+
const operation = handlerConfig.operation || 'public';
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
// Rate limiting
|
|
171
|
+
const rateLimited = await enforceRateLimit(
|
|
172
|
+
request,
|
|
173
|
+
operation,
|
|
174
|
+
handlerConfig.rateLimit || AppRateLimits.publicRead
|
|
175
|
+
);
|
|
176
|
+
if (rateLimited) return rateLimited as NextResponse;
|
|
177
|
+
|
|
178
|
+
const response = await handler(request, { requestId });
|
|
179
|
+
response.headers.set('x-request-id', requestId);
|
|
180
|
+
return response;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const { status, body } = classifyError(error, process.env.NODE_ENV === 'development');
|
|
183
|
+
return NextResponse.json({ ...body, requestId }, { status });
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Wrap an authenticated API route with session validation, rate limiting, and error handling.
|
|
190
|
+
* Requires a valid Auth.js session.
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* export const POST = withAuthenticatedApi({ operation: 'create-item' }, async (req, ctx) => {
|
|
194
|
+
* const { session, requestId } = ctx;
|
|
195
|
+
* return NextResponse.json({ userId: session.user.id });
|
|
196
|
+
* });
|
|
197
|
+
*/
|
|
198
|
+
export function withAuthenticatedApi(
|
|
199
|
+
handlerConfig: ApiWrapperConfig,
|
|
200
|
+
handler: AuthenticatedApiHandler
|
|
201
|
+
): (request: NextRequest) => Promise<NextResponse> {
|
|
202
|
+
return async (request: NextRequest) => {
|
|
203
|
+
const requestId = getRequestId(request);
|
|
204
|
+
const operation = handlerConfig.operation || 'authenticated';
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
// Dynamic import to avoid Edge runtime issues
|
|
208
|
+
const { auth } = await import('@/auth');
|
|
209
|
+
const session = await auth();
|
|
210
|
+
|
|
211
|
+
if (!session?.user?.id) {
|
|
212
|
+
return NextResponse.json({ error: 'Unauthorized', requestId }, { status: 401 });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Rate limiting (per-user)
|
|
216
|
+
const rateLimited = await enforceRateLimit(
|
|
217
|
+
request,
|
|
218
|
+
operation,
|
|
219
|
+
handlerConfig.rateLimit || AppRateLimits.authMutation,
|
|
220
|
+
{ userId: session.user.id }
|
|
221
|
+
);
|
|
222
|
+
if (rateLimited) return rateLimited as NextResponse;
|
|
223
|
+
|
|
224
|
+
const response = await handler(request, {
|
|
225
|
+
session: session as AuthenticatedApiContext['session'],
|
|
226
|
+
requestId,
|
|
227
|
+
});
|
|
228
|
+
response.headers.set('x-request-id', requestId);
|
|
229
|
+
return response;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
const { status, body } = classifyError(error, process.env.NODE_ENV === 'development');
|
|
232
|
+
return NextResponse.json({ ...body, requestId }, { status });
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Wrap an admin API route with Bearer token auth, rate limiting, and error handling.
|
|
239
|
+
* Requires ADMIN_SECRET Bearer token.
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* export const POST = withAdminApi({ operation: 'admin-action' }, async (req, ctx) => {
|
|
243
|
+
* return NextResponse.json({ success: true });
|
|
244
|
+
* });
|
|
245
|
+
*/
|
|
246
|
+
export function withAdminApi(
|
|
247
|
+
handlerConfig: ApiWrapperConfig,
|
|
248
|
+
handler: AdminApiHandler
|
|
249
|
+
): (request: NextRequest) => Promise<NextResponse> {
|
|
250
|
+
return async (request: NextRequest) => {
|
|
251
|
+
const requestId = getRequestId(request);
|
|
252
|
+
const operation = handlerConfig.operation || 'admin';
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
if (!isAdminRequest(request)) {
|
|
256
|
+
return NextResponse.json({ error: 'Forbidden', requestId }, { status: 403 });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Rate limiting
|
|
260
|
+
const rateLimited = await enforceRateLimit(
|
|
261
|
+
request,
|
|
262
|
+
operation,
|
|
263
|
+
handlerConfig.rateLimit || AppRateLimits.admin
|
|
264
|
+
);
|
|
265
|
+
if (rateLimited) return rateLimited as NextResponse;
|
|
266
|
+
|
|
267
|
+
const response = await handler(request, { requestId });
|
|
268
|
+
response.headers.set('x-request-id', requestId);
|
|
269
|
+
return response;
|
|
270
|
+
} catch (error) {
|
|
271
|
+
const { status, body } = classifyError(error, process.env.NODE_ENV === 'development');
|
|
272
|
+
return NextResponse.json({ ...body, requestId }, { status });
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Wrap a cron/scheduled API route with CRON_SECRET Bearer token auth,
|
|
279
|
+
* falling back to admin session check. Uses generous rate limits since
|
|
280
|
+
* cron jobs are server-to-server.
|
|
281
|
+
*
|
|
282
|
+
* @example
|
|
283
|
+
* export const POST = withCronApi({ operation: 'daily-digest' }, async (req, ctx) => {
|
|
284
|
+
* // Run scheduled task...
|
|
285
|
+
* return NextResponse.json({ processed: 42 });
|
|
286
|
+
* });
|
|
287
|
+
*/
|
|
288
|
+
export function withCronApi(
|
|
289
|
+
handlerConfig: ApiWrapperConfig,
|
|
290
|
+
handler: CronApiHandler
|
|
291
|
+
): (request: NextRequest) => Promise<NextResponse> {
|
|
292
|
+
return async (request: NextRequest) => {
|
|
293
|
+
const requestId = getRequestId(request);
|
|
294
|
+
const operation = handlerConfig.operation || 'cron';
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
// Accept CRON_SECRET Bearer token or fall back to ADMIN_SECRET
|
|
298
|
+
if (!isCronRequest(request) && !isAdminRequest(request)) {
|
|
299
|
+
return NextResponse.json({ error: 'Forbidden', requestId }, { status: 403 });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Rate limiting (generous — cron jobs are server-to-server)
|
|
303
|
+
const rateLimited = await enforceRateLimit(
|
|
304
|
+
request,
|
|
305
|
+
operation,
|
|
306
|
+
handlerConfig.rateLimit || AppRateLimits.webhook
|
|
307
|
+
);
|
|
308
|
+
if (rateLimited) return rateLimited as NextResponse;
|
|
309
|
+
|
|
310
|
+
const response = await handler(request, { requestId });
|
|
311
|
+
response.headers.set('x-request-id', requestId);
|
|
312
|
+
return response;
|
|
313
|
+
} catch (error) {
|
|
314
|
+
const { status, body } = classifyError(error, process.env.NODE_ENV === 'development');
|
|
315
|
+
return NextResponse.json({ ...body, requestId }, { status });
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { ImageResponse } from 'next/og';
|
|
2
|
+
import { NextRequest } from 'next/server';
|
|
3
|
+
|
|
4
|
+
export const runtime = 'edge';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Dynamic Open Graph image generator.
|
|
8
|
+
*
|
|
9
|
+
* Usage: /api/og?title=My+Page&subtitle=Description+here
|
|
10
|
+
*
|
|
11
|
+
* Customize the colors and branding below to match your app.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ─── Customize these ────────────────────────────────────────
|
|
15
|
+
const APP_NAME = 'My App';
|
|
16
|
+
const BG_FROM = '#1e3a5f';
|
|
17
|
+
const BG_TO = '#0f172a';
|
|
18
|
+
const ACCENT = '#3b82f6';
|
|
19
|
+
// ─────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export async function GET(request: NextRequest) {
|
|
22
|
+
const { searchParams } = request.nextUrl;
|
|
23
|
+
const title = searchParams.get('title') || APP_NAME;
|
|
24
|
+
const subtitle = searchParams.get('subtitle') || '';
|
|
25
|
+
|
|
26
|
+
return new ImageResponse(
|
|
27
|
+
(
|
|
28
|
+
<div
|
|
29
|
+
style={{
|
|
30
|
+
width: '100%',
|
|
31
|
+
height: '100%',
|
|
32
|
+
display: 'flex',
|
|
33
|
+
flexDirection: 'column',
|
|
34
|
+
justifyContent: 'center',
|
|
35
|
+
padding: '60px 80px',
|
|
36
|
+
background: `linear-gradient(135deg, ${BG_FROM} 0%, ${BG_TO} 100%)`,
|
|
37
|
+
color: 'white',
|
|
38
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
{/* App branding */}
|
|
42
|
+
<div
|
|
43
|
+
style={{
|
|
44
|
+
display: 'flex',
|
|
45
|
+
alignItems: 'center',
|
|
46
|
+
marginBottom: 40,
|
|
47
|
+
fontSize: 24,
|
|
48
|
+
opacity: 0.8,
|
|
49
|
+
letterSpacing: '0.05em',
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
{APP_NAME}
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
{/* Title */}
|
|
56
|
+
<div
|
|
57
|
+
style={{
|
|
58
|
+
fontSize: title.length > 40 ? 48 : 64,
|
|
59
|
+
fontWeight: 700,
|
|
60
|
+
lineHeight: 1.1,
|
|
61
|
+
maxWidth: '90%',
|
|
62
|
+
marginBottom: subtitle ? 24 : 0,
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{title.length > 80 ? title.slice(0, 77) + '...' : title}
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{/* Subtitle */}
|
|
69
|
+
{subtitle && (
|
|
70
|
+
<div
|
|
71
|
+
style={{
|
|
72
|
+
fontSize: 28,
|
|
73
|
+
opacity: 0.7,
|
|
74
|
+
maxWidth: '80%',
|
|
75
|
+
lineHeight: 1.3,
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{subtitle.length > 120 ? subtitle.slice(0, 117) + '...' : subtitle}
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
{/* Accent bar */}
|
|
83
|
+
<div
|
|
84
|
+
style={{
|
|
85
|
+
position: 'absolute',
|
|
86
|
+
bottom: 0,
|
|
87
|
+
left: 0,
|
|
88
|
+
right: 0,
|
|
89
|
+
height: 6,
|
|
90
|
+
background: ACCENT,
|
|
91
|
+
}}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
),
|
|
95
|
+
{ width: 1200, height: 630 },
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { MetadataRoute } from 'next';
|
|
2
|
+
|
|
3
|
+
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Robots.txt configuration.
|
|
7
|
+
*
|
|
8
|
+
* Controls which paths search engines and AI crawlers can access.
|
|
9
|
+
* Adjust the rules to match your app's public and private routes.
|
|
10
|
+
*
|
|
11
|
+
* @see https://nextjs.org/docs/app/api-reference/file-conventions/metadata/robots
|
|
12
|
+
*/
|
|
13
|
+
export default function robots(): MetadataRoute.Robots {
|
|
14
|
+
return {
|
|
15
|
+
rules: [
|
|
16
|
+
// ─── Main search engines (Google, Bing, etc.) ──────────────
|
|
17
|
+
{
|
|
18
|
+
userAgent: ['Googlebot', 'Bingbot', 'Slurp', 'DuckDuckBot', 'Baiduspider', 'YandexBot'],
|
|
19
|
+
allow: '/',
|
|
20
|
+
disallow: ['/api/', '/dashboard/', '/admin/', '/auth/'],
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
// ─── AI crawlers (allow indexing, block private routes) ────
|
|
24
|
+
{
|
|
25
|
+
userAgent: [
|
|
26
|
+
'GPTBot',
|
|
27
|
+
'Google-Extended',
|
|
28
|
+
'ChatGPT-User',
|
|
29
|
+
'ClaudeBot',
|
|
30
|
+
'PerplexityBot',
|
|
31
|
+
'Amazonbot',
|
|
32
|
+
'YouBot',
|
|
33
|
+
],
|
|
34
|
+
allow: '/',
|
|
35
|
+
disallow: ['/api/', '/dashboard/', '/admin/', '/auth/'],
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// ─── Training-only crawlers (block entirely) ───────────────
|
|
39
|
+
{
|
|
40
|
+
userAgent: ['CCBot', 'anthropic-ai'],
|
|
41
|
+
disallow: '/',
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
// ─── Default (allow public content) ────────────────────────
|
|
45
|
+
{
|
|
46
|
+
userAgent: '*',
|
|
47
|
+
allow: '/',
|
|
48
|
+
disallow: ['/api/', '/dashboard/', '/admin/', '/auth/'],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
sitemap: `${BASE_URL}/sitemap.xml`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { MetadataRoute } from 'next';
|
|
2
|
+
|
|
3
|
+
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Dynamic sitemap generator.
|
|
7
|
+
*
|
|
8
|
+
* Generates a sitemap for search engine crawlers.
|
|
9
|
+
* Add your app's dynamic routes (e.g., user profiles, blog posts)
|
|
10
|
+
* alongside the static pages.
|
|
11
|
+
*
|
|
12
|
+
* @see https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap
|
|
13
|
+
*/
|
|
14
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
15
|
+
const now = new Date();
|
|
16
|
+
|
|
17
|
+
// ─── Static Pages ────────────────────────────────────────────
|
|
18
|
+
const staticPages: MetadataRoute.Sitemap = [
|
|
19
|
+
{
|
|
20
|
+
url: BASE_URL,
|
|
21
|
+
lastModified: now,
|
|
22
|
+
changeFrequency: 'daily',
|
|
23
|
+
priority: 1.0,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
url: `${BASE_URL}/terms`,
|
|
27
|
+
lastModified: now,
|
|
28
|
+
changeFrequency: 'monthly',
|
|
29
|
+
priority: 0.3,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
url: `${BASE_URL}/privacy`,
|
|
33
|
+
lastModified: now,
|
|
34
|
+
changeFrequency: 'monthly',
|
|
35
|
+
priority: 0.3,
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// ─── Dynamic Pages ───────────────────────────────────────────
|
|
40
|
+
// TODO: Add your dynamic routes here. Example:
|
|
41
|
+
//
|
|
42
|
+
// const { data: posts } = await db.from('posts').select('slug, updated_at');
|
|
43
|
+
// const postPages = (posts || []).map((post) => ({
|
|
44
|
+
// url: `${BASE_URL}/blog/${post.slug}`,
|
|
45
|
+
// lastModified: new Date(post.updated_at),
|
|
46
|
+
// changeFrequency: 'weekly' as const,
|
|
47
|
+
// priority: 0.7,
|
|
48
|
+
// }));
|
|
49
|
+
//
|
|
50
|
+
// return [...staticPages, ...postPages];
|
|
51
|
+
|
|
52
|
+
return staticPages;
|
|
53
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { classifyError, buildPagination } from '@digilogiclabs/platform-core/auth';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Standardized API response helpers.
|
|
6
|
+
*
|
|
7
|
+
* Provides consistent response shapes across all API routes.
|
|
8
|
+
* Uses classifyError from platform-core for structured error handling.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Success response with optional status code */
|
|
12
|
+
export function successResponse<T>(data: T, status = 200) {
|
|
13
|
+
return NextResponse.json({ success: true, data }, { status });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Success response with a message */
|
|
17
|
+
export function successWithMessage<T>(data: T, message: string, status = 200) {
|
|
18
|
+
return NextResponse.json({ success: true, message, data }, { status });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 201 Created response */
|
|
22
|
+
export function createdResponse<T>(data: T, message = 'Created successfully') {
|
|
23
|
+
return NextResponse.json({ success: true, message, data }, { status: 201 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 204 No Content response */
|
|
27
|
+
export function noContentResponse() {
|
|
28
|
+
return new NextResponse(null, { status: 204 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 202 Accepted response (for async operations) */
|
|
32
|
+
export function acceptedResponse<T>(data: T, message = 'Request accepted') {
|
|
33
|
+
return NextResponse.json({ success: true, message, data }, { status: 202 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Error response with status code */
|
|
37
|
+
export function errorResponse(message: string, status = 500, code?: string) {
|
|
38
|
+
return NextResponse.json(
|
|
39
|
+
{ success: false, error: { message, ...(code && { code }) } },
|
|
40
|
+
{ status }
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Paginated response with metadata */
|
|
45
|
+
export function paginatedResponse<T>(data: T[], page: number, limit: number, total: number) {
|
|
46
|
+
return NextResponse.json({
|
|
47
|
+
success: true,
|
|
48
|
+
data,
|
|
49
|
+
pagination: buildPagination(page, limit, total),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Handle any error and return a structured response */
|
|
54
|
+
export function handleApiError(error: unknown) {
|
|
55
|
+
const isDev = process.env.NODE_ENV === 'development';
|
|
56
|
+
const { status, body } = classifyError(error, isDev);
|
|
57
|
+
return NextResponse.json({ success: false, error: body }, { status });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Wrap a handler with automatic error handling */
|
|
61
|
+
export function withErrorHandler(
|
|
62
|
+
handler: (request: Request) => Promise<NextResponse>
|
|
63
|
+
): (request: Request) => Promise<NextResponse> {
|
|
64
|
+
return async (request: Request) => {
|
|
65
|
+
try {
|
|
66
|
+
return await handler(request);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return handleApiError(error);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type ClassValue, clsx } from 'clsx';
|
|
2
|
+
import { twMerge } from 'tailwind-merge';
|
|
3
|
+
|
|
4
|
+
/** Merge Tailwind classes with conflict resolution */
|
|
5
|
+
export function cn(...inputs: ClassValue[]) {
|
|
6
|
+
return twMerge(clsx(inputs));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Format currency with locale support */
|
|
10
|
+
export function formatCurrency(amount: number, currency = 'USD', locale = 'en-US'): string {
|
|
11
|
+
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Format date with locale support */
|
|
15
|
+
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
|
|
16
|
+
const d = typeof date === 'string' ? new Date(date) : date;
|
|
17
|
+
return d.toLocaleDateString(
|
|
18
|
+
'en-US',
|
|
19
|
+
options || { year: 'numeric', month: 'long', day: 'numeric' }
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Get relative time string (e.g., "2 hours ago", "in 3 days") */
|
|
24
|
+
export function getRelativeTime(date: Date | string): string {
|
|
25
|
+
const d = typeof date === 'string' ? new Date(date) : date;
|
|
26
|
+
const now = new Date();
|
|
27
|
+
const diffMs = now.getTime() - d.getTime();
|
|
28
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
29
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
30
|
+
const diffHour = Math.floor(diffMin / 60);
|
|
31
|
+
const diffDay = Math.floor(diffHour / 24);
|
|
32
|
+
|
|
33
|
+
if (diffSec < 60) return 'just now';
|
|
34
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
35
|
+
if (diffHour < 24) return `${diffHour}h ago`;
|
|
36
|
+
if (diffDay < 7) return `${diffDay}d ago`;
|
|
37
|
+
if (diffDay < 30) return `${Math.floor(diffDay / 7)}w ago`;
|
|
38
|
+
return formatDate(d, {
|
|
39
|
+
month: 'short',
|
|
40
|
+
day: 'numeric',
|
|
41
|
+
year: diffDay > 365 ? 'numeric' : undefined,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Truncate text with ellipsis */
|
|
46
|
+
export function truncate(text: string, maxLength: number): string {
|
|
47
|
+
if (text.length <= maxLength) return text;
|
|
48
|
+
return text.slice(0, maxLength).trimEnd() + '...';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Get initials from a name (e.g., "John Doe" → "JD") */
|
|
52
|
+
export function getInitials(name: string, maxChars = 2): string {
|
|
53
|
+
return name
|
|
54
|
+
.split(' ')
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.map((part) => part[0]?.toUpperCase() || '')
|
|
57
|
+
.slice(0, maxChars)
|
|
58
|
+
.join('');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Debounce a function */
|
|
62
|
+
export function debounce<T extends (...args: unknown[]) => unknown>(
|
|
63
|
+
fn: T,
|
|
64
|
+
delayMs: number
|
|
65
|
+
): (...args: Parameters<T>) => void {
|
|
66
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
67
|
+
return (...args: Parameters<T>) => {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
timer = setTimeout(() => fn(...args), delayMs);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Sleep for a given number of milliseconds */
|
|
74
|
+
export function sleep(ms: number): Promise<void> {
|
|
75
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Safely parse JSON, returning null on failure */
|
|
79
|
+
export function safeJsonParse<T = unknown>(json: string): T | null {
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(json) as T;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|