Skip to content

Commit 142c444

Browse files
committed
be more opinionated
1 parent 292f1c7 commit 142c444

3 files changed

Lines changed: 363 additions & 16 deletions

File tree

.changeset/large-pans-notice.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opensaas/speccraft": patch
3+
---
4+
5+
Be more opinionated about keystone setup

src/lib/guidance/keystone-guidance.ts

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ export interface ImplementationPattern {
6868
example?: string;
6969
}
7070

71+
export interface ArchitectureGuidance {
72+
fileStructure: string;
73+
keystoneSetup: string;
74+
nextAuthSetup: string;
75+
apiRoutes: string;
76+
criticalPatterns: string[];
77+
}
78+
7179
export class KeystoneGuidanceProvider {
7280
generateImplementationGuidance(spec: ParsedSpecification): KeystoneGuidance {
7381
return {
@@ -80,6 +88,273 @@ export class KeystoneGuidanceProvider {
8088
};
8189
}
8290

91+
generateArchitectureGuidance(spec: ParsedSpecification, requiresAuth: boolean): ArchitectureGuidance {
92+
return {
93+
fileStructure: this.getOpinionatedFileStructure(),
94+
keystoneSetup: this.getKeystoneContextSetup(),
95+
nextAuthSetup: requiresAuth ? this.getNextAuthSetup() : '',
96+
apiRoutes: this.getAPIRouteSetup(),
97+
criticalPatterns: this.getCriticalPatterns(requiresAuth),
98+
};
99+
}
100+
101+
private getOpinionatedFileStructure(): string {
102+
return `## 📁 Required File Structure (Based on on-the-hill-drama-club)
103+
104+
\`\`\`
105+
src/
106+
├── app/ # Next.js 15 App Router
107+
│ ├── api/
108+
│ │ ├── graphql/
109+
│ │ │ └── route.ts # GraphQL API endpoint (uses Yoga + Keystone)
110+
│ │ └── auth/
111+
│ │ └── [...nextauth]/ # NextAuth routes (if auth required)
112+
│ ├── admin/ # Keystone Admin UI pages
113+
│ └── (your feature routes)/
114+
├── keystone/
115+
│ ├── context/
116+
│ │ └── index.ts # **CRITICAL**: getContext() setup
117+
│ ├── lists/ # Keystone schema definitions
118+
│ │ ├── User.ts
119+
│ │ └── (your list).ts
120+
│ ├── schema.ts # Exports all lists
121+
│ └── helpers.ts # Access control helpers (isAdmin, isLoggedIn)
122+
├── lib/
123+
│ ├── auth.ts # NextAuth configuration (if auth required)
124+
│ └── utils.ts
125+
└── types/
126+
└── session.ts # NextAuth session types
127+
128+
keystone.ts # Root Keystone config
129+
schema.prisma # Prisma schema
130+
\`\`\``;
131+
}
132+
133+
private getKeystoneContextSetup(): string {
134+
return `## 🔧 Keystone Context Setup (CRITICAL)
135+
136+
**Location**: \`src/keystone/context/index.ts\`
137+
138+
\`\`\`typescript
139+
import { getContext } from '@keystone-6/core/context';
140+
import config from '../../../keystone';
141+
import * as PrismaModule from '.prisma/client';
142+
import { Pool, neonConfig } from '@neondatabase/serverless';
143+
import { PrismaNeon } from '@prisma/adapter-neon';
144+
import { Prisma PrismaClient } from '@prisma/client';
145+
146+
// Neon serverless driver for Vercel deployment
147+
neonConfig.webSocketConstructor = ws;
148+
const connectionString = process.env.DATABASE_URL!;
149+
150+
// Custom Prisma Client with Neon adapter
151+
class NeonPrismaClient extends PrismaClient {
152+
constructor() {
153+
const pool = new Pool({ connectionString });
154+
const adapter = new PrismaNeon(pool);
155+
super({ adapter });
156+
}
157+
}
158+
159+
// Global singleton pattern (prevents multiple instances in dev)
160+
export const keystoneContext: Context =
161+
globalThis.keystoneContext ||
162+
getContext(config, { ...PrismaModule, PrismaClient: NeonPrismaClient });
163+
164+
if (process.env.NODE_ENV !== 'production') {
165+
globalThis.keystoneContext = keystoneContext;
166+
}
167+
168+
// Export for use in API routes and server actions
169+
export type Context = typeof keystoneContext;
170+
\`\`\`
171+
172+
**Why This Matters**:
173+
- ❌ **NEVER** start a separate GraphQL server
174+
- ✅ **ALWAYS** use \`getContext()\` to embed Keystone in Next.js
175+
- ✅ Use Neon adapter for serverless PostgreSQL on Vercel
176+
- ✅ Global singleton prevents multiple connections in development`;
177+
}
178+
179+
private getNextAuthSetup(): string {
180+
return `## 🔐 NextAuth Configuration (CRITICAL for Auth Features)
181+
182+
**Location**: \`src/lib/auth.ts\`
183+
184+
\`\`\`typescript
185+
import NextAuth from 'next-auth';
186+
import GoogleProvider from 'next-auth/providers/google';
187+
import CredentialsProvider from 'next-auth/providers/credentials';
188+
import { keystoneContext } from '@/keystone/context';
189+
190+
export const { handlers, auth, signIn, signOut } = NextAuth({
191+
providers: [
192+
GoogleProvider({
193+
clientId: process.env.GOOGLE_CLIENT_ID!,
194+
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
195+
}),
196+
CredentialsProvider({
197+
name: 'Email and Password',
198+
credentials: {
199+
email: { label: 'Email', type: 'email' },
200+
password: { label: 'Password', type: 'password' },
201+
},
202+
async authorize(credentials) {
203+
if (!credentials?.email || !credentials?.password) return null;
204+
205+
// **CRITICAL**: Use keystoneContext.sudo() for auth queries
206+
const user = await keystoneContext.sudo().query.User.findOne({
207+
where: { email: credentials.email },
208+
query: 'id name email password',
209+
});
210+
211+
if (!user || !user.password) return null;
212+
213+
// Verify password (implement your password verification)
214+
const isValid = await verifyPassword(credentials.password, user.password);
215+
if (!isValid) return null;
216+
217+
return { id: user.id, name: user.name, email: user.email };
218+
},
219+
}),
220+
],
221+
222+
callbacks: {
223+
async signIn({ user, account, profile }) {
224+
// For OAuth providers, create user in Keystone if doesn't exist
225+
if (account?.provider !== 'credentials') {
226+
const existing = await keystoneContext.sudo().query.User.findOne({
227+
where: { email: user.email! },
228+
query: 'id',
229+
});
230+
231+
if (!existing) {
232+
await keystoneContext.sudo().query.User.createOne({
233+
data: {
234+
name: user.name!,
235+
email: user.email!,
236+
// Set default role, etc.
237+
},
238+
});
239+
}
240+
}
241+
return true;
242+
},
243+
244+
async session({ session, token }) {
245+
// **CRITICAL**: Fetch full user data from Keystone
246+
const user = await keystoneContext.sudo().query.User.findOne({
247+
where: { email: session.user?.email! },
248+
query: 'id name email role allowAdminUI',
249+
});
250+
251+
if (user) {
252+
session.user.id = user.id;
253+
session.user.role = user.role;
254+
session.allowAdminUI = user.allowAdminUI || false;
255+
}
256+
257+
return session;
258+
},
259+
},
260+
261+
pages: {
262+
signIn: '/auth/signin',
263+
error: '/auth/error',
264+
},
265+
});
266+
\`\`\`
267+
268+
**Key Patterns**:
269+
- ✅ Use \`keystoneContext.sudo()\` for auth queries (bypasses access control)
270+
- ✅ Sync NextAuth users with Keystone User list
271+
- ✅ Store user roles and permissions in Keystone
272+
- ✅ Extend session with Keystone user data
273+
- ✅ Support both OAuth and credentials providers`;
274+
}
275+
276+
private getAPIRouteSetup(): string {
277+
return `## 🌐 GraphQL API Route Setup
278+
279+
**Location**: \`src/app/api/graphql/route.ts\`
280+
281+
\`\`\`typescript
282+
import { createYoga } from 'graphql-yoga';
283+
import { keystoneContext } from '@/keystone/context';
284+
import { auth } from '@/lib/auth';
285+
286+
const { handleRequest } = createYoga({
287+
schema: keystoneContext.graphql.schema,
288+
graphqlEndpoint: '/api/graphql',
289+
fetchAPI: { Response },
290+
context: async (req) => {
291+
// **CRITICAL**: Get NextAuth session
292+
const session = await auth();
293+
294+
// Return Keystone context with session
295+
return keystoneContext.withRequest(req, {
296+
session: session ? {
297+
itemId: session.user.id,
298+
data: session.user,
299+
allowAdminUI: session.allowAdminUI,
300+
} : undefined,
301+
});
302+
},
303+
});
304+
305+
// Protected GraphQL endpoint (requires auth)
306+
export async function GET(req: Request) {
307+
const session = await auth();
308+
if (!session?.allowAdminUI) {
309+
return new Response('Unauthorized', { status: 401 });
310+
}
311+
return handleRequest(req, {});
312+
}
313+
314+
export async function POST(req: Request) {
315+
const session = await auth();
316+
if (!session?.allowAdminUI) {
317+
return new Response('Unauthorized', { status: 401 });
318+
}
319+
return handleRequest(req, {});
320+
}
321+
322+
export async function OPTIONS(req: Request) {
323+
return handleRequest(req, {});
324+
}
325+
\`\`\`
326+
327+
**Critical Points**:
328+
- ✅ Use GraphQL Yoga (not Apollo Server)
329+
- ✅ Integrate NextAuth session with Keystone context
330+
- ✅ Protect GraphQL endpoint with auth middleware
331+
- ✅ Use \`keystoneContext.withRequest()\` to pass session`;
332+
}
333+
334+
private getCriticalPatterns(requiresAuth: boolean): string[] {
335+
const patterns = [
336+
'❌ **NEVER** run Keystone as a separate server - use getContext() only',
337+
'✅ **ALWAYS** use \`keystoneContext.graphql.run()\` for GraphQL queries in server actions',
338+
'✅ **ALWAYS** use \`keystoneContext.sudo()\` for auth-related queries (bypasses access control)',
339+
'✅ **ALWAYS** use Next.js Server Actions for mutations (NOT custom GraphQL resolvers)',
340+
'✅ **ALWAYS** validate with Zod schemas before calling Keystone mutations',
341+
'✅ **ALWAYS** use \`ResultOf<typeof QUERY>\` for GraphQL type inference (never \`any\`)',
342+
'✅ **ALWAYS** use Neon serverless adapter for Vercel deployment',
343+
'✅ **ALWAYS** define lists in separate files under \`src/keystone/lists/\`',
344+
'✅ **ALWAYS** export all lists from \`src/keystone/schema.ts\`',
345+
];
346+
347+
if (requiresAuth) {
348+
patterns.push(
349+
'✅ **ALWAYS** sync NextAuth users with Keystone User list',
350+
'✅ **ALWAYS** extend NextAuth session with Keystone user data',
351+
'✅ **ALWAYS** use \`auth()\` from NextAuth to get session in API routes',
352+
);
353+
}
354+
355+
return patterns;
356+
}
357+
83358
private generateSchemaGuidance(spec: ParsedSpecification): SchemaGuidance[] {
84359
const featureName = this.pascalCase(spec.title);
85360

0 commit comments

Comments
 (0)