` : ''}
${renderDispatchBadges(item.dispatches)}
` : ''}
@@ -224,8 +224,8 @@ function renderThread(item) {
threadHeader.innerHTML = `
${item.title ? `
${escapeHtml(item.title)}
` : ''}
${item.quote ? `
${escapeHtml(item.quote)}
` : ''}
@@ -302,7 +302,7 @@ function formatTime(isoString) {
function escapeHtml(str) {
if (!str) return '';
- return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+ return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
// ------------------------------------------------------------------ dispatch display (#115)
diff --git a/server/db.js b/server/db.js
index 7acc983..ac9602f 100644
--- a/server/db.js
+++ b/server/db.js
@@ -624,15 +624,15 @@ function initDb(dataDir) {
}
function getItemsByTag({ app_id, tag }) {
- // SQLite JSON: tags is stored as '["bug","ui"]', search with LIKE
+ // Use json_each() for proper JSON array searching (avoids LIKE injection)
if (app_id) {
return db.prepare(
- `SELECT * FROM items WHERE app_id = ? AND tags LIKE ? ORDER BY created_at DESC`
- ).all(app_id, `%"${tag}"%`);
+ `SELECT i.* FROM items i, json_each(i.tags) t WHERE i.app_id = ? AND t.value = ? ORDER BY i.created_at DESC`
+ ).all(app_id, tag);
}
return db.prepare(
- `SELECT * FROM items WHERE tags LIKE ? ORDER BY created_at DESC`
- ).all(`%"${tag}"%`);
+ `SELECT i.* FROM items i, json_each(i.tags) t WHERE t.value = ? ORDER BY i.created_at DESC`
+ ).all(tag);
}
function getDistinctUrls(app_id = 'default') {
@@ -835,6 +835,19 @@ function initDb(dataDir) {
// ---------------------------------------------------- endpoint methods
+ /** Decrypt config field in an endpoints row (in-place). */
+ function decryptEndpointRow(row) {
+ if (!row) return row;
+ if (isEncrypted(row.config)) {
+ try {
+ row.config = decrypt(row.config);
+ } catch (err) {
+ throw new Error(`Failed to decrypt config for endpoint ${row.id}: ${err.message}. Check CLAWMARK_ENCRYPTION_KEY.`);
+ }
+ }
+ return row;
+ }
+
function createEndpoint({ user_name, name, type, config, is_default = 0 }) {
const now = new Date().toISOString();
const id = genId('ep');
@@ -850,7 +863,7 @@ function initDb(dataDir) {
endpointStmts.insertEndpoint.run({
id, user_name, name, type,
- config: configStr,
+ config: encrypt(configStr),
is_default: shouldDefault,
created_at: now, updated_at: now,
});
@@ -858,28 +871,33 @@ function initDb(dataDir) {
}
function getEndpoints(user_name) {
- return endpointStmts.getEndpointsByUser.all(user_name);
+ return endpointStmts.getEndpointsByUser.all(user_name).map(decryptEndpointRow);
}
function getEndpoint(id) {
- return endpointStmts.getEndpointById.get(id) || null;
+ return decryptEndpointRow(endpointStmts.getEndpointById.get(id)) || null;
}
function updateEndpoint(id, updates) {
const existing = endpointStmts.getEndpointById.get(id);
if (!existing) return null;
const now = new Date().toISOString();
+ let configStr;
+ if (updates.config) {
+ configStr = typeof updates.config === 'string' ? updates.config : JSON.stringify(updates.config);
+ configStr = encrypt(configStr);
+ } else {
+ configStr = existing.config; // already encrypted (or plaintext legacy)
+ }
const merged = {
id,
name: updates.name ?? existing.name,
type: updates.type ?? existing.type,
- config: updates.config
- ? (typeof updates.config === 'string' ? updates.config : JSON.stringify(updates.config))
- : existing.config,
+ config: configStr,
updated_at: now,
};
endpointStmts.updateEndpoint.run(merged);
- return endpointStmts.getEndpointById.get(id);
+ return decryptEndpointRow(endpointStmts.getEndpointById.get(id));
}
function deleteEndpoint(id) {
@@ -903,7 +921,7 @@ function initDb(dataDir) {
const now = new Date().toISOString();
endpointStmts.clearDefaultForUser.run(now, existing.user_name);
endpointStmts.setDefault.run(now, id);
- return endpointStmts.getEndpointById.get(id);
+ return decryptEndpointRow(endpointStmts.getEndpointById.get(id));
}
// ---------------------------------------------------------- app methods
diff --git a/server/index.js b/server/index.js
index e5b751d..c88b69d 100644
--- a/server/index.js
+++ b/server/index.js
@@ -316,6 +316,18 @@ async function sendWebhook(event, payload) {
console.warn(`[routing] Auth ${t.matched_rule.auth_id} referenced by rule ${t.matched_rule.id} not found`);
}
}
+
+ // For auto-detected targets (github_auto, system_default) without a token,
+ // look up the user's first matching auth credential (#264).
+ if (t.target_type === 'github-issue' && !t.target_config.token && payload.created_by) {
+ const userAuths = itemsDb.getUserAuths(payload.created_by);
+ const ghAuth = userAuths.find(a => a.auth_type === 'github_pat' || a.auth_type === 'github_oauth');
+ if (ghAuth) {
+ let creds;
+ try { creds = typeof ghAuth.credentials === 'string' ? JSON.parse(ghAuth.credentials) : ghAuth.credentials; } catch { creds = {}; }
+ t.target_config = { ...t.target_config, ...creds };
+ }
+ }
}
console.log(`[routing] ${event}: ${filteredTargets.length} target(s) — ${filteredTargets.map(t => `${t.method}→${t.target_type}`).join(', ')}`);
@@ -376,6 +388,20 @@ function saveDiscussions(docId, data) {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
}
+// Per-document write lock to prevent concurrent read-modify-write data loss (#250)
+const discussionLocks = new Map();
+
+function withDiscussionLock(docId, fn) {
+ if (!discussionLocks.has(docId)) {
+ discussionLocks.set(docId, Promise.resolve());
+ }
+ const prev = discussionLocks.get(docId);
+ let resolve;
+ const next = new Promise(r => resolve = r);
+ discussionLocks.set(docId, next);
+ return prev.then(() => fn()).finally(() => resolve());
+}
+
// --------------------------------------------------------- discussion endpoints
// Get discussions for a document (auth added per #239 C-2)
@@ -398,45 +424,53 @@ app.post('/discussions', v2Auth, (req, res) => {
return res.status(400).json({ error: 'Missing required fields' });
}
- const data = loadDiscussions(doc);
-
- if (discussionId) {
- // Append to existing discussion
- const discussion = data.discussions.find(d => d.id === discussionId);
- if (!discussion) return res.status(404).json({ error: 'Discussion not found' });
+ withDiscussionLock(doc, () => {
+ const data = loadDiscussions(doc);
- discussion.messages.push({
- role: 'user',
- content: message,
- userName,
- timestamp: new Date().toISOString()
- });
-
- saveDiscussions(doc, data);
+ if (discussionId) {
+ // Append to existing discussion
+ const discussion = data.discussions.find(d => d.id === discussionId);
+ if (!discussion) {
+ res.status(404).json({ error: 'Discussion not found' });
+ return;
+ }
- sendWebhook('discussion.message', { doc, discussionId, userName, message });
- res.json({ success: true, discussionId });
- } else {
- // New discussion
- const newDiscussion = {
- id: `disc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
- quote: quote || '',
- version: version || 'latest',
- createdAt: new Date().toISOString(),
- messages: [{
+ discussion.messages.push({
role: 'user',
content: message,
userName,
timestamp: new Date().toISOString()
- }]
- };
+ });
- data.discussions.push(newDiscussion);
- saveDiscussions(doc, data);
+ saveDiscussions(doc, data);
- sendWebhook('discussion.created', { doc, discussionId: newDiscussion.id, userName, quote, message });
- res.json({ success: true, discussionId: newDiscussion.id });
- }
+ sendWebhook('discussion.message', { doc, discussionId, userName, message });
+ res.json({ success: true, discussionId });
+ } else {
+ // New discussion
+ const newDiscussion = {
+ id: `disc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ quote: quote || '',
+ version: version || 'latest',
+ createdAt: new Date().toISOString(),
+ messages: [{
+ role: 'user',
+ content: message,
+ userName,
+ timestamp: new Date().toISOString()
+ }]
+ };
+
+ data.discussions.push(newDiscussion);
+ saveDiscussions(doc, data);
+
+ sendWebhook('discussion.created', { doc, discussionId: newDiscussion.id, userName, quote, message });
+ res.json({ success: true, discussionId: newDiscussion.id });
+ }
+ }).catch(err => {
+ console.error('Discussion write error:', err);
+ if (!res.headersSent) res.status(500).json({ error: 'Internal server error' });
+ });
});
// Post a response to a discussion (called by an AI agent or admin)
@@ -446,27 +480,35 @@ app.post('/respond', v2Auth, (req, res) => {
return res.status(400).json({ error: 'Missing required fields' });
}
- const data = loadDiscussions(doc);
- const discussion = data.discussions.find(d => d.id === discussionId);
- if (!discussion) return res.status(404).json({ error: 'Discussion not found' });
-
- const pendingIdx = discussion.messages.findIndex(m => m.pending);
- if (pendingIdx !== -1) {
- discussion.messages[pendingIdx] = {
- role: 'assistant',
- content: response,
- timestamp: new Date().toISOString()
- };
- } else {
- discussion.messages.push({
- role: 'assistant',
- content: response,
- timestamp: new Date().toISOString()
- });
- }
+ withDiscussionLock(doc, () => {
+ const data = loadDiscussions(doc);
+ const discussion = data.discussions.find(d => d.id === discussionId);
+ if (!discussion) {
+ res.status(404).json({ error: 'Discussion not found' });
+ return;
+ }
- saveDiscussions(doc, data);
- res.json({ success: true });
+ const pendingIdx = discussion.messages.findIndex(m => m.pending);
+ if (pendingIdx !== -1) {
+ discussion.messages[pendingIdx] = {
+ role: 'assistant',
+ content: response,
+ timestamp: new Date().toISOString()
+ };
+ } else {
+ discussion.messages.push({
+ role: 'assistant',
+ content: response,
+ timestamp: new Date().toISOString()
+ });
+ }
+
+ saveDiscussions(doc, data);
+ res.json({ success: true });
+ }).catch(err => {
+ console.error('Discussion write error:', err);
+ if (!res.headersSent) res.status(500).json({ error: 'Internal server error' });
+ });
});
// Resolve or reopen a discussion
@@ -474,20 +516,28 @@ app.post('/discussions/resolve', v2Auth, (req, res) => {
const { doc, discussionId, action } = req.body;
if (!doc || !discussionId) return res.status(400).json({ error: 'Missing doc or discussionId' });
- const data = loadDiscussions(doc);
- const disc = data.discussions.find(d => d.id === discussionId);
- if (!disc) return res.status(404).json({ error: 'Discussion not found' });
+ withDiscussionLock(doc, () => {
+ const data = loadDiscussions(doc);
+ const disc = data.discussions.find(d => d.id === discussionId);
+ if (!disc) {
+ res.status(404).json({ error: 'Discussion not found' });
+ return;
+ }
- if (action === 'reopen') {
- disc.applied = false;
- disc.appliedAt = null;
- } else {
- disc.applied = true;
- disc.appliedAt = new Date().toISOString();
- }
+ if (action === 'reopen') {
+ disc.applied = false;
+ disc.appliedAt = null;
+ } else {
+ disc.applied = true;
+ disc.appliedAt = new Date().toISOString();
+ }
- saveDiscussions(doc, data);
- res.json({ success: true });
+ saveDiscussions(doc, data);
+ res.json({ success: true });
+ }).catch(err => {
+ console.error('Discussion write error:', err);
+ if (!res.headersSent) res.status(500).json({ error: 'Internal server error' });
+ });
});
// Submit a reply via API (for AI agent or admin use)
@@ -497,27 +547,35 @@ app.post('/submit-reply', v2Auth, (req, res) => {
return res.status(400).json({ error: 'Missing doc, discussionId, or reply' });
}
- const data = loadDiscussions(doc);
- const discussion = data.discussions.find(d => d.id === discussionId);
- if (!discussion) return res.status(404).json({ error: 'Discussion not found' });
-
- const pendingIdx = discussion.messages.findIndex(m => m.pending);
- if (pendingIdx !== -1) {
- discussion.messages[pendingIdx] = {
- role: 'assistant',
- content: reply,
- timestamp: new Date().toISOString()
- };
- } else {
- discussion.messages.push({
- role: 'assistant',
- content: reply,
- timestamp: new Date().toISOString()
- });
- }
+ withDiscussionLock(doc, () => {
+ const data = loadDiscussions(doc);
+ const discussion = data.discussions.find(d => d.id === discussionId);
+ if (!discussion) {
+ res.status(404).json({ error: 'Discussion not found' });
+ return;
+ }
- saveDiscussions(doc, data);
- res.json({ success: true });
+ const pendingIdx = discussion.messages.findIndex(m => m.pending);
+ if (pendingIdx !== -1) {
+ discussion.messages[pendingIdx] = {
+ role: 'assistant',
+ content: reply,
+ timestamp: new Date().toISOString()
+ };
+ } else {
+ discussion.messages.push({
+ role: 'assistant',
+ content: reply,
+ timestamp: new Date().toISOString()
+ });
+ }
+
+ saveDiscussions(doc, data);
+ res.json({ success: true });
+ }).catch(err => {
+ console.error('Discussion write error:', err);
+ if (!res.headersSent) res.status(500).json({ error: 'Internal server error' });
+ });
});
// List pending discussions (discussions that have an unanswered pending message)
@@ -592,6 +650,9 @@ function handleGetItems(req, res) {
function handleGetItem(req, res) {
const item = itemsDb.getItem(req.params.id);
if (!item) return res.status(404).json({ error: 'Item not found' });
+ if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) {
+ return res.status(403).json({ error: 'Access denied — item belongs to a different app' });
+ }
res.json(item);
}
@@ -635,6 +696,9 @@ function handleAddMessage(req, res) {
const item = itemsDb.getItem(req.params.id);
if (!item) return res.status(404).json({ error: 'Item not found' });
+ if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) {
+ return res.status(403).json({ error: 'Access denied — item belongs to a different app' });
+ }
const result = itemsDb.addMessage({
item_id: req.params.id,
@@ -650,6 +714,11 @@ function handleAddMessage(req, res) {
function handleAssignItem(req, res) {
const { assignee } = req.body;
if (!assignee) return res.status(400).json({ error: 'Missing assignee' });
+ const item = itemsDb.getItem(req.params.id);
+ if (!item) return res.status(404).json({ error: 'Item not found' });
+ if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) {
+ return res.status(403).json({ error: 'Access denied — item belongs to a different app' });
+ }
const result = itemsDb.assignItem(req.params.id, assignee);
if (!result.changes) return res.status(404).json({ error: 'Item not found' });
sendWebhook('item.assigned', { id: req.params.id, assignee });
@@ -658,6 +727,11 @@ function handleAssignItem(req, res) {
// -- POST /items/:id/resolve
function handleResolveItem(req, res) {
+ const item = itemsDb.getItem(req.params.id);
+ if (!item) return res.status(404).json({ error: 'Item not found' });
+ if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) {
+ return res.status(403).json({ error: 'Access denied — item belongs to a different app' });
+ }
const result = itemsDb.resolveItem(req.params.id);
if (!result.changes) return res.status(404).json({ error: 'Item not found' });
sendWebhook('item.resolved', { id: req.params.id });
@@ -666,6 +740,11 @@ function handleResolveItem(req, res) {
// -- POST /items/:id/verify
function handleVerifyItem(req, res) {
+ const item = itemsDb.getItem(req.params.id);
+ if (!item) return res.status(404).json({ error: 'Item not found' });
+ if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) {
+ return res.status(403).json({ error: 'Access denied — item belongs to a different app' });
+ }
const result = itemsDb.verifyItem(req.params.id);
if (!result.changes) return res.status(404).json({ error: 'Item not found' });
res.json({ success: true });
@@ -673,6 +752,11 @@ function handleVerifyItem(req, res) {
// -- POST /items/:id/reopen
function handleReopenItem(req, res) {
+ const item = itemsDb.getItem(req.params.id);
+ if (!item) return res.status(404).json({ error: 'Item not found' });
+ if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) {
+ return res.status(403).json({ error: 'Access denied — item belongs to a different app' });
+ }
const result = itemsDb.reopenItem(req.params.id);
if (!result.changes) return res.status(404).json({ error: 'Item not found' });
res.json({ success: true });
@@ -680,6 +764,11 @@ function handleReopenItem(req, res) {
// -- POST /items/:id/close
function handleCloseItem(req, res) {
+ const item = itemsDb.getItem(req.params.id);
+ if (!item) return res.status(404).json({ error: 'Item not found' });
+ if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) {
+ return res.status(403).json({ error: 'Access denied — item belongs to a different app' });
+ }
const result = itemsDb.closeItem(req.params.id);
if (!result.changes) return res.status(404).json({ error: 'Item not found' });
res.json({ success: true });
@@ -692,6 +781,9 @@ function handleRespondToItem(req, res) {
const item = itemsDb.getItem(req.params.id);
if (!item) return res.status(404).json({ error: 'Item not found' });
+ if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) {
+ return res.status(403).json({ error: 'Access denied — item belongs to a different app' });
+ }
itemsDb.respondToItem(req.params.id, response);
res.json({ success: true });
@@ -1095,7 +1187,7 @@ app.post('/api/v2/auth/apikey-legacy', apiWriteLimiter, (req, res) => {
});
// -- GET /api/v2/adapters — list adapter channels and their status
-app.get('/api/v2/adapters', (req, res) => {
+app.get('/api/v2/adapters', apiReadLimiter, v2Auth, (req, res) => {
res.json({ channels: registry.getStatus(), rules: registry.rules.length });
});
@@ -1106,9 +1198,10 @@ app.get('/api/v2/adapters', (req, res) => {
// -- GET /api/v2/distributions/:item_id — get dispatch log for an item
app.get('/api/v2/distributions/:item_id', apiReadLimiter, v2Auth, (req, res) => {
- // Verify item belongs to caller's app
+ // Verify item exists and belongs to caller's app
const item = itemsDb.getItem(req.params.item_id);
- if (item && req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) {
+ if (!item) return res.status(404).json({ error: 'Item not found' });
+ if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) {
return res.status(403).json({ error: 'Access denied — item belongs to a different app' });
}
const log = itemsDb.getDispatchLog(req.params.item_id);
@@ -1123,9 +1216,10 @@ app.get('/api/v2/distributions/:item_id', apiReadLimiter, v2Auth, (req, res) =>
// -- POST /api/v2/distributions/:item_id/retry — retry failed dispatches for an item
app.post('/api/v2/distributions/:item_id/retry', apiWriteLimiter, v2Auth, async (req, res) => {
- // Verify item belongs to caller's app
+ // Verify item exists and belongs to caller's app
const item = itemsDb.getItem(req.params.item_id);
- if (item && req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) {
+ if (!item) return res.status(404).json({ error: 'Item not found' });
+ if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) {
return res.status(403).json({ error: 'Access denied — item belongs to a different app' });
}
const log = itemsDb.getDispatchLog(req.params.item_id);
diff --git a/test/db.test.js b/test/db.test.js
index cb633f4..ad03977 100644
--- a/test/db.test.js
+++ b/test/db.test.js
@@ -433,6 +433,25 @@ describe('DB — V2 queries', () => {
assert.equal(items.length, 1);
});
+ it('getItemsByTag is not vulnerable to LIKE wildcards', () => {
+ dbApi.createItem({
+ doc: 'd', created_by: 'A',
+ tags: ['bug', 'ui'],
+ });
+ dbApi.createItem({
+ doc: 'd', created_by: 'B',
+ tags: ['feature'],
+ });
+
+ // '%' should not match all items (was vulnerable with LIKE pattern)
+ const wildcard = dbApi.getItemsByTag({ tag: '%' });
+ assert.equal(wildcard.length, 0);
+
+ // Partial match should not work — must be exact
+ const partial = dbApi.getItemsByTag({ tag: 'bu' });
+ assert.equal(partial.length, 0);
+ });
+
it('getDistinctUrls returns unique source_urls with counts', () => {
dbApi.createItem({ doc: 'd', created_by: 'A', source_url: 'https://a.com' });
dbApi.createItem({ doc: 'd', created_by: 'B', source_url: 'https://a.com' });