@thebtf/dsh-cliproxyapi-provider 0.0.0-stage → 0.4.3

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/src/gateway.js ADDED
@@ -0,0 +1,411 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { ConnectionError, resolveIdentity, saveConnection, validateEndpoint } from './connection.js'
3
+ import { readCatalogCache, writeCatalogCache } from './catalog-store.js'
4
+ import { legacyView, prepareLegacy, cutoverLegacy, finishLegacy, rollbackLegacy, discardPrepared } from './migration.js'
5
+ import { createGatewayAdapter } from './adapter.js'
6
+ import { validatePolicy } from './policy.js'
7
+ import { credentialField } from './secrets.js'
8
+ import { readConnectionSnapshot, removeConnectionSnapshot, writeConnectionSnapshot } from './connection-store.js'
9
+
10
+ const NS = 'llm-cliproxyapi'
11
+ const PROVIDER = 'CLIProxyAPI'
12
+ const safeObject = (value) => {
13
+ if (Array.isArray(value)) return value.map(safeObject)
14
+ if (!value || typeof value !== 'object') return value
15
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, credentialField(key) ? '[redacted]' : safeObject(item)]))
16
+ }
17
+ const errorView = (code, message) => ({ code, message })
18
+ const generationFor = (id, revision) => createHash('sha256').update(id).update('\0').update(String(revision)).digest('hex')
19
+ const fields = {
20
+ 'preview-models': ['type', 'baseURL', 'apiKey', 'allowEndpointReuse'],
21
+ 'refresh-models': ['type', 'connectionGeneration'],
22
+ 'save-connection': ['type', 'expectedRevision', 'operationId', 'baseURL', 'authEdit', 'policy', 'selectionChanges'],
23
+ 'save-selection': ['type', 'expectedRevision', 'operationId', 'selectionChanges'],
24
+ 'set-enabled': ['type', 'expectedRevision', 'operationId', 'enabled'],
25
+ 'forget-key': ['type', 'expectedRevision', 'operationId'],
26
+ 'import-legacy': ['type', 'expectedRevision', 'operationId', 'expectedLegacyRevision', 'fingerprint'],
27
+ 'restore-connection': ['type', 'expectedRevision', 'operationId'],
28
+ 'rollback-legacy': ['type', 'expectedRevision', 'operationId'],
29
+ }
30
+ const objectWithFields = (input, allowed) => {
31
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some((key) => !allowed.includes(key))) throw new ConnectionError('INVALID_REQUEST', 'Invalid request fields')
32
+ }
33
+ const validatePreview = (input, allowed) => {
34
+ objectWithFields(input, allowed)
35
+ if (input.apiKey !== undefined && (typeof input.apiKey !== 'string' || Buffer.byteLength(input.apiKey) > 4096)) throw new ConnectionError('INVALID_AUTH', 'Invalid key')
36
+ if (input.allowEndpointReuse !== undefined && typeof input.allowEndpointReuse !== 'boolean') throw new ConnectionError('INVALID_REQUEST', 'Invalid endpoint reuse')
37
+ }
38
+
39
+ export function installGateway(ctx, config, discover, readJson, previewKey) {
40
+ const diagnostics = []
41
+ let registration
42
+ let controller
43
+ let catalog
44
+ let freshness = 'empty'
45
+ let catalogError
46
+ let activationError
47
+ // Recovery diagnostics describe this process start, not the saved connection,
48
+ // so they are never persisted into the state the UI reads back.
49
+ let recoveryError
50
+ let loading = false
51
+ let stopped = false
52
+ let wakeDispose
53
+ const activeRefreshes = new Set()
54
+ const abortRefreshes = () => { for (const active of activeRefreshes) active.abort() }
55
+ let failures = 0
56
+ const cancelWake = () => { wakeDispose?.(); wakeDispose = undefined }
57
+ const schedule = (delay, generation) => {
58
+ cancelWake()
59
+ if (stopped || !generation || delay <= 0) return
60
+ wakeDispose = ctx.timeout(() => {
61
+ wakeDispose = undefined
62
+ const value = current()
63
+ if (stopped || value.generation !== generation || value.enabled === false || !value.baseURL || value.authIdentity?.type === 'missing' || ['prepared', 'rollback'].includes(value.migration?.phase)) return
64
+ controller = new AbortController()
65
+ void refresh(generation, controller.signal).catch(() => { })
66
+ }, delay)
67
+ }
68
+ const descriptor = () => {
69
+ const result = ctx.settings.describe().find((row) => row.ns === NS)
70
+ if (!result || !Number.isSafeInteger(result.revision)) throw new ConnectionError('SETTINGS_UNAVAILABLE', 'Connection settings are unavailable')
71
+ return result
72
+ }
73
+ const current = () => descriptor().value ?? {}
74
+ const identityKey = async (identity) => {
75
+ if (identity?.type === 'missing') return undefined
76
+ return resolveIdentity(ctx.credentials, identity)
77
+ }
78
+ const gateway = createGatewayAdapter(ctx, undefined, diagnostics, () => { void activate().catch(() => { }) })
79
+ const compatibleCatalog = (value) => catalog && catalog.generation === value.generation && catalog.baseURL === value.baseURL && JSON.stringify(catalog.authIdentity) === JSON.stringify(value.authIdentity)
80
+ // The settings row lives in the profile patch file. It can disappear when the
81
+ // profile is rebuilt, a compatibility gate denies a row, or the bundle is
82
+ // reinstalled, so every saved connection is mirrored into a plugin-owned
83
+ // snapshot under the DSH home and can be restored without the user retyping it.
84
+ let durable
85
+ let durableLoaded = false
86
+ const loadDurable = async () => {
87
+ if (durableLoaded) return durable
88
+ durableLoaded = true
89
+ try { durable = await readConnectionSnapshot(ctx.settings.documentPath) } catch { durable = undefined }
90
+ return durable
91
+ }
92
+ const persist = async (value) => {
93
+ if (!value?.baseURL || !value?.generation) return
94
+ try { durable = await writeConnectionSnapshot(ctx.settings.documentPath, value); durableLoaded = true } catch { }
95
+ }
96
+ const durability = async () => {
97
+ const snapshot = await loadDurable()
98
+ return snapshot ? { available: true, savedAt: snapshot.savedAt, baseURL: snapshot.baseURL } : { available: false }
99
+ }
100
+ const restore = async (operationId, expectedRevision) => {
101
+ const snapshot = await loadDurable()
102
+ if (!snapshot) throw new ConnectionError('NO_SNAPSHOT', 'No saved connection is available to restore')
103
+ const before = descriptor()
104
+ if (before.value?.baseURL) throw new ConnectionError('SETTINGS_CONFLICT', 'A connection is already configured')
105
+ if (typeof operationId !== 'string' || !operationId || operationId.length > 256) throw new ConnectionError('INVALID_REQUEST', 'An operation ID is required')
106
+ if (Number.isSafeInteger(expectedRevision) && expectedRevision !== before.revision) throw new ConnectionError('SETTINGS_CONFLICT', 'Connection settings have changed')
107
+ const generation = generationFor(operationId, before.revision)
108
+ const values = {
109
+ baseURL: snapshot.baseURL,
110
+ authIdentity: structuredClone(snapshot.authIdentity),
111
+ enabled: snapshot.enabled !== false,
112
+ policy: structuredClone(snapshot.policy ?? {}),
113
+ modelSelection: structuredClone(snapshot.modelSelection ?? {}),
114
+ overrides: structuredClone(snapshot.overrides ?? {}),
115
+ operationId,
116
+ generation,
117
+ }
118
+ const ops = Object.entries(values).map(([key, value]) => ({ op: 'set', path: [key], value }))
119
+ await ctx.settings.mutate(NS, ops, before.revision)
120
+ return generation
121
+ }
122
+ const activate = async () => {
123
+ const value = current()
124
+ let ready = !!value.baseURL && value.enabled !== false && !['prepared', 'rollback'].includes(value.migration?.phase) && compatibleCatalog(value)
125
+ if (ready && value.authIdentity?.type !== 'none') {
126
+ try { ready = !!(await identityKey(value.authIdentity)) } catch { ready = false }
127
+ }
128
+ ready = ready && catalog.effective.some((model) => value.modelSelection?.[model.id] !== false)
129
+ if (stopped || current().generation !== value.generation) return
130
+ if (!ready) {
131
+ try { registration?.replace([]) } catch { activationError = errorView('ACTIVATION_FAILED', 'Could not deactivate CPA route') }
132
+ return
133
+ }
134
+ try {
135
+ gateway.update({ ...value, models: catalog.effective })
136
+ if (registration) registration.replace([PROVIDER])
137
+ else registration = ctx.llm.registerAdapter([PROVIDER], gateway.adapter)
138
+ activationError = undefined
139
+ recoveryError = undefined
140
+ void persist(value)
141
+ } catch {
142
+ try { registration?.replace([]) } catch { }
143
+ activationError = errorView('ACTIVATION_FAILED', 'Could not activate CPA route; another owner may be using it')
144
+ }
145
+ }
146
+ const view = async () => {
147
+ const { value = {}, revision } = descriptor()
148
+ let keyConfigured = false
149
+ if (value.authIdentity && value.authIdentity.type !== 'none') {
150
+ try { keyConfigured = !!(await identityKey(value.authIdentity)) } catch { keyConfigured = false }
151
+ }
152
+ const authReady = value.authIdentity?.type === 'none' || keyConfigured
153
+ const models = compatibleCatalog(value) ? catalog.effective : []
154
+ const configured = !!value.baseURL
155
+ const saved = await durability()
156
+ const status = !configured ? 'not-configured' : value.enabled === false ? 'disabled' : !authReady ? 'missing-credential' : loading ? 'catalog-loading' : activationError ? 'error' : !models.length ? (catalogError ? 'error' : 'catalog-loading') : !models.some((m) => value.modelSelection?.[m.id] !== false) ? 'no-models' : freshness === 'stale' ? 'stale' : 'ready'
157
+ return {
158
+ revision, ...(value.generation ? { generation: value.generation } : {}), ...(value.operationId ? { operationId: value.operationId } : {}),
159
+ configured, enabled: value.enabled !== false, baseURL: value.baseURL ?? null,
160
+ authMode: value.authIdentity?.type === 'missing' ? 'record' : value.authIdentity?.type ?? null,
161
+ keyConfigured, keyWritable: ctx.settings.writable !== false,
162
+ modelSelection: value.modelSelection ?? {}, policy: safeObject(value.policy ?? {}), models,
163
+ status, catalog: { freshness: loading ? 'loading' : freshness, ...(compatibleCatalog(value) ? { fetchedAt: catalog.fetchedAt } : {}), ...(catalogError ? { error: catalogError } : {}) },
164
+ diagnostics: [...diagnostics], ...(activationError ? { activationError } : {}), ...(recoveryError ? { recoveryError } : {}),
165
+ durable: saved, legacy: legacyView(ctx.settings),
166
+ }
167
+ }
168
+ const load = async () => {
169
+ const value = current()
170
+ if (!value.baseURL || !value.generation) return
171
+ try {
172
+ const saved = await readCatalogCache(ctx.settings.documentPath, value.generation, value.baseURL, value.authIdentity)
173
+ if (saved && current().generation === value.generation) { catalog = saved; freshness = 'stale' }
174
+ } catch { catalogError = errorView('CACHE_UNAVAILABLE', 'Catalog cache could not be read') }
175
+ await activate()
176
+ }
177
+ const refresh = async (generation, signal) => {
178
+ if (stopped) throw new ConnectionError('STALE_GENERATION', 'Connection is no longer active')
179
+ const value = structuredClone(current())
180
+ if (!value.baseURL || generation !== value.generation) throw new ConnectionError('STALE_GENERATION', 'Connection has changed')
181
+ if (value.enabled === false || value.authIdentity?.type === 'missing') throw new ConnectionError('MISSING_CREDENTIAL', 'CPA connection is disabled or missing a credential')
182
+ const active = new AbortController()
183
+ const forwardAbort = () => active.abort(signal.reason)
184
+ if (signal?.aborted) forwardAbort()
185
+ else signal?.addEventListener('abort', forwardAbort, { once: true })
186
+ activeRefreshes.add(active)
187
+ loading = true
188
+ try {
189
+ const key = await identityKey(value.authIdentity)
190
+ if (value.authIdentity?.type !== 'none' && !key) throw new ConnectionError('MISSING_CREDENTIAL', 'CPA credential is missing')
191
+ if (active.signal.aborted) throw active.signal.reason
192
+ const result = await discover(value, active.signal, key)
193
+ if (active.signal.aborted) throw active.signal.reason
194
+ if (current().generation !== generation) throw new ConnectionError('STALE_GENERATION', 'Connection has changed')
195
+ const next = { generation, baseURL: value.baseURL, authIdentity: value.authIdentity, fetchedAt: Date.now(), raw: result.discovery.map((m) => m.catalogMetadata), effective: result.discovery }
196
+ let persistenceError
197
+ try { await writeCatalogCache(ctx.settings.documentPath, next) } catch { persistenceError = errorView('CACHE_UNAVAILABLE', 'Catalog cache could not be saved') }
198
+ if (active.signal.aborted) throw active.signal.reason
199
+ if (current().generation !== generation) throw new ConnectionError('STALE_GENERATION', 'Connection has changed')
200
+ catalog = next
201
+ freshness = 'fresh'
202
+ catalogError = persistenceError
203
+ failures = 0
204
+ } catch (error) {
205
+ if (!stopped && current().generation === generation && !active.signal.aborted) {
206
+ if (compatibleCatalog(value)) freshness = 'stale'
207
+ catalogError = { ...errorView(error?.code ?? 'CATALOG_TRANSPORT', 'Could not refresh the CPA catalog'), ...(Number.isInteger(error?.status) ? { status: error.status } : {}) }
208
+ if (error?.code !== 'MISSING_CREDENTIAL') failures++
209
+ }
210
+ throw error
211
+ } finally {
212
+ activeRefreshes.delete(active)
213
+ signal?.removeEventListener('abort', forwardAbort)
214
+ loading = false
215
+ await activate()
216
+ if (!stopped && current().generation === generation && current().migration?.phase === 'cutover' && registration && !activationError) await finishLegacy(ctx.settings)
217
+ if (!stopped && current().generation === generation && current().enabled !== false && current().authIdentity?.type !== 'missing' && !active.signal.aborted) {
218
+ const cfg = config()
219
+ schedule(failures ? Math.min(cfg.retryInitialMs * 2 ** Math.max(0, failures - 1), cfg.retryMaxMs) : cfg.refreshIntervalMs, generation)
220
+ }
221
+ }
222
+ }
223
+ const mutate = async (input, values) => {
224
+ const before = descriptor()
225
+ if (!Number.isSafeInteger(input.expectedRevision) || typeof input.operationId !== 'string' || !input.operationId || input.operationId.length > 256) throw new ConnectionError('INVALID_REQUEST', 'Revision and operation ID are required')
226
+ if (before.value?.operationId === input.operationId) {
227
+ if (Object.entries(values).some(([key, value]) => JSON.stringify(before.value?.[key]) !== JSON.stringify(value))) throw new ConnectionError('OPERATION_CONFLICT', 'Operation ID belongs to another operation')
228
+ return
229
+ }
230
+ if (before.revision !== input.expectedRevision) throw new ConnectionError('SETTINGS_CONFLICT', 'Connection settings have changed')
231
+ const generation = generationFor(input.operationId, before.revision)
232
+ const ops = Object.entries({ ...values, operationId: input.operationId, generation }).map(([key, value]) => ({ op: 'set', path: [key], value }))
233
+ try { await ctx.settings.mutate(NS, ops, before.revision) } catch (error) {
234
+ if (error?.code === 'SETTINGS_CONFLICT') throw error
235
+ if (descriptor().value?.operationId !== input.operationId) throw new ConnectionError('UNKNOWN', 'Save outcome is unknown; read state before retrying')
236
+ }
237
+ if (descriptor().value?.operationId !== input.operationId) throw new ConnectionError('UNKNOWN', 'Save outcome is unknown; read state before retrying')
238
+ cancelWake(); abortRefreshes()
239
+ if (catalog && catalog.generation === before.value?.generation) {
240
+ catalog = { ...catalog, generation }
241
+ try { await writeCatalogCache(ctx.settings.documentPath, catalog) } catch { freshness = 'stale'; catalogError = errorView('CACHE_UNAVAILABLE', 'Catalog cache could not be saved') }
242
+ }
243
+ await activate()
244
+ await persist(current())
245
+ const value = current()
246
+ if (value.generation === generation && value.enabled !== false && value.authIdentity?.type !== 'missing') {
247
+ const cfg = config()
248
+ schedule(failures ? Math.min(cfg.retryInitialMs * 2 ** Math.max(0, failures - 1), cfg.retryMaxMs) : cfg.refreshIntervalMs, generation)
249
+ }
250
+ }
251
+ const preview = async (input, signal) => {
252
+ const endpoint = validateEndpoint(input.baseURL)
253
+ const key = await previewKey(endpoint, input.apiKey, input.allowEndpointReuse)
254
+ const result = await discover({ baseURL: endpoint }, signal, key)
255
+ return result.discovery
256
+ }
257
+ const command = async (input, signal) => {
258
+ objectWithFields(input, fields[input?.type] ?? [])
259
+ if (input.type === 'preview-models') { validatePreview(input, fields[input.type]); return preview(input, signal) }
260
+ if (input.type === 'save-connection') {
261
+ const authFields = { keep: ['type', 'allowEndpointReuse'], replace: ['type', 'key'], reference: ['type', 'ref', 'allowEndpointReuse'], none: ['type'] }
262
+ objectWithFields(input.authEdit, authFields[input.authEdit?.type] ?? [])
263
+ if (input.authEdit?.allowEndpointReuse !== undefined && typeof input.authEdit.allowEndpointReuse !== 'boolean') throw new ConnectionError('INVALID_AUTH', 'Invalid endpoint reuse')
264
+ if (input.authEdit?.type === 'replace' && (typeof input.authEdit.key !== 'string' || Buffer.byteLength(input.authEdit.key) > 4096)) throw new ConnectionError('INVALID_AUTH', 'Invalid key')
265
+ }
266
+ if (input.type === 'refresh-models') { await refresh(input.connectionGeneration, signal); return view() }
267
+ if (input.type === 'save-connection') {
268
+ validatePolicy(input.policy === undefined ? current().policy ?? {} : input.policy)
269
+ // A legacy llm-pi-ai provider owns the same route. Adopt it instead of
270
+ // refusing the save: prepareLegacy records a full backup, so the cutover
271
+ // stays reversible through rollback-legacy. The cutover runs only after the
272
+ // save itself succeeded, so a rejected draft leaves the legacy route intact.
273
+ let save = input
274
+ let adopted = false
275
+ if (!current().baseURL) {
276
+ const summary = legacyView(ctx.settings)
277
+ if (summary.available) {
278
+ await prepareLegacy(ctx.settings, { expectedRevision: descriptor().revision, expectedLegacyRevision: summary.revision, fingerprint: summary.fingerprint, operationId: input.operationId + ':adopt' })
279
+ save = { ...input, expectedRevision: descriptor().revision }
280
+ adopted = true
281
+ } else if (ctx.settings.describe().find((row) => row.ns === 'llm-pi-ai')?.value?.providers?.[PROVIDER]) {
282
+ throw new ConnectionError('LEGACY_CONFLICT', summary.reason ?? 'The existing CPA provider must be resolved first')
283
+ }
284
+ }
285
+ const previous = current()
286
+ try {
287
+ await saveConnection(ctx.settings, ctx.credentials, save)
288
+ } catch (error) {
289
+ // The prepared migration already wrote our row. A definite rejection is
290
+ // undone so the legacy route keeps working; an ambiguous outcome is left
291
+ // for rollback-legacy, because discarding could erase a real save.
292
+ if (adopted && error?.code !== 'UNKNOWN') { try { await discardPrepared(ctx.settings) } catch { } }
293
+ throw error
294
+ }
295
+ if (adopted) await cutoverLegacy(ctx.settings)
296
+ const value = current()
297
+ if (previous.generation !== value.generation) { cancelWake(); abortRefreshes(); failures = 0; catalog = undefined; freshness = 'empty'; catalogError = undefined }
298
+ try { await refresh(value.generation, signal) } catch { await activate() }
299
+ await persist(current())
300
+ return view()
301
+ }
302
+ if (input.type === 'save-selection') {
303
+ if (!input.selectionChanges || typeof input.selectionChanges !== 'object' || Array.isArray(input.selectionChanges) || Object.values(input.selectionChanges).some((x) => typeof x !== 'boolean')) throw new ConnectionError('INVALID_SELECTION', 'Invalid model selection')
304
+ await mutate(input, { modelSelection: { ...(current().modelSelection ?? {}), ...input.selectionChanges } })
305
+ return view()
306
+ }
307
+ if (input.type === 'set-enabled') {
308
+ if (typeof input.enabled !== 'boolean') throw new ConnectionError('INVALID_REQUEST', 'Enabled must be a boolean')
309
+ if (input.enabled === (current().enabled !== false)) {
310
+ if (descriptor().revision !== input.expectedRevision && current().operationId !== input.operationId) throw new ConnectionError('SETTINGS_CONFLICT', 'Connection settings have changed')
311
+ } else await mutate(input, { enabled: input.enabled })
312
+ if (input.enabled) { const value = current(); try { await refresh(value.generation, signal) } catch { } } else { cancelWake(); abortRefreshes() }
313
+ return view()
314
+ }
315
+ if (input.type === 'forget-key') { cancelWake(); abortRefreshes(); await mutate(input, { authIdentity: { type: 'missing' } }); return view() }
316
+ if (input.type === 'import-legacy') {
317
+ await prepareLegacy(ctx.settings, input)
318
+ await cutoverLegacy(ctx.settings)
319
+ const value = current()
320
+ try { await refresh(value.generation, signal) } catch { }
321
+ if (current().migration?.phase !== 'active') recoveryError = errorView('MIGRATION_PENDING', 'Migration is prepared; refresh the catalog to finish activation')
322
+ return view()
323
+ }
324
+ if (input.type === 'restore-connection') {
325
+ if (typeof input.operationId !== 'string' || !input.operationId || input.operationId.length > 256) throw new ConnectionError('INVALID_REQUEST', 'An operation ID is required')
326
+ cancelWake(); abortRefreshes()
327
+ const generation = await restore(input.operationId, input.expectedRevision)
328
+ failures = 0
329
+ catalog = undefined
330
+ freshness = 'empty'
331
+ catalogError = undefined
332
+ recoveryError = undefined
333
+ try { await refresh(generation, signal) } catch { await activate() }
334
+ return view()
335
+ }
336
+ if (input.type === 'rollback-legacy') {
337
+ if (!Number.isSafeInteger(input.expectedRevision) || descriptor().revision !== input.expectedRevision) throw new ConnectionError('SETTINGS_CONFLICT', 'Connection settings have changed')
338
+ if (typeof input.operationId !== 'string' || !input.operationId) throw new ConnectionError('INVALID_OPERATION', 'An operation ID is required')
339
+ cancelWake(); abortRefreshes()
340
+ await rollbackLegacy(ctx.settings, async () => { registration?.replace([]) })
341
+ catalog = undefined
342
+ freshness = 'empty'
343
+ activationError = undefined
344
+ recoveryError = undefined
345
+ try { await removeConnectionSnapshot(ctx.settings.documentPath) } catch { }
346
+ durable = undefined
347
+ durableLoaded = true
348
+ return view()
349
+ }
350
+ throw new ConnectionError('INVALID_REQUEST', 'Unknown command')
351
+ }
352
+ const reply = (status, value) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } })
353
+ ctx.inject(['connection'], (web) => {
354
+ for (const [path, methods] of [['/api/cliproxyapi/state', ['GET']], ['/api/cliproxyapi/command', ['POST']], ['/api/cliproxyapi/catalog', ['POST']]]) {
355
+ web.connection.fetch.register({
356
+ path, methods, requestBody: 'buffered', async fetch(request) {
357
+ try {
358
+ if (path.endsWith('/state')) return reply(200, { ok: true, value: await view() })
359
+ if (request.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase() !== 'application/json') return reply(415, { ok: false, error: { ...errorView('INVALID_REQUEST', 'Expected JSON'), details: {} } })
360
+ let input
361
+ try { input = await readJson(request, 16 * 1024) } catch { throw new ConnectionError('INVALID_REQUEST', 'Invalid or oversized JSON') }
362
+ if (path.endsWith('/catalog')) validatePreview(input, ['baseURL', 'apiKey', 'allowEndpointReuse'])
363
+ const value = path.endsWith('/catalog') ? await preview(input, request.signal) : await command(input, request.signal)
364
+ return reply(200, { ok: true, value })
365
+ } catch (error) {
366
+ const code = error instanceof ConnectionError ? error.code : error?.code === 'SETTINGS_CONFLICT' ? 'SETTINGS_CONFLICT' : 'REQUEST_FAILED'
367
+ const status = code === 'SETTINGS_CONFLICT' || code === 'OPERATION_CONFLICT' || code === 'STALE_GENERATION' || code === 'LEGACY_CONFLICT' ? 409 : code === 'REQUEST_FAILED' || code === 'UNKNOWN' || code.startsWith('CATALOG_') ? 502 : 400
368
+ return reply(status, { ok: false, error: { ...errorView(code, error instanceof ConnectionError ? error.message : 'CPA request failed'), details: Number.isInteger(error?.status) ? { status: error.status } : {} } })
369
+ }
370
+ }
371
+ })
372
+ }
373
+ })
374
+ ctx.on('credentials/reference-updated', () => { void activate().catch(() => { }) })
375
+ ctx.on('credentials/record-updated', () => { void activate().catch(() => { }) })
376
+ ctx.on('settings/document-updated', (ns) => { if (ns === NS) { const value = current(); if (value.enabled === false || value.authIdentity?.type === 'missing') { cancelWake(); abortRefreshes() } void activate().catch(() => { }) } })
377
+ ctx.effect(() => () => { stopped = true; cancelWake(); abortRefreshes(); registration?.() })
378
+ // The plugin fiber is not marked ready while its apply runs, and the settings
379
+ // service only reports rows whose fiber is ready. Reading our row during that
380
+ // window throws and used to latch a recovery error for the whole process, so
381
+ // startup waits for the row instead of reporting a connection that is fine.
382
+ const whenAvailable = async () => {
383
+ for (let attempt = 0; attempt < 600; attempt++) {
384
+ if (stopped) return false
385
+ try { descriptor(); return true } catch { await new Promise((resolve) => setTimeout(resolve, 50)) }
386
+ }
387
+ return false
388
+ }
389
+ void (async () => {
390
+ if (!(await whenAvailable())) return
391
+ // A prepared migration means the process stopped between writing our row and
392
+ // removing the legacy provider. Completing the cutover is the only forward
393
+ // path; leaving it prepared would keep the route deactivated forever.
394
+ if (current().migration?.phase === 'prepared') { try { await cutoverLegacy(ctx.settings) } catch { } }
395
+ if (current().migration?.phase === 'cutover') await cutoverLegacy(ctx.settings)
396
+ if (current().migration?.phase === 'rollback') await rollbackLegacy(ctx.settings, async () => { registration?.replace([]) })
397
+ await load()
398
+ if (stopped) return
399
+ const value = current()
400
+ // The profile patch file can be rebuilt or a row can be denied by a core
401
+ // gate, which erases the saved settings row. The plugin-owned snapshot is
402
+ // offered through state.durable and restore-connection instead of being
403
+ // applied implicitly, because an absent connection can also be deliberate.
404
+ await loadDurable()
405
+ if (!stopped && value.baseURL && value.generation && value.enabled !== false && value.authIdentity?.type !== 'missing' && !['prepared', 'rollback'].includes(value.migration?.phase)) { controller = new AbortController(); try { await refresh(value.generation, controller.signal) } catch { } }
406
+ })().catch(() => {
407
+ // Only a failure after the settings row became readable is a real recovery
408
+ // problem; a successful activation clears it again through activate().
409
+ recoveryError = errorView('MIGRATION_PENDING', 'Connection recovery needs attention')
410
+ })
411
+ }