This file provides guidance for Claude (Anthropic) when working with this codebase.
Countly is a product analytics platform. Backend: Node.js 22+, MongoDB. Frontend: Vue 2, Element UI, Backbone (legacy). Architecture is plugin-based.
npm run start:all:dev # Start all services (API:3001, Frontend:6001)
npx grunt dist-all # Build static assets (required after JS changes)
npx grunt locales # Build locale files
npx grunt sass # Compile SASS only
# Testing
npm run test:unit # Unit tests
npm run test:plugin -- name # Single plugin tests
countly plugin lint name # Lint plugin
countly shellcheck # Validate shell scriptsALWAYS follow these - no exceptions:
-
API endpoints must use validation:
const { validateRead, validateCreate, validateUpdate, validateDelete } = require('../../../api/utils/rights.js'); validateRead(params, FEATURE_NAME, () => { /* handler */ });
-
Write operations must include app_id:
// CORRECT - prevents cross-app access db.collection("items").deleteOne({_id: id, app_id: params.app_id + ""});
-
Cast user input to strings for auth:
params.username = params.username + "";
-
Use spawn, not exec for shell commands:
cp.spawn("command", [userInput]); // Safe // exec("command " + userInput); // VULNERABLE
-
Never use v-html with user data in Vue templates.
-
Validate user-supplied Mongo queries — reject, never strip. Any query/filter that comes from a request and reaches
find/aggregate/update/deletemust be checked with thecommonhelpers at the endpoint where it is first parsed (NOT inside deep helpers or the/drill/preprocess_queryhook). The query is run exactly as submitted or the request is rejected with400— it is never modified.// raw query STRING from a request param → parse + validate in one step var parsed = common.parseUserQuery(params.qstring.query); // accepts string OR object if (parsed.error) { log.d("Rejected user query" + common.reqInfo(params) + ": " + parsed.error); return common.returnMessage(params, 400, parsed.error); } var query = parsed.query; // safe to run as-is // ALREADY-parsed object — e.g. dbviewer parses with EJSON, or the query is // nested in a larger saved payload. Validate that parsed object directly: var parsedQuery = EJSON.parse(params.qstring.filter); // example: already parsed (EJSON / stored doc) var badOp = common.findUnsafeMongoOperator(parsedQuery); if (badOp) { log.d("Rejected user query" + common.reqInfo(params) + ": Query contains disallowed operator: " + badOp); return common.returnMessage(params, 400, "Query contains disallowed operator: " + badOp); }
$expris allowed;$where/$function/$accumulatorare rejected at any depth (including nested inside$expr). Log the rejection at the call site using the file'slogandcommon.reqInfo(params)(which adds the endpoint path/method, without the api_key). Do NOT passparamsintoparseUserQueryand do NOT log inside it.
| What | Where |
|---|---|
| Plugin code | plugins/<name>/api/api.js |
| Vue views | plugins/<name>/frontend/public/javascripts/countly.views.js |
| Templates | plugins/<name>/frontend/public/templates/ |
| Localization | plugins/<name>/frontend/public/localization/<name>.properties |
| Tests | plugins/<name>/tests.js |
var plugins = require('../../pluginManager.js');
var common = require('../../../api/utils/common.js');
const { validateRead } = require('../../../api/utils/rights.js');
const FEATURE_NAME = 'myfeature';
plugins.register("/o/myfeature", function(ob) {
var params = ob.params;
validateRead(params, FEATURE_NAME, function() {
// Validate input
var argProps = {
'id': { 'required': true, 'type': 'String' }
};
var validation = common.validateArgs(params.qstring, argProps, true);
if (!validation.obj) {
common.returnMessage(params, 400, 'Error: ' + validation.errors);
return;
}
// Query with app_id
common.db.collection('mydata').findOne(
{_id: validation.obj.id, app_id: params.app_id + ""},
function(err, result) {
common.returnOutput(params, result || {});
}
);
});
});var MyComponent = countlyVue.views.create({
template: countlyVue.T("/myplugin/templates/main.html"),
mixins: [countlyVue.mixins.auth(FEATURE_NAME)],
data: function() {
return { items: [] };
},
computed: {
// Prefer computed over watchers
},
methods: {
refresh: function() {
// Called for auto-refresh
}
}
});
app.route('/dashboard/myfeature', 'myfeature', function() {
new countlyVue.views.BackboneWrapper({ component: MyComponent }).render();
});// Read batcher for hot documents
common.readBatcher.getOne("collection", {_id: id}, callback);
// Write batcher for multiple updates
common.writeBatcher.add("collection", id, {$inc: {count: 1}});
// Always use projection
db.collection('x').findOne({_id: id}, {projection: {field: 1}});// Required if your plugin creates per-app collections
plugins.register("/i/apps/create", function(ob) {
common.db.collection('mydata' + ob.appId).createIndex({"field": 1});
});
plugins.register("/i/apps/delete", function(ob) {
common.db.collection('mydata' + ob.appId).drop();
});
plugins.register("/i/app_users/delete", function(ob) {
common.db.collection("mydata" + ob.app_id).deleteMany({uid: {$in: ob.uids}});
});- Use SASS with SCSS syntax
- BEM naming with
cly-vue-prefix - Bulma classes use
bu-prefix - Use
@use, never@import
| Don't | Do Instead |
|---|---|
this.$parent.value = x |
Emit events: this.$emit('update', x) |
| Deep watchers | Watch specific properties |
v-html with user data |
Use {{ }} text interpolation |
| Query without app_id | Always include app_id in queries |
exec(cmd + userInput) |
spawn(cmd, [userInput]) |
replace(' ', '-') |
replace(/ /g, '-') for all occurrences |
For comprehensive guidelines, read:
CODING_GUIDELINES.md- Full development standardsdocs/SECURITY.md- Security requirementsdocs/VUEJS_GUIDELINES.md- Vue patternsdocs/CSS_STYLE_GUIDE.md- SASS/BEM conventionsdocs/UI_TESTING.md- Cypress testingtest/README.md- Test suite documentationplugins/empty/- Sample plugin structure