poi-plugin-mcp 0.2.24 → 0.2.26

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.
@@ -1,3 +1,4 @@
1
+ const path = require('path')
1
2
  const { createPoiDataBridge } = require('./poi-http-bridge')
2
3
  const { loadOrCreateInputToken } = require('./input-token')
3
4
  const {
@@ -10,13 +11,14 @@ const {
10
11
  function createBridgeController(options = {}) {
11
12
  const settingsPath = options.settingsPath || DEFAULT_SETTINGS_FILE
12
13
  const logger = options.logger || console
13
- const createBridge = options.createBridge || createPoiDataBridge
14
+ let createBridge = options.createBridge || createPoiDataBridge
14
15
 
15
16
  let settings = loadSettings(settingsPath)
16
17
  let bridge = null
17
18
  let recorder = null
18
19
  let inputToken = options.inputToken || null
19
20
  let pending = Promise.resolve()
21
+ let reloading = false
20
22
 
21
23
  function enqueue(action) {
22
24
  pending = pending.then(action, action)
@@ -48,6 +50,8 @@ function createBridgeController(options = {}) {
48
50
  port: settings.port,
49
51
  portFile: options.portFile,
50
52
  logger,
53
+ isWriterTokenEnforced: () => settings.writerTokenEnforced === true,
54
+ reloadModules: hotReloadModules,
51
55
  })
52
56
  }
53
57
 
@@ -99,6 +103,64 @@ function createBridgeController(options = {}) {
99
103
  await runningBridge.stop()
100
104
  }
101
105
 
106
+ /**
107
+ * Hot reload (2026-09-12): purge this plugin's lib/ require cache and
108
+ * rebuild the bridge from the on-disk code. Live instances (this
109
+ * controller, the telemetry object wired in index.js, the recorder) keep
110
+ * their closures and state; only the module table refreshes. Returns after
111
+ * validation — the swap is scheduled so the HTTP response can flush first.
112
+ */
113
+ function hotReloadModules() {
114
+ if (reloading) {
115
+ return Promise.reject(new Error('a reload is already in progress'))
116
+ }
117
+ return enqueue(async () => {
118
+ reloading = true
119
+ try {
120
+ const libRoot = __dirname
121
+ for (const key of Object.keys(require.cache)) {
122
+ if (key.startsWith(libRoot + path.sep) || key === libRoot) {
123
+ delete require.cache[key]
124
+ }
125
+ }
126
+ // Fresh require throws on a broken build → old bridge untouched.
127
+ const fresh = require('./poi-http-bridge')
128
+ const freshFactory = fresh.createPoiDataBridge
129
+ if (typeof freshFactory !== 'function') {
130
+ throw new Error('fresh poi-http-bridge does not export createPoiDataBridge')
131
+ }
132
+ // Re-read settings: the fresh settings module may normalize new keys.
133
+ const freshSettings = require('./settings')
134
+ settings = freshSettings.normalizeSettings(
135
+ freshSettings.loadSettings(settingsPath),
136
+ )
137
+ createBridge = freshFactory
138
+ const wasEnabled = settings.enabled
139
+ // Let the /admin/reload response flush on the old connection before
140
+ // it drops; the swap then runs on the controller's serial queue.
141
+ setTimeout(() => {
142
+ enqueue(async () => {
143
+ try {
144
+ await stopCurrentBridge()
145
+ if (wasEnabled) await ensureStarted()
146
+ logger.log('[poi-plugin-mcp] hot reload complete')
147
+ } catch (error) {
148
+ logger.error(`[poi-plugin-mcp] hot reload swap failed: ${error.message}`)
149
+ // Best effort: the factory is already fresh; the next
150
+ // ensureStarted (any settings apply or manual start) recovers.
151
+ try {
152
+ if (wasEnabled) await ensureStarted()
153
+ } catch (_) { /* surfaced by the next status poll */ }
154
+ }
155
+ })
156
+ }, 250)
157
+ return { port: settings.port, deferredSwap: true }
158
+ } finally {
159
+ reloading = false
160
+ }
161
+ })
162
+ }
163
+
102
164
  function persist(nextSettings) {
103
165
  settings = saveSettings(normalizeSettings(nextSettings), settingsPath)
104
166
  return settings
@@ -175,6 +237,18 @@ function createBridgeController(options = {}) {
175
237
  return { ...settings }
176
238
  },
177
239
 
240
+ /**
241
+ * 2026-09-12 hot reload: rebuild the HTTP/input layer from the on-disk
242
+ * lib/ code without restarting poi. Telemetry getters are re-injected
243
+ * (event generations survive); the recorder session is untouched.
244
+ *
245
+ * Ordering: the fresh modules are required BEFORE anything stops — a bad
246
+ * build throws here, the old bridge keeps serving, and the caller gets a
247
+ * 500 with the require error. The swap itself is deferred so the
248
+ * /admin/reload response flushes on the old connection before it drops.
249
+ */
250
+ reloadModules: hotReloadModules,
251
+
178
252
  getStatus() {
179
253
  const actualPort = bridge ? bridge.getPort() : 0
180
254
  const recorderStatus = recorder && typeof recorder.getStatus === 'function'
@@ -5,6 +5,7 @@ const os = require('os')
5
5
  const path = require('path')
6
6
  const packageJson = require('../package.json')
7
7
  const { loadOrCreateInputToken } = require('./input-token')
8
+ const { getWriterTokenRegistry, hasValidWriterToken } = require('./writer-tokens')
8
9
  const { createPoiInputProvider } = require('./poi-input')
9
10
  const { createPoiInputLease } = require('./poi-input-lease')
10
11
  const { createPoiScreenshotProvider } = require('./poi-screenshot')
@@ -107,6 +108,14 @@ function createPoiDataBridge(options = {}) {
107
108
  inputEnabled ? loadOrCreateInputToken(options.inputTokenFile) : null
108
109
  )
109
110
  const inputLease = options.inputLease || createPoiInputLease(options.inputLeaseOptions)
111
+ // 2026-09-12 Phase C: daemon-delegated writer tokens gate the INPUT
112
+ // surface (clicks, lease takes, debug-eval). Module-level singleton so
113
+ // settings-triggered bridge re-creation keeps the registered set; a hot
114
+ // reload purges the module, and the daemon's periodic re-registration
115
+ // self-heals within one idle cycle.
116
+ const writerTokens = options.writerTokens || getWriterTokenRegistry()
117
+ const isWriterTokenEnforced = options.isWriterTokenEnforced ||
118
+ (() => options.writerTokenEnforced === true)
110
119
  let captureScreenshot = options.captureScreenshot || null
111
120
  let performInput = options.performInput || null
112
121
  let dataQuery = options.dataQuery || null
@@ -116,6 +125,33 @@ function createPoiDataBridge(options = {}) {
116
125
  let inputPending = Promise.resolve()
117
126
  const pendingActionEventWaits = new Set()
118
127
 
128
+ /**
129
+ * Writer-token gate for input-capable endpoints. Callers have already
130
+ * validated the static bearer (transport auth). Three outcomes:
131
+ * registered token -> pass; missing/rejected token + enforcement -> 403
132
+ * with a reason a stale process can act on; missing token in observation
133
+ * mode -> pass and count (legacyAuth), which is the bridge-side
134
+ * foreign-writer signal.
135
+ */
136
+ function authorizeWriterInput(req) {
137
+ const headerValue = req.headers['x-writer-token']
138
+ if (hasValidWriterToken(headerValue, writerTokens)) {
139
+ return { ok: true }
140
+ }
141
+ if (isWriterTokenEnforced()) {
142
+ return {
143
+ ok: false,
144
+ status: 403,
145
+ error:
146
+ 'Writer token rejected: no registered X-Writer-Token matches. ' +
147
+ 'Stale process from a previous daemon generation? Re-submit the work ' +
148
+ 'through the daemon (kc daemon submit) or renew the token.',
149
+ }
150
+ }
151
+ writerTokens.noteLegacyAuth()
152
+ return { ok: true, legacy: true }
153
+ }
154
+
119
155
  function readStore() {
120
156
  const store = getStore()
121
157
  if (!store || !store.info) {
@@ -176,6 +212,17 @@ function createPoiDataBridge(options = {}) {
176
212
  )
177
213
  return
178
214
  }
215
+ // /debug/evaluate executes arbitrary JS in the game WebView — it is a
216
+ // write-capable channel and takes the writer-token gate. /query is a
217
+ // read and stays static-bearer (diagnostics keep working).
218
+ if (endpoint === '/debug/evaluate') {
219
+ const writer = authorizeWriterInput(req)
220
+ if (!writer.ok) {
221
+ drainRequest(req)
222
+ sendInputJson(res, writer.status, { error: writer.error })
223
+ return
224
+ }
225
+ }
179
226
  try {
180
227
  const body = await readRequestBody(
181
228
  req,
@@ -261,6 +308,12 @@ function createPoiDataBridge(options = {}) {
261
308
  sendInputJson(res, 403, { error: 'WebView input is disabled.' })
262
309
  return
263
310
  }
311
+ const writer = authorizeWriterInput(req)
312
+ if (!writer.ok) {
313
+ drainRequest(req)
314
+ sendInputJson(res, writer.status, { error: writer.error })
315
+ return
316
+ }
264
317
 
265
318
  try {
266
319
  const body = await readRequestBody(
@@ -295,6 +348,15 @@ function createPoiDataBridge(options = {}) {
295
348
  )
296
349
  return
297
350
  }
351
+ // Lease take/renew are input-adjacent (they gate who may click); the
352
+ // writer-token applies. Release is also gated for symmetry — leases
353
+ // expire via TTL anyway, so a stale cleanup tool failing here is safe.
354
+ const leaseWriter = authorizeWriterInput(req)
355
+ if (!leaseWriter.ok) {
356
+ drainRequest(req)
357
+ sendInputJson(res, leaseWriter.status, { error: leaseWriter.error })
358
+ return
359
+ }
298
360
  if (endpoint === '/input/lease') {
299
361
  if (req.method !== 'GET') {
300
362
  drainRequest(req)
@@ -527,6 +589,79 @@ function createPoiDataBridge(options = {}) {
527
589
  return
528
590
  }
529
591
 
592
+ // 2026-09-12 Phase C: writer-token administration. The static bearer is
593
+ // the ADMIN credential here — exactly one caller (the daemon) uses it
594
+ // to delegate; input endpoints no longer accept it as a writer.
595
+ if (endpoint === '/writer-tokens') {
596
+ if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
597
+ drainRequest(req)
598
+ sendInputJson(res, 401, { error: 'A valid Bearer token is required.' })
599
+ return
600
+ }
601
+ if (req.method === 'GET') {
602
+ sendInputJson(res, 200, {
603
+ enforced: isWriterTokenEnforced(),
604
+ ...writerTokens.status(),
605
+ })
606
+ return
607
+ }
608
+ if (req.method !== 'PUT') {
609
+ drainRequest(req)
610
+ sendInputJson(res, 405, { error: '/writer-tokens accepts GET or PUT.' })
611
+ return
612
+ }
613
+ try {
614
+ const body = await readRequestBody(
615
+ req,
616
+ MAX_QUERY_BODY_BYTES,
617
+ 'Writer token request body exceeds 64KB.',
618
+ )
619
+ const payload = JSON.parse(body || '{}')
620
+ const outcome = writerTokens.register(payload)
621
+ sendInputJson(res, 200, { ok: true, ...outcome, ...writerTokens.status() })
622
+ } catch (error) {
623
+ sendInputJson(res, 400, { error: error.message })
624
+ }
625
+ return
626
+ }
627
+
628
+ // 2026-09-12 hot reload: rebuild the HTTP/input layer from the on-disk
629
+ // lib/ code without restarting poi. Telemetry state and the recorder
630
+ // session survive (the controller re-injects the same getters); open
631
+ // connections drop and clients reconnect.
632
+ if (endpoint === '/admin/reload') {
633
+ if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
634
+ drainRequest(req)
635
+ sendInputJson(res, 401, { error: 'A valid Bearer token is required.' })
636
+ return
637
+ }
638
+ if (req.method !== 'POST') {
639
+ drainRequest(req)
640
+ sendInputJson(res, 405, { error: '/admin/reload only accepts POST.' })
641
+ return
642
+ }
643
+ drainRequest(req)
644
+ if (typeof options.reloadModules !== 'function') {
645
+ sendInputJson(res, 501, { error: 'reloadModules is not wired by this controller build' })
646
+ return
647
+ }
648
+ try {
649
+ const outcome = await options.reloadModules()
650
+ // After a successful reload THIS bridge instance is stopped and the
651
+ // fresh one owns the port — report the outcome's port, not the
652
+ // stale local getPort().
653
+ sendInputJson(res, 200, {
654
+ ok: true,
655
+ reloaded: true,
656
+ port: (outcome && outcome.port) || getPort(),
657
+ ...(outcome || {}),
658
+ })
659
+ } catch (error) {
660
+ sendInputJson(res, 500, { ok: false, error: error.message })
661
+ }
662
+ return
663
+ }
664
+
530
665
  if (endpoint === '/screenshot') {
531
666
  if (req.method !== 'GET') {
532
667
  sendJson(
@@ -769,6 +904,15 @@ function createPoiDataBridge(options = {}) {
769
904
  actualPort = 0
770
905
  resolve()
771
906
  })
907
+ // Keep-alive clients (the daemon holds persistent connections) never
908
+ // let close() finish on their own — reap them, but only after the
909
+ // aborted long-poll responses had a turn to flush (live 0912: an
910
+ // immediate reap raced the wait-abort writes into ECONNRESET).
911
+ setImmediate(() => {
912
+ if (typeof closingServer.closeAllConnections === 'function') {
913
+ closingServer.closeAllConnections()
914
+ }
915
+ })
772
916
  })
773
917
  }
774
918
 
@@ -16,8 +16,17 @@ const DEFAULT_MAX_SESSION_BYTES = 8 * 1024 * 1024 * 1024
16
16
  const DEFAULT_MAX_TIMELINE_EVENTS = 20000
17
17
  const DEFAULT_MAX_SESSION_DURATION_MS = 4 * 60 * 60 * 1000
18
18
  const DEFAULT_MAX_SCREENSHOT_BYTES = 16 * 1024 * 1024
19
- const DEFAULT_MAX_STORED_SESSIONS = 200
20
- const DEFAULT_MAX_TOTAL_RECORDING_BYTES = 64 * 1024 * 1024 * 1024
19
+ const DEFAULT_MAX_STORED_SESSIONS = envPositiveInteger(
20
+ 'POI_MCP_RECORDING_MAX_SESSIONS',
21
+ 200,
22
+ )
23
+ const DEFAULT_MAX_TOTAL_RECORDING_BYTES = envPositiveInteger(
24
+ 'POI_MCP_RECORDING_MAX_TOTAL_BYTES',
25
+ 20 * 1024 * 1024 * 1024,
26
+ )
27
+ // Byte-gate headroom: evict oldest sessions until the root is below this
28
+ // fraction of the cap so a fresh session cannot immediately re-trip the gate.
29
+ const RECORDING_BYTE_HEADROOM_FRACTION = 0.9
21
30
  const DEFAULT_FINAL_MANIFEST_RESERVE_BYTES = 64 * 1024
22
31
  const DEFAULT_CHECKPOINT_DELAYS_MS = Object.freeze([0, 250, 1000])
23
32
  const DEFAULT_OUTPUT_ROOT = process.platform === 'win32'
@@ -713,9 +722,17 @@ function createPoiInteractionRecorder(options = {}) {
713
722
  if (running) return getStatus()
714
723
  try {
715
724
  const recordingRoot = await inspectRecordingRoot(outputRoot)
716
- if (recordingRoot.sessionCount >= maxStoredSessions) {
717
- const overflow = recordingRoot.sessionCount - maxStoredSessions + 1
718
- await pruneOldestSessions(recordingRoot.sessionDirectories, overflow, logger)
725
+ // Retention: both the session-count and byte gates evict oldest-first
726
+ // instead of refusing to start; the byte gate leaves headroom for the
727
+ // session that is about to begin.
728
+ const evictCount = recordingRetentionEvictionCount({
729
+ sessions: recordingRoot.sessions,
730
+ totalBytes: recordingRoot.byteCount,
731
+ maxStoredSessions,
732
+ maxTotalRecordingBytes,
733
+ })
734
+ if (evictCount > 0) {
735
+ await pruneOldestSessions(recordingRoot.sessionDirectories, evictCount, logger)
719
736
  }
720
737
  const afterPrune = await inspectRecordingRoot(outputRoot)
721
738
  if (afterPrune.byteCount >= maxTotalRecordingBytes) {
@@ -950,7 +967,7 @@ async function inspectRecordingRoot(outputRoot) {
950
967
  entries = await fs.promises.readdir(outputRoot, { withFileTypes: true })
951
968
  } catch (error) {
952
969
  if (error && error.code === 'ENOENT') {
953
- return { sessionCount: 0, byteCount: 0, sessionDirectories: [] }
970
+ return { sessionCount: 0, byteCount: 0, sessionDirectories: [], sessions: [] }
954
971
  }
955
972
  throw error
956
973
  }
@@ -969,9 +986,12 @@ async function inspectRecordingRoot(outputRoot) {
969
986
  if (!error || error.code !== 'ENOENT') throw error
970
987
  }
971
988
  }
972
- const stack = [...directories]
989
+ // Attribute every byte to its top-level directory so retention can rank
990
+ // sessions by size as well as age.
991
+ const rootBytes = new Map(directories.map((directory) => [directory, 0]))
992
+ const stack = directories.map((directory) => ({ directory, root: directory }))
973
993
  while (stack.length > 0) {
974
- const directory = stack.pop()
994
+ const { directory, root } = stack.pop()
975
995
  let children
976
996
  try {
977
997
  children = await fs.promises.readdir(directory, { withFileTypes: true })
@@ -983,10 +1003,12 @@ async function inspectRecordingRoot(outputRoot) {
983
1003
  if (child.isSymbolicLink()) continue
984
1004
  const childPath = path.join(directory, child.name)
985
1005
  if (child.isDirectory()) {
986
- stack.push(childPath)
1006
+ stack.push({ directory: childPath, root })
987
1007
  } else if (child.isFile()) {
988
1008
  try {
989
- byteCount += (await fs.promises.stat(childPath)).size
1009
+ const size = (await fs.promises.stat(childPath)).size
1010
+ byteCount += size
1011
+ rootBytes.set(root, (rootBytes.get(root) || 0) + size)
990
1012
  } catch (error) {
991
1013
  if (!error || error.code !== 'ENOENT') throw error
992
1014
  }
@@ -996,13 +1018,41 @@ async function inspectRecordingRoot(outputRoot) {
996
1018
  const sessionDirectories = directories.filter((directory) =>
997
1019
  SESSION_DIRECTORY_PATTERN.test(path.basename(directory)),
998
1020
  )
1021
+ const sessions = sessionDirectories.map((directory) => ({
1022
+ directory,
1023
+ bytes: rootBytes.get(directory) || 0,
1024
+ }))
999
1025
  return {
1000
1026
  sessionCount: sessionDirectories.length,
1001
1027
  byteCount,
1002
1028
  sessionDirectories,
1029
+ sessions,
1003
1030
  }
1004
1031
  }
1005
1032
 
1033
+ // Oldest-first eviction count for the two retention gates. `sessions` must be
1034
+ // sorted oldest-first (inspectRecordingRoot guarantees this via name sort).
1035
+ function recordingRetentionEvictionCount({
1036
+ sessions,
1037
+ totalBytes,
1038
+ maxStoredSessions,
1039
+ maxTotalRecordingBytes,
1040
+ }) {
1041
+ if (!Array.isArray(sessions)) return 0
1042
+ const countOverflow = sessions.length >= maxStoredSessions
1043
+ ? sessions.length - maxStoredSessions + 1
1044
+ : 0
1045
+ const byteTarget = Math.floor(maxTotalRecordingBytes * RECORDING_BYTE_HEADROOM_FRACTION)
1046
+ let byteEvictions = 0
1047
+ let recovered = 0
1048
+ for (const session of sessions) {
1049
+ if (totalBytes - recovered <= byteTarget) break
1050
+ recovered += Number.isFinite(session.bytes) ? session.bytes : 0
1051
+ byteEvictions += 1
1052
+ }
1053
+ return Math.max(countOverflow, byteEvictions)
1054
+ }
1055
+
1006
1056
  async function pruneOldestSessions(sessionDirectories, count, logger) {
1007
1057
  if (count <= 0) return
1008
1058
  for (const directory of sessionDirectories.slice(0, count)) {
@@ -1550,6 +1600,14 @@ function positiveInteger(value, fallback, name) {
1550
1600
  return selected
1551
1601
  }
1552
1602
 
1603
+ function envPositiveInteger(name, fallback) {
1604
+ const raw = process.env[name]
1605
+ if (raw === undefined || raw === '') return fallback
1606
+ const parsed = Number(raw)
1607
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) return fallback
1608
+ return parsed
1609
+ }
1610
+
1553
1611
  function positiveNumber(value, fallback, name) {
1554
1612
  const selected = value == null ? fallback : value
1555
1613
  if (!Number.isFinite(selected) || selected <= 0) {
@@ -1687,7 +1745,11 @@ module.exports = {
1687
1745
  DEFAULT_MAX_STORED_SESSIONS,
1688
1746
  DEFAULT_MAX_TOTAL_RECORDING_BYTES,
1689
1747
  DEFAULT_OUTPUT_ROOT,
1748
+ RECORDING_BYTE_HEADROOM_FRACTION,
1690
1749
  captureEquipmentUiStateFromWebContents: defaultCaptureEquipmentUiState,
1691
1750
  captureWebStorageFromWebContents: defaultCaptureWebStorage,
1692
1751
  createPoiInteractionRecorder,
1752
+ inspectRecordingRoot,
1753
+ pruneOldestSessions,
1754
+ recordingRetentionEvictionCount,
1693
1755
  }
package/lib/settings.js CHANGED
@@ -8,6 +8,12 @@ const DEFAULT_SETTINGS = Object.freeze({
8
8
  inputEnabled: false,
9
9
  recordingEnabled: false,
10
10
  debugEvalEnabled: false,
11
+ // 2026-09-12 Phase C: when true, INPUT endpoints (/input, /input/lease*,
12
+ // /debug/evaluate) require a registered X-Writer-Token in addition to the
13
+ // static bearer. Observation mode (false) serves legacy static-bearer
14
+ // requests but counts them — the daemon registers tokens and sends them
15
+ // either way, so flipping this only rejects leftovers.
16
+ writerTokenEnforced: false,
11
17
  })
12
18
 
13
19
  const DEFAULT_SETTINGS_FILE = path.join(os.homedir(), '.poi-mcp', 'settings.json')
@@ -26,6 +32,9 @@ function normalizeSettings(input = {}) {
26
32
  debugEvalEnabled: typeof input.debugEvalEnabled === 'boolean'
27
33
  ? input.debugEvalEnabled
28
34
  : DEFAULT_SETTINGS.debugEvalEnabled,
35
+ writerTokenEnforced: typeof input.writerTokenEnforced === 'boolean'
36
+ ? input.writerTokenEnforced
37
+ : DEFAULT_SETTINGS.writerTokenEnforced,
29
38
  }
30
39
  }
31
40
 
@@ -0,0 +1,130 @@
1
+ const crypto = require('crypto')
2
+
3
+ const TOKEN_PATTERN = /^[a-f0-9]{32,128}$/
4
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000
5
+
6
+ /**
7
+ * Daemon-delegated writer tokens (2026-09-12 single-writer plan, Phase C).
8
+ *
9
+ * The static input token authorizes whoever can read the file — forever. A
10
+ * zombie from an earlier session therefore holds a valid credential forever.
11
+ * This registry replaces that for INPUT endpoints: the daemon mints short
12
+ * lived tokens (a generation token for itself, one per spawned job), pushes
13
+ * them here over the admin endpoint, and revokes job tokens when the reaper
14
+ * collects the job. A process that missed a rotation is rejected with a
15
+ * readable reason instead of silently competing for clicks.
16
+ *
17
+ * Module-level singleton: bridge restarts (settings apply, hot reload) must
18
+ * not silently drop the registered set — and when a hot reload DOES purge
19
+ * this module, the daemon's periodic re-registration self-heals within one
20
+ * idle cycle. The threat model is accidents, not adversaries: holding a
21
+ * current token is a sufficient proxy for "delegated by the current daemon
22
+ * generation", so there is deliberately no per-pid challenge/response.
23
+ */
24
+ function createWriterTokenRegistry() {
25
+ const entries = new Map() // token -> { label, registeredAt, expiresAt }
26
+ let legacyAuthCount = 0
27
+ let lastLegacyAuthAt = null
28
+
29
+ function sweep(now = Date.now()) {
30
+ for (const [token, entry] of entries) {
31
+ if (entry.expiresAt <= now) entries.delete(token)
32
+ }
33
+ }
34
+
35
+ return {
36
+ register(payload, now = Date.now()) {
37
+ const mode = payload && (payload.mode === 'add' || payload.mode === 'remove') ? payload.mode : 'replace'
38
+ const list = payload && Array.isArray(payload.tokens) ? payload.tokens : []
39
+ if (mode === 'replace') entries.clear()
40
+ let accepted = 0
41
+ let rejected = []
42
+ for (const item of list) {
43
+ const token = item && typeof item.token === 'string' ? item.token : null
44
+ if (!TOKEN_PATTERN.test(token || '')) {
45
+ rejected.push('invalid token shape')
46
+ continue
47
+ }
48
+ const ttlMs = Number.isFinite(item.ttlMs) && item.ttlMs > 0 && item.ttlMs <= 7 * DEFAULT_TTL_MS
49
+ ? Math.trunc(item.ttlMs)
50
+ : DEFAULT_TTL_MS
51
+ entries.set(token, {
52
+ label: typeof item.label === 'string' && item.label !== '' ? item.label.slice(0, 128) : 'unlabeled',
53
+ registeredAt: now,
54
+ expiresAt: now + ttlMs,
55
+ })
56
+ accepted += 1
57
+ }
58
+ if (mode === 'remove') {
59
+ for (const item of list) {
60
+ if (item && typeof item.label === 'string') {
61
+ for (const [token, entry] of entries) {
62
+ if (entry.label === item.label) entries.delete(token)
63
+ }
64
+ }
65
+ }
66
+ }
67
+ sweep(now)
68
+ return { accepted, rejected: rejected.length }
69
+ },
70
+
71
+ validate(token, now = Date.now()) {
72
+ if (typeof token !== 'string' || !TOKEN_PATTERN.test(token)) return false
73
+ const entry = entries.get(token)
74
+ if (!entry) return false
75
+ if (entry.expiresAt <= now) {
76
+ entries.delete(token)
77
+ return false
78
+ }
79
+ return true
80
+ },
81
+
82
+ noteLegacyAuth(now = Date.now()) {
83
+ legacyAuthCount += 1
84
+ lastLegacyAuthAt = new Date(now).toISOString()
85
+ },
86
+
87
+ status(now = Date.now()) {
88
+ sweep(now)
89
+ return {
90
+ registered: entries.size,
91
+ tokens: [...entries.values()].map((entry) => ({
92
+ label: entry.label,
93
+ registeredAt: new Date(entry.registeredAt).toISOString(),
94
+ expiresAt: new Date(entry.expiresAt).toISOString(),
95
+ })),
96
+ legacyAuthCount,
97
+ lastLegacyAuthAt,
98
+ }
99
+ },
100
+ }
101
+ }
102
+
103
+ let singleton = null
104
+
105
+ function getWriterTokenRegistry() {
106
+ if (!singleton) singleton = createWriterTokenRegistry()
107
+ return singleton
108
+ }
109
+
110
+ function mintWriterToken() {
111
+ return crypto.randomBytes(32).toString('hex')
112
+ }
113
+
114
+ function hasValidWriterToken(headerValue, registry, now = Date.now()) {
115
+ if (typeof headerValue !== 'string' || headerValue === '') return false
116
+ // In-process registry lookup: the timing-safe compare discipline applies to
117
+ // the STATIC bearer token (a file secret); membership here is an in-memory
118
+ // Map hit against an unregistered attacker-chosen value with no secret to
119
+ // leak.
120
+ return registry.validate(headerValue, now)
121
+ }
122
+
123
+ module.exports = {
124
+ TOKEN_PATTERN,
125
+ DEFAULT_TTL_MS,
126
+ createWriterTokenRegistry,
127
+ getWriterTokenRegistry,
128
+ mintWriterToken,
129
+ hasValidWriterToken,
130
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poi-plugin-mcp",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
4
4
  "description": "Poi data, WebView capture, and opt-in authenticated input bridge for local KanColle tools.",
5
5
  "main": "index.js",
6
6
  "keywords": [