@techstream/quark-create-app 1.5.2 → 1.6.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/README.md +38 -0
- package/package.json +5 -2
- package/src/index.js +142 -12
- package/templates/base-project/.github/copilot-instructions.md +7 -0
- package/templates/base-project/.github/dependabot.yml +12 -0
- package/templates/base-project/.github/skills/project-context/SKILL.md +106 -0
- package/templates/base-project/.github/workflows/ci.yml +97 -0
- package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
- package/templates/base-project/.github/workflows/release.yml +38 -0
- package/templates/base-project/apps/web/biome.json +7 -0
- package/templates/base-project/apps/web/jsconfig.json +10 -0
- package/templates/base-project/apps/web/next.config.js +86 -1
- package/templates/base-project/apps/web/package.json +4 -4
- package/templates/base-project/apps/web/src/app/api/auth/register/route.js +9 -9
- package/templates/base-project/apps/web/src/app/api/files/route.js +3 -2
- package/templates/base-project/apps/web/src/app/layout.js +3 -4
- package/templates/base-project/apps/web/src/app/manifest.js +12 -0
- package/templates/base-project/apps/web/src/app/robots.js +21 -0
- package/templates/base-project/apps/web/src/app/sitemap.js +20 -0
- package/templates/base-project/apps/web/src/lib/seo/indexing.js +23 -0
- package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +33 -0
- package/templates/base-project/apps/web/src/proxy.js +1 -2
- package/templates/base-project/apps/worker/package.json +4 -4
- package/templates/base-project/apps/worker/src/index.js +40 -12
- package/templates/base-project/apps/worker/src/index.test.js +296 -15
- package/templates/base-project/biome.json +44 -0
- package/templates/base-project/docker-compose.yml +7 -4
- package/templates/base-project/package.json +1 -1
- package/templates/base-project/packages/db/package.json +1 -1
- package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +42 -0
- package/templates/base-project/packages/db/prisma/schema.prisma +20 -16
- package/templates/base-project/packages/db/prisma.config.ts +3 -2
- package/templates/base-project/packages/db/scripts/seed.js +117 -30
- package/templates/base-project/packages/db/src/queries.js +58 -68
- package/templates/base-project/packages/db/src/queries.test.js +0 -29
- package/templates/base-project/packages/db/src/schemas.js +4 -10
- package/templates/base-project/pnpm-workspace.yaml +4 -0
- package/templates/base-project/turbo.json +5 -3
- package/templates/config/package.json +2 -0
- package/templates/config/src/environment.js +270 -0
- package/templates/config/src/index.js +10 -18
- package/templates/config/src/load-config.js +135 -0
- package/templates/config/src/validate-env.js +60 -2
- package/templates/jobs/package.json +2 -2
- package/templates/jobs/src/definitions.test.js +34 -0
- package/templates/jobs/src/index.js +1 -1
- package/templates/ui/package.json +4 -4
- package/templates/ui/src/button.test.js +23 -0
- package/templates/ui/src/index.js +1 -3
- package/templates/base-project/apps/web/src/app/api/posts/[id]/route.js +0 -65
- package/templates/base-project/apps/web/src/app/api/posts/route.js +0 -42
- package/templates/ui/src/card.js +0 -14
- package/templates/ui/src/input.js +0 -11
|
@@ -1,6 +1,91 @@
|
|
|
1
1
|
/** @type {import('next').NextConfig} */
|
|
2
2
|
const nextConfig = {
|
|
3
|
-
//
|
|
3
|
+
// Support workspace package resolution (including @techstream/quark-db which uses
|
|
4
|
+
// the Prisma driver-adapter pattern — pure JS, no native engine binary)
|
|
5
|
+
transpilePackages: [
|
|
6
|
+
"@techstream/quark-core",
|
|
7
|
+
"@techstream/quark-db",
|
|
8
|
+
"@techstream/quark-ui",
|
|
9
|
+
"@techstream/quark-jobs",
|
|
10
|
+
],
|
|
11
|
+
|
|
12
|
+
// Security headers
|
|
13
|
+
// NOTE: These are also applied by proxy.js for proxy-matched routes.
|
|
14
|
+
// Keeping them here as a fallback for routes the proxy doesn't match.
|
|
15
|
+
async headers() {
|
|
16
|
+
return [
|
|
17
|
+
{
|
|
18
|
+
source: "/:path*",
|
|
19
|
+
headers: [
|
|
20
|
+
{
|
|
21
|
+
key: "X-DNS-Prefetch-Control",
|
|
22
|
+
value: "on",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
key: "X-Frame-Options",
|
|
26
|
+
value: "SAMEORIGIN",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
key: "X-Content-Type-Options",
|
|
30
|
+
value: "nosniff",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
key: "Referrer-Policy",
|
|
34
|
+
value: "strict-origin-when-cross-origin",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
key: "Permissions-Policy",
|
|
38
|
+
value: "camera=(), microphone=(), geolocation=()",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
key: "Content-Security-Policy",
|
|
42
|
+
value:
|
|
43
|
+
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self';",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// Environment variables validation
|
|
51
|
+
env: {
|
|
52
|
+
APP_URL: process.env.APP_URL,
|
|
53
|
+
NEXTAUTH_URL: process.env.NEXTAUTH_URL || process.env.APP_URL,
|
|
54
|
+
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
// Request body size limits (security)
|
|
58
|
+
experimental: {
|
|
59
|
+
// Limit request body size to prevent DoS attacks
|
|
60
|
+
// Default is 4MB, we're being explicit here
|
|
61
|
+
// Adjust based on your needs (e.g., larger for file uploads)
|
|
62
|
+
serverActions: {
|
|
63
|
+
bodySizeLimit: "2mb", // For Server Actions
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
// API route configuration
|
|
68
|
+
async rewrites() {
|
|
69
|
+
return [];
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
// Compiler options for production optimization
|
|
73
|
+
compiler: {
|
|
74
|
+
removeConsole:
|
|
75
|
+
process.env.NODE_ENV === "production"
|
|
76
|
+
? { exclude: ["error", "warn"] }
|
|
77
|
+
: false,
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
// Image optimization configuration
|
|
81
|
+
images: {
|
|
82
|
+
domains: [],
|
|
83
|
+
formats: ["image/avif", "image/webp"],
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
// Production-only settings
|
|
87
|
+
poweredByHeader: false,
|
|
88
|
+
compress: true,
|
|
4
89
|
};
|
|
5
90
|
|
|
6
91
|
export default nextConfig;
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"zod": "^4.3.6"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@
|
|
29
|
-
"@
|
|
30
|
-
"
|
|
31
|
-
"
|
|
28
|
+
"@tailwindcss/postcss": "^4.1.18",
|
|
29
|
+
"@types/node": "^20.19.33",
|
|
30
|
+
"tailwindcss": "^4.1.18",
|
|
31
|
+
"@techstream/quark-config": "workspace:*"
|
|
32
32
|
}
|
|
33
33
|
}
|
|
@@ -2,13 +2,14 @@ import {
|
|
|
2
2
|
createQueue,
|
|
3
3
|
hashPassword,
|
|
4
4
|
validateBody,
|
|
5
|
+
withCsrfProtection,
|
|
5
6
|
} from "@techstream/quark-core";
|
|
6
7
|
import { user, userRegisterSchema } from "@techstream/quark-db";
|
|
7
8
|
import { JOB_NAMES, JOB_QUEUES } from "@techstream/quark-jobs";
|
|
8
9
|
import { NextResponse } from "next/server";
|
|
9
10
|
import { handleError } from "../../error-handler";
|
|
10
11
|
|
|
11
|
-
export
|
|
12
|
+
export const POST = withCsrfProtection(async (request) => {
|
|
12
13
|
try {
|
|
13
14
|
const data = await validateBody(request, userRegisterSchema);
|
|
14
15
|
|
|
@@ -34,22 +35,21 @@ export async function POST(request) {
|
|
|
34
35
|
password: hashedPassword,
|
|
35
36
|
});
|
|
36
37
|
|
|
37
|
-
//
|
|
38
|
+
// Don't return the password
|
|
39
|
+
const { password: _, ...safeUser } = newUser;
|
|
40
|
+
|
|
41
|
+
// Enqueue welcome email (fire-and-forget — don't block the response)
|
|
38
42
|
try {
|
|
39
43
|
const emailQueue = createQueue(JOB_QUEUES.EMAIL);
|
|
40
44
|
await emailQueue.add(JOB_NAMES.SEND_WELCOME_EMAIL, {
|
|
41
45
|
userId: newUser.id,
|
|
42
46
|
});
|
|
43
|
-
} catch
|
|
44
|
-
//
|
|
45
|
-
console.error("Failed to enqueue welcome email:", emailError);
|
|
47
|
+
} catch {
|
|
48
|
+
// Non-critical — user is created even if email fails to enqueue
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
// Don't return the password
|
|
49
|
-
const { password: _, ...safeUser } = newUser;
|
|
50
|
-
|
|
51
51
|
return NextResponse.json(safeUser, { status: 201 });
|
|
52
52
|
} catch (error) {
|
|
53
53
|
return handleError(error);
|
|
54
54
|
}
|
|
55
|
-
}
|
|
55
|
+
});
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
generateStorageKey,
|
|
10
10
|
parseMultipart,
|
|
11
11
|
validateFile,
|
|
12
|
+
withCsrfProtection,
|
|
12
13
|
} from "@techstream/quark-core";
|
|
13
14
|
import { file } from "@techstream/quark-db";
|
|
14
15
|
import { NextResponse } from "next/server";
|
|
@@ -26,7 +27,7 @@ const paginationSchema = z.object({
|
|
|
26
27
|
* Upload one or more files via multipart/form-data.
|
|
27
28
|
* Requires authentication.
|
|
28
29
|
*/
|
|
29
|
-
export
|
|
30
|
+
export const POST = withCsrfProtection(async (request) => {
|
|
30
31
|
try {
|
|
31
32
|
const session = await requireAuth();
|
|
32
33
|
|
|
@@ -94,7 +95,7 @@ export async function POST(request) {
|
|
|
94
95
|
} catch (error) {
|
|
95
96
|
return handleError(error);
|
|
96
97
|
}
|
|
97
|
-
}
|
|
98
|
+
});
|
|
98
99
|
|
|
99
100
|
/**
|
|
100
101
|
* GET /api/files
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
};
|
|
1
|
+
import { getSiteMetadata } from "../lib/seo/site-metadata.js";
|
|
2
|
+
|
|
3
|
+
export const metadata = getSiteMetadata();
|
|
5
4
|
|
|
6
5
|
export default function RootLayout({ children }) {
|
|
7
6
|
return (
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { config, getAppUrl } from "@techstream/quark-config";
|
|
2
|
+
|
|
3
|
+
export default function manifest() {
|
|
4
|
+
return {
|
|
5
|
+
name: config.appName,
|
|
6
|
+
short_name: config.appName,
|
|
7
|
+
description: config.appDescription,
|
|
8
|
+
start_url: "/",
|
|
9
|
+
display: "standalone",
|
|
10
|
+
id: getAppUrl(),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getAppUrl } from "@techstream/quark-config";
|
|
2
|
+
import { isWebsiteIndexable } from "../lib/seo/indexing.js";
|
|
3
|
+
|
|
4
|
+
export default function robots() {
|
|
5
|
+
const appUrl = getAppUrl();
|
|
6
|
+
const indexable = isWebsiteIndexable();
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
rules: indexable
|
|
10
|
+
? {
|
|
11
|
+
userAgent: "*",
|
|
12
|
+
allow: "/",
|
|
13
|
+
disallow: ["/api/"],
|
|
14
|
+
}
|
|
15
|
+
: {
|
|
16
|
+
userAgent: "*",
|
|
17
|
+
disallow: "/",
|
|
18
|
+
},
|
|
19
|
+
sitemap: indexable ? `${appUrl}/sitemap.xml` : undefined,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getAppUrl } from "@techstream/quark-config";
|
|
2
|
+
import { isWebsiteIndexable } from "../lib/seo/indexing.js";
|
|
3
|
+
|
|
4
|
+
const STATIC_ROUTES = [{ path: "/", changeFrequency: "daily", priority: 1 }];
|
|
5
|
+
|
|
6
|
+
export default function sitemap() {
|
|
7
|
+
if (!isWebsiteIndexable()) {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const appUrl = getAppUrl();
|
|
12
|
+
const lastModified = new Date();
|
|
13
|
+
|
|
14
|
+
return STATIC_ROUTES.map((route) => ({
|
|
15
|
+
url: `${appUrl}${route.path}`,
|
|
16
|
+
lastModified,
|
|
17
|
+
changeFrequency: route.changeFrequency,
|
|
18
|
+
priority: route.priority,
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function isWebsiteIndexable(env = process.env) {
|
|
2
|
+
return (env.NODE_ENV || "").toLowerCase() === "production";
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function getMetadataRobots(env = process.env) {
|
|
6
|
+
if (isWebsiteIndexable(env)) {
|
|
7
|
+
return {
|
|
8
|
+
index: true,
|
|
9
|
+
follow: true,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
index: false,
|
|
15
|
+
follow: false,
|
|
16
|
+
nocache: true,
|
|
17
|
+
googleBot: {
|
|
18
|
+
index: false,
|
|
19
|
+
follow: false,
|
|
20
|
+
noimageindex: true,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { config, getAppUrl } from "@techstream/quark-config";
|
|
2
|
+
import { getMetadataRobots } from "./indexing.js";
|
|
3
|
+
|
|
4
|
+
const appUrl = getAppUrl();
|
|
5
|
+
const { appName, appDescription } = config;
|
|
6
|
+
|
|
7
|
+
export function getSiteMetadata() {
|
|
8
|
+
return {
|
|
9
|
+
metadataBase: new URL(appUrl),
|
|
10
|
+
title: {
|
|
11
|
+
default: appName,
|
|
12
|
+
template: `%s | ${appName}`,
|
|
13
|
+
},
|
|
14
|
+
description: appDescription,
|
|
15
|
+
applicationName: appName,
|
|
16
|
+
alternates: {
|
|
17
|
+
canonical: "/",
|
|
18
|
+
},
|
|
19
|
+
openGraph: {
|
|
20
|
+
type: "website",
|
|
21
|
+
url: appUrl,
|
|
22
|
+
title: appName,
|
|
23
|
+
description: appDescription,
|
|
24
|
+
siteName: appName,
|
|
25
|
+
},
|
|
26
|
+
twitter: {
|
|
27
|
+
card: "summary",
|
|
28
|
+
title: appName,
|
|
29
|
+
description: appDescription,
|
|
30
|
+
},
|
|
31
|
+
robots: getMetadataRobots(),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -151,8 +151,7 @@ export function proxy(request) {
|
|
|
151
151
|
|
|
152
152
|
// Apply rate limiting to API routes only
|
|
153
153
|
if (pathname.startsWith("/api/")) {
|
|
154
|
-
const ip =
|
|
155
|
-
request.ip || request.headers.get("x-forwarded-for") || "unknown";
|
|
154
|
+
const ip = request.ip || "unknown";
|
|
156
155
|
const rateLimitResult = checkRateLimit(ip, pathname);
|
|
157
156
|
|
|
158
157
|
if (rateLimitResult.limited) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@techstream/quark-worker",
|
|
3
3
|
"version": "1.0.0",
|
|
4
|
-
"type": "module",
|
|
5
4
|
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
6
|
"description": "",
|
|
7
7
|
"main": "index.js",
|
|
8
8
|
"scripts": {
|
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
"@techstream/quark-core": "^1.0.0",
|
|
19
19
|
"@techstream/quark-db": "workspace:*",
|
|
20
20
|
"@techstream/quark-jobs": "workspace:*",
|
|
21
|
-
"bullmq": "^5.
|
|
21
|
+
"bullmq": "^5.67.3"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@techstream/quark-config": "workspace:*",
|
|
25
|
-
"@types/node": "^24.10.
|
|
26
|
-
"tsx": "^4.
|
|
25
|
+
"@types/node": "^24.10.12",
|
|
26
|
+
"tsx": "^4.21.0"
|
|
27
27
|
}
|
|
28
28
|
}
|
|
@@ -17,6 +17,7 @@ const logger = createLogger("worker");
|
|
|
17
17
|
|
|
18
18
|
// Store workers for graceful shutdown
|
|
19
19
|
const workers = [];
|
|
20
|
+
let isShuttingDown = false;
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Generic queue processor — dispatches jobs to registered handlers
|
|
@@ -56,6 +57,20 @@ function createQueueWorker(queueName) {
|
|
|
56
57
|
);
|
|
57
58
|
});
|
|
58
59
|
|
|
60
|
+
queueWorker.on("stalled", (jobId) => {
|
|
61
|
+
logger.warn(`Job ${jobId} in queue "${queueName}" has stalled`, {
|
|
62
|
+
queueName,
|
|
63
|
+
jobId,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
queueWorker.on("error", (error) => {
|
|
68
|
+
logger.error(`Worker error in queue "${queueName}"`, {
|
|
69
|
+
error: error.message,
|
|
70
|
+
queueName,
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
59
74
|
logger.info(
|
|
60
75
|
`Queue "${queueName}" worker started (concurrency: ${queueWorker.opts.concurrency})`,
|
|
61
76
|
);
|
|
@@ -99,15 +114,31 @@ async function startWorker() {
|
|
|
99
114
|
/**
|
|
100
115
|
* Graceful shutdown handler
|
|
101
116
|
*/
|
|
102
|
-
async function shutdown() {
|
|
103
|
-
|
|
117
|
+
async function shutdown(signal = "unknown") {
|
|
118
|
+
if (isShuttingDown) {
|
|
119
|
+
logger.warn("Shutdown already in progress", { signal });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
isShuttingDown = true;
|
|
124
|
+
logger.info("Shutting down worker service", { signal });
|
|
104
125
|
|
|
105
126
|
try {
|
|
106
127
|
for (const worker of workers) {
|
|
107
128
|
await worker.close();
|
|
108
129
|
}
|
|
109
130
|
|
|
110
|
-
|
|
131
|
+
try {
|
|
132
|
+
await prisma.$disconnect();
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error.message?.includes("environment variable is required")) {
|
|
135
|
+
logger.warn("Skipping Prisma disconnect due missing database env", {
|
|
136
|
+
error: error.message,
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
111
142
|
|
|
112
143
|
logger.info("All workers closed");
|
|
113
144
|
process.exit(0);
|
|
@@ -120,14 +151,11 @@ async function shutdown() {
|
|
|
120
151
|
}
|
|
121
152
|
}
|
|
122
153
|
|
|
123
|
-
process.on("SIGTERM",
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
process.on("SIGTERM", shutdown);
|
|
130
|
-
process.on("SIGINT", shutdown);
|
|
154
|
+
process.on("SIGTERM", () => {
|
|
155
|
+
void shutdown("SIGTERM");
|
|
156
|
+
});
|
|
157
|
+
process.on("SIGINT", () => {
|
|
158
|
+
void shutdown("SIGINT");
|
|
159
|
+
});
|
|
131
160
|
|
|
132
|
-
// Start the worker service
|
|
133
161
|
startWorker();
|