{
  "id": "web-db-user",
  "name": "Web App (db,user)",
  "description": "Full-stack web template with database + user flows",
  "capabilities": [
    "server",
    "db",
    "user"
  ],
  "files": {
    "package.json": "{\n  \"name\": \"marl-scan\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"license\": \"MIT\",\n  \"scripts\": {\n    \"dev\": \"NODE_ENV=development tsx watch server/_core/index.ts\",\n    \"build\": \"vite build && esbuild server/_core/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist\",\n    \"start\": \"NODE_ENV=production node dist/index.js\",\n    \"check\": \"tsc --noEmit\",\n    \"format\": \"prettier --write .\",\n    \"test\": \"vitest run\",\n    \"db:push\": \"drizzle-kit generate && drizzle-kit migrate\"\n  },\n  \"dependencies\": {\n    \"@aws-sdk/client-s3\": \"^3.693.0\",\n    \"@aws-sdk/s3-request-presigner\": \"^3.693.0\",\n    \"@hookform/resolvers\": \"^5.2.2\",\n    \"@radix-ui/react-accordion\": \"^1.2.12\",\n    \"@radix-ui/react-alert-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-aspect-ratio\": \"^1.1.7\",\n    \"@radix-ui/react-avatar\": \"^1.1.10\",\n    \"@radix-ui/react-checkbox\": \"^1.3.3\",\n    \"@radix-ui/react-collapsible\": \"^1.1.12\",\n    \"@radix-ui/react-context-menu\": \"^2.2.16\",\n    \"@radix-ui/react-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.16\",\n    \"@radix-ui/react-hover-card\": \"^1.1.15\",\n    \"@radix-ui/react-label\": \"^2.1.7\",\n    \"@radix-ui/react-menubar\": \"^1.1.16\",\n    \"@radix-ui/react-navigation-menu\": \"^1.2.14\",\n    \"@radix-ui/react-popover\": \"^1.1.15\",\n    \"@radix-ui/react-progress\": \"^1.1.7\",\n    \"@radix-ui/react-radio-group\": \"^1.3.8\",\n    \"@radix-ui/react-scroll-area\": \"^1.2.10\",\n    \"@radix-ui/react-select\": \"^2.2.6\",\n    \"@radix-ui/react-separator\": \"^1.1.7\",\n    \"@radix-ui/react-slider\": \"^1.3.6\",\n    \"@radix-ui/react-slot\": \"^1.2.3\",\n    \"@radix-ui/react-switch\": \"^1.2.6\",\n    \"@radix-ui/react-tabs\": \"^1.1.13\",\n    \"@radix-ui/react-toggle\": \"^1.1.10\",\n    \"@radix-ui/react-toggle-group\": \"^1.1.11\",\n    \"@radix-ui/react-tooltip\": \"^1.2.8\",\n    \"@tanstack/react-query\": \"^5.90.2\",\n    \"@trpc/client\": \"^11.6.0\",\n    \"@trpc/react-query\": \"^11.6.0\",\n    \"@trpc/server\": \"^11.6.0\",\n    \"axios\": \"^1.12.0\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"^1.1.1\",\n    \"cookie\": \"^1.0.2\",\n    \"date-fns\": \"^4.1.0\",\n    \"dotenv\": \"^17.2.2\",\n    \"drizzle-orm\": \"^0.44.5\",\n    \"embla-carousel-react\": \"^8.6.0\",\n    \"express\": \"^4.21.2\",\n    \"framer-motion\": \"^12.23.22\",\n    \"input-otp\": \"^1.4.2\",\n    \"jose\": \"6.1.0\",\n    \"lucide-react\": \"^0.453.0\",\n    \"mysql2\": \"^3.15.0\",\n    \"nanoid\": \"^5.1.5\",\n    \"next-themes\": \"^0.4.6\",\n    \"react\": \"^19.2.1\",\n    \"react-day-picker\": \"^9.11.1\",\n    \"react-dom\": \"^19.2.1\",\n    \"react-hook-form\": \"^7.64.0\",\n    \"react-resizable-panels\": \"^3.0.6\",\n    \"recharts\": \"^2.15.2\",\n    \"sonner\": \"^2.0.7\",\n    \"streamdown\": \"^1.4.0\",\n    \"superjson\": \"^1.13.3\",\n    \"tailwind-merge\": \"^3.3.1\",\n    \"tailwindcss-animate\": \"^1.0.7\",\n    \"vaul\": \"^1.1.2\",\n    \"wouter\": \"^3.3.5\",\n    \"zod\": \"^4.1.12\"\n  },\n  \"devDependencies\": {\n    \"@builder.io/vite-plugin-jsx-loc\": \"^0.1.1\",\n    \"@tailwindcss/typography\": \"^0.5.15\",\n    \"@tailwindcss/vite\": \"^4.1.3\",\n    \"@types/express\": \"4.17.21\",\n    \"@types/google.maps\": \"^3.58.1\",\n    \"@types/node\": \"^24.7.0\",\n    \"@types/react\": \"^19.2.1\",\n    \"@types/react-dom\": \"^19.2.1\",\n    \"@vitejs/plugin-react\": \"^5.0.4\",\n    \"add\": \"^2.0.6\",\n    \"autoprefixer\": \"^10.4.20\",\n    \"drizzle-kit\": \"^0.31.4\",\n    \"esbuild\": \"^0.25.0\",\n    \"pnpm\": \"^10.15.1\",\n    \"postcss\": \"^8.4.47\",\n    \"prettier\": \"^3.6.2\",\n    \"tailwindcss\": \"^4.1.14\",\n    \"tsx\": \"^4.19.1\",\n    \"tw-animate-css\": \"^1.4.0\",\n    \"typescript\": \"5.9.3\",\n    \"vite\": \"^7.1.7\",\n    \"vite-plugin-manus-runtime\": \"^0.0.58\",\n    \"vitest\": \"^2.1.4\"\n  },\n  \"packageManager\": \"pnpm@10.4.1+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88af\",\n  \"pnpm\": {\n    \"patchedDependencies\": {\n      \"wouter@3.7.1\": \"patches/wouter@3.7.1.patch\"\n    },\n    \"overrides\": {\n      \"tailwindcss>nanoid\": \"3.3.7\"\n    }\n  }\n}",
    "drizzle/schema.ts": "import { int, mysqlEnum, mysqlTable, text, timestamp, varchar } from \"drizzle-orm/mysql-core\";\n\n/**\n * Core user table backing auth flow.\n * Extend this file with additional tables as your product grows.\n * Columns use camelCase to match both database fields and generated types.\n */\nexport const users = mysqlTable(\"users\", {\n  /**\n   * Surrogate primary key. Auto-incremented numeric value managed by the database.\n   * Use this for relations between tables.\n   */\n  id: int(\"id\").autoincrement().primaryKey(),\n  /** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */\n  openId: varchar(\"openId\", { length: 64 }).notNull().unique(),\n  name: text(\"name\"),\n  email: varchar(\"email\", { length: 320 }),\n  loginMethod: varchar(\"loginMethod\", { length: 64 }),\n  role: mysqlEnum(\"role\", [\"user\", \"admin\"]).default(\"user\").notNull(),\n  createdAt: timestamp(\"createdAt\").defaultNow().notNull(),\n  updatedAt: timestamp(\"updatedAt\").defaultNow().onUpdateNow().notNull(),\n  lastSignedIn: timestamp(\"lastSignedIn\").defaultNow().notNull(),\n});\n\nexport type User = typeof users.$inferSelect;\nexport type InsertUser = typeof users.$inferInsert;\n\n// TODO: Add your tables here",
    "server/db.ts": "import { eq } from \"drizzle-orm\";\nimport { drizzle } from \"drizzle-orm/mysql2\";\nimport { InsertUser, users } from \"../drizzle/schema\";\nimport { ENV } from './_core/env';\n\nlet _db: ReturnType<typeof drizzle> | null = null;\n\n// Lazily create the drizzle instance so local tooling can run without a DB.\nexport async function getDb() {\n  if (!_db && process.env.DATABASE_URL) {\n    try {\n      _db = drizzle(process.env.DATABASE_URL);\n    } catch (error) {\n      console.warn(\"[Database] Failed to connect:\", error);\n      _db = null;\n    }\n  }\n  return _db;\n}\n\nexport async function upsertUser(user: InsertUser): Promise<void> {\n  if (!user.openId) {\n    throw new Error(\"User openId is required for upsert\");\n  }\n\n  const db = await getDb();\n  if (!db) {\n    console.warn(\"[Database] Cannot upsert user: database not available\");\n    return;\n  }\n\n  try {\n    const values: InsertUser = {\n      openId: user.openId,\n    };\n    const updateSet: Record<string, unknown> = {};\n\n    const textFields = [\"name\", \"email\", \"loginMethod\"] as const;\n    type TextField = (typeof textFields)[number];\n\n    const assignNullable = (field: TextField) => {\n      const value = user[field];\n      if (value === undefined) return;\n      const normalized = value ?? null;\n      values[field] = normalized;\n      updateSet[field] = normalized;\n    };\n\n    textFields.forEach(assignNullable);\n\n    if (user.lastSignedIn !== undefined) {\n      values.lastSignedIn = user.lastSignedIn;\n      updateSet.lastSignedIn = user.lastSignedIn;\n    }\n    if (user.role !== undefined) {\n      values.role = user.role;\n      updateSet.role = user.role;\n    } else if (user.openId === ENV.ownerOpenId) {\n      values.role = 'admin';\n      updateSet.role = 'admin';\n    }\n\n    if (!values.lastSignedIn) {\n      values.lastSignedIn = new Date();\n    }\n\n    if (Object.keys(updateSet).length === 0) {\n      updateSet.lastSignedIn = new Date();\n    }\n\n    await db.insert(users).values(values).onDuplicateKeyUpdate({\n      set: updateSet,\n    });\n  } catch (error) {\n    console.error(\"[Database] Failed to upsert user:\", error);\n    throw error;\n  }\n}\n\nexport async function getUserByOpenId(openId: string) {\n  const db = await getDb();\n  if (!db) {\n    console.warn(\"[Database] Cannot get user: database not available\");\n    return undefined;\n  }\n\n  const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);\n\n  return result.length > 0 ? result[0] : undefined;\n}\n\n// TODO: add feature queries here as your schema grows.",
    "server/routers.ts": "import { COOKIE_NAME } from \"@shared/const\";\nimport { getSessionCookieOptions } from \"./_core/cookies\";\nimport { systemRouter } from \"./_core/systemRouter\";\nimport { publicProcedure, router } from \"./_core/trpc\";\n\nexport const appRouter = router({\n    // if you need to use socket.io, read and register route in server/_core/index.ts, all api should start with '/api/' so that the gateway can route correctly\n  system: systemRouter,\n  auth: router({\n    me: publicProcedure.query(opts => opts.ctx.user),\n    logout: publicProcedure.mutation(({ ctx }) => {\n      const cookieOptions = getSessionCookieOptions(ctx.req);\n      ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });\n      return {\n        success: true,\n      } as const;\n    }),\n  }),\n\n  // TODO: add feature routers here, e.g.\n  // todo: router({\n  //   list: protectedProcedure.query(({ ctx }) =>\n  //     db.getUserTodos(ctx.user.id)\n  //   ),\n  // }),\n});\n\nexport type AppRouter = typeof appRouter;",
    "client/index.html": "<!doctype html>\n<html lang=\"en\">\n\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta\n      name=\"viewport\"\n      content=\"width=device-width, initial-scale=1.0, maximum-scale=1\" />\n    <title>Marl Scan</title>    \n    <!-- THIS IS THE START OF A COMMENT BLOCK, BLOCK TO BE DELETED: Google Fonts here, example:\n    <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n    <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n    <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\" />\n    THIS IS THE END OF A COMMENT BLOCK, BLOCK TO BE DELETED -->\n  </head>\n\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n    <script\n      defer\n      src=\"%VITE_ANALYTICS_ENDPOINT%/umami\"\n      data-website-id=\"%VITE_ANALYTICS_WEBSITE_ID%\"></script>\n  </body>\n\n</html>",
    "client/src/App.tsx": "import { Toaster } from \"@/components/ui/sonner\";\nimport { TooltipProvider } from \"@/components/ui/tooltip\";\nimport NotFound from \"@/pages/NotFound\";\nimport { Route, Switch } from \"wouter\";\nimport ErrorBoundary from \"./components/ErrorBoundary\";\nimport { ThemeProvider } from \"./contexts/ThemeContext\";\nimport Home from \"./pages/Home\";\n\nfunction Router() {\n  // make sure to consider if you need authentication for certain routes\n  return (\n    <Switch>\n      <Route path={\"/\"} component={Home} />\n      <Route path={\"/404\"} component={NotFound} />\n      {/* Final fallback route */}\n      <Route component={NotFound} />\n    </Switch>\n  );\n}\n\n// NOTE: About Theme\n// - First choose a default theme according to your design style (dark or light bg), than change color palette in index.css\n//   to keep consistent foreground/background color across components\n// - If you want to make theme switchable, pass `switchable` ThemeProvider and use `useTheme` hook\n\nfunction App() {\n  return (\n    <ErrorBoundary>\n      <ThemeProvider\n        defaultTheme=\"light\"\n        // switchable\n      >\n        <TooltipProvider>\n          <Toaster />\n          <Router />\n        </TooltipProvider>\n      </ThemeProvider>\n    </ErrorBoundary>\n  );\n}\n\nexport default App;",
    "client/src/lib/trpc.ts": "import { createTRPCReact } from \"@trpc/react-query\";\nimport type { AppRouter } from \"../../../server/routers\";\n\nexport const trpc = createTRPCReact<AppRouter>();",
    "client/src/pages/Home.tsx": "import { useAuth } from \"@/_core/hooks/useAuth\";\nimport { Button } from \"@/components/ui/button\";\nimport { Loader2 } from \"lucide-react\";\nimport { getLoginUrl } from \"@/const\";\nimport { Streamdown } from 'streamdown';\n\n/**\n * All content in this page are only for example, replace with your own feature implementation\n * When building pages, remember your instructions in Frontend Workflow, Frontend Best Practices, Design Guide and Common Pitfalls\n */\nexport default function Home() {\n  // The userAuth hooks provides authentication state\n  // To implement login/logout functionality, simply call logout() or redirect to getLoginUrl()\n  let { user, loading, error, isAuthenticated, logout } = useAuth();\n\n  // If theme is switchable in App.tsx, we can implement theme toggling like this:\n  // const { theme, toggleTheme } = useTheme();\n\n  return (\n    <div className=\"min-h-screen flex flex-col\">\n      <main>\n        {/* Example: lucide-react for icons */}\n        <Loader2 className=\"animate-spin\" />\n        Example Page\n        {/* Example: Streamdown for markdown rendering */}\n        <Streamdown>Any **markdown** content</Streamdown>\n        <Button variant=\"default\">Example Button</Button>\n      </main>\n    </div>\n  );\n}",
    "server/auth.logout.test.ts": "import { describe, expect, it } from \"vitest\";\nimport { appRouter } from \"./routers\";\nimport { COOKIE_NAME } from \"../shared/const\";\nimport type { TrpcContext } from \"./_core/context\";\n\ntype CookieCall = {\n  name: string;\n  options: Record<string, unknown>;\n};\n\ntype AuthenticatedUser = NonNullable<TrpcContext[\"user\"]>;\n\nfunction createAuthContext(): { ctx: TrpcContext; clearedCookies: CookieCall[] } {\n  const clearedCookies: CookieCall[] = [];\n\n  const user: AuthenticatedUser = {\n    id: 1,\n    openId: \"sample-user\",\n    email: \"sample@example.com\",\n    name: \"Sample User\",\n    loginMethod: \"manus\",\n    role: \"user\",\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    lastSignedIn: new Date(),\n  };\n\n  const ctx: TrpcContext = {\n    user,\n    req: {\n      protocol: \"https\",\n      headers: {},\n    } as TrpcContext[\"req\"],\n    res: {\n      clearCookie: (name: string, options: Record<string, unknown>) => {\n        clearedCookies.push({ name, options });\n      },\n    } as TrpcContext[\"res\"],\n  };\n\n  return { ctx, clearedCookies };\n}\n\ndescribe(\"auth.logout\", () => {\n  it(\"clears the session cookie and reports success\", async () => {\n    const { ctx, clearedCookies } = createAuthContext();\n    const caller = appRouter.createCaller(ctx);\n\n    const result = await caller.auth.logout();\n\n    expect(result).toEqual({ success: true });\n    expect(clearedCookies).toHaveLength(1);\n    expect(clearedCookies[0]?.name).toBe(COOKIE_NAME);\n    expect(clearedCookies[0]?.options).toMatchObject({\n      maxAge: -1,\n      secure: true,\n      sameSite: \"none\",\n      httpOnly: true,\n      path: \"/\",\n    });\n  });\n});"
  }
}
