karma365-mcp 2.0.0 → 2.0.1

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 (2) hide show
  1. package/index.js +264 -32
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -215,6 +215,74 @@ function resolveTodo(nameOrId) {
215
215
  return found
216
216
  }
217
217
 
218
+ // ── Security & PIN System ─────────────────────────────────────────────────────
219
+
220
+ function sha256(text) {
221
+ return crypto.createHash('sha256').update(String(text)).digest('hex')
222
+ }
223
+
224
+ function getSetting(key) {
225
+ try {
226
+ const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key)
227
+ return row ? row.value : null
228
+ } catch {
229
+ return null
230
+ }
231
+ }
232
+
233
+ function setSetting(key, value) {
234
+ db.prepare(`INSERT INTO settings (key, value) VALUES (?, ?)
235
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(key, String(value))
236
+ }
237
+
238
+ function hasPin() {
239
+ const hash = getSetting('pin_hash')
240
+ const salt = getSetting('pin_salt')
241
+ return Boolean(hash && salt)
242
+ }
243
+
244
+ function setMasterPin(newPin) {
245
+ const trimmed = String(newPin ?? '').trim()
246
+ if (!trimmed) {
247
+ throw new Error('PIN cannot be empty. Please provide a numeric PIN (e.g. 111111).')
248
+ }
249
+ const salt = crypto.randomBytes(16).toString('hex')
250
+ const hash = sha256(salt + trimmed)
251
+ setSetting('pin_salt', salt)
252
+ setSetting('pin_hash', hash)
253
+ return true
254
+ }
255
+
256
+ function verifyPin(pin) {
257
+ const salt = getSetting('pin_salt')
258
+ const storedHash = getSetting('pin_hash')
259
+ if (!salt || !storedHash) return false
260
+ const trimmed = String(pin ?? '').trim()
261
+ if (!trimmed) return false
262
+ return sha256(salt + trimmed) === storedHash
263
+ }
264
+
265
+ /**
266
+ * Checks if a locked item requires PIN verification.
267
+ * Returns:
268
+ * 'ok' -> Not locked OR valid PIN provided
269
+ * 'required' -> Item is locked and no PIN provided
270
+ * 'invalid' -> Item is locked and wrong PIN provided
271
+ */
272
+ function checkPinIfLocked(isPinned, pin) {
273
+ if (!isPinned || isPinned === 0) return 'ok'
274
+ if (!hasPin()) return 'required'
275
+ const trimmed = String(pin ?? '').trim()
276
+ if (!trimmed) return 'required'
277
+ return verifyPin(trimmed) ? 'ok' : 'invalid'
278
+ }
279
+
280
+ const PIN_REQ = (name, action = 'this action') =>
281
+ `PIN_REQUIRED: "${name}" is locked/PIN-protected 🔒. Prompt the user for their PIN to authorize ${action}, then retry with pin="<their_pin>".`
282
+
283
+ const PIN_BAD = (name, action = 'performed') =>
284
+ `PIN_INVALID: Authentication failed — incorrect PIN entered ❌. "${name}" was NOT ${action}.`
285
+
218
286
  // ── Search helpers (DuckDuckGo / SearXNG / YouTube) ───────────────────────────
219
287
 
220
288
  const SEARCH_UA = 'Mozilla/5.0 (compatible; KarmaSearch/2.0)'
@@ -375,18 +443,19 @@ const TOOLS = [
375
443
  },
376
444
  {
377
445
  name: 'get_topic',
378
- description: 'Get full details of a topic including all its resources',
446
+ description: 'Get full details of a topic including all its resources. PIN required if topic is locked.',
379
447
  inputSchema: {
380
448
  type: 'object',
381
449
  required: ['name'],
382
450
  properties: {
383
- name: { type: 'string', description: 'Topic name or ID' }
451
+ name: { type: 'string', description: 'Topic name or ID' },
452
+ pin: { type: 'string', description: 'Optional PIN required if the topic is locked' }
384
453
  }
385
454
  }
386
455
  },
387
456
  {
388
457
  name: 'create_topic',
389
- description: 'Create a new topic in KARMA 365',
458
+ description: 'Create a new topic in KARMA 365. If parent major topic is locked, PIN is required.',
390
459
  inputSchema: {
391
460
  type: 'object',
392
461
  required: ['name'],
@@ -394,20 +463,24 @@ const TOOLS = [
394
463
  name: { type: 'string', description: 'Topic name' },
395
464
  difficulty: { type: 'string', enum: ['Easy', 'Medium', 'Hard'] },
396
465
  major_topic_name: { type: 'string', description: 'Assign to this major topic' },
397
- color: { type: 'string', description: 'Hex color code e.g. #FF5733' }
466
+ color: { type: 'string', description: 'Hex color code e.g. #FF5733' },
467
+ is_pinned: { type: 'boolean', description: 'Whether to lock/pin this topic' },
468
+ pin: { type: 'string', description: 'Optional PIN if parent major topic is locked' }
398
469
  }
399
470
  }
400
471
  },
401
472
  {
402
473
  name: 'create_subtopic',
403
- description: 'Create a subtopic under an existing topic',
474
+ description: 'Create a subtopic under an existing topic. If parent topic is locked, PIN is required.',
404
475
  inputSchema: {
405
476
  type: 'object',
406
477
  required: ['name', 'parent_topic_name'],
407
478
  properties: {
408
479
  name: { type: 'string', description: 'Subtopic name' },
409
480
  parent_topic_name: { type: 'string', description: 'Parent topic name or ID' },
410
- difficulty: { type: 'string', enum: ['Easy', 'Medium', 'Hard'] }
481
+ difficulty: { type: 'string', enum: ['Easy', 'Medium', 'Hard'] },
482
+ is_pinned: { type: 'boolean', description: 'Whether to lock/pin this subtopic' },
483
+ pin: { type: 'string', description: 'Optional PIN if parent topic is locked' }
411
484
  }
412
485
  }
413
486
  },
@@ -651,73 +724,102 @@ const TOOLS = [
651
724
  },
652
725
  {
653
726
  name: 'pin_major_topic',
654
- description: 'Pin (lock) or unpin a major topic.',
727
+ description: 'Pin (lock) or unpin a major topic. Unpinning requires PIN if locked.',
655
728
  inputSchema: {
656
729
  type: 'object',
657
730
  required: ['name', 'pinned'],
658
731
  properties: {
659
732
  name: { type: 'string', description: 'Major topic name or ID' },
660
- pinned: { type: 'boolean', description: 'true = lock/pin, false = unlock/unpin' }
733
+ pinned: { type: 'boolean', description: 'true = lock/pin, false = unlock/unpin' },
734
+ pin: { type: 'string', description: 'Optional PIN required if currently locked and unpinning' }
661
735
  }
662
736
  }
663
737
  },
664
738
  {
665
739
  name: 'pin_topic',
666
- description: 'Pin (lock) or unpin a topic.',
740
+ description: 'Pin (lock) or unpin a topic. Unpinning requires PIN if locked.',
667
741
  inputSchema: {
668
742
  type: 'object',
669
743
  required: ['name', 'pinned'],
670
744
  properties: {
671
745
  name: { type: 'string', description: 'Topic name or ID' },
672
- pinned: { type: 'boolean', description: 'true = lock/pin, false = unlock/unpin' }
746
+ pinned: { type: 'boolean', description: 'true = lock/pin, false = unlock/unpin' },
747
+ pin: { type: 'string', description: 'Optional PIN required if currently locked and unpinning' }
673
748
  }
674
749
  }
675
750
  },
676
751
  {
677
752
  name: 'set_major_topic_color',
678
- description: 'Change the colour of a major topic.',
753
+ description: 'Change the colour of a major topic. PIN required if locked.',
679
754
  inputSchema: {
680
755
  type: 'object',
681
756
  required: ['name', 'color'],
682
757
  properties: {
683
758
  name: { type: 'string', description: 'Major topic name or ID' },
684
- color: { type: 'string', description: 'Hex colour or color name' }
759
+ color: { type: 'string', description: 'Hex colour or color name' },
760
+ pin: { type: 'string', description: 'Optional PIN if locked' }
685
761
  }
686
762
  }
687
763
  },
688
764
  {
689
765
  name: 'set_topic_color',
690
- description: 'Change the colour of a topic.',
766
+ description: 'Change the colour of a topic. PIN required if locked.',
691
767
  inputSchema: {
692
768
  type: 'object',
693
769
  required: ['name', 'color'],
694
770
  properties: {
695
771
  name: { type: 'string', description: 'Topic name or ID' },
696
- color: { type: 'string', description: 'Hex colour or color name' }
772
+ color: { type: 'string', description: 'Hex colour or color name' },
773
+ pin: { type: 'string', description: 'Optional PIN if locked' }
697
774
  }
698
775
  }
699
776
  },
700
777
  {
701
778
  name: 'rename_major_topic',
702
- description: 'Rename a major topic.',
779
+ description: 'Rename a major topic. PIN required if locked.',
703
780
  inputSchema: {
704
781
  type: 'object',
705
782
  required: ['name', 'new_name'],
706
783
  properties: {
707
784
  name: { type: 'string', description: 'CURRENT major topic name or ID' },
708
- new_name: { type: 'string', description: 'The NEW name' }
785
+ new_name: { type: 'string', description: 'The NEW name' },
786
+ pin: { type: 'string', description: 'Optional PIN if locked' }
709
787
  }
710
788
  }
711
789
  },
712
790
  {
713
791
  name: 'rename_topic',
714
- description: 'Rename a topic.',
792
+ description: 'Rename a topic. PIN required if locked.',
715
793
  inputSchema: {
716
794
  type: 'object',
717
795
  required: ['name', 'new_name'],
718
796
  properties: {
719
797
  name: { type: 'string', description: 'CURRENT topic name or ID' },
720
- new_name: { type: 'string', description: 'The NEW name' }
798
+ new_name: { type: 'string', description: 'The NEW name' },
799
+ pin: { type: 'string', description: 'Optional PIN if locked' }
800
+ }
801
+ }
802
+ },
803
+ {
804
+ name: 'set_pin',
805
+ description: 'Set or update the security PIN for KARMA 365. Used to unlock, view, edit, or delete locked/PIN-protected topics and categories.',
806
+ inputSchema: {
807
+ type: 'object',
808
+ required: ['new_pin'],
809
+ properties: {
810
+ new_pin: { type: 'string', description: 'New numeric PIN (e.g. 111111)' },
811
+ current_pin: { type: 'string', description: 'Current PIN (required if a PIN is already set)' }
812
+ }
813
+ }
814
+ },
815
+ {
816
+ name: 'verify_pin',
817
+ description: 'Verify if a PIN is correct against the KARMA 365 master PIN',
818
+ inputSchema: {
819
+ type: 'object',
820
+ required: ['pin'],
821
+ properties: {
822
+ pin: { type: 'string', description: 'PIN to test' }
721
823
  }
722
824
  }
723
825
  },
@@ -786,11 +888,12 @@ const TOOLS = [
786
888
  ]
787
889
 
788
890
  // ── Phone Live Bridge Setup & Auto-Discovery (Wi-Fi, Hotspot, USB) ─────────
789
- const EXPLICIT_PHONE_URL = process.env.KARMA365_PHONE_URL ? process.env.KARMA365_PHONE_URL.trim().replace(/\/+$/, '') : null
891
+ const LOCAL_ONLY = process.env.KARMA365_LOCAL_ONLY === 'true' || process.env.KARMA365_PHONE_URL === 'none' || process.env.KARMA365_PHONE_URL === 'false'
892
+ const EXPLICIT_PHONE_URL = (!LOCAL_ONLY && process.env.KARMA365_PHONE_URL) ? process.env.KARMA365_PHONE_URL.trim().replace(/\/+$/, '') : null
790
893
  const KARMA_TOKEN = process.env.KARMA365_TOKEN ? process.env.KARMA365_TOKEN.trim() : null
791
894
  const PHONE_CACHE_FILE = path.join(os.homedir(), '.karma365', 'last_phone_url.txt')
792
895
 
793
- let activePhoneUrl = EXPLICIT_PHONE_URL || readCachedPhoneUrl()
896
+ let activePhoneUrl = LOCAL_ONLY ? null : (EXPLICIT_PHONE_URL || readCachedPhoneUrl())
794
897
  let isDiscoveryRunning = false
795
898
  let lastDiscoveryTime = 0
796
899
 
@@ -812,7 +915,7 @@ function writeCachedPhoneUrl(url) {
812
915
  } catch (_) {}
813
916
  }
814
917
 
815
- async function probePhoneHealth(baseUrl, timeoutMs = 600) {
918
+ async function probePhoneHealth(baseUrl, timeoutMs = 1200) {
816
919
  try {
817
920
  const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(timeoutMs) })
818
921
  if (!res.ok) return false
@@ -824,6 +927,7 @@ async function probePhoneHealth(baseUrl, timeoutMs = 600) {
824
927
  }
825
928
 
826
929
  async function autoDiscoverPhone(force = false) {
930
+ if (LOCAL_ONLY) return null
827
931
  if (EXPLICIT_PHONE_URL) return EXPLICIT_PHONE_URL
828
932
  const now = Date.now()
829
933
  if (!force && activePhoneUrl && (now - lastDiscoveryTime < 15000)) {
@@ -842,7 +946,7 @@ async function autoDiscoverPhone(force = false) {
842
946
  ].filter(Boolean)
843
947
 
844
948
  for (const url of quickCandidates) {
845
- if (await probePhoneHealth(url, 350)) {
949
+ if (await probePhoneHealth(url, 1500)) {
846
950
  activePhoneUrl = url
847
951
  writeCachedPhoneUrl(url)
848
952
  process.stderr.write(`[karma365-mcp] 📱 Connected to KARMA 365 Mobile App: ${url}\n`)
@@ -927,7 +1031,12 @@ async function callPhoneBridge(name, args) {
927
1031
 
928
1032
  if (res.status === 401) {
929
1033
  const data = await res.json().catch(() => ({}))
930
- throw new Error(`Phone Authentication Failed (401): ${data.error || 'Invalid or missing KARMA365_TOKEN'}. Check your pairing token in Karma 365 app settings.`)
1034
+ if (EXPLICIT_PHONE_URL) {
1035
+ throw new Error(`Phone Authentication Failed (401): ${data.error || 'Invalid or missing KARMA365_TOKEN'}. Check your pairing token in Karma 365 app settings.`)
1036
+ }
1037
+ process.stderr.write(`[karma365-mcp] Auto-discovered phone returned 401 (not paired). Falling back to local SQLite DB.\n`)
1038
+ activePhoneUrl = null
1039
+ return null
931
1040
  }
932
1041
 
933
1042
  if (!res.ok) {
@@ -943,7 +1052,7 @@ async function callPhoneBridge(name, args) {
943
1052
  return data.result
944
1053
  } catch (err) {
945
1054
  clearTimeout(timeoutId)
946
- if (err.message && err.message.includes('401')) {
1055
+ if (err.message && err.message.includes('401') && EXPLICIT_PHONE_URL) {
947
1056
  throw err
948
1057
  }
949
1058
  process.stderr.write(`[karma365-mcp] Phone bridge unreachable at ${targetBase} (${err.message}). Checking auto-discovery or local SQLite...\n`)
@@ -1024,6 +1133,11 @@ async function handleTool(name, args) {
1024
1133
 
1025
1134
  case 'delete_major_topic': {
1026
1135
  const item = resolveMajorTopic(args.name)
1136
+ if (item.is_pinned === 1) {
1137
+ const guard = checkPinIfLocked(item.is_pinned, args.pin)
1138
+ if (guard === 'required') return PIN_REQ(item.name, 'deletion')
1139
+ if (guard === 'invalid') return PIN_BAD(item.name, 'deleted')
1140
+ }
1027
1141
  db.prepare('UPDATE topics SET major_topic_id = NULL WHERE major_topic_id = ?').run(item.id)
1028
1142
  db.prepare('DELETE FROM major_topics WHERE id = ?').run(item.id)
1029
1143
  return `✓ Deleted major topic "${item.name}". Subtopics under it have been kept.`
@@ -1031,6 +1145,11 @@ async function handleTool(name, args) {
1031
1145
 
1032
1146
  case 'rename_major_topic': {
1033
1147
  const item = resolveMajorTopic(args.name)
1148
+ if (item.is_pinned === 1) {
1149
+ const guard = checkPinIfLocked(item.is_pinned, args.pin)
1150
+ if (guard === 'required') return PIN_REQ(item.name, 'renaming')
1151
+ if (guard === 'invalid') return PIN_BAD(item.name, 'renamed')
1152
+ }
1034
1153
  const newName = args.new_name.trim()
1035
1154
  db.prepare('UPDATE major_topics SET name = ? WHERE id = ?').run(newName, item.id)
1036
1155
  return `✓ Renamed major topic from "${item.name}" to "${newName}"`
@@ -1038,6 +1157,11 @@ async function handleTool(name, args) {
1038
1157
 
1039
1158
  case 'set_major_topic_color': {
1040
1159
  const item = resolveMajorTopic(args.name)
1160
+ if (item.is_pinned === 1) {
1161
+ const guard = checkPinIfLocked(item.is_pinned, args.pin)
1162
+ if (guard === 'required') return PIN_REQ(item.name, 'changing colour')
1163
+ if (guard === 'invalid') return PIN_BAD(item.name, 'color-changed')
1164
+ }
1041
1165
  const color = resolveColor(args.color)
1042
1166
  db.prepare('UPDATE major_topics SET color = ? WHERE id = ?').run(color, item.id)
1043
1167
  return `✓ Changed colour of major topic "${item.name}" to ${color}`
@@ -1046,6 +1170,11 @@ async function handleTool(name, args) {
1046
1170
  case 'pin_major_topic': {
1047
1171
  const item = resolveMajorTopic(args.name)
1048
1172
  const pinned = args.pinned === true || String(args.pinned).toLowerCase() === 'true' ? 1 : 0
1173
+ if (!pinned && item.is_pinned === 1) {
1174
+ const guard = checkPinIfLocked(item.is_pinned, args.pin)
1175
+ if (guard === 'required') return PIN_REQ(item.name, 'unlocking')
1176
+ if (guard === 'invalid') return PIN_BAD(item.name, 'unlocked')
1177
+ }
1049
1178
  db.prepare('UPDATE major_topics SET is_pinned = ? WHERE id = ?').run(pinned, item.id)
1050
1179
  return pinned ? `✓ Locked/pinned major topic "${item.name}" 🔒` : `✓ Unlocked major topic "${item.name}"`
1051
1180
  }
@@ -1094,6 +1223,18 @@ async function handleTool(name, args) {
1094
1223
 
1095
1224
  case 'get_topic': {
1096
1225
  const topic = resolveTopic(args.name)
1226
+ let majorIsPinned = 0
1227
+ if (topic.major_topic_id) {
1228
+ const m = db.prepare('SELECT is_pinned, name FROM major_topics WHERE id = ?').get(topic.major_topic_id)
1229
+ if (m && m.is_pinned === 1) majorIsPinned = 1
1230
+ }
1231
+ const isLocked = topic.is_pinned === 1 || majorIsPinned === 1
1232
+ if (isLocked) {
1233
+ const guard = checkPinIfLocked(1, args.pin)
1234
+ if (guard === 'required') return PIN_REQ(topic.name, 'opening / reading')
1235
+ if (guard === 'invalid') return PIN_BAD(topic.name, 'opened')
1236
+ }
1237
+
1097
1238
  const resources = db.prepare('SELECT * FROM resources WHERE topic_id = ? ORDER BY sort_order ASC, created_at DESC').all(topic.id)
1098
1239
  let majorName = null
1099
1240
  if (topic.major_topic_id) {
@@ -1124,25 +1265,48 @@ async function handleTool(name, args) {
1124
1265
  let majorId = null
1125
1266
  if (args.major_topic_name) {
1126
1267
  const m = resolveMajorTopic(args.major_topic_name)
1268
+ if (m.is_pinned === 1) {
1269
+ const guard = checkPinIfLocked(m.is_pinned, args.pin)
1270
+ if (guard === 'required') return PIN_REQ(m.name, 'adding topic under it')
1271
+ if (guard === 'invalid') return PIN_BAD(m.name, 'modified')
1272
+ }
1127
1273
  majorId = m.id
1128
1274
  }
1129
1275
  const id = newId()
1130
1276
  const color = resolveColor(args.color)
1131
- db.prepare(`INSERT INTO topics (id, name, difficulty, major_topic_id, color, created_at, synced)
1132
- VALUES (?, ?, ?, ?, ?, ?, 1)`).run(id, args.name.trim(), args.difficulty || 'Medium', majorId, color, nowIso())
1133
- return `✓ Created topic "${args.name}" (ID: ${id})`
1277
+ const isPinned = args.is_pinned === true || args.pinned === true || String(args.is_pinned).toLowerCase() === 'true' || String(args.pinned).toLowerCase() === 'true' ? 1 : 0
1278
+ db.prepare(`INSERT INTO topics (id, name, difficulty, major_topic_id, color, is_pinned, created_at, synced)
1279
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1)`).run(id, args.name.trim(), args.difficulty || 'Medium', majorId, color, isPinned, nowIso())
1280
+ return `✓ Created topic "${args.name}" (ID: ${id})${isPinned ? ' 🔒' : ''}`
1134
1281
  }
1135
1282
 
1136
1283
  case 'create_subtopic': {
1137
1284
  const parent = resolveTopic(args.parent_topic_name)
1285
+ if (parent.is_pinned === 1) {
1286
+ const guard = checkPinIfLocked(parent.is_pinned, args.pin)
1287
+ if (guard === 'required') return PIN_REQ(parent.name, 'adding subtopic under it')
1288
+ if (guard === 'invalid') return PIN_BAD(parent.name, 'modified')
1289
+ }
1138
1290
  const id = newId()
1139
- db.prepare(`INSERT INTO topics (id, name, difficulty, parent_topic_id, major_topic_id, created_at, synced)
1140
- VALUES (?, ?, ?, ?, ?, ?, 1)`).run(id, args.name.trim(), args.difficulty || 'Medium', parent.id, parent.major_topic_id, nowIso())
1141
- return `✓ Created subtopic "${args.name}" under parent "${parent.name}" (ID: ${id})`
1291
+ const isPinned = args.is_pinned === true || args.pinned === true || String(args.is_pinned).toLowerCase() === 'true' || String(args.pinned).toLowerCase() === 'true' ? 1 : 0
1292
+ db.prepare(`INSERT INTO topics (id, name, difficulty, parent_topic_id, major_topic_id, is_pinned, created_at, synced)
1293
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1)`).run(id, args.name.trim(), args.difficulty || 'Medium', parent.id, parent.major_topic_id, isPinned, nowIso())
1294
+ return `✓ Created subtopic "${args.name}" under parent "${parent.name}" (ID: ${id})${isPinned ? ' 🔒' : ''}`
1142
1295
  }
1143
1296
 
1144
1297
  case 'delete_topic': {
1145
1298
  const topic = resolveTopic(args.name)
1299
+ let majorIsPinned = 0
1300
+ if (topic.major_topic_id) {
1301
+ const m = db.prepare('SELECT is_pinned FROM major_topics WHERE id = ?').get(topic.major_topic_id)
1302
+ if (m && m.is_pinned === 1) majorIsPinned = 1
1303
+ }
1304
+ const isLocked = topic.is_pinned === 1 || majorIsPinned === 1
1305
+ if (isLocked) {
1306
+ const guard = checkPinIfLocked(1, args.pin)
1307
+ if (guard === 'required') return PIN_REQ(topic.name, 'deletion')
1308
+ if (guard === 'invalid') return PIN_BAD(topic.name, 'deleted')
1309
+ }
1146
1310
  db.prepare('DELETE FROM resources WHERE topic_id = ?').run(topic.id)
1147
1311
  db.prepare('DELETE FROM topics WHERE id = ?').run(topic.id)
1148
1312
  return `✓ Deleted topic "${topic.name}" and all its resources.`
@@ -1150,6 +1314,11 @@ async function handleTool(name, args) {
1150
1314
 
1151
1315
  case 'rename_topic': {
1152
1316
  const topic = resolveTopic(args.name)
1317
+ if (topic.is_pinned === 1) {
1318
+ const guard = checkPinIfLocked(topic.is_pinned, args.pin)
1319
+ if (guard === 'required') return PIN_REQ(topic.name, 'renaming')
1320
+ if (guard === 'invalid') return PIN_BAD(topic.name, 'renamed')
1321
+ }
1153
1322
  const newName = args.new_name.trim()
1154
1323
  db.prepare('UPDATE topics SET name = ?, updated_at = ? WHERE id = ?').run(newName, nowIso(), topic.id)
1155
1324
  return `✓ Renamed topic from "${topic.name}" to "${newName}"`
@@ -1157,6 +1326,11 @@ async function handleTool(name, args) {
1157
1326
 
1158
1327
  case 'set_topic_color': {
1159
1328
  const topic = resolveTopic(args.name)
1329
+ if (topic.is_pinned === 1) {
1330
+ const guard = checkPinIfLocked(topic.is_pinned, args.pin)
1331
+ if (guard === 'required') return PIN_REQ(topic.name, 'changing colour')
1332
+ if (guard === 'invalid') return PIN_BAD(topic.name, 'color-changed')
1333
+ }
1160
1334
  const color = resolveColor(args.color)
1161
1335
  db.prepare('UPDATE topics SET color = ?, updated_at = ? WHERE id = ?').run(color, nowIso(), topic.id)
1162
1336
  return `✓ Changed colour of topic "${topic.name}" to ${color}`
@@ -1165,6 +1339,11 @@ async function handleTool(name, args) {
1165
1339
  case 'pin_topic': {
1166
1340
  const topic = resolveTopic(args.name)
1167
1341
  const pinned = args.pinned === true || String(args.pinned).toLowerCase() === 'true' ? 1 : 0
1342
+ if (!pinned && topic.is_pinned === 1) {
1343
+ const guard = checkPinIfLocked(topic.is_pinned, args.pin)
1344
+ if (guard === 'required') return PIN_REQ(topic.name, 'unlocking')
1345
+ if (guard === 'invalid') return PIN_BAD(topic.name, 'unlocked')
1346
+ }
1168
1347
  db.prepare('UPDATE topics SET is_pinned = ?, updated_at = ? WHERE id = ?').run(pinned, nowIso(), topic.id)
1169
1348
  return pinned ? `✓ Locked/pinned topic "${topic.name}" 🔒` : `✓ Unlocked topic "${topic.name}"`
1170
1349
  }
@@ -1199,6 +1378,11 @@ async function handleTool(name, args) {
1199
1378
  case 'add_image':
1200
1379
  case 'add_code': {
1201
1380
  const topic = resolveTopic(args.topic_name)
1381
+ if (topic.is_pinned === 1) {
1382
+ const guard = checkPinIfLocked(topic.is_pinned, args.pin)
1383
+ if (guard === 'required') return PIN_REQ(topic.name, 'adding resource to it')
1384
+ if (guard === 'invalid') return PIN_BAD(topic.name, 'modified')
1385
+ }
1202
1386
  const type = RESOURCE_TYPE_MAP[name]
1203
1387
  const id = newId()
1204
1388
  let finalPathOrUrl = String(args.url || '').trim()
@@ -1218,10 +1402,54 @@ async function handleTool(name, args) {
1218
1402
  case 'delete_resource': {
1219
1403
  const row = db.prepare('SELECT * FROM resources WHERE id = ?').get(args.resource_id)
1220
1404
  if (!row) throw new Error(`Resource not found with ID: "${args.resource_id}"`)
1405
+ let isLocked = row.is_pinned || 0
1406
+ if (!isLocked && row.topic_id) {
1407
+ const t = db.prepare('SELECT is_pinned, major_topic_id FROM topics WHERE id = ?').get(row.topic_id)
1408
+ if (t && t.is_pinned === 1) isLocked = 1
1409
+ else if (t && t.major_topic_id) {
1410
+ const m = db.prepare('SELECT is_pinned FROM major_topics WHERE id = ?').get(t.major_topic_id)
1411
+ if (m && m.is_pinned === 1) isLocked = 1
1412
+ }
1413
+ }
1414
+ if (isLocked) {
1415
+ const guard = checkPinIfLocked(1, args.pin)
1416
+ if (guard === 'required') return PIN_REQ(row.description || 'Resource', 'deletion')
1417
+ if (guard === 'invalid') return PIN_BAD(row.description || 'Resource', 'deleted')
1418
+ }
1221
1419
  db.prepare('DELETE FROM resources WHERE id = ?').run(row.id)
1222
1420
  return `✓ Deleted resource "${row.description || row.path_or_url}"`
1223
1421
  }
1224
1422
 
1423
+ case 'set_pin':
1424
+ case 'update_pin': {
1425
+ const newPin = String(args.new_pin ?? args.pin ?? '').trim()
1426
+ const currentPin = args.current_pin ? String(args.current_pin).trim() : undefined
1427
+ if (!newPin) {
1428
+ return '❌ PIN cannot be empty. Please provide a numeric PIN (e.g. 111111).'
1429
+ }
1430
+ if (hasPin()) {
1431
+ if (!currentPin) {
1432
+ return 'PIN_REQUIRED: A PIN is already set. Please provide current_pin to authorize updating to a new PIN.'
1433
+ }
1434
+ if (!verifyPin(currentPin)) {
1435
+ return 'PIN_INVALID: Current PIN is incorrect. Master PIN was NOT updated.'
1436
+ }
1437
+ }
1438
+ setMasterPin(newPin)
1439
+ return `✓ Master PIN successfully updated to ${newPin.replace(/./g, '•')} 🔒`
1440
+ }
1441
+
1442
+ case 'verify_pin': {
1443
+ const pin = String(args.pin ?? '').trim()
1444
+ if (!hasPin()) {
1445
+ return 'ℹ️ No master PIN is set yet. Use set_pin to create one.'
1446
+ }
1447
+ if (verifyPin(pin)) {
1448
+ return '✓ PIN verified successfully.'
1449
+ }
1450
+ return 'PIN_INVALID: Incorrect PIN.'
1451
+ }
1452
+
1225
1453
  case 'mark_resource_complete': {
1226
1454
  const isCompleted = args.is_completed === true || String(args.is_completed).toLowerCase() === 'true' ? 1 : 0
1227
1455
  const row = db.prepare('SELECT * FROM resources WHERE id = ?').get(args.resource_id)
@@ -1289,7 +1517,11 @@ async function handleTool(name, args) {
1289
1517
 
1290
1518
  case 'write_topic_note': {
1291
1519
  const topic = resolveTopic(args.topic_name)
1292
- checkPinIfLocked(topic.is_pinned, args.pin)
1520
+ if (topic.is_pinned === 1) {
1521
+ const guard = checkPinIfLocked(topic.is_pinned, args.pin)
1522
+ if (guard === 'required') return PIN_REQ(topic.name, 'writing/updating note')
1523
+ if (guard === 'invalid') return PIN_BAD(topic.name, 'updated')
1524
+ }
1293
1525
  const existing = (topic.note || '').trim()
1294
1526
  const newNote = existing ? `${existing}\n\n${args.note.trim()}` : args.note.trim()
1295
1527
  const title = args.title ? args.title.trim() : topic.note_title
@@ -1421,7 +1653,7 @@ async function handleTool(name, args) {
1421
1653
  // ── MCP Server Startup ────────────────────────────────────────────────────────
1422
1654
 
1423
1655
  const server = new Server(
1424
- { name: 'karma365-mcp', version: '2.0.0' },
1656
+ { name: 'karma365-mcp', version: '2.0.1' },
1425
1657
  { capabilities: { tools: {} } }
1426
1658
  )
1427
1659
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karma365-mcp",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "KARMA 365 MCP server — connect Claude to your learning workspace",
5
5
  "main": "index.js",
6
6
  "bin": {