@usequark/quark-create-app 1.23.11

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 (237) hide show
  1. package/README.md +210 -0
  2. package/package.json +56 -0
  3. package/src/dependabot-config.test.js +128 -0
  4. package/src/deploy/adapters/iac.js +203 -0
  5. package/src/deploy/adapters/index.js +27 -0
  6. package/src/deploy/adapters/railway.js +508 -0
  7. package/src/deploy/adapters/railway.test.js +318 -0
  8. package/src/deploy/contract.js +59 -0
  9. package/src/deploy/deploy.integration.test.js +183 -0
  10. package/src/deploy/deploy.js +430 -0
  11. package/src/deploy/deploy.test.js +105 -0
  12. package/src/deploy/discovery.js +115 -0
  13. package/src/deploy/discovery.test.js +141 -0
  14. package/src/deploy/index.js +38 -0
  15. package/src/deploy/inspect.js +130 -0
  16. package/src/index.js +2920 -0
  17. package/src/mobile-template.test.js +232 -0
  18. package/src/prepare-hooks.test.js +535 -0
  19. package/src/scaffold-guards.test.js +140 -0
  20. package/src/scaffold-output.test.js +228 -0
  21. package/src/template-scripts.test.js +160 -0
  22. package/src/utils.js +77 -0
  23. package/src/utils.test.js +95 -0
  24. package/src/worker-dockerfile.test.js +85 -0
  25. package/templates/base-project/.cursor/rules/quark.mdc +172 -0
  26. package/templates/base-project/.dockerignore +14 -0
  27. package/templates/base-project/.env.railway.example +87 -0
  28. package/templates/base-project/.github/copilot-instructions.md +65 -0
  29. package/templates/base-project/.github/dependabot.yml +31 -0
  30. package/templates/base-project/.github/skills/project-context/SKILL.md +109 -0
  31. package/templates/base-project/.github/workflows/ci.yml +116 -0
  32. package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
  33. package/templates/base-project/.github/workflows/install-windows.yml +27 -0
  34. package/templates/base-project/.github/workflows/release.yml +67 -0
  35. package/templates/base-project/.railway/railway.ts +40 -0
  36. package/templates/base-project/CLAUDE.md +163 -0
  37. package/templates/base-project/MAIN.md +21 -0
  38. package/templates/base-project/README.md +164 -0
  39. package/templates/base-project/apps/web/.env.example +99 -0
  40. package/templates/base-project/apps/web/Dockerfile +38 -0
  41. package/templates/base-project/apps/web/biome.json +8 -0
  42. package/templates/base-project/apps/web/jsconfig.json +9 -0
  43. package/templates/base-project/apps/web/next.config.js +154 -0
  44. package/templates/base-project/apps/web/package.json +42 -0
  45. package/templates/base-project/apps/web/postcss.config.mjs +7 -0
  46. package/templates/base-project/apps/web/public/.well-known/security.txt +3 -0
  47. package/templates/base-project/apps/web/public/quark.svg +47 -0
  48. package/templates/base-project/apps/web/scripts/prepare-standalone.mjs +71 -0
  49. package/templates/base-project/apps/web/src/app/_components/AsciiPanel.js +103 -0
  50. package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
  51. package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
  52. package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
  53. package/templates/base-project/apps/web/src/app/_components/SyncedPanels.js +212 -0
  54. package/templates/base-project/apps/web/src/app/_components/UmamiReplayRecorder.js +198 -0
  55. package/templates/base-project/apps/web/src/app/_components/UmamiWebVitals.js +39 -0
  56. package/templates/base-project/apps/web/src/app/api/auth/[...nextauth]/route.js +7 -0
  57. package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +82 -0
  58. package/templates/base-project/apps/web/src/app/api/auth/google/route.js +59 -0
  59. package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +48 -0
  60. package/templates/base-project/apps/web/src/app/api/auth/register/route.js +63 -0
  61. package/templates/base-project/apps/web/src/app/api/auth/token/route.js +44 -0
  62. package/templates/base-project/apps/web/src/app/api/csrf/route.js +40 -0
  63. package/templates/base-project/apps/web/src/app/api/device/register/route.js +57 -0
  64. package/templates/base-project/apps/web/src/app/api/error-handler.js +23 -0
  65. package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +89 -0
  66. package/templates/base-project/apps/web/src/app/api/files/route.js +120 -0
  67. package/templates/base-project/apps/web/src/app/api/health/route.js +160 -0
  68. package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +54 -0
  69. package/templates/base-project/apps/web/src/app/api/users/me/route.js +67 -0
  70. package/templates/base-project/apps/web/src/app/api/users/route.js +45 -0
  71. package/templates/base-project/apps/web/src/app/auth/error/page.js +83 -0
  72. package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +58 -0
  73. package/templates/base-project/apps/web/src/app/auth/provider-loading.js +25 -0
  74. package/templates/base-project/apps/web/src/app/auth/register/RegisterPageClient.js +340 -0
  75. package/templates/base-project/apps/web/src/app/auth/register/page.js +17 -0
  76. package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +230 -0
  77. package/templates/base-project/apps/web/src/app/auth/signin/page.js +10 -0
  78. package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
  79. package/templates/base-project/apps/web/src/app/global-error.js +58 -0
  80. package/templates/base-project/apps/web/src/app/globals.css +1166 -0
  81. package/templates/base-project/apps/web/src/app/icon.svg +47 -0
  82. package/templates/base-project/apps/web/src/app/layout.js +57 -0
  83. package/templates/base-project/apps/web/src/app/manifest.js +12 -0
  84. package/templates/base-project/apps/web/src/app/not-found.js +39 -0
  85. package/templates/base-project/apps/web/src/app/page.js +262 -0
  86. package/templates/base-project/apps/web/src/app/robots.js +23 -0
  87. package/templates/base-project/apps/web/src/app/sitemap.js +14 -0
  88. package/templates/base-project/apps/web/src/lib/analytics/umami-before-send.js +29 -0
  89. package/templates/base-project/apps/web/src/lib/analytics/umami-config.js +95 -0
  90. package/templates/base-project/apps/web/src/lib/analytics/umami-csp.js +24 -0
  91. package/templates/base-project/apps/web/src/lib/analytics/umami-marketing.js +131 -0
  92. package/templates/base-project/apps/web/src/lib/analytics/umami-replay.js +204 -0
  93. package/templates/base-project/apps/web/src/lib/analytics/umami-web-vitals.js +237 -0
  94. package/templates/base-project/apps/web/src/lib/analytics/umami.js +57 -0
  95. package/templates/base-project/apps/web/src/lib/auth-middleware.js +82 -0
  96. package/templates/base-project/apps/web/src/lib/auth-signup.js +5 -0
  97. package/templates/base-project/apps/web/src/lib/auth.js +191 -0
  98. package/templates/base-project/apps/web/src/lib/csrf-cookie.js +11 -0
  99. package/templates/base-project/apps/web/src/lib/jwt.js +84 -0
  100. package/templates/base-project/apps/web/src/lib/load-crm-config.js +35 -0
  101. package/templates/base-project/apps/web/src/lib/normalize-auth-request.js +33 -0
  102. package/templates/base-project/apps/web/src/lib/proxy-auth.js +80 -0
  103. package/templates/base-project/apps/web/src/lib/seo/indexing.js +25 -0
  104. package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +74 -0
  105. package/templates/base-project/apps/web/src/lib/sitemap-entries.js +22 -0
  106. package/templates/base-project/apps/web/src/preflight.js +55 -0
  107. package/templates/base-project/apps/web/src/proxy.js +328 -0
  108. package/templates/base-project/biome.json +52 -0
  109. package/templates/base-project/docker-compose.override.yml +17 -0
  110. package/templates/base-project/docker-compose.yml +37 -0
  111. package/templates/base-project/docs/FIRST_FEATURE.md +74 -0
  112. package/templates/base-project/docs/START_HERE.md +70 -0
  113. package/templates/base-project/docs/adr/README.md +42 -0
  114. package/templates/base-project/package.json +58 -0
  115. package/templates/base-project/packages/db/package.json +38 -0
  116. package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +202 -0
  117. package/templates/base-project/packages/db/prisma/migrations/migration_lock.toml +3 -0
  118. package/templates/base-project/packages/db/prisma/schema.prisma +161 -0
  119. package/templates/base-project/packages/db/prisma/seed.js +152 -0
  120. package/templates/base-project/packages/db/prisma.config.js +27 -0
  121. package/templates/base-project/packages/db/src/client.js +75 -0
  122. package/templates/base-project/packages/db/src/connection.js +44 -0
  123. package/templates/base-project/packages/db/src/context.js +275 -0
  124. package/templates/base-project/packages/db/src/index.js +5 -0
  125. package/templates/base-project/packages/db/src/ping.js +66 -0
  126. package/templates/base-project/packages/db/src/queries.js +292 -0
  127. package/templates/base-project/packages/db/src/schemas.js +30 -0
  128. package/templates/base-project/packages/db/src/umami.js +299 -0
  129. package/templates/base-project/pnpm-workspace.yaml +20 -0
  130. package/templates/base-project/scripts/check-loading.mjs +49 -0
  131. package/templates/base-project/scripts/check-standards.mjs +186 -0
  132. package/templates/base-project/scripts/clean-workspace.mjs +479 -0
  133. package/templates/base-project/scripts/dev-preflight.mjs +48 -0
  134. package/templates/base-project/scripts/doctor.js +408 -0
  135. package/templates/base-project/scripts/prepare.js +490 -0
  136. package/templates/base-project/scripts/run-tests.mjs +71 -0
  137. package/templates/base-project/skills/add-dashboard/SKILL.md +28 -0
  138. package/templates/base-project/skills/add-endpoint/SKILL.md +29 -0
  139. package/templates/base-project/skills/add-model/SKILL.md +28 -0
  140. package/templates/base-project/skills/ecommerce/SKILL.md +224 -0
  141. package/templates/base-project/skills/ecommerce-cart/SKILL.md +193 -0
  142. package/templates/base-project/skills/ecommerce-catalog/SKILL.md +179 -0
  143. package/templates/base-project/skills/ecommerce-checkout/SKILL.md +195 -0
  144. package/templates/base-project/skills/i18n/SKILL.md +211 -0
  145. package/templates/base-project/skills/payment/SKILL.md +280 -0
  146. package/templates/base-project/skills/quark-skills/SKILL.md +37 -0
  147. package/templates/base-project/turbo.json +143 -0
  148. package/templates/config/package.json +20 -0
  149. package/templates/config/src/app-url.js +134 -0
  150. package/templates/config/src/environment.js +284 -0
  151. package/templates/config/src/index.js +16 -0
  152. package/templates/config/src/load-config.js +157 -0
  153. package/templates/config/src/rate-limit.js +136 -0
  154. package/templates/config/src/redis.js +43 -0
  155. package/templates/config/src/validate-env.js +532 -0
  156. package/templates/jobs/README.md +52 -0
  157. package/templates/jobs/package.json +9 -0
  158. package/templates/jobs/src/definitions.js +13 -0
  159. package/templates/jobs/src/index.js +1 -0
  160. package/templates/mobile/.env.example +16 -0
  161. package/templates/mobile/CHECKLIST.md +40 -0
  162. package/templates/mobile/README.md +130 -0
  163. package/templates/mobile/app/(app)/_layout.tsx +83 -0
  164. package/templates/mobile/app/(app)/index.tsx +147 -0
  165. package/templates/mobile/app/(app)/profile.tsx +204 -0
  166. package/templates/mobile/app/(app)/settings.tsx +80 -0
  167. package/templates/mobile/app/(auth)/_layout.tsx +10 -0
  168. package/templates/mobile/app/(auth)/sign-in.tsx +140 -0
  169. package/templates/mobile/app/(auth)/sign-up.tsx +178 -0
  170. package/templates/mobile/app/+not-found.tsx +32 -0
  171. package/templates/mobile/app/_layout.tsx +18 -0
  172. package/templates/mobile/app.json +47 -0
  173. package/templates/mobile/assets/adaptive-icon.png +0 -0
  174. package/templates/mobile/assets/icon.png +0 -0
  175. package/templates/mobile/assets/notification-icon.png +0 -0
  176. package/templates/mobile/assets/splash.png +0 -0
  177. package/templates/mobile/biome.json +27 -0
  178. package/templates/mobile/components/AuthGate.tsx +19 -0
  179. package/templates/mobile/components/ThemeProvider.tsx +23 -0
  180. package/templates/mobile/eas.json +29 -0
  181. package/templates/mobile/hooks/use-auth.ts +127 -0
  182. package/templates/mobile/lib/api-client.ts +138 -0
  183. package/templates/mobile/lib/api.ts +83 -0
  184. package/templates/mobile/lib/auth-providers-google.ts +74 -0
  185. package/templates/mobile/lib/auth-providers.ts +74 -0
  186. package/templates/mobile/lib/auth.ts +100 -0
  187. package/templates/mobile/lib/config.ts +14 -0
  188. package/templates/mobile/lib/errors.ts +46 -0
  189. package/templates/mobile/lib/notifications.ts +77 -0
  190. package/templates/mobile/lib/storage.ts +25 -0
  191. package/templates/mobile/lib/theme-tokens.ts +16 -0
  192. package/templates/mobile/package.json +37 -0
  193. package/templates/mobile/tsconfig.json +10 -0
  194. package/templates/pwa/app/_components/PWARegister.js +15 -0
  195. package/templates/pwa/app/manifest.js +32 -0
  196. package/templates/pwa/public/sw.js +72 -0
  197. package/templates/ui/README.md +44 -0
  198. package/templates/ui/package.json +21 -0
  199. package/templates/ui/src/badge.js +23 -0
  200. package/templates/ui/src/button.js +111 -0
  201. package/templates/ui/src/card.js +128 -0
  202. package/templates/ui/src/checkbox.js +20 -0
  203. package/templates/ui/src/container.js +13 -0
  204. package/templates/ui/src/dialog.js +137 -0
  205. package/templates/ui/src/error-banner.js +15 -0
  206. package/templates/ui/src/footer.js +239 -0
  207. package/templates/ui/src/form-field.integration-test.js +142 -0
  208. package/templates/ui/src/form-field.js +50 -0
  209. package/templates/ui/src/index.js +24 -0
  210. package/templates/ui/src/input.js +11 -0
  211. package/templates/ui/src/label.js +14 -0
  212. package/templates/ui/src/lightbox.integration-test.js +167 -0
  213. package/templates/ui/src/lightbox.js +243 -0
  214. package/templates/ui/src/logo.js +52 -0
  215. package/templates/ui/src/navbar.js +602 -0
  216. package/templates/ui/src/password-input.js +31 -0
  217. package/templates/ui/src/rich-text.js +514 -0
  218. package/templates/ui/src/select.js +317 -0
  219. package/templates/ui/src/skeleton.js +10 -0
  220. package/templates/ui/src/spinner.js +31 -0
  221. package/templates/ui/src/table.js +169 -0
  222. package/templates/ui/src/textarea.js +11 -0
  223. package/templates/ui/src/theme-constants.js +24 -0
  224. package/templates/ui/src/theme.js +196 -0
  225. package/templates/ui/src/toast.js +206 -0
  226. package/templates/worker/Dockerfile +64 -0
  227. package/templates/worker/README.md +56 -0
  228. package/templates/worker/package.json +36 -0
  229. package/templates/worker/src/config/pricing.js +46 -0
  230. package/templates/worker/src/handlers/email-job-validation.js +36 -0
  231. package/templates/worker/src/handlers/email.js +98 -0
  232. package/templates/worker/src/handlers/files.js +59 -0
  233. package/templates/worker/src/handlers/index.js +20 -0
  234. package/templates/worker/src/handlers/push-notification.js +234 -0
  235. package/templates/worker/src/index.js +505 -0
  236. package/templates/worker/src/lib/railway.js +438 -0
  237. package/templates/worker/src/lib/tokens.js +81 -0
@@ -0,0 +1,59 @@
1
+ import { validateBody } from "@techstream/quark-core";
2
+ import { user } from "@techstream/quark-db";
3
+ import { NextResponse } from "next/server";
4
+ import { z } from "zod";
5
+ import { issueTokenPair } from "../../../../lib/jwt";
6
+ import { handleError } from "../../error-handler";
7
+
8
+ const googleAuthSchema = z.object({
9
+ idToken: z.string().min(1),
10
+ });
11
+
12
+ const GOOGLE_TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo";
13
+
14
+ /**
15
+ * POST /api/auth/google
16
+ * Verify Google ID token and issue a custom JWT.
17
+ */
18
+ export async function POST(request) {
19
+ try {
20
+ const { idToken } = await validateBody(request, googleAuthSchema);
21
+
22
+ // Verify the token with Google's tokeninfo endpoint
23
+ const tokenInfoResponse = await fetch(
24
+ `${GOOGLE_TOKENINFO_URL}?id_token=${idToken}`,
25
+ );
26
+
27
+ if (!tokenInfoResponse.ok) {
28
+ return NextResponse.json(
29
+ { message: "Invalid Google token" },
30
+ { status: 401 },
31
+ );
32
+ }
33
+
34
+ const tokenInfo = await tokenInfoResponse.json();
35
+
36
+ const googleEmail = tokenInfo.email;
37
+ if (!googleEmail) {
38
+ return NextResponse.json(
39
+ { message: "Google token does not contain an email" },
40
+ { status: 400 },
41
+ );
42
+ }
43
+
44
+ // Find or create user
45
+ let existingUser = await user.findByEmail(googleEmail);
46
+ if (!existingUser) {
47
+ existingUser = await user.create({
48
+ email: googleEmail,
49
+ name: tokenInfo.name || null,
50
+ });
51
+ }
52
+
53
+ const { token, refreshToken, expiresAt } =
54
+ await issueTokenPair(existingUser);
55
+ return NextResponse.json({ token, refreshToken, expiresAt });
56
+ } catch (error) {
57
+ return handleError(error);
58
+ }
59
+ }
@@ -0,0 +1,48 @@
1
+ import { createLogger, validateBody } from "@techstream/quark-core";
2
+ import { NextResponse } from "next/server";
3
+ import { z } from "zod";
4
+ import { issueTokenPair, verifyMobileToken } from "../../../../lib/jwt";
5
+ import { handleError } from "../../error-handler";
6
+
7
+ const logger = createLogger("auth-refresh");
8
+
9
+ const refreshRequestSchema = z.object({
10
+ refreshToken: z.string().min(1),
11
+ });
12
+
13
+ /**
14
+ * POST /api/auth/refresh
15
+ * Exchange a refresh token for a new access token.
16
+ */
17
+ export async function POST(request) {
18
+ try {
19
+ const { refreshToken } = await validateBody(request, refreshRequestSchema);
20
+
21
+ const payload = await verifyMobileToken(refreshToken);
22
+ if (!payload) {
23
+ logger.warn("Invalid refresh token");
24
+ return NextResponse.json(
25
+ { message: "Invalid or expired refresh token" },
26
+ { status: 401 },
27
+ );
28
+ }
29
+
30
+ if (payload.type !== "refresh") {
31
+ return NextResponse.json(
32
+ { message: "Invalid token type" },
33
+ { status: 401 },
34
+ );
35
+ }
36
+
37
+ const { token } = await issueTokenPair({
38
+ id: payload.sub,
39
+ email: payload.email,
40
+ name: payload.name,
41
+ role: payload.role,
42
+ });
43
+
44
+ return NextResponse.json({ token });
45
+ } catch (error) {
46
+ return handleError(error);
47
+ }
48
+ }
@@ -0,0 +1,63 @@
1
+ import {
2
+ createQueue,
3
+ hashPassword,
4
+ validateBody,
5
+ withCsrfProtection,
6
+ } from "@techstream/quark-core";
7
+ import { user, userRegisterSchema } from "@techstream/quark-db";
8
+ import { JOB_NAMES, JOB_QUEUES } from "@techstream/quark-jobs";
9
+ import { NextResponse } from "next/server";
10
+ import { isSignupEnabled } from "@/lib/auth-signup";
11
+ import { handleError } from "../../error-handler";
12
+
13
+ export const POST = withCsrfProtection(async (request) => {
14
+ if (!isSignupEnabled()) {
15
+ return NextResponse.json(
16
+ { message: "Registration is disabled." },
17
+ { status: 403 },
18
+ );
19
+ }
20
+
21
+ try {
22
+ const data = await validateBody(request, userRegisterSchema);
23
+
24
+ // Check if user exists
25
+ const existing = await user.findByEmail(data.email);
26
+ if (existing) {
27
+ return NextResponse.json(
28
+ { message: "User with this email already exists" },
29
+ { status: 409 },
30
+ );
31
+ }
32
+
33
+ const hashedPassword = await hashPassword(data.password);
34
+
35
+ // Remove password from data before passing to create (create expects plain data object, but we need to inject hashed password)
36
+ // We need to update the user.create method or pass it manually.
37
+ // The current user.create just takes 'data'.
38
+
39
+ // Prisma create data:
40
+ const newUser = await user.create({
41
+ email: data.email,
42
+ name: data.name,
43
+ password: hashedPassword,
44
+ });
45
+
46
+ // Don't return the password
47
+ const { password: _, ...safeUser } = newUser;
48
+
49
+ // Enqueue welcome email (fire-and-forget - don't block the response)
50
+ try {
51
+ const emailQueue = createQueue(JOB_QUEUES.EMAIL);
52
+ await emailQueue.add(JOB_NAMES.SEND_WELCOME_EMAIL, {
53
+ userId: newUser.id,
54
+ });
55
+ } catch {
56
+ // Non-critical - user is created even if email fails to enqueue
57
+ }
58
+
59
+ return NextResponse.json(safeUser, { status: 201 });
60
+ } catch (error) {
61
+ return handleError(error);
62
+ }
63
+ });
@@ -0,0 +1,44 @@
1
+ import { validateBody, verifyPassword } from "@techstream/quark-core";
2
+ import { user } from "@techstream/quark-db";
3
+ import { NextResponse } from "next/server";
4
+ import { z } from "zod";
5
+ import { issueTokenPair } from "../../../../lib/jwt";
6
+ import { handleError } from "../../error-handler";
7
+
8
+ const tokenRequestSchema = z.object({
9
+ email: z.string().email(),
10
+ password: z.string().min(1),
11
+ });
12
+
13
+ /**
14
+ * POST /api/auth/token
15
+ * Exchange email + password for JWT tokens.
16
+ * Used by the mobile app for Bearer-token auth.
17
+ */
18
+ export async function POST(request) {
19
+ try {
20
+ const { email, password } = await validateBody(request, tokenRequestSchema);
21
+
22
+ const existingUser = await user.findByEmail(email);
23
+ if (!existingUser?.password) {
24
+ return NextResponse.json(
25
+ { message: "Invalid credentials" },
26
+ { status: 401 },
27
+ );
28
+ }
29
+
30
+ const isValid = await verifyPassword(password, existingUser.password);
31
+ if (!isValid) {
32
+ return NextResponse.json(
33
+ { message: "Invalid credentials" },
34
+ { status: 401 },
35
+ );
36
+ }
37
+
38
+ const { token, refreshToken, expiresAt } =
39
+ await issueTokenPair(existingUser);
40
+ return NextResponse.json({ token, refreshToken, expiresAt });
41
+ } catch (error) {
42
+ return handleError(error);
43
+ }
44
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * CSRF Token API Endpoint
3
+ * Generates a CSRF token, stores it in a secure HTTP-only cookie,
4
+ * and returns it to the client for inclusion in request headers.
5
+ */
6
+
7
+ import { generateCsrfToken } from "@techstream/quark-core";
8
+ import { NextResponse } from "next/server";
9
+ import { auth } from "@/lib/auth";
10
+ import { getCsrfCookieOptions } from "@/lib/csrf-cookie";
11
+
12
+ export async function GET(request) {
13
+ try {
14
+ const session = await auth();
15
+
16
+ if (!session) {
17
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
18
+ }
19
+
20
+ // Generate CSRF token
21
+ const csrfToken = generateCsrfToken();
22
+
23
+ const response = NextResponse.json({ csrfToken });
24
+
25
+ // Store the token in a secure HTTP-only cookie so the server can
26
+ // validate it on subsequent state-changing requests.
27
+ response.cookies.set(
28
+ "csrf_token",
29
+ csrfToken,
30
+ getCsrfCookieOptions(request),
31
+ );
32
+
33
+ return response;
34
+ } catch (_error) {
35
+ return NextResponse.json(
36
+ { error: "Failed to generate CSRF token" },
37
+ { status: 500 },
38
+ );
39
+ }
40
+ }
@@ -0,0 +1,57 @@
1
+ import { validateBody } from "@techstream/quark-core";
2
+ import { prisma } from "@techstream/quark-db";
3
+ import { NextResponse } from "next/server";
4
+ import { z } from "zod";
5
+ import { extractBearerPayload } from "../../../../lib/jwt";
6
+ import { handleError } from "../../error-handler";
7
+
8
+ const deviceRegisterSchema = z.object({
9
+ platform: z.enum(["ios", "android"]),
10
+ pushToken: z.string().min(1),
11
+ deviceId: z.string().min(1),
12
+ });
13
+
14
+ /**
15
+ * POST /api/device/register
16
+ * Register a mobile device for push notifications.
17
+ * Requires authentication via Bearer token.
18
+ */
19
+ export async function POST(request) {
20
+ try {
21
+ const payload = await extractBearerPayload(request);
22
+ if (!payload?.sub) {
23
+ return NextResponse.json(
24
+ { message: "Authentication required" },
25
+ { status: 401 },
26
+ );
27
+ }
28
+
29
+ const userId = payload.sub;
30
+
31
+ const { platform, pushToken, deviceId } = await validateBody(
32
+ request,
33
+ deviceRegisterSchema,
34
+ );
35
+
36
+ // Upsert device record
37
+ await prisma.device.upsert({
38
+ where: {
39
+ userId_deviceId: { userId, deviceId },
40
+ },
41
+ update: {
42
+ pushToken,
43
+ platform,
44
+ },
45
+ create: {
46
+ userId,
47
+ platform,
48
+ pushToken,
49
+ deviceId,
50
+ },
51
+ });
52
+
53
+ return NextResponse.json({ success: true });
54
+ } catch (error) {
55
+ return handleError(error);
56
+ }
57
+ }
@@ -0,0 +1,23 @@
1
+ import { AppError, createLogger } from "@techstream/quark-core";
2
+ import { NextResponse } from "next/server";
3
+
4
+ const logger = createLogger("api");
5
+
6
+ export function handleError(error) {
7
+ logger.error("API Error", { error: error.message, stack: error.stack });
8
+
9
+ if (error instanceof AppError) {
10
+ return NextResponse.json(error.toJSON(), { status: error.statusCode });
11
+ }
12
+
13
+ // Fallback for unhandled errors
14
+ return NextResponse.json(
15
+ {
16
+ name: "InternalServerError",
17
+ message: "An unexpected error occurred",
18
+ statusCode: 500,
19
+ code: "INTERNAL_ERROR",
20
+ },
21
+ { status: 500 },
22
+ );
23
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Single File API
3
+ * GET /api/files/[id] - Download / serve a file
4
+ * DELETE /api/files/[id] - Delete a file (owner or admin only)
5
+ */
6
+
7
+ import {
8
+ createStorage,
9
+ ForbiddenError,
10
+ NotFoundError,
11
+ withCsrfProtection,
12
+ } from "@techstream/quark-core";
13
+ import { file } from "@techstream/quark-db";
14
+ import { NextResponse } from "next/server";
15
+ import { requireAuth } from "@/lib/auth-middleware";
16
+ import { handleError } from "../../error-handler";
17
+
18
+ /**
19
+ * GET /api/files/[id]
20
+ * Serve / download a file by its database ID.
21
+ * Public endpoint (no auth required) - access control is by knowledge of ID.
22
+ */
23
+ export async function GET(_request, { params }) {
24
+ try {
25
+ const { id } = await params;
26
+
27
+ const record = await file.findById(id);
28
+ if (!record) {
29
+ throw new NotFoundError("File not found");
30
+ }
31
+
32
+ const storage = createStorage();
33
+ const { body, contentType } = await storage.get(record.storageKey);
34
+
35
+ const headers = new Headers();
36
+ headers.set("Content-Type", contentType || record.mimeType);
37
+ headers.set("Content-Length", String(body.length));
38
+ headers.set("Cache-Control", "public, max-age=31536000, immutable");
39
+
40
+ // Inline display for images; attachment download for everything else
41
+ const isImage = record.mimeType.startsWith("image/");
42
+ const safeName = record.originalName.replace(/[\\"\r\n]/g, "_");
43
+ const encodedName = encodeURIComponent(record.originalName);
44
+ headers.set(
45
+ "Content-Disposition",
46
+ isImage
47
+ ? `inline; filename="${safeName}"; filename*=UTF-8''${encodedName}`
48
+ : `attachment; filename="${safeName}"; filename*=UTF-8''${encodedName}`,
49
+ );
50
+
51
+ return new Response(body, { status: 200, headers });
52
+ } catch (error) {
53
+ return handleError(error);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * DELETE /api/files/[id]
59
+ * Delete a file. Only the uploader or an admin can delete.
60
+ */
61
+ export const DELETE = withCsrfProtection(async (_request, { params }) => {
62
+ try {
63
+ const session = await requireAuth();
64
+ const { id } = await params;
65
+
66
+ const record = await file.findById(id);
67
+ if (!record) {
68
+ throw new NotFoundError("File not found");
69
+ }
70
+
71
+ // Authorization: uploader or admin
72
+ const isOwner = record.uploadedById === session.user.id;
73
+ const isAdmin = session.user.role === "admin";
74
+ if (!isOwner && !isAdmin) {
75
+ throw new ForbiddenError("You can only delete your own files");
76
+ }
77
+
78
+ // Remove from storage
79
+ const storage = createStorage();
80
+ await storage.delete(record.storageKey);
81
+
82
+ // Remove database record
83
+ await file.delete(record.id);
84
+
85
+ return NextResponse.json({ message: "File deleted" });
86
+ } catch (error) {
87
+ return handleError(error);
88
+ }
89
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * File Upload API
3
+ * POST /api/files - Upload a file (multipart/form-data)
4
+ * GET /api/files - List current user's files
5
+ */
6
+
7
+ import {
8
+ createStorage,
9
+ generateStorageKey,
10
+ parseMultipart,
11
+ parsePaginationQuery,
12
+ validateFile,
13
+ withCsrfProtection,
14
+ } from "@techstream/quark-core";
15
+ import { file } from "@techstream/quark-db";
16
+ import { NextResponse } from "next/server";
17
+ import { requireAuth } from "@/lib/auth-middleware";
18
+ import { handleError } from "../error-handler";
19
+
20
+ /**
21
+ * POST /api/files
22
+ * Upload one or more files via multipart/form-data.
23
+ * Requires authentication.
24
+ */
25
+ export const POST = withCsrfProtection(async (request) => {
26
+ try {
27
+ const session = await requireAuth();
28
+
29
+ // Parse multipart body
30
+ const { files: parsedFiles } = await parseMultipart(request);
31
+
32
+ if (parsedFiles.length === 0) {
33
+ return NextResponse.json(
34
+ { message: "No files provided" },
35
+ { status: 400 },
36
+ );
37
+ }
38
+
39
+ const storage = createStorage();
40
+ const results = [];
41
+
42
+ for (const parsed of parsedFiles) {
43
+ // Validate each file
44
+ const validation = validateFile({
45
+ filename: parsed.filename,
46
+ mimeType: parsed.mimeType,
47
+ size: parsed.size,
48
+ buffer: parsed.buffer,
49
+ });
50
+
51
+ if (!validation.valid) {
52
+ return NextResponse.json(
53
+ { message: validation.error, filename: parsed.filename },
54
+ { status: 422 },
55
+ );
56
+ }
57
+
58
+ // Generate storage key and upload
59
+ const storageKey = generateStorageKey(parsed.filename);
60
+
61
+ await storage.put(storageKey, parsed.buffer, {
62
+ contentType: parsed.mimeType,
63
+ });
64
+
65
+ // Save metadata to database
66
+ const record = await file.create({
67
+ filename: storageKey.split("/").pop(),
68
+ originalName: parsed.filename,
69
+ mimeType: parsed.mimeType,
70
+ size: parsed.size,
71
+ storageKey,
72
+ storageProvider: storage.provider,
73
+ uploadedById: session.user.id,
74
+ });
75
+
76
+ results.push({
77
+ id: record.id,
78
+ originalName: record.originalName,
79
+ mimeType: record.mimeType,
80
+ size: record.size,
81
+ url: `/api/files/${record.id}`,
82
+ createdAt: record.createdAt,
83
+ });
84
+ }
85
+
86
+ const status = results.length === 1 ? 201 : 200;
87
+ const body = results.length === 1 ? results[0] : { files: results };
88
+
89
+ return NextResponse.json(body, { status });
90
+ } catch (error) {
91
+ return handleError(error);
92
+ }
93
+ });
94
+
95
+ /**
96
+ * GET /api/files
97
+ * List files uploaded by the current user.
98
+ */
99
+ export async function GET(request) {
100
+ try {
101
+ const session = await requireAuth();
102
+
103
+ const { searchParams } = new URL(request.url);
104
+ const { skip, take } = parsePaginationQuery(searchParams);
105
+ const files = await file.findByUploader(session.user.id, { skip, take });
106
+
107
+ const mapped = files.map((f) => ({
108
+ id: f.id,
109
+ originalName: f.originalName,
110
+ mimeType: f.mimeType,
111
+ size: f.size,
112
+ url: `/api/files/${f.id}`,
113
+ createdAt: f.createdAt,
114
+ }));
115
+
116
+ return NextResponse.json(mapped);
117
+ } catch (error) {
118
+ return handleError(error);
119
+ }
120
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Health Check Endpoint
3
+ * Verifies the application and its dependencies are functioning correctly.
4
+ * Times out after 5 seconds to prevent hanging.
5
+ */
6
+
7
+ import {
8
+ createLogger,
9
+ createStorage,
10
+ getRegisteredQueues,
11
+ pingRedis,
12
+ } from "@techstream/quark-core";
13
+ import { pingDatabase } from "@techstream/quark-db";
14
+ import { NextResponse } from "next/server";
15
+
16
+ const logger = createLogger("health");
17
+
18
+ /** Overall timeout for the health check (ms). */
19
+ const HEALTH_CHECK_TIMEOUT = 5000;
20
+
21
+ export async function GET() {
22
+ try {
23
+ const result = await new Promise((resolve, reject) => {
24
+ const timer = setTimeout(
25
+ () => reject(new Error("Health check timed out")),
26
+ HEALTH_CHECK_TIMEOUT,
27
+ );
28
+ runHealthChecks().then(
29
+ (val) => {
30
+ clearTimeout(timer);
31
+ resolve(val);
32
+ },
33
+ (err) => {
34
+ clearTimeout(timer);
35
+ reject(err);
36
+ },
37
+ );
38
+ });
39
+
40
+ return NextResponse.json(result, {
41
+ status: result.status === "error" ? 503 : 200,
42
+ });
43
+ } catch (error) {
44
+ logger.error("Health check failed", {
45
+ error: error.message,
46
+ stack: error.stack,
47
+ });
48
+ return NextResponse.json(
49
+ {
50
+ status: "error",
51
+ timestamp: new Date().toISOString(),
52
+ message: "Service health check failed",
53
+ },
54
+ { status: 500 },
55
+ );
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Runs all individual health checks and aggregates the results.
61
+ * @returns {Promise<{ status: string, timestamp: string, checks: Record<string, object> }>}
62
+ */
63
+ async function runHealthChecks() {
64
+ const health = {
65
+ status: "ok",
66
+ timestamp: new Date().toISOString(),
67
+ checks: {},
68
+ };
69
+
70
+ // Check database connectivity
71
+ const dbResult = await pingDatabase();
72
+ if (dbResult.status === "ok") {
73
+ health.checks.database = { status: "ok", latencyMs: dbResult.latencyMs };
74
+ } else {
75
+ health.checks.database = {
76
+ status: "error",
77
+ message: dbResult.message,
78
+ };
79
+ health.status = "degraded";
80
+ }
81
+
82
+ // Check Redis connectivity (actual PING)
83
+ const redisResult = await pingRedis();
84
+ if (redisResult.status === "ok") {
85
+ health.checks.redis = { status: "ok", latencyMs: redisResult.latencyMs };
86
+ } else {
87
+ health.checks.redis = {
88
+ status: "error",
89
+ message: redisResult.message,
90
+ };
91
+ health.status = "degraded";
92
+ }
93
+
94
+ // Check storage connectivity
95
+ const storageResult = await checkStorage();
96
+ health.checks.storage = storageResult;
97
+ if (storageResult.status === "error") {
98
+ health.status = "degraded";
99
+ }
100
+
101
+ // Check queue depths (only when queues are registered in this process)
102
+ const registeredQueues = getRegisteredQueues();
103
+ if (registeredQueues.size > 0) {
104
+ const entries = [...registeredQueues.entries()];
105
+ const results = await Promise.allSettled(
106
+ entries.map(([, queue]) =>
107
+ Promise.all([
108
+ queue.getWaitingCount(),
109
+ queue.getActiveCount(),
110
+ queue.getFailedCount(),
111
+ ]),
112
+ ),
113
+ );
114
+
115
+ const queueChecks = {};
116
+ for (let i = 0; i < entries.length; i++) {
117
+ const [name] = entries[i];
118
+ const result = results[i];
119
+ if (result.status === "fulfilled") {
120
+ const [waiting, active, failed] = result.value;
121
+ queueChecks[name] = { status: "ok", waiting, active, failed };
122
+ } else {
123
+ const message =
124
+ process.env.NODE_ENV === "production"
125
+ ? "Queue unavailable"
126
+ : result.reason?.message;
127
+ queueChecks[name] = { status: "error", message };
128
+ health.status = "degraded";
129
+ }
130
+ }
131
+ health.checks.queues = queueChecks;
132
+ }
133
+
134
+ return health;
135
+ }
136
+
137
+ /**
138
+ * Verifies storage is reachable and writable by writing then deleting a
139
+ * small sentinel object. Uses whichever provider is configured via
140
+ * STORAGE_PROVIDER (defaults to "local" when unset).
141
+ * @returns {Promise<{ status: string, provider: string, message?: string }>}
142
+ */
143
+ async function checkStorage() {
144
+ const provider = process.env.STORAGE_PROVIDER || "local";
145
+ try {
146
+ const storage = createStorage();
147
+ const sentinelKey = ".health-check-sentinel";
148
+ await storage.put(sentinelKey, Buffer.from("ok"), {
149
+ contentType: "text/plain",
150
+ });
151
+ await storage.delete(sentinelKey);
152
+ return { status: "ok", provider };
153
+ } catch (error) {
154
+ const message =
155
+ process.env.NODE_ENV === "production"
156
+ ? "Storage unavailable"
157
+ : error.message;
158
+ return { status: "error", provider, message };
159
+ }
160
+ }