instar 1.3.1348 → 1.3.1349

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.
Files changed (67) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +15 -2
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/core/LifelineIngressSecret.d.ts +83 -0
  5. package/dist/core/LifelineIngressSecret.d.ts.map +1 -0
  6. package/dist/core/LifelineIngressSecret.js +203 -0
  7. package/dist/core/LifelineIngressSecret.js.map +1 -0
  8. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  9. package/dist/core/PostUpdateMigrator.js +15 -0
  10. package/dist/core/PostUpdateMigrator.js.map +1 -1
  11. package/dist/core/WriteDomainRegistry.d.ts.map +1 -1
  12. package/dist/core/WriteDomainRegistry.js +17 -0
  13. package/dist/core/WriteDomainRegistry.js.map +1 -1
  14. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  15. package/dist/core/devGatedFeatures.js +6 -0
  16. package/dist/core/devGatedFeatures.js.map +1 -1
  17. package/dist/core/keyMaterialPaths.d.ts.map +1 -1
  18. package/dist/core/keyMaterialPaths.js +16 -0
  19. package/dist/core/keyMaterialPaths.js.map +1 -1
  20. package/dist/core/types.d.ts +23 -0
  21. package/dist/core/types.d.ts.map +1 -1
  22. package/dist/core/types.js.map +1 -1
  23. package/dist/core/version-skew.d.ts +4 -2
  24. package/dist/core/version-skew.d.ts.map +1 -1
  25. package/dist/core/version-skew.js +3 -1
  26. package/dist/core/version-skew.js.map +1 -1
  27. package/dist/data/standards-guard-index.json +1 -1
  28. package/dist/data/standards-guard-index.meta.json +2 -2
  29. package/dist/data/standards-registry.meta.json +1 -1
  30. package/dist/lifeline/TelegramLifeline.d.ts +49 -0
  31. package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
  32. package/dist/lifeline/TelegramLifeline.js +202 -17
  33. package/dist/lifeline/TelegramLifeline.js.map +1 -1
  34. package/dist/lifeline/forwardErrors.d.ts +11 -0
  35. package/dist/lifeline/forwardErrors.d.ts.map +1 -1
  36. package/dist/lifeline/forwardErrors.js +15 -1
  37. package/dist/lifeline/forwardErrors.js.map +1 -1
  38. package/dist/monitoring/guardManifest.d.ts.map +1 -1
  39. package/dist/monitoring/guardManifest.js +24 -0
  40. package/dist/monitoring/guardManifest.js.map +1 -1
  41. package/dist/scaffold/templates.d.ts.map +1 -1
  42. package/dist/scaffold/templates.js +4 -0
  43. package/dist/scaffold/templates.js.map +1 -1
  44. package/dist/server/LifelineIngressGuard.d.ts +326 -0
  45. package/dist/server/LifelineIngressGuard.d.ts.map +1 -0
  46. package/dist/server/LifelineIngressGuard.js +713 -0
  47. package/dist/server/LifelineIngressGuard.js.map +1 -0
  48. package/dist/server/TelegramCallbackDedupe.d.ts +58 -0
  49. package/dist/server/TelegramCallbackDedupe.d.ts.map +1 -0
  50. package/dist/server/TelegramCallbackDedupe.js +151 -0
  51. package/dist/server/TelegramCallbackDedupe.js.map +1 -0
  52. package/dist/server/routes.d.ts.map +1 -1
  53. package/dist/server/routes.js +218 -10
  54. package/dist/server/routes.js.map +1 -1
  55. package/dist/testing/selfActionRegistry.d.ts.map +1 -1
  56. package/dist/testing/selfActionRegistry.js +98 -0
  57. package/dist/testing/selfActionRegistry.js.map +1 -1
  58. package/package.json +1 -1
  59. package/src/data/builtin-manifest.json +64 -64
  60. package/src/data/standards-guard-index.json +1 -1
  61. package/src/data/standards-guard-index.meta.json +2 -2
  62. package/src/data/standards-registry.meta.json +1 -1
  63. package/src/data/state-coherence-registry.json +19 -0
  64. package/src/scaffold/templates.ts +4 -0
  65. package/upgrades/1.3.1349.md +59 -0
  66. package/upgrades/side-effects/telegram-internal-route-lifeline-secret.md +187 -0
  67. package/upgrades/1.3.1348.md +0 -24
@@ -143,6 +143,9 @@ import { reviewWithinBudget } from './outboundGateBudget.js';
143
143
  import { resolveToneRecipientClass } from './toneRecipientClass.js';
144
144
  import { RULE_DISPOSITIONS, buildDegradedToneResult, resolveToneGateOperatorConfig, fingerprintAutomatedTemplate } from '../core/MessagingToneGate.js';
145
145
  import { writeLifelineRestartSignal } from '../core/version-skew.js';
146
+ import { LifelineIngressGuard } from './LifelineIngressGuard.js';
147
+ import { sharedLifelineIngressSecretHolder } from '../core/LifelineIngressSecret.js';
148
+ import { TelegramCallbackDedupe } from './TelegramCallbackDedupe.js';
146
149
  import { readSessionClocks } from '../core/SessionClockReader.js';
147
150
  import { P13_PROTOCOL_VERSION } from '../core/CompletionEvaluator.js';
148
151
  import { CodexTaskContinuationStore, parseContinuationTasks } from '../core/CodexTaskContinuationStore.js';
@@ -2413,6 +2416,90 @@ export function createRoutes(ctx) {
2413
2416
  dryRun: ctx.liveConfig?.get('threadline.localRouteSignature.dryRun', undefined),
2414
2417
  }, ctx.config);
2415
2418
  const localEnvelopeSigner = createAgentLocalEnvelopeSigner(ctx.config.projectName, ctx.config.stateDir);
2419
+ // Lifeline ingress secret (docs/specs/telegram-internal-route-lifeline-secret.md):
2420
+ // the two internal Telegram routes require proof the caller is the lifeline,
2421
+ // not a session holding the ordinary Bearer token. The mode is read live.
2422
+ const lifelineIngressGuard = new LifelineIngressGuard({
2423
+ stateDir: ctx.config.stateDir,
2424
+ // Live when a live reader exists (so REMOVING the key takes effect too);
2425
+ // the boot snapshot only when there is none.
2426
+ bypass: () => (ctx.liveConfig
2427
+ ? ctx.liveConfig.get('telegramIngress.lifelineSecret.bypass', undefined)
2428
+ : ctx.config.telegramIngress?.lifelineSecret?.bypass) === true,
2429
+ explicitEnforce: () => ctx.liveConfig
2430
+ ? ctx.liveConfig.get('telegramIngress.lifelineSecret.enforce', undefined)
2431
+ : ctx.config.telegramIngress?.lifelineSecret?.enforce,
2432
+ config: ctx.config,
2433
+ holder: sharedLifelineIngressSecretHolder(ctx.config.stateDir),
2434
+ // The 60-second boot nudge is a real-time timer that writes into the
2435
+ // state dir; under the test runner routes are built thousands of times
2436
+ // against throwaway dirs, so it is exercised directly there instead.
2437
+ bootNudgeDelayMs: process.env.VITEST ? null : undefined,
2438
+ // The lifeline is the Telegram ingress unless this server polls Telegram
2439
+ // itself, or Telegram is not CONFIGURED at all (then no lifeline can
2440
+ // exist). A configured adapter that is merely absent is UNKNOWN, and
2441
+ // unknown waits for proof like any other machine.
2442
+ lifelineIsIngress: () => {
2443
+ const t = ctx.telegram;
2444
+ if (t)
2445
+ return t.isPolling !== true;
2446
+ const configured = (ctx.config.messaging ?? []).some((m) => m.type === 'telegram' && m.enabled !== false);
2447
+ return configured;
2448
+ },
2449
+ // Answers false when nothing reached the operator, so the guard's daily
2450
+ // throttle is released and the next occurrence tries again.
2451
+ raiseAttention: async (item) => {
2452
+ if (!ctx.telegram)
2453
+ return false;
2454
+ try {
2455
+ await ctx.telegram.createAttentionItem({
2456
+ id: item.id,
2457
+ title: item.title,
2458
+ summary: item.summary,
2459
+ category: 'security',
2460
+ priority: 'HIGH',
2461
+ sourceContext: 'telegram-ingress-lifeline-secret',
2462
+ healthKey: item.id,
2463
+ });
2464
+ return true;
2465
+ }
2466
+ catch {
2467
+ // @silent-fallback-ok — the degradation record and /health already carry it.
2468
+ return false;
2469
+ }
2470
+ },
2471
+ writeRestartSignal: ({ previousVersion }) => {
2472
+ // Never write a target the lifeline cannot match: it would restart an
2473
+ // up-to-date lifeline. No known version → decline, budget unspent.
2474
+ const targetVersion = ProcessIntegrity.getInstance()?.getState().runningVersion;
2475
+ if (!targetVersion)
2476
+ return false;
2477
+ writeLifelineRestartSignal({
2478
+ stateDir: ctx.config.stateDir,
2479
+ requestedBy: 'server-lifeline-secret',
2480
+ reason: 'lifeline-secret-required',
2481
+ previousVersion,
2482
+ targetVersion,
2483
+ });
2484
+ return true;
2485
+ },
2486
+ reportDegradation: ({ kind, reason, impact }) => {
2487
+ DegradationReporter.getInstance().report({
2488
+ feature: 'TelegramIngress.lifelineSecret',
2489
+ primary: 'Internal Telegram routes accept only requests that carry the lifeline secret',
2490
+ fallback: 'Request handled per the current mode; see /health telegramIngress.lifelineSecret',
2491
+ reason: `${kind}: ${reason}`,
2492
+ impact,
2493
+ });
2494
+ },
2495
+ });
2496
+ ctx.guardRegistry?.register('telegramIngress.lifelineSecret', () => lifelineIngressGuard.guardStatus());
2497
+ // One inline-button tap has one effect, however many times it is delivered.
2498
+ // Proven taps go to the durable store; a tap accepted without proof (only
2499
+ // possible before the guard arms) is deduped in memory, so an unproven
2500
+ // caller can neither fill nor evict the durable record of real taps.
2501
+ const telegramCallbackDedupe = new TelegramCallbackDedupe(ctx.config.stateDir);
2502
+ const telegramCallbackDedupeUnproven = new TelegramCallbackDedupe(null);
2416
2503
  const localRouteTrustCounters = createLocalRouteTrustCounters();
2417
2504
  const localRouteTrustMode = () => resolveLocalRouteTrustMode({
2418
2505
  enabled: ctx.liveConfig?.get('threadline.localRouteTrust.enabled', undefined),
@@ -3923,6 +4010,22 @@ export function createRoutes(ctx) {
3923
4010
  if (ctx.a2aRelayForward) {
3924
4011
  base.threadline = { ...(base.threadline ?? {}), relayForward: { ...ctx.a2aRelayForward.counters } };
3925
4012
  }
4013
+ // Lifeline ingress secret: mode, arming, the observe exit rule and the
4014
+ // PERSISTED counters (they survive a restart). AUTHED only; no secret.
4015
+ try {
4016
+ base.telegramIngress = {
4017
+ lifelineSecret: lifelineIngressGuard.health(),
4018
+ callbackDedupe: {
4019
+ tracked: telegramCallbackDedupe.size,
4020
+ duplicatesDropped: telegramCallbackDedupe.duplicates + telegramCallbackDedupeUnproven.duplicates,
4021
+ writeFailures: telegramCallbackDedupe.writeFailures,
4022
+ corruptAtLoad: telegramCallbackDedupe.corruptAtLoad,
4023
+ },
4024
+ };
4025
+ }
4026
+ catch {
4027
+ base.telegramIngress = { lifelineSecret: { state: 'unavailable' } };
4028
+ }
3926
4029
  // Local-route trust (docs/specs/a2a-local-route-trust.md): the live mode,
3927
4030
  // whether a trust manager is wired, and the verdict counters. AUTHED only.
3928
4031
  base.threadline = {
@@ -23243,7 +23346,10 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
23243
23346
  // handler below). Avoids per-request log spam while preserving the signal.
23244
23347
  let _versionMissingLogged = false;
23245
23348
  router.post('/internal/telegram-forward', async (req, res) => {
23246
- const { topicId, text, fromUserId, fromUsername, fromFirstName, messageId, lifelineVersion,
23349
+ const { topicId, text, messageId, lifelineVersion } = req.body;
23350
+ // The sender-identity fields are `let`: a request accepted WITHOUT proof of
23351
+ // the lifeline has them removed below, in one place, before any use.
23352
+ let { fromUserId, fromUsername, fromFirstName,
23247
23353
  // a2a spoof-defense fields (MENTOR-LIVE-READINESS-SPEC §Recipient side). The
23248
23354
  // lifeline forwards these when present in the Telegram update. Older lifelines
23249
23355
  // omit them → `senderIsBot` defaults to falsy → any marker-bearing forwarded
@@ -23251,6 +23357,33 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
23251
23357
  // defense, matching the spec invariant that a real user typing a marker MUST
23252
23358
  // be dropped). Upgraded lifelines populate them and the hook routes normally.
23253
23359
  senderIsBot, senderChatId, senderBotId, } = req.body;
23360
+ // Lifeline-secret gate — FIRST, before any side effect, so a refused
23361
+ // request has had no effect at all and its replay applies exactly once.
23362
+ const ingressDecision = lifelineIngressGuard.evaluate(req.headers, 'telegram-forward', { lifelineVersion });
23363
+ if (ingressDecision.action === 'refuse') {
23364
+ res.status(401).json({ ok: false, error: 'lifeline-secret-required', refused: true, reason: ingressDecision.reason });
23365
+ return;
23366
+ }
23367
+ // Accepted with a secret that is not ours (observe): tell the caller, so a
23368
+ // current lifeline re-reads the file instead of staying stale forever.
23369
+ if (ingressDecision.staleSecretHint)
23370
+ res.setHeader('X-Instar-Lifeline-Secret-Stale', '1');
23371
+ // SENDER NOT TRUSTED: the message is still delivered, but every
23372
+ // body-supplied identity claim is dropped HERE, once, so nothing below —
23373
+ // the inbound log (standing-authorization evidence), the ratifiers, the
23374
+ // a2a hook, either operator auto-bind, command and profile authority in
23375
+ // the message pipeline, the session's sender tag — can read it as an
23376
+ // authenticated sender. Downstream already treats "no sender id" as "no
23377
+ // first-party identity".
23378
+ const senderUntrusted = !ingressDecision.senderTrusted;
23379
+ if (senderUntrusted) {
23380
+ fromUserId = undefined;
23381
+ fromUsername = undefined;
23382
+ fromFirstName = 'Unverified sender';
23383
+ senderIsBot = false;
23384
+ senderChatId = undefined;
23385
+ senderBotId = undefined;
23386
+ }
23254
23387
  // Server boot window: if version cache wasn't populated, skip handshake
23255
23388
  // rather than 426-erroneously. Lifeline retries after retryAfterMs.
23256
23389
  if (!_serverVersionParsed) {
@@ -23279,14 +23412,18 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
23279
23412
  // that even if AutoUpdater missed the boundary (deferred restart,
23280
23413
  // lockfile race, etc.), the lifeline self-corrects on its next
23281
23414
  // tick / supervisor poll. Idempotent vs. AutoUpdater's prior write.
23415
+ // An unverified caller can name any lifelineVersion, so its signal
23416
+ // write goes through the guard's persisted budget; a verified lifeline
23417
+ // is direct evidence and writes as before.
23282
23418
  try {
23283
- writeLifelineRestartSignal({
23284
- stateDir: ctx.config.stateDir,
23285
- requestedBy: 'server-426',
23286
- reason: 'server-426-direct-evidence',
23287
- previousVersion: lifelineVersion,
23288
- targetVersion: decision.serverVersionString,
23289
- });
23419
+ if (ingressDecision.verified || lifelineIngressGuard.spendSignalBudget())
23420
+ writeLifelineRestartSignal({
23421
+ stateDir: ctx.config.stateDir,
23422
+ requestedBy: 'server-426',
23423
+ reason: 'server-426-direct-evidence',
23424
+ previousVersion: lifelineVersion,
23425
+ targetVersion: decision.serverVersionString,
23426
+ });
23290
23427
  }
23291
23428
  catch (err) {
23292
23429
  // Non-fatal — the 426 response itself still drives the lifeline's
@@ -23509,7 +23646,10 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
23509
23646
  console.error(`[telegram-forward] ownership read error (fail-open, claiming/routing normally): ${err instanceof Error ? err.message : err}`);
23510
23647
  }
23511
23648
  }
23512
- if (ctx.messageLedger && shouldClaimInbound) {
23649
+ // A request accepted WITHOUT proof of the lifeline chose its own
23650
+ // updateId/messageId: it must not claim a key in the exactly-once ledger,
23651
+ // or it could pre-claim the id of a real message yet to arrive.
23652
+ if (ctx.messageLedger && shouldClaimInbound && !senderUntrusted) {
23513
23653
  try {
23514
23654
  const eventId = req.body.updateId ?? messageId ?? `${topicId}:${req.body.timestamp ?? Date.now()}`;
23515
23655
  const dedupeKey = dedupeKeyFor('telegram', topicId, eventId);
@@ -23593,8 +23733,10 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
23593
23733
  if (lifelineSignedByAgent) {
23594
23734
  console.log(`[telegram-forward] topic ${topicId} message ${messageId ?? '?'} is agent-signed by "${lifelineSignedByAgent}" — operator auto-bind skipped`);
23595
23735
  }
23736
+ // Lifeline-secret gate: belt and braces. The identity was already removed
23737
+ // above for an untrusted request; this bind also checks the flag itself.
23596
23738
  if (ctx.topicOperatorStore && !lifelineSignedByAgent && fromUserId !== undefined && fromUserId !== null
23597
- && typeof topicId === 'number') {
23739
+ && typeof topicId === 'number' && !senderUntrusted) {
23598
23740
  try {
23599
23741
  if (ctx.telegram?.isAuthorizedSender?.(fromUserId)) {
23600
23742
  ctx.topicOperatorStore.setAuthenticatedOperator(topicId, {
@@ -23629,6 +23771,9 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
23629
23771
  firstName: fromFirstName,
23630
23772
  messageThreadId: topicId,
23631
23773
  viaLifeline: true,
23774
+ // Explicit, so the message pipeline can refuse authority by the
23775
+ // flag as well as by the missing id.
23776
+ ...(senderUntrusted ? { senderUnverified: true } : {}),
23632
23777
  // Preserve the platform message id separately from Message.id. The
23633
23778
  // latter has a Date.now fallback for routing/dedupe compatibility and
23634
23779
  // must NEVER be laundered into operator-establishment evidence.
@@ -24067,6 +24212,23 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
24067
24212
  }
24068
24213
  res.json({ resolved: entry.resolved, key: entry.key });
24069
24214
  });
24215
+ // ── Lifeline startup proof ─────────────────────────────────────
24216
+ // The lifeline posts here once at startup (and whenever the server comes
24217
+ // back) with the ingress secret, so the guard is armed within seconds of a
24218
+ // current lifeline starting instead of waiting for the next Telegram
24219
+ // message. Carries no Telegram input and has no effect beyond the guard.
24220
+ router.post('/internal/lifeline-proof', (req, res) => {
24221
+ const decision = lifelineIngressGuard.evaluate(req.headers, 'lifeline-proof', {
24222
+ lifelineVersion: req.body?.lifelineVersion,
24223
+ });
24224
+ if (decision.action === 'refuse') {
24225
+ res.status(401).json({ ok: false, error: 'lifeline-secret-required', refused: true, reason: decision.reason });
24226
+ return;
24227
+ }
24228
+ if (decision.staleSecretHint)
24229
+ res.setHeader('X-Instar-Lifeline-Secret-Stale', '1');
24230
+ res.json({ ok: true, verified: decision.verified, mode: decision.mode });
24231
+ });
24070
24232
  // ── Telegram Callback Query Forwarding (from Lifeline) ────────
24071
24233
  // Receives inline keyboard callback queries that the Lifeline forwarded.
24072
24234
  // Processes them through TelegramAdapter.processCallbackQuery().
@@ -24076,10 +24238,54 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
24076
24238
  res.status(400).json({ error: 'callbackQueryId and data required' });
24077
24239
  return;
24078
24240
  }
24241
+ if (typeof callbackQueryId !== 'string' && typeof callbackQueryId !== 'number') {
24242
+ res.status(400).json({ error: 'callbackQueryId must be a string or a number' });
24243
+ return;
24244
+ }
24245
+ // Lifeline-secret gate — before any effect. A tap is nothing but a
24246
+ // privileged act (it can be an approval), so once the guard is armed an
24247
+ // unproven tap is refused in EITHER mode, and a tap is refused while our
24248
+ // own secret is unreadable (nobody can be verified). The user is told to
24249
+ // try again by the lifeline.
24250
+ const callbackDecision = lifelineIngressGuard.evaluate(req.headers, 'telegram-callback');
24251
+ if (callbackDecision.action === 'refuse') {
24252
+ if (callbackDecision.reason === 'secret-unavailable') {
24253
+ res.status(503).json({ ok: false, error: 'lifeline-secret-unavailable', refused: true });
24254
+ }
24255
+ else {
24256
+ res.status(401).json({ ok: false, error: 'lifeline-secret-required', refused: true, reason: callbackDecision.reason });
24257
+ }
24258
+ return;
24259
+ }
24260
+ if (callbackDecision.staleSecretHint)
24261
+ res.setHeader('X-Instar-Lifeline-Secret-Stale', '1');
24079
24262
  if (!ctx.telegram) {
24080
24263
  res.status(503).json({ error: 'Telegram not configured' });
24081
24264
  return;
24082
24265
  }
24266
+ // Idempotency: the same tap delivered twice (a lifeline retry, or a
24267
+ // re-polled update after a lifeline restart) has ONE effect. Claimed
24268
+ // before processing and kept on failure — at-most-once.
24269
+ const claimStore = callbackDecision.verified ? telegramCallbackDedupe : telegramCallbackDedupeUnproven;
24270
+ // A tap accepted unproven before the guard armed may be re-polled and
24271
+ // arrive again, now proven, after the lifeline restarts: one effect still.
24272
+ const seenUnproven = callbackDecision.verified ? telegramCallbackDedupeUnproven.peek(callbackQueryId) : 'none';
24273
+ const claim = seenUnproven !== 'none' ? seenUnproven : claimStore.claim(callbackQueryId);
24274
+ if (claim === 'duplicate') {
24275
+ res.json({ ok: true, deduped: true });
24276
+ return;
24277
+ }
24278
+ if (claim === 'duplicate-of-failed' || claim === 'duplicate-of-unknown') {
24279
+ // A refusal stays a refusal: the first delivery of this tap FAILED, or
24280
+ // never recorded an outcome (the server stopped mid-handling). Its
24281
+ // redelivery must not read as success. It is still not re-applied.
24282
+ res.status(409).json({
24283
+ ok: false,
24284
+ deduped: true,
24285
+ error: claim === 'duplicate-of-failed' ? 'callback-previously-failed' : 'callback-outcome-unknown',
24286
+ });
24287
+ return;
24288
+ }
24083
24289
  try {
24084
24290
  // Reconstruct a callback query object and process it through TelegramAdapter
24085
24291
  const query = {
@@ -24090,9 +24296,11 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
24090
24296
  };
24091
24297
  // Use the adapter's public method for processing forwarded callbacks
24092
24298
  await ctx.telegram.handleForwardedCallback(query);
24299
+ claimStore.markDone(callbackQueryId);
24093
24300
  res.json({ ok: true });
24094
24301
  }
24095
24302
  catch (err) {
24303
+ claimStore.markFailed(callbackQueryId);
24096
24304
  console.error(`[telegram-callback] Processing failed:`, err instanceof Error ? err.message : err);
24097
24305
  res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
24098
24306
  }