dsh-conversation-recycle-bin 0.2.1 → 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/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
- const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{6,80}$/
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 originHostname = ''
99
- let trustedAppShell = false
135
+ let parsed = null
100
136
  try {
101
- const parsed = new URL(origin)
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
- originHostname = ''
139
+ parsed = null
109
140
  }
110
- if (trustedAppShell !== true && (originHostname === '' || !sameHostname(originHostname, requestHostname))) {
111
- return { ok: false, error: new DeleteError('E_FORBIDDEN_ORIGIN', 'The request origin does not match this Host.', { origin }) }
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', reject)
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
- reject(new DeleteError('E_BAD_REQUEST', 'Request body exceeds the size limit.', { limit: MAX_BODY_BYTES }, { httpStatus: 413 }))
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
- resolve({})
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
- reject(new DeleteError('E_BAD_REQUEST', 'Request body must be a JSON object.', {}))
257
+ finish(new DeleteError('E_BAD_REQUEST', 'Request body must be a JSON object.', {}))
190
258
  return
191
259
  }
192
- resolve(parsed)
260
+ finish(undefined, parsed)
193
261
  } catch (error) {
194
- reject(new DeleteError('E_BAD_REQUEST', 'Request body is not valid JSON.', {}, { cause: error }))
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(operation, operation)
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: 'web-ui',
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
- return sessions.map((session) => ({
598
- id: session.id,
599
- title: session.title,
600
- cwd: session.cwd,
601
- updatedAt: session.updatedAt,
602
- running: session.running,
603
- live: session.live,
604
- blank: session.blank,
605
- archived: session.archived,
606
- pinned: session.pinned,
607
- origin: session.origin,
608
- workspace: session.workspaceId === null ? null : { id: session.workspaceId },
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
- /** One request → one response. Route matching stays here, not in `apply`. */
648
- const handler = (req, res) => {
649
- void (async () => {
650
- const method = String(req.method ?? 'GET').toUpperCase()
651
- const url = new URL(req.url ?? '/', 'http://host.invalid')
652
- const path = url.pathname.startsWith(ROUTE_PREFIX) ? url.pathname.slice(ROUTE_PREFIX.length) : url.pathname
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
- const origin = requestOriginVerdict(req.headers, config)
655
- if (!origin.ok) {
656
- sendJson(res, origin.error.httpStatus, toWireError(origin.error))
657
- return
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
- try {
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
- throw new DeleteError('E_NOT_FOUND', 'Unknown endpoint.', { path: path.slice(0, 120) })
719
- } catch (error) {
720
- const normalized = asDeleteError(error)
721
- logger.error?.(`[conversation-recycle-bin] ${method} ${path}: ${normalized.code} ${normalized.message}`)
722
- sendJson(res, normalized.httpStatus, toWireError(normalized))
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)