poi-plugin-mcp 0.2.16 → 0.2.21

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,292 +1,405 @@
1
- const crypto = require('node:crypto')
2
-
3
- const DEFAULT_CAPACITY = 256
4
- const DEFAULT_LIMIT = 64
5
- const MAX_CAPACITY = 256
6
- const MAX_OBJECT_KEYS = 32
7
- const MAX_ARRAY_ITEMS = 16
8
- const MAX_STRING_LENGTH = 256
9
- const MAX_RESPONSE_DEPTH = 4
10
-
11
- const EXACT_PATHS = new Set([
12
- '/kcsapi/api_start2/getData',
13
- '/kcsapi/api_port/port',
14
- '/kcsapi/api_get_member/base_air_corps',
15
- '/kcsapi/api_get_member/deck',
16
- '/kcsapi/api_get_member/kdock',
17
- '/kcsapi/api_get_member/mapinfo',
18
- '/kcsapi/api_get_member/material',
19
- '/kcsapi/api_get_member/ndock',
20
- '/kcsapi/api_get_member/practice',
21
- '/kcsapi/api_get_member/questlist',
22
- '/kcsapi/api_get_member/require_info',
23
- '/kcsapi/api_get_member/ship2',
24
- '/kcsapi/api_get_member/ship3',
25
- '/kcsapi/api_get_member/slot_item',
26
- '/kcsapi/api_get_member/unsetslot',
27
- '/kcsapi/api_get_member/useitem',
28
- '/kcsapi/api_req_air_corps/set_action',
29
- '/kcsapi/api_req_air_corps/set_plane',
30
- '/kcsapi/api_req_air_corps/supply',
31
- '/kcsapi/api_req_battle_midnight/battle',
32
- '/kcsapi/api_req_battle_midnight/sp_midnight',
33
- '/kcsapi/api_req_hensei/change',
34
- '/kcsapi/api_req_hensei/combined',
35
- '/kcsapi/api_req_hensei/preset_select',
36
- '/kcsapi/api_req_hokyu/charge',
37
- '/kcsapi/api_req_kaisou/powerup',
38
- '/kcsapi/api_req_kaisou/slot_select',
39
- '/kcsapi/api_req_kaisou/slot_deprive',
40
- '/kcsapi/api_req_kaisou/slotset',
41
- '/kcsapi/api_req_kaisou/slotset_ex',
42
- '/kcsapi/api_req_kaisou/unsetslot_all',
43
- '/kcsapi/api_req_kousyou/createitem',
44
- '/kcsapi/api_req_kousyou/createship',
45
- '/kcsapi/api_req_kousyou/createship_speedchange',
46
- '/kcsapi/api_req_kousyou/destroyitem2',
47
- '/kcsapi/api_req_kousyou/destroyship',
48
- '/kcsapi/api_req_kousyou/getship',
49
- '/kcsapi/api_req_kousyou/remodel_slot',
50
- '/kcsapi/api_req_map/next',
51
- '/kcsapi/api_req_map/select_eventmap_rank',
52
- '/kcsapi/api_req_map/start',
53
- '/kcsapi/api_req_member/get_practice_enemyinfo',
54
- '/kcsapi/api_req_mission/result',
55
- '/kcsapi/api_req_mission/start',
56
- '/kcsapi/api_req_nyukyo/speedchange',
57
- '/kcsapi/api_req_nyukyo/start',
58
- '/kcsapi/api_req_practice/battle',
59
- '/kcsapi/api_req_practice/battle_result',
60
- '/kcsapi/api_req_practice/midnight_battle',
61
- '/kcsapi/api_req_quest/clearitemget',
62
- '/kcsapi/api_req_quest/start',
63
- '/kcsapi/api_req_quest/stop',
64
- ])
65
-
66
- const ALLOWED_PREFIXES = [
67
- '/kcsapi/api_req_sortie/',
68
- '/kcsapi/api_req_combined_battle/',
69
- ]
70
-
71
- const SHARED_TELEMETRY_TIMESTAMP_PATHS = new Set([
72
- '/kcsapi/api_get_member/questlist',
73
- '/kcsapi/api_get_member/unsetslot',
74
- '/kcsapi/api_req_quest/start',
75
- '/kcsapi/api_req_quest/stop',
76
- '/kcsapi/api_req_kaisou/slotset',
77
- '/kcsapi/api_req_kaisou/slotset_ex',
78
- '/kcsapi/api_req_kaisou/slot_deprive',
79
- '/kcsapi/api_req_kaisou/unsetslot_all',
80
- '/kcsapi/api_req_hensei/change',
81
- ])
82
-
83
- function createPoiActionEvents(options = {}) {
84
- const now = options.now || (() => new Date())
85
- const inferredNow = options.inferredNow || (() => new Date())
86
- const sessionId = boundedId(options.sessionId || crypto.randomUUID(), 'sessionId')
87
- const capacity = boundedInteger(
88
- options.capacity == null ? DEFAULT_CAPACITY : options.capacity,
89
- 1,
90
- MAX_CAPACITY,
91
- 'capacity',
92
- )
93
- const events = []
94
- let latestGeneration = 0
95
-
96
- function capture(detail) {
97
- if (!isCapturableDetail(detail)) return null
98
- const explicitApiResult = readApiResult(detail)
99
- const apiResult = explicitApiResult == null && hasUnwrappedResponseBody(detail)
100
- ? 1
101
- : explicitApiResult
102
- if (apiResult !== 1) return null
103
-
104
- latestGeneration += 1
105
- const eventNow = explicitApiResult != null ||
106
- SHARED_TELEMETRY_TIMESTAMP_PATHS.has(detail.path)
107
- ? now
108
- : inferredNow
109
- const event = Object.freeze({
110
- generation: latestGeneration,
111
- capturedAt: timestamp(eventNow()),
112
- path: detail.path,
113
- apiResult,
114
- postBody: sanitizePostBody(detail.postBody),
115
- responseSummary: sanitizeResponse(detail.body),
116
- })
117
- events.push(event)
118
- if (events.length > capacity) events.splice(0, events.length - capacity)
119
- return event
120
- }
121
-
122
- function read(options = {}) {
123
- const after = boundedInteger(
124
- options.after == null ? 0 : options.after,
125
- 0,
126
- Number.MAX_SAFE_INTEGER,
127
- 'after',
128
- )
129
- const limit = boundedInteger(
130
- options.limit == null ? DEFAULT_LIMIT : options.limit,
131
- 1,
132
- MAX_CAPACITY,
133
- 'limit',
134
- )
135
- return {
136
- available: true,
137
- sessionId,
138
- latestGeneration,
139
- events: events
140
- .filter((event) => event.generation > after)
141
- .slice(0, limit),
142
- }
143
- }
144
-
145
- return Object.freeze({ capture, read })
146
- }
147
-
148
- function isCapturableDetail(detail) {
149
- return Boolean(
150
- detail &&
151
- typeof detail === 'object' &&
152
- !Array.isArray(detail) &&
153
- typeof detail.path === 'string' &&
154
- (
155
- EXACT_PATHS.has(detail.path) ||
156
- ALLOWED_PREFIXES.some((prefix) => detail.path.startsWith(prefix))
157
- ),
158
- )
159
- }
160
-
161
- function readApiResult(detail) {
162
- const candidates = [
163
- detail.apiResult,
164
- detail.api_result,
165
- detail.result,
166
- detail.body && detail.body.api_result,
167
- ]
168
- for (const candidate of candidates) {
169
- if (candidate == null || candidate === '') continue
170
- const parsed = Number(candidate)
171
- return Number.isInteger(parsed) ? parsed : null
172
- }
173
- return null
174
- }
175
-
176
- function hasUnwrappedResponseBody(detail) {
177
- if (!Object.prototype.hasOwnProperty.call(detail, 'body')) return false
178
- const body = detail.body
179
- if (!isPlainObject(body) && !Array.isArray(body)) return false
180
- return !(
181
- isPlainObject(body) &&
182
- (
183
- Object.prototype.hasOwnProperty.call(body, 'api_result') ||
184
- Object.prototype.hasOwnProperty.call(body, 'api_data')
185
- )
186
- )
187
- }
188
-
189
- function sanitizePostBody(value) {
190
- if (!isPlainObject(value)) return {}
191
- const output = {}
192
- for (const [key, item] of Object.entries(value).slice(0, MAX_OBJECT_KEYS)) {
193
- if (!safeKey(key)) continue
194
- const scalar = sanitizeScalar(item)
195
- if (scalar !== undefined) {
196
- output[key] = scalar
197
- continue
198
- }
199
- if (
200
- Array.isArray(item) &&
201
- item.length <= MAX_ARRAY_ITEMS
202
- ) {
203
- const array = item.map(sanitizeScalar)
204
- if (array.every((entry) => entry !== undefined)) output[key] = array
205
- }
206
- }
207
- return output
208
- }
209
-
210
- function sanitizeResponse(value) {
211
- const sanitized = sanitizeNested(value, 0)
212
- return isPlainObject(sanitized) ? sanitized : {}
213
- }
214
-
215
- function sanitizeNested(value, depth) {
216
- const scalar = sanitizeScalar(value)
217
- if (scalar !== undefined) return scalar
218
- if (depth >= MAX_RESPONSE_DEPTH) return undefined
219
- if (Array.isArray(value)) {
220
- return value
221
- .slice(0, MAX_ARRAY_ITEMS)
222
- .map((item) => sanitizeNested(item, depth + 1))
223
- .filter((item) => item !== undefined)
224
- }
225
- if (!isPlainObject(value)) return undefined
226
- const output = {}
227
- for (const [key, item] of Object.entries(value).slice(0, MAX_OBJECT_KEYS)) {
228
- if (!safeKey(key)) continue
229
- const sanitized = sanitizeNested(item, depth + 1)
230
- if (sanitized !== undefined) output[key] = sanitized
231
- }
232
- return output
233
- }
234
-
235
- function sanitizeScalar(value) {
236
- if (
237
- value === null ||
238
- typeof value === 'boolean' ||
239
- (typeof value === 'number' && Number.isFinite(value))
240
- ) {
241
- return value
242
- }
243
- if (typeof value === 'string') return value.slice(0, MAX_STRING_LENGTH)
244
- return undefined
245
- }
246
-
247
- function safeKey(key) {
248
- return (
249
- typeof key === 'string' &&
250
- key.length > 0 &&
251
- key.length <= 64 &&
252
- !/(authorization|cookie|header|password|secret|token)/i.test(key)
253
- )
254
- }
255
-
256
- function timestamp(value) {
257
- const date = value instanceof Date ? value : new Date(value)
258
- if (!Number.isFinite(date.getTime())) throw new Error('now must return a valid date')
259
- return date.toISOString()
260
- }
261
-
262
- function boundedId(value, name) {
263
- if (typeof value !== 'string' || value.length === 0 || value.length > 256) {
264
- throw new Error(`${name} must be a non-empty bounded string`)
265
- }
266
- return value
267
- }
268
-
269
- function boundedInteger(value, minimum, maximum, name) {
270
- if (
271
- !Number.isSafeInteger(value) ||
272
- value < minimum ||
273
- value > maximum
274
- ) {
275
- throw new Error(
276
- `${name} must be an integer from ${minimum} through ${maximum}`,
277
- )
278
- }
279
- return value
280
- }
281
-
282
- function isPlainObject(value) {
283
- return Boolean(
284
- value &&
285
- typeof value === 'object' &&
286
- !Array.isArray(value),
287
- )
288
- }
289
-
290
- module.exports = {
291
- createPoiActionEvents,
292
- }
1
+ const crypto = require('node:crypto')
2
+
3
+ const DEFAULT_CAPACITY = 256
4
+ const DEFAULT_LIMIT = 64
5
+ const DEFAULT_WAIT_TIMEOUT_MS = 30_000
6
+ const MAX_CAPACITY = 256
7
+ const MAX_WAIT_TIMEOUT_MS = 60_000
8
+ const DEFAULT_MAX_WAITERS = 128
9
+ const MAX_WAITERS = 256
10
+ const MAX_OBJECT_KEYS = 32
11
+ const MAX_ARRAY_ITEMS = 16
12
+ const MAX_STRING_LENGTH = 256
13
+ const MAX_RESPONSE_DEPTH = 4
14
+
15
+ const EXACT_PATHS = new Set([
16
+ '/kcsapi/api_start2/getData',
17
+ '/kcsapi/api_port/port',
18
+ '/kcsapi/api_get_member/base_air_corps',
19
+ '/kcsapi/api_get_member/deck',
20
+ '/kcsapi/api_get_member/kdock',
21
+ '/kcsapi/api_get_member/mapinfo',
22
+ '/kcsapi/api_get_member/material',
23
+ '/kcsapi/api_get_member/ndock',
24
+ '/kcsapi/api_get_member/practice',
25
+ '/kcsapi/api_get_member/questlist',
26
+ '/kcsapi/api_get_member/require_info',
27
+ '/kcsapi/api_get_member/ship2',
28
+ '/kcsapi/api_get_member/ship3',
29
+ '/kcsapi/api_get_member/slot_item',
30
+ '/kcsapi/api_get_member/unsetslot',
31
+ '/kcsapi/api_get_member/useitem',
32
+ '/kcsapi/api_req_air_corps/set_action',
33
+ '/kcsapi/api_req_air_corps/change_deployment_base',
34
+ '/kcsapi/api_req_air_corps/set_plane',
35
+ '/kcsapi/api_req_air_corps/supply',
36
+ '/kcsapi/api_req_battle_midnight/battle',
37
+ '/kcsapi/api_req_battle_midnight/sp_midnight',
38
+ '/kcsapi/api_req_hensei/change',
39
+ '/kcsapi/api_req_hensei/combined',
40
+ '/kcsapi/api_req_hensei/preset_select',
41
+ '/kcsapi/api_req_hokyu/charge',
42
+ '/kcsapi/api_req_kaisou/powerup',
43
+ '/kcsapi/api_req_kaisou/slot_select',
44
+ '/kcsapi/api_req_kaisou/slot_deprive',
45
+ '/kcsapi/api_req_kaisou/slotset',
46
+ '/kcsapi/api_req_kaisou/slotset_ex',
47
+ '/kcsapi/api_req_kaisou/unsetslot_all',
48
+ '/kcsapi/api_req_kousyou/createitem',
49
+ '/kcsapi/api_req_kousyou/createship',
50
+ '/kcsapi/api_req_kousyou/createship_speedchange',
51
+ '/kcsapi/api_req_kousyou/destroyitem2',
52
+ '/kcsapi/api_req_kousyou/destroyship',
53
+ '/kcsapi/api_req_kousyou/getship',
54
+ '/kcsapi/api_req_kousyou/remodel_slot',
55
+ '/kcsapi/api_req_map/next',
56
+ '/kcsapi/api_req_map/select_eventmap_rank',
57
+ '/kcsapi/api_req_map/start',
58
+ '/kcsapi/api_req_map/start_air_base',
59
+ '/kcsapi/api_req_member/get_practice_enemyinfo',
60
+ '/kcsapi/api_req_mission/result',
61
+ '/kcsapi/api_req_mission/start',
62
+ '/kcsapi/api_req_nyukyo/speedchange',
63
+ '/kcsapi/api_req_nyukyo/start',
64
+ '/kcsapi/api_req_practice/battle',
65
+ '/kcsapi/api_req_practice/battle_result',
66
+ '/kcsapi/api_req_practice/midnight_battle',
67
+ '/kcsapi/api_req_quest/clearitemget',
68
+ '/kcsapi/api_req_quest/start',
69
+ '/kcsapi/api_req_quest/stop',
70
+ '/kcsapi/api_req_quest/clearitemget',
71
+ ])
72
+
73
+ const ALLOWED_PREFIXES = [
74
+ '/kcsapi/api_req_sortie/',
75
+ '/kcsapi/api_req_combined_battle/',
76
+ ]
77
+
78
+ const SHARED_TELEMETRY_TIMESTAMP_PATHS = new Set([
79
+ '/kcsapi/api_get_member/questlist',
80
+ '/kcsapi/api_get_member/unsetslot',
81
+ '/kcsapi/api_req_quest/start',
82
+ '/kcsapi/api_req_quest/stop',
83
+ '/kcsapi/api_req_quest/clearitemget',
84
+ '/kcsapi/api_req_kaisou/slotset',
85
+ '/kcsapi/api_req_kaisou/slotset_ex',
86
+ '/kcsapi/api_req_kaisou/slot_deprive',
87
+ '/kcsapi/api_req_kaisou/unsetslot_all',
88
+ '/kcsapi/api_req_hensei/change',
89
+ ])
90
+
91
+ function createPoiActionEvents(options = {}) {
92
+ const now = options.now || (() => new Date())
93
+ const inferredNow = options.inferredNow || (() => new Date())
94
+ const sessionId = boundedId(options.sessionId || crypto.randomUUID(), 'sessionId')
95
+ const capacity = boundedInteger(
96
+ options.capacity == null ? DEFAULT_CAPACITY : options.capacity,
97
+ 1,
98
+ MAX_CAPACITY,
99
+ 'capacity',
100
+ )
101
+ const maxWaiters = boundedInteger(
102
+ options.maxWaiters == null ? DEFAULT_MAX_WAITERS : options.maxWaiters,
103
+ 1,
104
+ MAX_WAITERS,
105
+ 'maxWaiters',
106
+ )
107
+ const events = []
108
+ const waiters = new Set()
109
+ let latestGeneration = 0
110
+
111
+ function capture(detail) {
112
+ if (!isCapturableDetail(detail)) return null
113
+ const explicitApiResult = readApiResult(detail)
114
+ const apiResult = explicitApiResult == null && hasUnwrappedResponseBody(detail)
115
+ ? 1
116
+ : explicitApiResult
117
+ if (apiResult !== 1) return null
118
+
119
+ latestGeneration += 1
120
+ const eventNow = explicitApiResult != null ||
121
+ SHARED_TELEMETRY_TIMESTAMP_PATHS.has(detail.path)
122
+ ? now
123
+ : inferredNow
124
+ const event = Object.freeze({
125
+ generation: latestGeneration,
126
+ capturedAt: timestamp(eventNow()),
127
+ path: detail.path,
128
+ apiResult,
129
+ postBody: sanitizePostBody(detail.postBody),
130
+ responseSummary: sanitizeResponse(detail.body),
131
+ })
132
+ events.push(event)
133
+ if (events.length > capacity) events.splice(0, events.length - capacity)
134
+ notifyWaiters()
135
+ return event
136
+ }
137
+
138
+ function read(options = {}) {
139
+ const after = boundedInteger(
140
+ options.after == null ? 0 : options.after,
141
+ 0,
142
+ Number.MAX_SAFE_INTEGER,
143
+ 'after',
144
+ )
145
+ const limit = boundedInteger(
146
+ options.limit == null ? DEFAULT_LIMIT : options.limit,
147
+ 1,
148
+ MAX_CAPACITY,
149
+ 'limit',
150
+ )
151
+ return {
152
+ available: true,
153
+ sessionId,
154
+ latestGeneration,
155
+ events: events
156
+ .filter((event) => event.generation > after)
157
+ .slice(0, limit),
158
+ }
159
+ }
160
+
161
+ function wait(options = {}) {
162
+ const after = boundedInteger(
163
+ options.after == null ? 0 : options.after,
164
+ 0,
165
+ Number.MAX_SAFE_INTEGER,
166
+ 'after',
167
+ )
168
+ const limit = boundedInteger(
169
+ options.limit == null ? DEFAULT_LIMIT : options.limit,
170
+ 1,
171
+ MAX_CAPACITY,
172
+ 'limit',
173
+ )
174
+ const timeoutMs = boundedInteger(
175
+ options.timeoutMs == null ? DEFAULT_WAIT_TIMEOUT_MS : options.timeoutMs,
176
+ 1,
177
+ MAX_WAIT_TIMEOUT_MS,
178
+ 'timeoutMs',
179
+ )
180
+ const signal = options.signal
181
+ if (
182
+ signal != null &&
183
+ (
184
+ typeof signal !== 'object' ||
185
+ typeof signal.addEventListener !== 'function' ||
186
+ typeof signal.removeEventListener !== 'function'
187
+ )
188
+ ) {
189
+ throw new Error('signal must be an AbortSignal')
190
+ }
191
+
192
+ const immediate = read({ after, limit })
193
+ if (immediate.events.length > 0) {
194
+ return Promise.resolve({ ...immediate, timedOut: false })
195
+ }
196
+ if (signal && signal.aborted) return Promise.reject(createAbortError())
197
+ if (waiters.size >= maxWaiters) {
198
+ const error = new Error('Too many action event waiters.')
199
+ error.code = 'ACTION_EVENT_WAITERS_LIMIT'
200
+ return Promise.reject(error)
201
+ }
202
+
203
+ return new Promise((resolve, reject) => {
204
+ const waiter = {
205
+ after,
206
+ limit,
207
+ resolve,
208
+ reject,
209
+ signal,
210
+ onAbort: null,
211
+ timer: null,
212
+ }
213
+
214
+ const settle = (settlement) => {
215
+ if (!waiters.delete(waiter)) return
216
+ if (waiter.timer != null) clearTimeout(waiter.timer)
217
+ if (waiter.signal && waiter.onAbort) {
218
+ waiter.signal.removeEventListener('abort', waiter.onAbort)
219
+ }
220
+ settlement()
221
+ }
222
+
223
+ waiter.resolveWait = (result) => settle(() => resolve(result))
224
+ waiter.rejectWait = (error) => settle(() => reject(error))
225
+ waiter.timer = setTimeout(() => {
226
+ waiter.resolveWait({ ...read({ after, limit }), timedOut: true })
227
+ }, timeoutMs)
228
+ if (signal) {
229
+ waiter.onAbort = () => waiter.rejectWait(createAbortError())
230
+ signal.addEventListener('abort', waiter.onAbort, { once: true })
231
+ }
232
+ waiters.add(waiter)
233
+ })
234
+ }
235
+
236
+ function notifyWaiters() {
237
+ if (waiters.size === 0) return
238
+ for (const waiter of Array.from(waiters)) {
239
+ if (latestGeneration <= waiter.after) continue
240
+ waiter.resolveWait({
241
+ ...read({ after: waiter.after, limit: waiter.limit }),
242
+ timedOut: false,
243
+ })
244
+ }
245
+ }
246
+
247
+ function close(reason = createAbortError('Action event provider closed.')) {
248
+ for (const waiter of Array.from(waiters)) waiter.rejectWait(reason)
249
+ }
250
+
251
+ return Object.freeze({ capture, read, wait, close })
252
+ }
253
+
254
+ function isCapturableDetail(detail) {
255
+ return Boolean(
256
+ detail &&
257
+ typeof detail === 'object' &&
258
+ !Array.isArray(detail) &&
259
+ typeof detail.path === 'string' &&
260
+ (
261
+ EXACT_PATHS.has(detail.path) ||
262
+ ALLOWED_PREFIXES.some((prefix) => detail.path.startsWith(prefix))
263
+ ),
264
+ )
265
+ }
266
+
267
+ function readApiResult(detail) {
268
+ const candidates = [
269
+ detail.apiResult,
270
+ detail.api_result,
271
+ detail.result,
272
+ detail.body && detail.body.api_result,
273
+ ]
274
+ for (const candidate of candidates) {
275
+ if (candidate == null || candidate === '') continue
276
+ const parsed = Number(candidate)
277
+ return Number.isInteger(parsed) ? parsed : null
278
+ }
279
+ return null
280
+ }
281
+
282
+ function hasUnwrappedResponseBody(detail) {
283
+ if (!Object.prototype.hasOwnProperty.call(detail, 'body')) return false
284
+ const body = detail.body
285
+ if (!isPlainObject(body) && !Array.isArray(body)) return false
286
+ return !(
287
+ isPlainObject(body) &&
288
+ (
289
+ Object.prototype.hasOwnProperty.call(body, 'api_result') ||
290
+ Object.prototype.hasOwnProperty.call(body, 'api_data')
291
+ )
292
+ )
293
+ }
294
+
295
+ function sanitizePostBody(value) {
296
+ if (!isPlainObject(value)) return {}
297
+ const output = {}
298
+ for (const [key, item] of Object.entries(value).slice(0, MAX_OBJECT_KEYS)) {
299
+ if (!safeKey(key)) continue
300
+ const scalar = sanitizeScalar(item)
301
+ if (scalar !== undefined) {
302
+ output[key] = scalar
303
+ continue
304
+ }
305
+ if (
306
+ Array.isArray(item) &&
307
+ item.length <= MAX_ARRAY_ITEMS
308
+ ) {
309
+ const array = item.map(sanitizeScalar)
310
+ if (array.every((entry) => entry !== undefined)) output[key] = array
311
+ }
312
+ }
313
+ return output
314
+ }
315
+
316
+ function sanitizeResponse(value) {
317
+ const sanitized = sanitizeNested(value, 0)
318
+ return isPlainObject(sanitized) ? sanitized : {}
319
+ }
320
+
321
+ function sanitizeNested(value, depth) {
322
+ const scalar = sanitizeScalar(value)
323
+ if (scalar !== undefined) return scalar
324
+ if (depth >= MAX_RESPONSE_DEPTH) return undefined
325
+ if (Array.isArray(value)) {
326
+ return value
327
+ .slice(0, MAX_ARRAY_ITEMS)
328
+ .map((item) => sanitizeNested(item, depth + 1))
329
+ .filter((item) => item !== undefined)
330
+ }
331
+ if (!isPlainObject(value)) return undefined
332
+ const output = {}
333
+ for (const [key, item] of Object.entries(value).slice(0, MAX_OBJECT_KEYS)) {
334
+ if (!safeKey(key)) continue
335
+ const sanitized = sanitizeNested(item, depth + 1)
336
+ if (sanitized !== undefined) output[key] = sanitized
337
+ }
338
+ return output
339
+ }
340
+
341
+ function sanitizeScalar(value) {
342
+ if (
343
+ value === null ||
344
+ typeof value === 'boolean' ||
345
+ (typeof value === 'number' && Number.isFinite(value))
346
+ ) {
347
+ return value
348
+ }
349
+ if (typeof value === 'string') return value.slice(0, MAX_STRING_LENGTH)
350
+ return undefined
351
+ }
352
+
353
+ function safeKey(key) {
354
+ return (
355
+ typeof key === 'string' &&
356
+ key.length > 0 &&
357
+ key.length <= 64 &&
358
+ !/(authorization|cookie|header|password|secret|token)/i.test(key)
359
+ )
360
+ }
361
+
362
+ function timestamp(value) {
363
+ const date = value instanceof Date ? value : new Date(value)
364
+ if (!Number.isFinite(date.getTime())) throw new Error('now must return a valid date')
365
+ return date.toISOString()
366
+ }
367
+
368
+ function boundedId(value, name) {
369
+ if (typeof value !== 'string' || value.length === 0 || value.length > 256) {
370
+ throw new Error(`${name} must be a non-empty bounded string`)
371
+ }
372
+ return value
373
+ }
374
+
375
+ function boundedInteger(value, minimum, maximum, name) {
376
+ if (
377
+ !Number.isSafeInteger(value) ||
378
+ value < minimum ||
379
+ value > maximum
380
+ ) {
381
+ throw new Error(
382
+ `${name} must be an integer from ${minimum} through ${maximum}`,
383
+ )
384
+ }
385
+ return value
386
+ }
387
+
388
+ function createAbortError(message = 'Action event wait aborted.') {
389
+ const error = new Error(message)
390
+ error.name = 'AbortError'
391
+ error.code = 'ABORT_ERR'
392
+ return error
393
+ }
394
+
395
+ function isPlainObject(value) {
396
+ return Boolean(
397
+ value &&
398
+ typeof value === 'object' &&
399
+ !Array.isArray(value),
400
+ )
401
+ }
402
+
403
+ module.exports = {
404
+ createPoiActionEvents,
405
+ }