poi-plugin-mcp 0.2.25 → 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.
- package/lib/bridge-controller.js +75 -1
- package/lib/poi-http-bridge.js +144 -0
- package/lib/settings.js +9 -0
- package/lib/writer-tokens.js +130 -0
- package/package.json +1 -1
package/lib/bridge-controller.js
CHANGED
|
@@ -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
|
-
|
|
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'
|
package/lib/poi-http-bridge.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|