create-solostack 1.2.2 → 1.3.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.
@@ -0,0 +1,464 @@
1
+ /**
2
+ * API Keys Generator - Pro Feature
3
+ * Generates API key management system with rate limiting
4
+ */
5
+
6
+ import path from 'path';
7
+ import { writeFile, ensureDir } from '../../utils/files.js';
8
+
9
+ export async function generateApiKeys(projectPath) {
10
+ await ensureDir(path.join(projectPath, 'src/app/api/keys'));
11
+ await ensureDir(path.join(projectPath, 'src/app/api/keys/create'));
12
+ await ensureDir(path.join(projectPath, 'src/app/api/keys/revoke'));
13
+ await ensureDir(path.join(projectPath, 'src/app/dashboard/api-keys'));
14
+
15
+ // Generate API key utility
16
+ const apiKeyUtil = `import crypto from 'crypto';
17
+ import { db } from '@/lib/db';
18
+
19
+ /**
20
+ * Generate a new API key
21
+ * Format: sk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
22
+ */
23
+ export function generateApiKey(): { key: string; hash: string; prefix: string } {
24
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
25
+ let randomPart = '';
26
+ for (let i = 0; i < 32; i++) {
27
+ randomPart += chars.charAt(Math.floor(Math.random() * chars.length));
28
+ }
29
+
30
+ const key = \`sk_live_\${randomPart}\`;
31
+ const hash = crypto.createHash('sha256').update(key).digest('hex');
32
+ const prefix = key.substring(0, 12); // sk_live_XXXX
33
+
34
+ return { key, hash, prefix };
35
+ }
36
+
37
+ /**
38
+ * Hash an API key for storage
39
+ */
40
+ export function hashApiKey(key: string): string {
41
+ return crypto.createHash('sha256').update(key).digest('hex');
42
+ }
43
+
44
+ /**
45
+ * Validate an API key and return the user
46
+ */
47
+ export async function validateApiKey(key: string) {
48
+ if (!key || !key.startsWith('sk_live_')) {
49
+ return null;
50
+ }
51
+
52
+ const hash = hashApiKey(key);
53
+
54
+ const apiKey = await db.apiKey.findUnique({
55
+ where: { key: hash },
56
+ include: {
57
+ user: {
58
+ select: { id: true, email: true, role: true },
59
+ },
60
+ },
61
+ });
62
+
63
+ if (!apiKey) {
64
+ return null;
65
+ }
66
+
67
+ // Check expiration
68
+ if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
69
+ return null;
70
+ }
71
+
72
+ // Update last used timestamp
73
+ await db.apiKey.update({
74
+ where: { id: apiKey.id },
75
+ data: { lastUsedAt: new Date() },
76
+ });
77
+
78
+ return apiKey.user;
79
+ }
80
+ `;
81
+
82
+ await writeFile(
83
+ path.join(projectPath, 'src/lib/api-key-auth.ts'),
84
+ apiKeyUtil
85
+ );
86
+
87
+ // Generate rate limiting utility (works without Redis, in-memory fallback)
88
+ const rateLimitUtil = `/**
89
+ * Simple in-memory rate limiter
90
+ * For production, consider using Upstash Redis
91
+ */
92
+
93
+ interface RateLimitEntry {
94
+ count: number;
95
+ resetAt: number;
96
+ }
97
+
98
+ const rateLimitStore = new Map<string, RateLimitEntry>();
99
+
100
+ const RATE_LIMIT = 100; // requests
101
+ const WINDOW_MS = 60 * 60 * 1000; // 1 hour
102
+
103
+ export async function rateLimit(identifier: string): Promise<{
104
+ success: boolean;
105
+ remaining: number;
106
+ reset: number;
107
+ }> {
108
+ const now = Date.now();
109
+ const entry = rateLimitStore.get(identifier);
110
+
111
+ if (!entry || now > entry.resetAt) {
112
+ // Create new window
113
+ rateLimitStore.set(identifier, {
114
+ count: 1,
115
+ resetAt: now + WINDOW_MS,
116
+ });
117
+ return { success: true, remaining: RATE_LIMIT - 1, reset: now + WINDOW_MS };
118
+ }
119
+
120
+ if (entry.count >= RATE_LIMIT) {
121
+ return { success: false, remaining: 0, reset: entry.resetAt };
122
+ }
123
+
124
+ entry.count++;
125
+ return { success: true, remaining: RATE_LIMIT - entry.count, reset: entry.resetAt };
126
+ }
127
+ `;
128
+
129
+ await writeFile(
130
+ path.join(projectPath, 'src/lib/rate-limit.ts'),
131
+ rateLimitUtil
132
+ );
133
+
134
+ // Generate create API key endpoint
135
+ const createKeyRoute = `import { NextRequest, NextResponse } from 'next/server';
136
+ import { auth } from '@/lib/auth';
137
+ import { db } from '@/lib/db';
138
+ import { generateApiKey } from '@/lib/api-key-auth';
139
+
140
+ export async function POST(req: NextRequest) {
141
+ try {
142
+ const session = await auth();
143
+
144
+ if (!session?.user?.id) {
145
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
146
+ }
147
+
148
+ const { name } = await req.json();
149
+
150
+ if (!name || name.length < 1) {
151
+ return NextResponse.json({ error: 'Name is required' }, { status: 400 });
152
+ }
153
+
154
+ // Check existing keys count (limit to 5)
155
+ const existingCount = await db.apiKey.count({
156
+ where: { userId: session.user.id },
157
+ });
158
+
159
+ if (existingCount >= 5) {
160
+ return NextResponse.json(
161
+ { error: 'Maximum 5 API keys allowed' },
162
+ { status: 400 }
163
+ );
164
+ }
165
+
166
+ const { key, hash, prefix } = generateApiKey();
167
+
168
+ await db.apiKey.create({
169
+ data: {
170
+ userId: session.user.id,
171
+ key: hash,
172
+ keyPrefix: prefix,
173
+ name,
174
+ },
175
+ });
176
+
177
+ // Return the actual key only once - user must save it
178
+ return NextResponse.json({
179
+ key, // Full key - only shown once!
180
+ prefix,
181
+ name,
182
+ message: 'Save this key securely - it will not be shown again.',
183
+ });
184
+ } catch (error: any) {
185
+ console.error('Create API key error:', error);
186
+ return NextResponse.json({ error: error.message }, { status: 500 });
187
+ }
188
+ }
189
+ `;
190
+
191
+ await writeFile(
192
+ path.join(projectPath, 'src/app/api/keys/create/route.ts'),
193
+ createKeyRoute
194
+ );
195
+
196
+ // Generate revoke API key endpoint
197
+ const revokeKeyRoute = `import { NextRequest, NextResponse } from 'next/server';
198
+ import { auth } from '@/lib/auth';
199
+ import { db } from '@/lib/db';
200
+
201
+ export async function POST(req: NextRequest) {
202
+ try {
203
+ const session = await auth();
204
+
205
+ if (!session?.user?.id) {
206
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
207
+ }
208
+
209
+ const { keyId } = await req.json();
210
+
211
+ if (!keyId) {
212
+ return NextResponse.json({ error: 'Key ID is required' }, { status: 400 });
213
+ }
214
+
215
+ // Ensure user owns this key
216
+ const apiKey = await db.apiKey.findFirst({
217
+ where: {
218
+ id: keyId,
219
+ userId: session.user.id,
220
+ },
221
+ });
222
+
223
+ if (!apiKey) {
224
+ return NextResponse.json({ error: 'API key not found' }, { status: 404 });
225
+ }
226
+
227
+ await db.apiKey.delete({
228
+ where: { id: keyId },
229
+ });
230
+
231
+ return NextResponse.json({ success: true });
232
+ } catch (error: any) {
233
+ console.error('Revoke API key error:', error);
234
+ return NextResponse.json({ error: error.message }, { status: 500 });
235
+ }
236
+ }
237
+ `;
238
+
239
+ await writeFile(
240
+ path.join(projectPath, 'src/app/api/keys/revoke/route.ts'),
241
+ revokeKeyRoute
242
+ );
243
+
244
+ // Generate API keys management page
245
+ const apiKeysPage = `'use client';
246
+
247
+ import { useState, useEffect } from 'react';
248
+ import { Key, Plus, Trash2, Copy, Check, Loader2 } from 'lucide-react';
249
+
250
+ interface ApiKey {
251
+ id: string;
252
+ name: string;
253
+ keyPrefix: string;
254
+ lastUsedAt: string | null;
255
+ createdAt: string;
256
+ }
257
+
258
+ export default function ApiKeysPage() {
259
+ const [keys, setKeys] = useState<ApiKey[]>([]);
260
+ const [loading, setLoading] = useState(true);
261
+ const [creating, setCreating] = useState(false);
262
+ const [newKeyName, setNewKeyName] = useState('');
263
+ const [newKey, setNewKey] = useState<string | null>(null);
264
+ const [copied, setCopied] = useState(false);
265
+
266
+ useEffect(() => {
267
+ fetchKeys();
268
+ }, []);
269
+
270
+ async function fetchKeys() {
271
+ try {
272
+ const res = await fetch('/api/keys');
273
+ if (res.ok) {
274
+ const data = await res.json();
275
+ setKeys(data.keys);
276
+ }
277
+ } finally {
278
+ setLoading(false);
279
+ }
280
+ }
281
+
282
+ async function createKey() {
283
+ if (!newKeyName.trim()) return;
284
+
285
+ setCreating(true);
286
+ try {
287
+ const res = await fetch('/api/keys/create', {
288
+ method: 'POST',
289
+ headers: { 'Content-Type': 'application/json' },
290
+ body: JSON.stringify({ name: newKeyName }),
291
+ });
292
+
293
+ if (res.ok) {
294
+ const data = await res.json();
295
+ setNewKey(data.key);
296
+ setNewKeyName('');
297
+ fetchKeys();
298
+ }
299
+ } finally {
300
+ setCreating(false);
301
+ }
302
+ }
303
+
304
+ async function revokeKey(keyId: string) {
305
+ if (!confirm('Are you sure you want to revoke this API key?')) return;
306
+
307
+ await fetch('/api/keys/revoke', {
308
+ method: 'POST',
309
+ headers: { 'Content-Type': 'application/json' },
310
+ body: JSON.stringify({ keyId }),
311
+ });
312
+
313
+ fetchKeys();
314
+ }
315
+
316
+ function copyToClipboard(text: string) {
317
+ navigator.clipboard.writeText(text);
318
+ setCopied(true);
319
+ setTimeout(() => setCopied(false), 2000);
320
+ }
321
+
322
+ if (loading) {
323
+ return (
324
+ <div className="flex items-center justify-center min-h-[400px]">
325
+ <Loader2 className="h-8 w-8 animate-spin text-indigo-600" />
326
+ </div>
327
+ );
328
+ }
329
+
330
+ return (
331
+ <div className="max-w-4xl mx-auto p-6">
332
+ <h1 className="text-3xl font-bold mb-2">API Keys</h1>
333
+ <p className="text-gray-600 mb-8">Manage your API keys for programmatic access</p>
334
+
335
+ {/* New key created banner */}
336
+ {newKey && (
337
+ <div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
338
+ <p className="text-sm text-amber-800 mb-2 font-medium">
339
+ šŸ”‘ Save this API key now - it will not be shown again!
340
+ </p>
341
+ <div className="flex items-center gap-2">
342
+ <code className="flex-1 bg-white border rounded px-3 py-2 text-sm font-mono break-all">
343
+ {newKey}
344
+ </code>
345
+ <button
346
+ onClick={() => copyToClipboard(newKey)}
347
+ className="p-2 hover:bg-amber-100 rounded"
348
+ >
349
+ {copied ? <Check className="h-5 w-5 text-green-600" /> : <Copy className="h-5 w-5" />}
350
+ </button>
351
+ </div>
352
+ <button
353
+ onClick={() => setNewKey(null)}
354
+ className="mt-3 text-sm text-amber-700 hover:underline"
355
+ >
356
+ I have saved the key
357
+ </button>
358
+ </div>
359
+ )}
360
+
361
+ {/* Create new key */}
362
+ <div className="bg-white rounded-xl border p-6 mb-8">
363
+ <h2 className="text-lg font-semibold mb-4">Create New API Key</h2>
364
+ <div className="flex gap-3">
365
+ <input
366
+ type="text"
367
+ value={newKeyName}
368
+ onChange={(e) => setNewKeyName(e.target.value)}
369
+ placeholder="Key name (e.g., Production, Development)"
370
+ className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
371
+ />
372
+ <button
373
+ onClick={createKey}
374
+ disabled={creating || !newKeyName.trim()}
375
+ className="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-500 disabled:opacity-50"
376
+ >
377
+ {creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
378
+ Create Key
379
+ </button>
380
+ </div>
381
+ </div>
382
+
383
+ {/* Existing keys */}
384
+ <div className="bg-white rounded-xl border overflow-hidden">
385
+ <div className="px-6 py-4 border-b">
386
+ <h2 className="text-lg font-semibold">Your API Keys</h2>
387
+ </div>
388
+
389
+ {keys.length === 0 ? (
390
+ <div className="p-6 text-center text-gray-500">
391
+ <Key className="h-12 w-12 mx-auto mb-3 text-gray-300" />
392
+ <p>No API keys yet. Create one above.</p>
393
+ </div>
394
+ ) : (
395
+ <ul className="divide-y">
396
+ {keys.map((key) => (
397
+ <li key={key.id} className="px-6 py-4 flex items-center justify-between">
398
+ <div>
399
+ <p className="font-medium">{key.name}</p>
400
+ <p className="text-sm text-gray-500 font-mono">{key.keyPrefix}••••••••</p>
401
+ <p className="text-xs text-gray-400 mt-1">
402
+ {key.lastUsedAt
403
+ ? \`Last used: \${new Date(key.lastUsedAt).toLocaleDateString()}\`
404
+ : 'Never used'}
405
+ </p>
406
+ </div>
407
+ <button
408
+ onClick={() => revokeKey(key.id)}
409
+ className="p-2 text-red-500 hover:bg-red-50 rounded"
410
+ >
411
+ <Trash2 className="h-5 w-5" />
412
+ </button>
413
+ </li>
414
+ ))}
415
+ </ul>
416
+ )}
417
+ </div>
418
+ </div>
419
+ );
420
+ }
421
+ `;
422
+
423
+ await writeFile(
424
+ path.join(projectPath, 'src/app/dashboard/api-keys/page.tsx'),
425
+ apiKeysPage
426
+ );
427
+
428
+ // Generate list API keys endpoint
429
+ const listKeysRoute = `import { NextResponse } from 'next/server';
430
+ import { auth } from '@/lib/auth';
431
+ import { db } from '@/lib/db';
432
+
433
+ export async function GET() {
434
+ try {
435
+ const session = await auth();
436
+
437
+ if (!session?.user?.id) {
438
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
439
+ }
440
+
441
+ const keys = await db.apiKey.findMany({
442
+ where: { userId: session.user.id },
443
+ select: {
444
+ id: true,
445
+ name: true,
446
+ keyPrefix: true,
447
+ lastUsedAt: true,
448
+ createdAt: true,
449
+ },
450
+ orderBy: { createdAt: 'desc' },
451
+ });
452
+
453
+ return NextResponse.json({ keys });
454
+ } catch (error: any) {
455
+ return NextResponse.json({ error: error.message }, { status: 500 });
456
+ }
457
+ }
458
+ `;
459
+
460
+ await writeFile(
461
+ path.join(projectPath, 'src/app/api/keys/route.ts'),
462
+ listKeysRoute
463
+ );
464
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Full Database Schema Generator - Pro Feature
3
+ * Generates complete Prisma schema with Subscription, Payment, ApiKey models
4
+ */
5
+
6
+ import path from 'path';
7
+ import { writeFile, ensureDir } from '../../utils/files.js';
8
+
9
+ export async function generateFullDatabase(projectPath) {
10
+ await ensureDir(path.join(projectPath, 'prisma'));
11
+
12
+ // Generate full Prisma schema
13
+ const schema = `// This is your Prisma schema file
14
+ // Generated by SoloStack Pro
15
+
16
+ generator client {
17
+ provider = "prisma-client-js"
18
+ }
19
+
20
+ datasource db {
21
+ provider = "postgresql"
22
+ url = env("DATABASE_URL")
23
+ }
24
+
25
+ // ==========================================
26
+ // User & Authentication Models
27
+ // ==========================================
28
+
29
+ model User {
30
+ id String @id @default(cuid())
31
+ email String @unique
32
+ name String?
33
+ emailVerified DateTime?
34
+ image String?
35
+ password String?
36
+ role Role @default(USER)
37
+ stripeCustomerId String? @unique
38
+ subscription Subscription?
39
+ apiKeys ApiKey[]
40
+ createdAt DateTime @default(now())
41
+ updatedAt DateTime @updatedAt
42
+
43
+ accounts Account[]
44
+ sessions Session[]
45
+ }
46
+
47
+ model Account {
48
+ userId String
49
+ type String
50
+ provider String
51
+ providerAccountId String
52
+ refresh_token String?
53
+ access_token String?
54
+ expires_at Int?
55
+ token_type String?
56
+ scope String?
57
+ id_token String?
58
+ session_state String?
59
+
60
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
61
+
62
+ @@id([provider, providerAccountId])
63
+ }
64
+
65
+ model Session {
66
+ sessionToken String @unique
67
+ userId String
68
+ expires DateTime
69
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
70
+ }
71
+
72
+ model VerificationToken {
73
+ identifier String
74
+ token String @unique
75
+ expires DateTime
76
+
77
+ @@unique([identifier, token])
78
+ }
79
+
80
+ // ==========================================
81
+ // Subscription & Payments Models
82
+ // ==========================================
83
+
84
+ model Subscription {
85
+ id String @id @default(cuid())
86
+ userId String @unique
87
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
88
+ stripeSubscriptionId String @unique
89
+ stripePriceId String
90
+ status SubscriptionStatus
91
+ currentPeriodStart DateTime
92
+ currentPeriodEnd DateTime
93
+ cancelAtPeriodEnd Boolean @default(false)
94
+ createdAt DateTime @default(now())
95
+ updatedAt DateTime @updatedAt
96
+ }
97
+
98
+ model Payment {
99
+ id String @id @default(cuid())
100
+ userId String
101
+ stripePaymentId String @unique
102
+ amount Int // Amount in cents
103
+ currency String @default("usd")
104
+ status String
105
+ createdAt DateTime @default(now())
106
+ }
107
+
108
+ model StripeEvent {
109
+ id String @id
110
+ type String
111
+ processed Boolean @default(false)
112
+ createdAt DateTime @default(now())
113
+ }
114
+
115
+ // ==========================================
116
+ // API Key Model
117
+ // ==========================================
118
+
119
+ model ApiKey {
120
+ id String @id @default(cuid())
121
+ userId String
122
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
123
+ key String @unique // Hashed key
124
+ keyPrefix String // First 8 chars for display (sk_live_abc1...)
125
+ name String
126
+ lastUsedAt DateTime?
127
+ createdAt DateTime @default(now())
128
+ expiresAt DateTime?
129
+ }
130
+
131
+ // ==========================================
132
+ // Enums
133
+ // ==========================================
134
+
135
+ enum Role {
136
+ USER
137
+ ADMIN
138
+ }
139
+
140
+ enum SubscriptionStatus {
141
+ ACTIVE
142
+ CANCELED
143
+ PAST_DUE
144
+ TRIALING
145
+ INCOMPLETE
146
+ }
147
+ `;
148
+
149
+ await writeFile(path.join(projectPath, 'prisma/schema.prisma'), schema);
150
+
151
+ // Generate seed script
152
+ const seed = `import { PrismaClient } from '@prisma/client';
153
+ import bcrypt from 'bcryptjs';
154
+
155
+ const prisma = new PrismaClient();
156
+
157
+ async function main() {
158
+ console.log('🌱 Seeding database...');
159
+
160
+ // Create admin user
161
+ const adminPassword = await bcrypt.hash('admin123', 12);
162
+ const admin = await prisma.user.upsert({
163
+ where: { email: 'admin@example.com' },
164
+ update: {},
165
+ create: {
166
+ email: 'admin@example.com',
167
+ name: 'Admin User',
168
+ password: adminPassword,
169
+ role: 'ADMIN',
170
+ emailVerified: new Date(),
171
+ },
172
+ });
173
+ console.log('āœ“ Created admin user:', admin.email);
174
+
175
+ // Create test user with subscription
176
+ const userPassword = await bcrypt.hash('user123', 12);
177
+ const user = await prisma.user.upsert({
178
+ where: { email: 'user@example.com' },
179
+ update: {},
180
+ create: {
181
+ email: 'user@example.com',
182
+ name: 'Test User',
183
+ password: userPassword,
184
+ role: 'USER',
185
+ emailVerified: new Date(),
186
+ stripeCustomerId: 'cus_test123',
187
+ subscription: {
188
+ create: {
189
+ stripeSubscriptionId: 'sub_test123',
190
+ stripePriceId: 'price_pro',
191
+ status: 'ACTIVE',
192
+ currentPeriodStart: new Date(),
193
+ currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
194
+ },
195
+ },
196
+ },
197
+ });
198
+ console.log('āœ“ Created test user with subscription:', user.email);
199
+
200
+ // Create free user
201
+ const freePassword = await bcrypt.hash('free123', 12);
202
+ const freeUser = await prisma.user.upsert({
203
+ where: { email: 'free@example.com' },
204
+ update: {},
205
+ create: {
206
+ email: 'free@example.com',
207
+ name: 'Free User',
208
+ password: freePassword,
209
+ role: 'USER',
210
+ emailVerified: new Date(),
211
+ },
212
+ });
213
+ console.log('āœ“ Created free user:', freeUser.email);
214
+
215
+ console.log('\\nāœ… Seeding complete!');
216
+ console.log('\\nšŸ“ Test credentials:');
217
+ console.log(' Admin: admin@example.com / admin123');
218
+ console.log(' Pro User: user@example.com / user123');
219
+ console.log(' Free User: free@example.com / free123');
220
+ }
221
+
222
+ main()
223
+ .catch((e) => {
224
+ console.error(e);
225
+ process.exit(1);
226
+ })
227
+ .finally(async () => {
228
+ await prisma.$disconnect();
229
+ });
230
+ `;
231
+
232
+ await writeFile(path.join(projectPath, 'prisma/seed.ts'), seed);
233
+ }