@wangxiaocuo/dsh-session-trash 0.0.0-stage → 0.6.0

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/http.js ADDED
@@ -0,0 +1,351 @@
1
+ import { TrashError, statusOf, toWireError } from './errors.js'
2
+
3
+ /** Prefix shared by every route this plugin owns. */
4
+ export const API_PREFIX = '/plugins/dsh-session-trash/api'
5
+
6
+ /** Request header that proves a same-origin scripted call. */
7
+ export const CSRF_HEADER = 'x-dsh-session-trash'
8
+
9
+ /** Largest accepted JSON request body. */
10
+ export const MAX_BODY_BYTES = 64 * 1024
11
+
12
+ /** Budget for one native reveal request. */
13
+ const REVEAL_TIMEOUT_MS = 10_000
14
+
15
+ /**
16
+ * @returns {AbortSignal} a signal that aborts the reveal after its budget.
17
+ */
18
+ function revealSignal() {
19
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
20
+ return AbortSignal.timeout(REVEAL_TIMEOUT_MS)
21
+ }
22
+ const controller = new AbortController()
23
+ const handle = setTimeout(() => controller.abort(), REVEAL_TIMEOUT_MS)
24
+ handle.unref?.()
25
+ return controller.signal
26
+ }
27
+
28
+ /**
29
+ * @param {import('node:http').IncomingMessage} req incoming request.
30
+ * @returns {Headers} a Fetch-shaped header bag for the connection service.
31
+ */
32
+ function headerBag(req) {
33
+ const headers = new Headers()
34
+ for (const [name, value] of Object.entries(req.headers)) {
35
+ if (value === undefined) continue
36
+ if (Array.isArray(value)) {
37
+ for (const item of value) headers.append(name, item)
38
+ } else {
39
+ headers.set(name, value)
40
+ }
41
+ }
42
+ return headers
43
+ }
44
+
45
+ /**
46
+ * @param {import('node:http').ServerResponse} res response to complete.
47
+ * @param {number} status HTTP status.
48
+ * @param {unknown} body JSON-serializable body.
49
+ * @returns {void}
50
+ */
51
+ function sendJson(res, status, body) {
52
+ if (res.headersSent || res.writableEnded) return
53
+ const payload = JSON.stringify(body)
54
+ res.writeHead(status, {
55
+ 'Content-Type': 'application/json; charset=utf-8',
56
+ 'Content-Length': Buffer.byteLength(payload),
57
+ 'Cache-Control': 'no-store',
58
+ 'X-Content-Type-Options': 'nosniff',
59
+ })
60
+ res.end(payload)
61
+ }
62
+
63
+ /**
64
+ * @param {import('node:http').ServerResponse} res response to complete.
65
+ * @param {unknown} error thrown failure.
66
+ * @returns {void}
67
+ */
68
+ function sendError(res, error) {
69
+ sendJson(res, statusOf(error), { ok: false, error: toWireError(error) })
70
+ }
71
+
72
+ /**
73
+ * @param {import('node:http').IncomingMessage} req request whose body to read.
74
+ * @param {number} limit maximum accepted bytes.
75
+ * @returns {Promise<Buffer>} the complete body.
76
+ */
77
+ function readBody(req, limit) {
78
+ return new Promise((resolve, reject) => {
79
+ const chunks = []
80
+ let size = 0
81
+ let failed = false
82
+ req.on('data', (chunk) => {
83
+ if (failed) return
84
+ size += chunk.length
85
+ if (size > limit) {
86
+ failed = true
87
+ chunks.length = 0
88
+ reject(new TrashError('INVALID_REQUEST', 'Request body is too large.'))
89
+ return
90
+ }
91
+ chunks.push(chunk)
92
+ })
93
+ req.on('end', () => {
94
+ if (!failed) resolve(Buffer.concat(chunks))
95
+ })
96
+ req.on('error', (error) => {
97
+ if (!failed) reject(error)
98
+ })
99
+ req.on('aborted', () => {
100
+ if (!failed) reject(new TrashError('INVALID_REQUEST', 'Request was aborted.'))
101
+ })
102
+ })
103
+ }
104
+
105
+ /**
106
+ * @param {Buffer} buffer raw request body.
107
+ * @returns {Record<string, unknown>} the decoded JSON object.
108
+ */
109
+ function parseJsonObject(buffer) {
110
+ if (buffer.length === 0) return {}
111
+ let parsed
112
+ try {
113
+ parsed = JSON.parse(buffer.toString('utf8'))
114
+ } catch {
115
+ throw new TrashError('INVALID_REQUEST', 'Request body is not valid JSON.')
116
+ }
117
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
118
+ throw new TrashError('INVALID_REQUEST', 'Request body must be a JSON object.')
119
+ }
120
+ return parsed
121
+ }
122
+
123
+ /**
124
+ * @param {import('node:http').IncomingMessage} req request whose JSON body to read.
125
+ * @returns {Promise<Record<string, unknown>>} the validated JSON object.
126
+ */
127
+ async function readJsonRequest(req) {
128
+ if (!String(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) {
129
+ throw new TrashError('INVALID_REQUEST', 'Content-Type must be application/json.')
130
+ }
131
+ return parseJsonObject(await readBody(req, MAX_BODY_BYTES))
132
+ }
133
+
134
+ /**
135
+ * @param {Record<string, unknown>} body decoded request body.
136
+ * @returns {string} a validated session identity.
137
+ */
138
+ function requireSessionId(body) {
139
+ const sessionId = body.sessionId
140
+ if (typeof sessionId !== 'string' || sessionId === '' || sessionId.length > 256) {
141
+ throw new TrashError('INVALID_REQUEST', 'sessionId must be a non-empty string.')
142
+ }
143
+ return sessionId
144
+ }
145
+
146
+ /**
147
+ * Register the plugin HTTP surface: state, menu preflight, trash/restore,
148
+ * permanent deletion (single and bulk), index maintenance, reveal, and
149
+ * cleanup.
150
+ *
151
+ * @param {{
152
+ * ctx: import('@deepseek-ai/cordis').Context,
153
+ * eligibility: { evaluate: Function, invalidate: Function },
154
+ * stateService: { snapshot: () => Promise<object>, describe: (entry: object) => Promise<object> },
155
+ * trashService: {
156
+ * trash: (sessionId: string) => Promise<object>,
157
+ * restore: (sessionId: string, mode?: string) => Promise<object>,
158
+ * purge: (sessionId: string) => Promise<object>,
159
+ * purgeAll: () => Promise<object>,
160
+ * forget: (sessionId: string) => Promise<object>,
161
+ * reveal: (sessionId: string, signal?: AbortSignal) => Promise<object>,
162
+ * rebuild: () => Promise<object>,
163
+ * },
164
+ * cleanupService?: { runNow: (options?: object) => Promise<object>, maybeSweepOnPageOpen: () => void },
165
+ * ready: Promise<unknown>,
166
+ * config: object,
167
+ * logger: object,
168
+ * }} deps host services.
169
+ * @returns {void}
170
+ */
171
+ export function registerApi({ ctx, eligibility, stateService, trashService, cleanupService, ready, config, logger }) {
172
+ /**
173
+ * Apply the shared trust, ownership, and CSRF checks.
174
+ *
175
+ * @param {import('node:http').IncomingMessage} req incoming request.
176
+ * @param {import('node:http').ServerResponse} res response to complete on refusal.
177
+ * @param {{ csrf: boolean }} options whether the scripted-call header is required.
178
+ * @returns {boolean} whether the request may proceed.
179
+ */
180
+ function authorize(req, res, { csrf }) {
181
+ const headers = headerBag(req)
182
+ let rejection
183
+ try {
184
+ rejection = ctx.connection.requestRejection({ headers })
185
+ } catch (error) {
186
+ logger.warn(`connection rejection check failed: ${error instanceof Error ? error.message : String(error)}`)
187
+ sendError(res, new TrashError('FORBIDDEN', 'The request could not be authenticated.'))
188
+ return false
189
+ }
190
+ if (rejection !== undefined) {
191
+ sendJson(res, rejection, { ok: false, error: { code: 'FORBIDDEN', message: 'The request is not trusted.' } })
192
+ return false
193
+ }
194
+ let admission
195
+ try {
196
+ admission = ctx.connection.admit({ headers })
197
+ } catch (error) {
198
+ logger.warn(`connection admission failed: ${error instanceof Error ? error.message : String(error)}`)
199
+ sendError(res, new TrashError('FORBIDDEN', 'The request could not be admitted.'))
200
+ return false
201
+ }
202
+ if (admission.rejection !== undefined) {
203
+ sendJson(res, admission.rejection, {
204
+ ok: false,
205
+ error: { code: 'FORBIDDEN', message: 'The request is not authenticated.' },
206
+ })
207
+ return false
208
+ }
209
+ if (admission.peer?.principal?.kind !== 'owner') {
210
+ sendJson(res, 403, { ok: false, error: { code: 'FORBIDDEN', message: 'Only the owner may use the trash.' } })
211
+ return false
212
+ }
213
+ if (csrf && headers.get(CSRF_HEADER) !== '1') {
214
+ sendJson(res, 403, { ok: false, error: { code: 'FORBIDDEN', message: `Missing the ${CSRF_HEADER} header.` } })
215
+ return false
216
+ }
217
+ return true
218
+ }
219
+
220
+ /**
221
+ * @param {string} pathname route path below the API prefix.
222
+ * @param {string} method allowed HTTP method.
223
+ * @param {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => Promise<void>} handler route body.
224
+ * @returns {void}
225
+ */
226
+ function route(pathname, method, handler) {
227
+ const path = `${API_PREFIX}/${pathname}`
228
+ ctx.effect(
229
+ () =>
230
+ ctx.webServer.register({
231
+ kind: 'exact',
232
+ path,
233
+ async handler(req, res) {
234
+ const startedAt = Date.now()
235
+ try {
236
+ if (req.method !== method) {
237
+ res.setHeader('Allow', method)
238
+ sendJson(res, 405, {
239
+ ok: false,
240
+ error: { code: 'METHOD_NOT_ALLOWED', message: `Use ${method} for this endpoint.` },
241
+ })
242
+ return
243
+ }
244
+ if (!authorize(req, res, { csrf: method !== 'GET' })) return
245
+ await ready
246
+ await handler(req, res)
247
+ } catch (error) {
248
+ if (error instanceof TrashError) {
249
+ logger.debug(`${method} ${path} -> ${error.code} (${Date.now() - startedAt}ms)`)
250
+ } else {
251
+ logger.error(`${method} ${path} failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`)
252
+ }
253
+ sendError(res, error)
254
+ }
255
+ },
256
+ }),
257
+ `session-trash: route ${method} ${path}`,
258
+ )
259
+ }
260
+
261
+ route('state', 'GET', async (_req, res) => {
262
+ sendJson(res, 200, await stateService.snapshot())
263
+ try {
264
+ cleanupService?.maybeSweepOnPageOpen()
265
+ } catch (error) {
266
+ logger.warn(`cannot schedule the page-open trash sweep: ${error instanceof Error ? error.message : String(error)}`)
267
+ }
268
+ })
269
+
270
+ route('preflight', 'POST', async (req, res) => {
271
+ const body = await readJsonRequest(req)
272
+ const sessionId = requireSessionId(body)
273
+ const verdict = await eligibility.evaluate(sessionId)
274
+ if (config.debug) {
275
+ logger.debug(`preflight ${sessionId} -> ${verdict.eligible ? 'eligible' : verdict.code}`)
276
+ }
277
+ if (verdict.eligible === true) {
278
+ const members = Array.isArray(verdict.members) ? verdict.members : []
279
+ sendJson(res, 200, { eligible: true, descendants: Math.max(0, members.length - 1) })
280
+ return
281
+ }
282
+ sendJson(res, 200, verdict)
283
+ })
284
+
285
+ route('trash', 'POST', async (req, res) => {
286
+ const body = await readJsonRequest(req)
287
+ const sessionId = requireSessionId(body)
288
+ const entry = await trashService.trash(sessionId)
289
+ sendJson(res, 200, { ok: true, entry: await stateService.describe(entry) })
290
+ })
291
+
292
+ route('restore', 'POST', async (req, res) => {
293
+ const body = await readJsonRequest(req)
294
+ const sessionId = requireSessionId(body)
295
+ const mode = body.mode
296
+ if (mode !== undefined && mode !== 'normal' && mode !== 'orphan') {
297
+ throw new TrashError('INVALID_REQUEST', 'mode must be "normal" or "orphan".')
298
+ }
299
+ const session = await trashService.restore(sessionId, mode ?? 'normal')
300
+ sendJson(res, 200, { ok: true, session })
301
+ })
302
+
303
+ route('purge', 'POST', async (req, res) => {
304
+ const body = await readJsonRequest(req)
305
+ const sessionId = requireSessionId(body)
306
+ const result = await trashService.purge(sessionId)
307
+ sendJson(res, 200, { ok: true, sessionId: result.sessionId })
308
+ })
309
+
310
+ route('purge-all', 'POST', async (_req, res) => {
311
+ const result = await trashService.purgeAll()
312
+ sendJson(res, 200, {
313
+ ok: true,
314
+ purged: result.purged ?? 0,
315
+ forgotten: result.forgotten ?? 0,
316
+ failed: result.failed ?? 0,
317
+ })
318
+ })
319
+
320
+ route('forget', 'POST', async (req, res) => {
321
+ const body = await readJsonRequest(req)
322
+ const sessionId = requireSessionId(body)
323
+ const result = await trashService.forget(sessionId)
324
+ sendJson(res, 200, { ok: true, sessionId: result.sessionId })
325
+ })
326
+
327
+ route('reveal', 'POST', async (req, res) => {
328
+ const body = await readJsonRequest(req)
329
+ const sessionId = requireSessionId(body)
330
+ await trashService.reveal(sessionId, revealSignal())
331
+ sendJson(res, 200, { ok: true, opened: true })
332
+ })
333
+
334
+ route('cleanup', 'POST', async (_req, res) => {
335
+ const result = await cleanupService.runNow({ manual: true, reason: 'manual' })
336
+ const diagnostics =
337
+ typeof cleanupService.diagnostics === 'function' ? cleanupService.diagnostics() : undefined
338
+ sendJson(res, 200, {
339
+ ok: true,
340
+ purged: result.purged ?? 0,
341
+ failed: result.failed ?? 0,
342
+ settled: result.changed ?? 0,
343
+ ...(diagnostics === undefined ? {} : { cleanup: diagnostics }),
344
+ })
345
+ })
346
+
347
+ route('rebuild', 'POST', async (_req, res) => {
348
+ const result = await trashService.rebuild()
349
+ sendJson(res, 200, { ok: true, entries: result.entries, unindexed: result.unindexed })
350
+ })
351
+ }