dsh-conversation-recycle-bin 0.2.0 → 0.2.2
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/AUDIT-2026-10-17-desktop-web.md +333 -0
- package/CHANGELOG.md +57 -0
- package/INSTALL.md +2 -1
- package/README.md +16 -12
- package/client.js +197 -26
- package/config.schema.json +1 -1
- package/lib/core/cascade.js +53 -19
- package/lib/core/config.js +4 -1
- package/lib/core/errors.js +5 -2
- package/lib/core/file-lock.js +151 -0
- package/lib/core/memory/generic-json.js +2 -2
- package/lib/core/memory/json-index-adapter.js +182 -87
- package/lib/core/memory/lingshu.js +10 -4
- package/lib/core/memory/none.js +4 -2
- package/lib/core/memory/registry.js +9 -6
- package/lib/core/policy.js +4 -4
- package/lib/core/redact.js +66 -0
- package/lib/host/api.js +252 -55
- package/lib/host/catalog.js +101 -36
- package/lib/host/gateway.js +19 -5
- package/lib/host/paths.js +13 -2
- package/lib/version.js +1 -1
- package/package.json +11 -3
- package/locale/en.json +0 -63
- package/locale/zh.json +0 -63
package/lib/host/api.js
CHANGED
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
* POST /trash/discard remove an entry that never committed
|
|
18
18
|
* GET /audit?limit=N audit trail tail
|
|
19
19
|
*
|
|
20
|
+
* Transport layers, in order: this route's own loopback/origin fence, then DSH's
|
|
21
|
+
* `connection` fence (`requestRejection`) when the service exists, then the
|
|
22
|
+
* `connection/request` waterfall — which is what makes the desktop app-update
|
|
23
|
+
* lock (503 + drain) cover deletions instead of killing them mid-transaction.
|
|
24
|
+
* Mutations additionally take a cross-process lock on `<dataDir>/mutation.lock`
|
|
25
|
+
* so two Hosts sharing one DSH_HOME cannot write the same manifest at once.
|
|
26
|
+
*
|
|
20
27
|
* Confirmation is a two-part server-side protocol, not a Client-side courtesy:
|
|
21
28
|
* `/preview` issues a single-use token bound to (session ids, includeMemory,
|
|
22
29
|
* mode); `/delete` must present that token. An additional typed phrase is
|
|
@@ -25,7 +32,8 @@
|
|
|
25
32
|
* A replayed, edited or expired plan is refused.
|
|
26
33
|
*/
|
|
27
34
|
import { randomBytes } from 'node:crypto'
|
|
28
|
-
import { readdir, stat } from 'node:fs/promises'
|
|
35
|
+
import { mkdir, readdir, stat } from 'node:fs/promises'
|
|
36
|
+
import { join } from 'node:path'
|
|
29
37
|
import { DeleteError, ERROR_STATUS, asDeleteError, toWireError } from '../core/errors.js'
|
|
30
38
|
import { createAuditLog } from '../core/audit.js'
|
|
31
39
|
import { createTrashStore } from '../core/trash.js'
|
|
@@ -33,6 +41,7 @@ import { createMemoryService } from '../core/memory/registry.js'
|
|
|
33
41
|
import { performSessionDelete, performPurge, performRestore, performDiscard } from '../core/cascade.js'
|
|
34
42
|
import { evaluateSessionPolicy } from '../core/policy.js'
|
|
35
43
|
import { createSlidingWindowLimiter } from '../core/rate-limit.js'
|
|
44
|
+
import { createFileLock } from '../core/file-lock.js'
|
|
36
45
|
import { createCatalogOps, createSessionLogOps } from './catalog.js'
|
|
37
46
|
import { createLifecycleOps } from './lifecycle.js'
|
|
38
47
|
import { createSessionGateway } from './gateway.js'
|
|
@@ -42,7 +51,9 @@ import { PLUGIN_VERSION } from '../version.js'
|
|
|
42
51
|
export const ROUTE_PREFIX = '/conversation-recycle-bin/api'
|
|
43
52
|
export const REQUIRED_HEADER = 'x-conversation-recycle-bin'
|
|
44
53
|
const MAX_BODY_BYTES = 256 * 1024
|
|
45
|
-
|
|
54
|
+
// DSH's `encodeSegment` allows `.`, `_`, `-`; forked/custom ids can contain a
|
|
55
|
+
// dot, so refusing one made those conversations undeletable.
|
|
56
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9._-]{6,80}$/
|
|
46
57
|
|
|
47
58
|
function isLoopbackHostname(hostname) {
|
|
48
59
|
const value = String(hostname ?? '').trim().toLowerCase().replace(/^\[|\]$/g, '')
|
|
@@ -69,8 +80,34 @@ function sameHostname(left, right) {
|
|
|
69
80
|
return normalize(left) === normalize(right)
|
|
70
81
|
}
|
|
71
82
|
|
|
83
|
+
/** Port of a `Host`-style authority, or `''` when none is present. */
|
|
84
|
+
function portOf(hostHeader) {
|
|
85
|
+
const value = String(hostHeader ?? '').trim()
|
|
86
|
+
if (value.startsWith('[')) {
|
|
87
|
+
const end = value.indexOf(']')
|
|
88
|
+
if (end === -1) return ''
|
|
89
|
+
const rest = value.slice(end + 1)
|
|
90
|
+
return rest.startsWith(':') && /^\d+$/.test(rest.slice(1)) ? rest.slice(1) : ''
|
|
91
|
+
}
|
|
92
|
+
const colon = value.lastIndexOf(':')
|
|
93
|
+
return colon > 0 && /^\d+$/.test(value.slice(colon + 1)) ? value.slice(colon + 1) : ''
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The port a URL implies when it omits one. */
|
|
97
|
+
function defaultPortFor(protocol) {
|
|
98
|
+
if (protocol === 'https:' || protocol === 'wss:') return '443'
|
|
99
|
+
return '80'
|
|
100
|
+
}
|
|
101
|
+
|
|
72
102
|
/**
|
|
73
103
|
* Origin/Host verdict for one request.
|
|
104
|
+
*
|
|
105
|
+
* Hostname alone is not an origin: `http://localhost:9999` served by ANY other
|
|
106
|
+
* loopback process used to pass a Host-name comparison, so a second local web
|
|
107
|
+
* app could drive deletions. The full authority — hostname AND effective port —
|
|
108
|
+
* must match, with a missing port normalized against the Origin's own scheme
|
|
109
|
+
* (the `Host` header carries no scheme, so `https://host` and `Host: host`
|
|
110
|
+
* both mean 443).
|
|
74
111
|
* @returns {{ ok: true } | { ok: false, error: DeleteError }}
|
|
75
112
|
*/
|
|
76
113
|
export function requestOriginVerdict(headers, { requireSameOrigin, allowNonLoopback }) {
|
|
@@ -95,20 +132,27 @@ export function requestOriginVerdict(headers, { requireSameOrigin, allowNonLoopb
|
|
|
95
132
|
if (requireSameOrigin) {
|
|
96
133
|
const origin = read('origin').trim()
|
|
97
134
|
if (origin !== '') {
|
|
98
|
-
let
|
|
99
|
-
let trustedAppShell = false
|
|
135
|
+
let parsed = null
|
|
100
136
|
try {
|
|
101
|
-
|
|
102
|
-
originHostname = parsed.hostname.toLowerCase()
|
|
103
|
-
// The Electron shell serves the page from a private scheme and
|
|
104
|
-
// forwards the request to this same Host; no web origin can produce
|
|
105
|
-
// that scheme, so it is safe to trust it as same-origin.
|
|
106
|
-
trustedAppShell = parsed.protocol === 'dsh-app:'
|
|
137
|
+
parsed = new URL(origin)
|
|
107
138
|
} catch {
|
|
108
|
-
|
|
139
|
+
parsed = null
|
|
109
140
|
}
|
|
110
|
-
|
|
111
|
-
|
|
141
|
+
// The Electron shell serves the app from `dsh-app://app` and the Host's
|
|
142
|
+
// own fences compare against exactly this origin (verified in the shipped
|
|
143
|
+
// app: `out/lib/main.js` refuses `origin !== "dsh-app://app"`). No web
|
|
144
|
+
// origin can mint that scheme, so it is trusted as same-origin. On the
|
|
145
|
+
// real desktop route the shell's forwarder deletes the `Origin` header
|
|
146
|
+
// before the request reaches the Host, so this is defense-in-depth.
|
|
147
|
+
const trustedAppShell = parsed !== null && parsed.protocol === 'dsh-app:' && parsed.hostname === 'app'
|
|
148
|
+
if (trustedAppShell !== true) {
|
|
149
|
+
const requestPort = portOf(read('host'))
|
|
150
|
+
const originPort = parsed === null ? '' : (parsed.port === '' ? defaultPortFor(parsed.protocol) : parsed.port)
|
|
151
|
+
const hostnameMatches = parsed !== null && parsed.hostname !== '' && sameHostname(parsed.hostname, requestHostname)
|
|
152
|
+
const portMatches = originPort !== '' && originPort === (requestPort === '' ? originPort : requestPort)
|
|
153
|
+
if (!hostnameMatches || !portMatches) {
|
|
154
|
+
return { ok: false, error: new DeleteError('E_FORBIDDEN_ORIGIN', 'The request origin does not match this Host.', { origin }) }
|
|
155
|
+
}
|
|
112
156
|
}
|
|
113
157
|
}
|
|
114
158
|
}
|
|
@@ -160,9 +204,21 @@ function readJsonBody(req) {
|
|
|
160
204
|
return new Promise((resolve, reject) => {
|
|
161
205
|
let bytes = 0
|
|
162
206
|
let overflow = false
|
|
207
|
+
let settled = false
|
|
163
208
|
const chunks = []
|
|
209
|
+
// The promise MUST settle on every path: an unsettled `/delete` inside
|
|
210
|
+
// `serialize()` parks every later mutation behind it until the Host is
|
|
211
|
+
// restarted. `aborted`/`close` fires when the Client disconnects mid-body
|
|
212
|
+
// (its own timeout, or the tab/app closing) and `end` may never come.
|
|
213
|
+
const finish = (error, value) => {
|
|
214
|
+
if (settled) return
|
|
215
|
+
settled = true
|
|
216
|
+
if (error !== undefined) reject(error)
|
|
217
|
+
else resolve(value)
|
|
218
|
+
}
|
|
164
219
|
req.setEncoding('utf8')
|
|
165
220
|
req.on('data', (chunk) => {
|
|
221
|
+
if (settled) return
|
|
166
222
|
if (overflow) return
|
|
167
223
|
bytes += Buffer.byteLength(chunk, 'utf8')
|
|
168
224
|
if (bytes > MAX_BODY_BYTES) {
|
|
@@ -172,26 +228,38 @@ function readJsonBody(req) {
|
|
|
172
228
|
}
|
|
173
229
|
chunks.push(chunk)
|
|
174
230
|
})
|
|
175
|
-
req.on('error',
|
|
231
|
+
req.on('error', (error) => {
|
|
232
|
+
finish(new DeleteError('E_BAD_REQUEST', 'The request stream failed.', {}, { cause: error }))
|
|
233
|
+
})
|
|
234
|
+
req.on('aborted', () => {
|
|
235
|
+
finish(new DeleteError('E_BAD_REQUEST', 'The request was aborted before its body arrived.', {}))
|
|
236
|
+
})
|
|
237
|
+
req.on('close', () => {
|
|
238
|
+
// `close` after a complete `end` is the normal path; without it the
|
|
239
|
+
// stream was cut short.
|
|
240
|
+
if (!settled) {
|
|
241
|
+
finish(new DeleteError('E_BAD_REQUEST', 'The request was closed before its body arrived.', {}))
|
|
242
|
+
}
|
|
243
|
+
})
|
|
176
244
|
req.on('end', () => {
|
|
177
245
|
if (overflow) {
|
|
178
|
-
|
|
246
|
+
finish(new DeleteError('E_BAD_REQUEST', 'Request body exceeds the size limit.', { limit: MAX_BODY_BYTES }, { httpStatus: 413 }))
|
|
179
247
|
return
|
|
180
248
|
}
|
|
181
249
|
const raw = chunks.join('').trim()
|
|
182
250
|
if (raw === '') {
|
|
183
|
-
|
|
251
|
+
finish(undefined, {})
|
|
184
252
|
return
|
|
185
253
|
}
|
|
186
254
|
try {
|
|
187
255
|
const parsed = JSON.parse(raw)
|
|
188
256
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
189
|
-
|
|
257
|
+
finish(new DeleteError('E_BAD_REQUEST', 'Request body must be a JSON object.', {}))
|
|
190
258
|
return
|
|
191
259
|
}
|
|
192
|
-
|
|
260
|
+
finish(undefined, parsed)
|
|
193
261
|
} catch (error) {
|
|
194
|
-
|
|
262
|
+
finish(new DeleteError('E_BAD_REQUEST', 'Request body is not valid JSON.', {}, { cause: error }))
|
|
195
263
|
}
|
|
196
264
|
})
|
|
197
265
|
})
|
|
@@ -215,8 +283,12 @@ function sendJson(res, status, payload) {
|
|
|
215
283
|
* @param {object} input.paths resolution from `host/paths.js`
|
|
216
284
|
* @param {() => number} [input.now]
|
|
217
285
|
* @param {Pick<Console, 'error'|'warn'|'info'>} [input.logger]
|
|
286
|
+
* @param {{ acquire: () => Promise<() => Promise<void>>, lockPath?: string }} [input.mutationLock]
|
|
287
|
+
* Cross-process lock serializing mutations. Injectable so a test can drive the
|
|
288
|
+
* E_LOCK_BUSY path with a short wait budget; production uses 30s because a
|
|
289
|
+
* deletion transaction legitimately takes a while and waiting beats failing.
|
|
218
290
|
*/
|
|
219
|
-
export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = console }) {
|
|
291
|
+
export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = console, mutationLock = null }) {
|
|
220
292
|
const gateway = createSessionGateway({ ctx, paths, now })
|
|
221
293
|
const memoryService = createMemoryService({
|
|
222
294
|
adapters: createMemoryAdapters({ config, dshHome: paths.dshHome, now }),
|
|
@@ -231,6 +303,19 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
231
303
|
now,
|
|
232
304
|
})
|
|
233
305
|
const limiter = createSlidingWindowLimiter({ limit: config.maxDeletesPerMinute, windowMs: 60_000, now })
|
|
306
|
+
// Two Hosts can share one DSH_HOME (the desktop app and `dsh web`), and both
|
|
307
|
+
// write the same trash manifests, bundles and audit trail. The in-process
|
|
308
|
+
// `serialize` chain below cannot see the other one, so every mutation also
|
|
309
|
+
// takes a cross-process advisory lock on `<dataDir>/mutation.lock`. A busy
|
|
310
|
+
// lock surfaces as E_LOCK_BUSY (409) instead of a lost manifest update.
|
|
311
|
+
const mutationLockInstance = mutationLock ?? createFileLock({
|
|
312
|
+
lockPath: join(paths.dataDir, 'mutation.lock'),
|
|
313
|
+
timeoutMs: 30_000,
|
|
314
|
+
staleMs: 120_000,
|
|
315
|
+
})
|
|
316
|
+
// Which UI asked: the desktop Electron shell and `dsh web` share this code,
|
|
317
|
+
// and the audit trail used to record both as `web-ui`.
|
|
318
|
+
const auditSource = process.versions?.electron !== undefined ? 'desktop-ui' : 'web-ui'
|
|
234
319
|
const ops = {
|
|
235
320
|
memory: memoryService,
|
|
236
321
|
sessionLog: createSessionLogOps({ gateway, paths, config, now, logger }),
|
|
@@ -260,8 +345,31 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
260
345
|
let mutationChain = Promise.resolve()
|
|
261
346
|
let requestCounter = 0
|
|
262
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Serialize one mutation: in-process first (so two HTTP requests never
|
|
350
|
+
* interleave their transactions), then across processes (so a second Host
|
|
351
|
+
* sharing this DSH_HOME cannot write the same manifest concurrently).
|
|
352
|
+
*/
|
|
263
353
|
function serialize(operation) {
|
|
264
|
-
const next = mutationChain.then(
|
|
354
|
+
const next = mutationChain.then(async () => {
|
|
355
|
+
// The lock lives in the data dir, which a fresh install has not created
|
|
356
|
+
// yet (the trash store and audit log mkdir their own targets lazily).
|
|
357
|
+
await mkdir(paths.dataDir, { recursive: true }).catch(() => {})
|
|
358
|
+
let release
|
|
359
|
+
try {
|
|
360
|
+
release = await mutationLockInstance.acquire()
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (error?.code === 'E_LOCK_BUSY') {
|
|
363
|
+
throw new DeleteError('E_LOCK_BUSY', 'Another DSH instance is using this recycle bin; retry in a moment.', {})
|
|
364
|
+
}
|
|
365
|
+
throw error
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
return await operation()
|
|
369
|
+
} finally {
|
|
370
|
+
await release()
|
|
371
|
+
}
|
|
372
|
+
})
|
|
265
373
|
mutationChain = next.then(() => undefined, () => undefined)
|
|
266
374
|
return next
|
|
267
375
|
}
|
|
@@ -389,6 +497,14 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
389
497
|
const adapterStates = await memoryService.status()
|
|
390
498
|
const confirmation = blocked.length === 0 ? issueConfirmation({ sessionIds, includeMemory, mode }) : null
|
|
391
499
|
|
|
500
|
+
// Worst-case wall time for the matching `/delete`: one lock acquisition,
|
|
501
|
+
// plus per live session a bounded drain, plus per session the memory
|
|
502
|
+
// export/restore under the plugin-private lock. The Client MUST use this
|
|
503
|
+
// instead of its own estimate, otherwise it aborts while the Host is still
|
|
504
|
+
// deleting and retries into an in-flight transaction.
|
|
505
|
+
const liveCount = sessions.filter((item) => item.live === true).length
|
|
506
|
+
const planTimeoutMs = Math.min(900_000, 15_000 + sessionIds.length * 16_000 + liveCount * config.liveDrainMs)
|
|
507
|
+
|
|
392
508
|
return {
|
|
393
509
|
sessions,
|
|
394
510
|
blocked,
|
|
@@ -410,6 +526,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
410
526
|
rate: limiter.status(),
|
|
411
527
|
trashRetentionDays: config.trashRetentionDays,
|
|
412
528
|
confirmTokenTtlMs: config.confirmTokenTtlMs,
|
|
529
|
+
planTimeoutMs,
|
|
413
530
|
},
|
|
414
531
|
}
|
|
415
532
|
}
|
|
@@ -467,7 +584,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
467
584
|
includeMemory,
|
|
468
585
|
actor: 'local-user',
|
|
469
586
|
refusals,
|
|
470
|
-
source:
|
|
587
|
+
source: auditSource,
|
|
471
588
|
})
|
|
472
589
|
.catch(() => {})
|
|
473
590
|
// Report the ACTUAL first refusal. The wire code is what the Client
|
|
@@ -504,6 +621,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
504
621
|
config,
|
|
505
622
|
now,
|
|
506
623
|
actor: 'local-user',
|
|
624
|
+
source: auditSource,
|
|
507
625
|
requestId: request,
|
|
508
626
|
})
|
|
509
627
|
results.push({
|
|
@@ -533,7 +651,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
533
651
|
if (typeof trashId !== 'string' || trashId === '') throw new DeleteError('E_BAD_REQUEST', 'A recycle-bin id is required.', {})
|
|
534
652
|
const entry = await trash.read(trashId)
|
|
535
653
|
if (entry === null) throw new DeleteError('E_TRASH_NOT_FOUND', 'The recycle-bin entry was not found.', { trashId })
|
|
536
|
-
const result = await performRestore({ entry, ops, trash, audit, now, actor: 'local-user', requestId: requestId() })
|
|
654
|
+
const result = await performRestore({ entry, ops, trash, audit, now, actor: 'local-user', source: auditSource, requestId: requestId() })
|
|
537
655
|
gateway.invalidate()
|
|
538
656
|
return result
|
|
539
657
|
}
|
|
@@ -548,7 +666,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
548
666
|
// Purging a soft entry performs the missing hard stages on a still-live
|
|
549
667
|
// session. The cascade's quiesce step stops that session first; if it
|
|
550
668
|
// cannot be stopped within liveDrainMs, the purge fails and rolls back.
|
|
551
|
-
const result = await performPurge({ entry, ops, trash, audit, now, actor: 'local-user', requestId: requestId() })
|
|
669
|
+
const result = await performPurge({ entry, ops, trash, audit, now, actor: 'local-user', source: auditSource, requestId: requestId() })
|
|
552
670
|
gateway.invalidate()
|
|
553
671
|
return result
|
|
554
672
|
}
|
|
@@ -565,7 +683,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
565
683
|
}
|
|
566
684
|
const entry = await trash.read(trashId)
|
|
567
685
|
if (entry === null) throw new DeleteError('E_TRASH_NOT_FOUND', 'The recycle-bin entry was not found.', { trashId })
|
|
568
|
-
return performDiscard({ entry, ops, trash, audit, now, actor: 'local-user', requestId: requestId() })
|
|
686
|
+
return performDiscard({ entry, ops, trash, audit, now, actor: 'local-user', source: auditSource, requestId: requestId() })
|
|
569
687
|
}
|
|
570
688
|
|
|
571
689
|
async function state() {
|
|
@@ -594,19 +712,31 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
594
712
|
async function sessionsForUi() {
|
|
595
713
|
const sessions = await gateway.list()
|
|
596
714
|
await gateway.enrichTitles(sessions)
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
live: session.live,
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
715
|
+
// Every row carries its own policy verdict: "select all" used to include
|
|
716
|
+
// subagent/orphan/out-of-root conversations that `/preview` then refuses
|
|
717
|
+
// as a whole batch, leaving the user with a disabled button and no hint
|
|
718
|
+
// which row to uncheck. Soft mode is the widest verdict (hard adds only
|
|
719
|
+
// the live-session stop), so a row marked deletable here stays deletable.
|
|
720
|
+
return sessions.map((session) => {
|
|
721
|
+
const verdict = evaluateSessionPolicy({ session, workspace: session.workspace, live: session.live, mode: 'soft', config })
|
|
722
|
+
return {
|
|
723
|
+
id: session.id,
|
|
724
|
+
title: session.title,
|
|
725
|
+
cwd: session.cwd,
|
|
726
|
+
updatedAt: session.updatedAt,
|
|
727
|
+
running: session.running,
|
|
728
|
+
live: session.live,
|
|
729
|
+
blank: session.blank,
|
|
730
|
+
archived: session.archived,
|
|
731
|
+
pinned: session.pinned,
|
|
732
|
+
origin: session.origin,
|
|
733
|
+
workspace: session.workspaceId === null ? null : { id: session.workspaceId },
|
|
734
|
+
deletable: verdict.ok,
|
|
735
|
+
policy: verdict.ok
|
|
736
|
+
? { ok: true, warnings: verdict.warnings }
|
|
737
|
+
: { ok: false, code: verdict.error.code, message: verdict.error.message },
|
|
738
|
+
}
|
|
739
|
+
})
|
|
610
740
|
}
|
|
611
741
|
|
|
612
742
|
function serializeEntry(entry) {
|
|
@@ -644,20 +774,43 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
644
774
|
return { records: result.records, malformed: result.malformed }
|
|
645
775
|
}
|
|
646
776
|
|
|
647
|
-
/**
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
777
|
+
/**
|
|
778
|
+
* The plugin's own transport fence: loopback-only unless `allowNonLoopback`,
|
|
779
|
+
* no cross-site requests, and — when `requireSameOrigin` — an Origin whose
|
|
780
|
+
* full authority (hostname AND effective port) matches the request Host, or
|
|
781
|
+
* the desktop `dsh-app://app` shell origin.
|
|
782
|
+
*
|
|
783
|
+
* This is the FIRST gate. The `connection` fence in `handler` is
|
|
784
|
+
* authoritative when present; this one keeps the route safe in a composition
|
|
785
|
+
* without it and produces the localized error codes the Client knows.
|
|
786
|
+
*/
|
|
787
|
+
function transportVerdict(req) {
|
|
788
|
+
return requestOriginVerdict(req.headers, config)
|
|
789
|
+
}
|
|
653
790
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
791
|
+
/** Read the connection service without declaring it a hard dependency. */
|
|
792
|
+
function connectionService() {
|
|
793
|
+
try {
|
|
794
|
+
const value = typeof ctx.get === 'function' ? ctx.get('connection') : ctx.connection
|
|
795
|
+
return value ?? undefined
|
|
796
|
+
} catch {
|
|
797
|
+
return undefined
|
|
798
|
+
}
|
|
799
|
+
}
|
|
659
800
|
|
|
660
|
-
|
|
801
|
+
/** One request → one response. Route matching stays here, not in `apply`. */
|
|
802
|
+
const handleRequest = async (req, res) => {
|
|
803
|
+
const method = String(req.method ?? 'GET').toUpperCase()
|
|
804
|
+
const url = new URL(req.url ?? '/', 'http://host.invalid')
|
|
805
|
+
const path = url.pathname.startsWith(ROUTE_PREFIX) ? url.pathname.slice(ROUTE_PREFIX.length) : url.pathname
|
|
806
|
+
|
|
807
|
+
const origin = transportVerdict(req)
|
|
808
|
+
if (!origin.ok) {
|
|
809
|
+
sendJson(res, origin.error.httpStatus, toWireError(origin.error))
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
try {
|
|
661
814
|
if (method === 'GET' && (path === '' || path === '/state')) {
|
|
662
815
|
sendJson(res, 200, { ok: true, state: await state() })
|
|
663
816
|
return
|
|
@@ -715,11 +868,55 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
715
868
|
sendJson(res, 200, { ok: true, ...result })
|
|
716
869
|
return
|
|
717
870
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
871
|
+
throw new DeleteError('E_NOT_FOUND', 'Unknown endpoint.', { path: path.slice(0, 120) })
|
|
872
|
+
} catch (error) {
|
|
873
|
+
const normalized = asDeleteError(error)
|
|
874
|
+
logger.error?.(`[conversation-recycle-bin] ${method} ${path}: ${normalized.code} ${normalized.message}`)
|
|
875
|
+
sendJson(res, normalized.httpStatus, toWireError(normalized))
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Route entry point.
|
|
881
|
+
*
|
|
882
|
+
* The plugin mounts a `webServer` prefix directly, so it does NOT pass
|
|
883
|
+
* through `/api` and therefore bypasses DSH's `connection` layer by default.
|
|
884
|
+
* That layer is not cosmetic: `connection.admit()` applies the Host/Origin
|
|
885
|
+
* fence and the browser-session admission, and the desktop update task
|
|
886
|
+
* control listens on `connection/request` to refuse new requests (503) and
|
|
887
|
+
* drain in-flight ones while an app update installs. Without participating,
|
|
888
|
+
* an update or a quit could kill a Host in the middle of a deletion
|
|
889
|
+
* transaction.
|
|
890
|
+
*
|
|
891
|
+
* The service is probed, not injected: a composition without `connection`
|
|
892
|
+
* keeps working behind this route's own loopback/origin fence.
|
|
893
|
+
*/
|
|
894
|
+
const handler = (req, res) => {
|
|
895
|
+
void (async () => {
|
|
896
|
+
const connection = connectionService()
|
|
897
|
+
if (connection !== undefined && typeof connection.requestRejection === 'function') {
|
|
898
|
+
let rejection
|
|
899
|
+
try {
|
|
900
|
+
rejection = connection.requestRejection(req)
|
|
901
|
+
} catch {
|
|
902
|
+
rejection = undefined
|
|
903
|
+
}
|
|
904
|
+
if (rejection === 401 || rejection === 403) {
|
|
905
|
+
const error = rejection === 401
|
|
906
|
+
? new DeleteError('E_UNAUTHORIZED', 'This request is not authenticated with the Host.', {})
|
|
907
|
+
: new DeleteError('E_FORBIDDEN_ORIGIN', 'The Host refused this request at the connection layer.', {})
|
|
908
|
+
sendJson(res, rejection, toWireError(error))
|
|
909
|
+
return
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
const run = () => handleRequest(req, res)
|
|
913
|
+
if (typeof ctx.waterfall === 'function') {
|
|
914
|
+
// Listeners (e.g. the desktop update lock) run outermost-first and may
|
|
915
|
+
// veto by never calling `next()`; when none is registered the built-in
|
|
916
|
+
// `run` executes directly.
|
|
917
|
+
await ctx.waterfall('connection/request', req, res, run)
|
|
918
|
+
} else {
|
|
919
|
+
await run()
|
|
723
920
|
}
|
|
724
921
|
})().catch((error) => {
|
|
725
922
|
logger.error?.('[conversation-recycle-bin] route failure:', error)
|